Skip to content

前端公共代码管理方案

npm

发一个包到npm服务器,或者自己公司搭建的私服npm服务器,即公共文件放到 node_modules 中管理。

  1. 简单快捷,但是某些情况可能不是特别合适,比如在微前端项目中的一些公共的配置。
  2. 管理困难 a. 如果采用单一负责人发布方式,负责人工作量很大,因为负责人需要处理代码上线关系,使得高版本包含低版本代码,而实际又经常因为进度原因,低版本后上线,对负责人要求比较高。此外,开发发布需要和负责人沟通也是不小的成本。 b. 如果采用开发自行发布方式,没有自动化流程,代码包含关系难以管理,很容易出现A发布的包缺少B的代码的情况,导致线上出问题。

dll共享

dll共享就是将公共模块单独作为一个工程打包,最终由使用方通过script标签引用,模块内的方法通过全局变量暴露给使用方。这种方式相比其他几种方式,最大的好处是公共模块可独立升级部署。但这个模式存在以下问题:

  1. 重复包、按需加载问题 dll模块必须经过打包,但打包后就会造成重复打包和无法按需加载的问题。
  2. 开发不便 公共模块是运行时加载的,编辑器在引用的工程内无法取得这个模块的任何定义,编辑器的补全、校验功能无法使用,降低了开发效率。如果是使用ts的话,可以考虑在公共模块写一份ts定义,在开发时,用软链接的方式把ts定义链接的引用工程,可一定程度上解决这个问题。

模块联邦

模块联邦(Module Federation)是 Webpack 5 的一个新特性,它允许多个独立的构建应用共享 JavaScript 模块。这是一种微前端解决方案,可以让你在不同的前端应用之间共享和使用代码。

模块联邦使用

导出方配置: 项目1 home 启动服务 http://localhost:3002

javascript
// home webpack.config.js
    new ModuleFederationPlugin({
      name: "home",
      filename: "remoteEntry.js",
      exposes: {
        "./Content": "./src/components/Content",
        "./Button": "./src/components/Button",
        "./VueDemo": "./src/components/VueDemo", // 组件
        "./Utils": "./src/utils", // 纯函数
      },
    }),

使用方配置: 项目2 layout 启动服务 http://localhost:3001

javascript
// layout webpack.config.js
    new ModuleFederationPlugin({
      name: "layout",
      filename: "remoteEntry.js",
      remotes: {
        home: "home@http://localhost:3002/remoteEntry.js",//cdn地址
      },
      exposes: {},
    }),
// layout main.js
    import Vue from "vue";
    import Layout from './Layout.vue';

    const Content = () => import("home/Content");
    const Button = () => import("home/Button");
    const VueDemo = () => import("home/VueDemo");

    (async () => {
    const { sayHi } = await import("home/Utils");
    sayHi();
    })();


    Vue.component("content-element", Content);
    Vue.component("button-element", Button);
    Vue.component("vue-demo", VueDemo);

    new Vue({
    render: h => h(Layout),
    }).$mount('#app')

模块联邦的原理基于 JavaScript 的动态导入(import())和 Webpack 的编译打包能力。以下是其工作原理的简单概述:

  1. 配置阶段:在 Webpack 配置文件中,使用 ModuleFederationPlugin 插件来定义一个模块联邦。在这个配置中,你可以指定哪些模块应该被共享,以及它们的版本信息。
  2. 编译阶段:在编译阶段,Webpack 会将这些共享模块打包成独立的 JavaScript 文件,并在输出的 JavaScript 文件中插入一些额外的代码,这些代码用于在运行时加载和解析这些共享模块。
  3. 运行时阶段:当应用运行时,它会通过动态导入(import())语法来加载需要的共享模块。Webpack 生成的代码会拦截这些导入请求,然后从对应的 JavaScript 文件中加载和解析共享模块。
  4. 版本管理:如果多个应用共享了同一个模块,但使用了不同的版本,Webpack 会根据配置的版本信息,决定是否需要加载新的版本,或者使用已经加载的版本。

模块联邦的主要优点

  1. 代码共享:可以在不同的应用之间共享和重用代码,而无需发布和安装 npm 包。
  2. 独立部署:每个应用可以独立部署和更新,无需重新构建和部署使用了共享模块的其他应用。
  3. 延迟加载:可以在运行时动态加载共享模块,而无需在构建时包含所有的代码。

模块联邦潜在的缺点和挑战

  1. 版本冲突:如果多个应用共享了同一个模块,但使用了不同的版本,可能会出现版本冲突。虽然 Webpack 会尝试管理这些冲突,但在某些情况下,可能需要手动解决。拆分粒度需要权衡,虽然能做到依赖共享,但是被共享的lib不能做tree-shaking,也就是说如果共享了一个lodash,那么整个lodash库都会被打包到shared-chunk中。虽然依赖共享能解决传统微前端的externals的版本一致性问题。
  2. 全局状态管理:如果共享的模块包含全局状态,可能会出现状态冲突。你需要确保共享的模块是无状态的,或者能够正确地管理它们的状态。
  3. 性能影响:动态加载共享模块可能会增加网络请求,从而影响应用的加载性能。你需要仔细考虑何时以及如何加载共享模块,以最小化性能影响。
  4. 安全风险:共享代码可能会增加安全风险,因为攻击者可能会尝试修改或篡改共享的代码。你需要确保共享的代码是安全的,以及使用了适当的安全措施。

腾讯出了一个微模块方案,hel-micro,是在MF基础上更进一步的方案。 使得最终方案具有了工具链无关、多版本共存、远程模块类型提示等特点。

git submodule

git submodule 原理是在一个git工程(父工程)下保存另一个git工程(子工程)的commitID,通过submodule的命令可以把这个commitID的代码同步到父工程。

由于submodule实际上只是把公共模块代码作为父工程的一个目录,与父工程共同运行,所以没有npm包、dll包这种独立于工程外引用造成的各种问题,submodule本身是git的功能,整个开发过程是纯git操作。

可能存在以下缺点

  1. 操作略显繁琐 父工程会记录一个submodule的commitId,这个id一旦变化,就需要执行submodule命令重新同步这个commitId的代码到本地,也就是切换分支,拉取新代码,所有引起commitID变化的操作,都需要执行额外的submodule命令。 这个问题可以规避,通过配置git config --global submodule.recurse true,使得每次切换分支或拉取代码时,自动update一下submodule。 另外修改submodule的代码需要同时操作父工程和子工程两个git,相比只操作单一工程更加繁琐。
  2. 学习成本 需要一定实践才能理解submodule的逻辑,在整个团队推广需要一些时间。

git subtree

Git subtree 是 Git 的一个子命令,它允许你将一个仓库作为另一个仓库的子目录,同时保持各自的提交历史。它与 Git submodule 类似,但操作方式和工作原理有所不同。

  1. submodule 在父工程维护子工程的git地址、当前代码绑定的commitID等,通过命令来同步这个commitID对应的代码到父工程,并且在git操作上,父工程和子工程也是分开操作的。

  2. subtree 不记录任何子工程的信息,不会在你的项目中保存子仓库的链接和提交 ID。每次输入命令,都必须带上git地址、分支名等信息,根据命令,把命令中指定的分支代码拷贝到本工程的指定目录,将子仓库的提交直接合并到你的项目中。也能用命令把本工程指定目录的代码push到子工程,在开发过程中,完全不用管subtree的存在,直接当成只有一个工程开发提交就好了,等需要把代码同步到subtree仓库时再执行subtree push命令。

1. 添加子树

可以将另一个存储库添加到此存储库中

bash
git subtree add --prefix {local directory being pulled into} {remote repo URL} {remote branch} --squash
bash
# 例如:
git subtree add --prefix subtreeDirectory https://github.com/test/test.git master --squash

# 这将克隆 https://github.com/test/test.git 到目录 subtreeDirectory

2. 拉取子树

从远程将任何新提交拉入子树。命令基本同上,仅将 add 替换为 pull。

bash
git subtree pull --prefix subtreeDirectory https://github.com/test/test.git master --squash

3. 更新/推送到子树远程仓库

如果对提交中 subtreeDirectory 的任何内容进行更改,则提交将存储在主存储库及其日志中。这是与子模块相比最大的变化。

如果您现在想要使用该提交更新子树远程存储库,则必须运行相同的命令,排除 --squash 并替换 pull push。

bash
git subtree push --prefix subtreeDirectory https://github.com/test/test.git master

pnpm

pnpm 内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。monorepo的根目录下必须包含pnpm-workspace.yaml文件。

安装外部依赖包

全局的公共依赖包

pnpm 提供了 -w, --workspace-root 参数,可以将依赖包安装到工程的根目录下,作为所有 package 的公共依赖。

bash
pnpm install react -w

# 如果是一个开发依赖的话,可以加上 -D 参数,表示这是一个开发依赖,会装到 pacakage.json 中的 devDependencies 中
pnpm install rollup -wD

给某个package单独安装指定依赖

因此,如果想给 pkg1 安装一个依赖包,比如 axios,可以进行如下操作:

bash
pnpm add axios --filter @test/pkg1
# --filter 参数跟着的是package下的 package.json 的 name 字段,并不是目录名。

# filter 后面除了可以指定具体的包名,还可以跟着匹配规则来指定对匹配上规则的包进行操作
pnpm build --filter "./packages/**"

模块间相互依赖

基于 pnpm 提供的 workspace:协议,可以方便的在 packages 内部进行互相引用。

一个内部模块 a 依赖同是内部模块 b 的例子:

bash
pnpm --filter @test/a add @test/b
json
{
  "name": "a",
  // ...
  "dependencies": {
    "@test/b": "workspace:^x.x.x"
  }
}

在实际发布 npm 包时(无论它是通过 pnpm pack ,还是 pnpm publish 之类的发布命令),workspace:^ 会被替换成内部模块 b 的对应版本号(对应 package.json 中的 version 字段)。替换规律如下所示:

手动修改package.json:

json
{
  "dependencies": {
    "a": "workspace:*", // 固定版本依赖,被转换成 x.x.x
    "b": "workspace:~", // minor 版本依赖,将被转换成 ~x.x.x
    "c": "workspace:^"  // major 版本依赖,将被转换成 ^x.x.x
  }
}

pnpm-workspace.yaml

pnpm-workspace.yaml 定义了 工作空间 的根目录,并能够使您从工作空间中包含 / 排除目录 。 默认情况下,包含所有子目录。

示例:

yaml
packages:
  # all packages in direct subdirs of packages/
  - 'packages/*'
  # all packages in subdirs of components/
  - 'components/**'
  # exclude packages that are inside test directories
  - '!**/test/**'

只允许pnpm

当在项目中使用 pnpm 时,如果不希望用户使用 yarn 或者 npm 安装依赖,可以将下面的这个 preinstall 脚本添加到工程根目录下的 package.json中,preinstall 脚本会在 install 之前执行,现在,只要有人运行 npm install 或 yarn install,就会调用 only-allow 去限制只允许使用 pnpm 安装依赖。

json
{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

减少因node或pnpm的版本的差异而产生开发环境错误,我们在package.json中增加engines字段来限制版本。

json
{
  "engines": {
      "node": ">=16",
      "pnpm": ">=7"
  }
}

pnpm依赖组织和管理

pnpm的用户可能会发现它node_modules并不是扁平化结构,而是目录树的结构,类似npm version 2.x版本中的结构。

同时还有个.pnpm目录,.pnpm 以平铺的形式储存着所有的包,正常的包都可以在下面这种命名模式的文件夹中被找到:

bash
.pnpm/<organization-name>+<package-name>@<version>/node_modules/<name>
# 组织名(若无会省略)+包名@版本号/node_modules/名称(项目名称)

.pnmp为虚拟存储目录,该目录通过<package-name>@<version>来实现相同模块不同版本之间隔离和复用,由于它只会根据项目中的依赖生成,并不存在提升,所以它不会存在幽灵依赖问题。

使用 npm 时,依赖每次被不同的项目使用,都会重复安装一次。 而在使用 pnpm 时,依赖会被存储在内容可寻址的存储中,所以:

如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。 例如,如果某个包有100个文件,而它的新版本只改变了其中1个文件。那么 pnpm update 时只会向存储中心额外添加1个新文件,而不会因为仅仅一个文件的改变复制整新版本包的内容。 所有文件都会存储在硬盘上的某一位置。 当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖。

为什么pnpm能提高安装速度?

pnpm 分三个阶段执行安装:

  1. 依赖解析。 仓库中没有的依赖都被识别并获取到仓库。
  2. 目录结构计算。 node_modules 目录结构是根据依赖计算出来的。
  3. 链接依赖项。 所有以前安装过的依赖项都会直接从仓库中获取并链接到 node_modules。

pnpm

这种方法比传统的三阶段安装过程(解析、获取和将所有依赖项写入node_modules)快得多。

pnpm

另一部分原因是使用了计算机当中的 Hard link ,它减少了文件下载的数量,从而提升了下载和响应速度。

pnpm会创建一个非扁平的 node_modules 目录

使用 npm 或 Yarn Classic 安装依赖项时,所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖。

默认情况下,pnpm 使用符号链接将项目的直接依赖项添加到模块目录的根目录中。

请看下面的例子:

我们创建两个目录,并在其中一个执行 npm add express,以下是npm命令下目录中的 node_modules 的顶级项目:

md
.bin
accepts
array-flatten
body-parser
bytes
content-disposition
cookie-signature
cookie
debug
depd
destroy
ee-first
encodeurl
escape-html
etag
express

然后在另一个中执行 pnpm add express

md
.pnpm
.modules.yaml
express

pnpm 命令下所有的(次级)依赖去哪了呢? node_modules 中只有一个叫 .pnpm 的文件夹以及一个叫做 express 的符号链接。 我们只安装了 express,所以它是唯一一个你的应用必须拥有访问权限的包。

pnpm命令下的express包中 没有 node_modules? express 的所有依赖都去哪里了?

诀窍是 express 只是一个符号链接。 当 Node.js 解析依赖的时候,它使用这些依赖的真实位置,所以它不保留符号链接。 但是你可能就会问了,express 的真实位置在哪呢?

在这里:node_modules/.pnpm/express@4.17.1/node_modules/express

.pnpm/ 以平铺的形式储存着所有的包,所以每个包都可以在这种命名模式的文件夹中被找到:

bash
.pnpm/<name>@<version>/node_modules/<name>

我们称之为虚拟存储目录。

这个平铺的结构避免了 npm v2 创建的嵌套 node_modules 引起的长路径问题,但与 npm v3,4,5,6 或 yarn v1 创建的平铺的 node_modules 不同的是,它保留了包之间的相互隔离。

pnpm 的 node_modules 结构的第二个诀窍是包的依赖项与依赖包的实际位置位于同一目录级别。 所以 express 的依赖不在 .pnpm/express@4.17.1/node_modules/express/node_modules/ 而是在 .pnpm/express@4.17.1/node_modules/ :

md
▾ node_modules
  ▾ .pnpm
    ▸ accepts@1.3.5
    ▸ array-flatten@1.1.1
    ...
    ▾ express@4.16.3
      ▾ node_modules
        ▸ accepts
        ▸ array-flatten
        ▸ body-parser
        ▸ content-disposition
        ...
        ▸ etag
        ▾ express
          ▸ lib
            History.md
            index.js
            LICENSE
            package.json
            Readme.md

express 所有的依赖都软链至了 node_modules/.pnpm/ 中的对应目录。 把 express 的依赖放置在同一级别避免了循环的软链。

软链接和硬链接有什么区别

硬链接和软链接(也称为符号链接)是两种不同类型的链接,它们在文件系统中的工作方式有所不同:

  1. 硬链接:硬链接是一个文件的别名,它和原始文件共享相同的索引节点(inode)。这意味着,硬链接和原始文件指向的是同一块数据。如果你删除了原始文件,硬链接仍然可以访问文件的内容。硬链接不能跨越不同的文件系统,也不能链接到目录。

  2. 软链接(符号链接):软链接是一个特殊的文件,它包含了指向另一个文件或目录的路径。如果你删除了原始文件,软链接将会失效,因为它的路径不再指向有效的文件。软链接可以跨越不同的文件系统,也可以链接到目录。

md
举例来说,假设你有一个在"C:\folder1\file1.txt"的文件,你不能在"D:\folder2"(一个不同的卷)上创建一个硬链接指向它,但是你可以在"D:\folder2"上创建一个软链接,指向"C:\folder1\file1.txt"。

inode是Unix和类Unix系统(如Linux)中的一个概念,它是文件系统中的一个数据结构,用于存储文件或目录的元数据(如大小、创建时间、修改时间、所有者等),以及指向文件数据块的指针。每个文件或目录在文件系统中都有一个唯一的inode号。

总的来说,硬链接是对文件内容的直接链接,而软链接是对文件路径的链接。

nodejs的寻址方式

  1. 对于核心模块(core module) => 绝对路径 寻址
  2. node标准库 => 相对路径寻址
  3. 第三方库(通过npm安装)到node_modules下的库: 3.1. 先在当前路径下,寻找 node_modules/xxx 3.2 递归从下往上到上级路径,寻找 ../node_modules/xxx 3.3 循环第二步 3.4 在全局环境路径下寻找 .node_modules/xxx

幽灵依赖或幻影依赖

解释起来很简单,即某个包没有在package.json 被依赖,但是用户却能够引用到这个包。引发这个现象的原因一般是因为 node_modules 结构所导致的。例如使用 npm或yarn 对项目安装依赖,依赖里面有个依赖叫做 foo,foo 这个依赖同时依赖了 bar,yarn 会对安装的 node_modules 做一个扁平化结构的处理,会把依赖在 node_modules 下打平,这样相当于 foo 和 bar 出现在同一层级下面。那么根据 nodejs 的寻径原理,用户能 require 到 foo,同样也能 require 到 bar。

pnpm如何跟文件资源进行关联?

pnpm在全局通过Store来存储所有的node_modules依赖,并且在.pnpm/node_modules中存储项目的hard links,通过hard link来链接真实的文件资源,项目中则通过symbolic link链接到.pnpm/node_modules目录中,依赖放置在同一级别避免了循环的软链。

pnpm 使用名为 .pnpm-store的 store dir。

Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;

windows下会设置到当前盘的根目录下,比如C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3)。

使用 pnpm 对项目安装依赖的时候,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。

pnpm 选择使用软链接而不是硬链接的原因

虽然硬链接在某些情况下可能会节省磁盘空间,但因其固有的限制和管理难度,对于像 pnpm 这样的包管理工具,使用软链接更加灵活和方便。因此,pnpm 采用软链接来有效管理项目依赖,以实现更好的性能和用户体验。具体原因如下:

  1. 跨文件系统的限制 • 硬链接的限制:硬链接只能在同一个文件系统中使用。这意味着,如果 pnpm 的缓存目录和项目目录位于不同的文件系统(例如不同的分区或磁盘),则硬链接不能工作。而软链接不受这种限制,可以指向任何文件系统的位置。
  2. 目录的链接 • 硬链接不能链接目录:在大多数操作系统中(至少在 UNIX/Linux 系统上),硬链接不能被用来链接目录。创建目录的硬链接通常是被禁止的。这限制了在项目中创建包含的目录链接的能力,而软链接可以指向目录。
  3. 文件的唯一性与清理 • 硬链接共享 i-node:由于硬链接指向相同的 i-node,当某个链接被删除时,只有链接计数减少,而文件内容依然存在。这可能导致包的意外共享以及开发模式下难以管理和清理软链接则不会有这个问题,当目标文件被删除时,链接不再有效,但文件的独立性不会受到影响。
  4. 路径变更 • 易于操作:如果目标文件的路径更改,软链接指向的新路径可以很容易地更新,而硬链接与原文件的路径是紧密绑定的,无法动态删除或更新。
  5. 处理断开的链接 • 识别失效链接:如果原始文件被删除,软链接会变成“悬挂链接”,这可以方便开发者识别出问题。而硬链接的存在虽然有助于数据保留,但对开发者来说可能不太直观。