JS工程化之代码共享(微前端+monorepo+模块联盟等)
大型应用、多人协作、团队多项目开发等场景增加了前端项目开发的复杂度,需要我们合理运用工程化手段来提效。代码共享是其中一种有效提效措施。
JavaScript 工程化的代码共享方式有很多,只有了解各种方式,才能结合我们具体的业务项目情况,给出合适的工程化解决方案。
1. NPM 包
- 含义: NPM 包是一种发布和共享 JavaScript 代码的方式,主要依赖于 Node Package Manager (NPM)。
- 背景: 在 JavaScript 生态中,有大量的开源库,通过 NPM 包管理器,开发者可以轻松引入并管理这些开源库。同时,它也可以用于共享自家的公共代码。
- 应用场景: 任何使用 Node.js 的项目都可以使用 NPM 管理依赖。例如,如果你的公司有一些公共的工具函数或组件,你可以将它们发布为 NPM 包,然后在其他项目中引入。
- 用法: 在项目中通过
npm install
命令安装 NPM 包,然后通过import
或require
语句在代码中引用这个包。
2. Monorepo
- 含义: Monorepo 是一种管理多个项目代码的方式,将多个项目存储在同一个版本库中。通过这种方式,多个项目可以共享代码,同时保持各自的独立性。
- 背景: Monorepo 的出现主要是为了解决跨项目代码复用、版本管理和协同开发的问题。对于大型组织来说,虽然 NPM 包也可以用于代码共享,但是在一些场景下维护管理起来比较复杂,在大型组织项目中,使用 Monorepo 可以更方便地管理和共享代码。
- 应用场景: 当你需要管理和维护多个有共享代码的项目,或者你需要协调一个团队开发多个项目,你可以考虑使用 Monorepo。例如,Google 和 Facebook 都使用 Monorepo 来管理他们的代码库。
2.1 原理
2.1.1 默认策略
在使用 Node.js 进行模块引用时,它的解析策略大致是这样的:当你使用 require
或 import
引入一个模块时,Node.js 首先会查看是否有本地文件或文件夹匹配该模块名。如果没有,它会向上遍历目录树,查看每个 node_modules
文件夹,看是否存在匹配的模块。
然而,在 Monorepo 结构中,app1
和 shared
并不在同一个文件夹内,也没有相同的父 node_modules
文件夹。因此,如果没有额外的帮助,Node.js 将无法正确解析跨包的 import
语句。
2.1.2 符号链接
这就是 Yarn Workspaces 和 Lerna 发挥作用的地方。它们通过创建符号链接(symlink)来帮助 Node.js 解析跨包引用。
当你在 Monorepo 中运行 yarn install
或 lerna bootstrap
时,Yarn 和 Lerna 会遍历所有的子项目,看它们的 package.json
中是否有对其他子项目的依赖。如果有,它们就会在该子项目的 node_modules
文件夹中创建一个指向被依赖子项目的符号链接。这样,当 Node.js 尝试查找模块时,它会找到这个符号链接,并被正确地重定向到被依赖的子项目。
所以,尽管 Node.js 本身并不支持 Monorepo 中的跨包引用,但通过使用 Yarn Workspaces 或 Lerna,我们可以“欺骗” Node.js,让它以为所有的子项目都在同一个 node_modules
文件夹中,从而正确地解析跨包的 import
语句。
2.2 用法
2.2.1 项目结构
在 Monorepo 中,你的代码库可能看起来像这样:
/my-monorepo
|-- package.json
|-- lerna.json
|-- /packages
|-- /app1
| |-- package.json
| |-- src
|-- /app2
| |-- package.json
| |-- src
|-- /shared
|-- package.json
|-- src
在这个例子中,你有两个应用 app1
和 app2
,以及一个 shared
库。所有这些项目都在一个代码库中,被组织在 packages
目录下。
2.2.2 代码写法
在上述的 Monorepo 结构中,如果你想在 app1
中引入 shared
的某个模块,你可以直接在 app1
的代码中这样做:
import { someFunction } from 'shared';
在package.json中声明
{
"name": "app1",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"shared": "*" // *表示使用最新版本
}
}
在这里,你不必先将 shared
发布到 npm,但可以像引用 npm 包一样引用 shared
代码库中的 someFunction
。
当然,要确保这个过程能够正确进行,你需要使用一些工具来帮助你管理 Monorepo。例如,Yarn 的 Workspaces 功能和 Lerna 都能够很好地处理 Monorepo 中的依赖管理,它们能确保 Node.js 能够正确地解析跨包的 import 语句。
3. 微前端
- 含义: 微前端是一种架构模式,允许多个团队独立开发和部署前端应用的不同部分,然后在运行时将这些部分组合在一起。
- 背景: 随着前端应用的复杂性不断增加,单一的前端代码库变得难以维护。微前端通过将大型应用分解为多个小型应用,使得每个团队可以独立开发和部署自己的部分,从而提高了开发效率和质量。
- 应用场景: 适合于有多个团队独立开发前端应用的大型组织。例如,一个大型电商网站可能会有搜索、产品列表、购物车等多个子应用,每个子应用可以由一个单独的团队开发和部署。以及例如我们大型的中后台系统。
3.1 用法
使用如 Single-SPA、qiankun 等微前端框架,或者使用模块联邦实现微前端架构。
3.2 原理
- 动态加载:使用 JavaScript 的异步加载能力来加载各个子应用的代码。
- js沙箱: 当子应用被激活(通常是基于路由的匹配)时,主应用会创建一个新的 JavaScript 运行环境(沙箱)来执行子应用的代码。沙箱环境可以保证子应用的全局变量和事件监听器等不会影响主应用或其他子应用。
- CSS沙箱
- 数据通信:此外,子应用还需要提供一些生命周期钩子函数,如
bootstrap
、mount
和unmount
,以供主应用在适当的时机调用。
如果想进一步了解原理,可以查看下面部分说明,简单了解的话直接阅读下一点。
是的,你的理解是正确的。以下是详细的代码实现方式。
3.2.1. 动态加载
使用 <script>
标签加载 JavaScript 资源是一种常见的方式。你可以使用 document.createElement
创建一个 <script>
标签,并设置其 src
属性,然后添加到文档中。
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
3.2.2 js沙箱
下面仅简单介绍了proxy来实现沙箱的方式,更多详细的知识可以参考 Qiankun原理——JS沙箱是怎么做隔离的
qiankun是国内知名的微前端框架,qiankun实现js沙箱主要有三种方式:
3.2.2.1 SanpshotSandbox 快照
这种方式会在应用启动时保存当前全局对象的一个“快照”,在卸载应用时,和微应用的环境进行diff, 将全局对象恢复到这个“快照”的状态,适用于低版本浏览器且性能较低的情况。
其主要步骤和实现原理如下:
- 在子应用加载之前,SnapshotSandbox 会对 window 对象做一个深度拷贝,即创建一个 "快照"。
- 当子应用加载和运行后,子应用可能会修改 window 对象。例如添加全局变量、更改全局方法等。
- 当子应用卸载时,SnapshotSandbox 会通过比对当前的 window 对象和之前存储的 "快照",将被子应用修改的部分进行恢复。
let snapshot = {};
const global = window;
for (const p in global) {
if (global.hasOwnProperty(p)) {
snapshot[p] = global[p];
}
}
// When unmount
for (const p in global) {
if (global.hasOwnProperty(p)) {
if (snapshot[p] !== undefined) {
global[p] = snapshot[p];
} else {
delete global[p];
}
}
}
3.2.2.2 LegacySandbox
这种沙箱方式适用于老版本浏览器(不支持 Proxy
的情况下),主要通过直接操作全局对象的方式进行隔离,以及借助 MutationObserver
来监听 window
属性的变更,具有一定的局限性。
const sandbox = {};
const global = window;
for (const p in global) {
if (global.hasOwnProperty(p)) {
sandbox[p] = global[p];
}
}
// When an attribute was added to window
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const { name } = mutation;
if (global.hasOwnProperty(name)) {
sandbox[name] = global[name];
}
});
});
observer.observe(window, { attributes: true });
3.2.2.3 ProxySandbox (qiankun优先使用的方式,兼容不行的情况才使用1、2种)
ProxySandbox
是一种更高级的隔离方案,通过 Proxy
对象拦截对全局对象的操作,这种方式可以实现真正意义上的全局变量隔离,是 qiankun 默认的沙箱方案。
const sandbox = new Proxy(window, {
get(target, key) {
return target[key];
},
set(target, key, value) {
target[key] = value;
return true;
},
});
以上只是简单的实现原理展示,实际 qiankun 的沙箱实现要考虑很多额外的情况,比如处理 iframe、web workers 的全局变量,处理特殊的全局对象如 localStorage
、sessionStorage
等等。
3.2.3 CSS沙箱
CSS 隔离主要通过 Shadow DOM 或样式前缀的方式实现。Shadow DOM 可以为元素创建一个隔离的 DOM 树,防止样式冲突。但是需要注意的是,Shadow DOM 并不是所有浏览器都支持。
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<style>/* your styles here */</style>
<div>/* your html here */</div>';
3.4. 数据通信
可以使用 CustomEvent 实现主应用和子应用之间的数据通信。子应用在需要的时候派发一个自定义事件,主应用监听这个事件,就可以接收到数据了。
// 子应用
const event = new CustomEvent('message', {detail: 'Hello from child app'});
window.dispatchEvent(event);
// 主应用
window.addEventListener('message', (event) => {
console.log(event.detail); // "Hello from child app"
});
4. 模块联盟
- 含义: 模块联邦是 webpack 5 提出的一种 JavaScript 模块共享方式。
- 背景: 随着微服务和微前端架构的兴起,运行时动态共享和加载模块的需求越来越强烈。模块联盟就是为了解决这个问题而提出的。
- 应用场景: 适合于需要动态共享和加载模块的微服务和微前端架构。例如,在一个微前端应用中,可以使用模块联盟动态加载其他子应用的模块。
4.1 实现原理
在较高的层次上,模块联盟的工作原理是通过在运行时动态加载模块。Webpack 在打包时,为每个需要共享的模块生成一个额外的文件,这个文件描述了如何加载模块的代码(这是一个异步过程)。每当一个应用需要一个模块,它都会去检查自己是否已经有了那个模块的代码。如果没有,它会异步加载模块的代码,然后把模块的代码保存在一个全局的模块缓存中。
这个过程可以简化为以下几个步骤:
-
建立共享规则:在打包过程中,Webpack 通过
ModuleFederationPlugin
配置来识别出那些需要共享的模块。这些规则被写入到一个称为 "remote entry" 的特殊文件中。这个文件是自动由 Webpack 生成的,包含了如何加载和解析模块的信息。 -
引用共享模块:在一个应用中,你可以直接像引用本地模块一样引用共享模块,例如
import { foo } from 'shared'
。Webpack 在打包这个应用时,会将这个引用转换为一个运行时的异步加载。 -
加载共享模块:当一个应用需要一个共享模块时,它首先会检查全局的模块缓存中是否已经有了那个模块的代码。如果没有,它会使用
<script>
标签或fetch
API 来异步加载 "remote entry" 文件,然后根据那个文件中的信息来加载模块的代码。这个过程可能涉及到跨域请求,所以你需要正确配置你的 CORS 策略。 -
缓存共享模块:一旦一个模块的代码被加载,它就会被保存在全局的模块缓存中。这样,其他需要这个模块的应用就可以直接从缓存中获取模块的代码,而不需要再次加载。
以上就是模块联盟的基本工作原理。虽然我简化了一些细节,但我希望这能帮助你理解模块联盟是如何工作的。如果你想深入理解它的实现,我建议你去阅读 Webpack 的源代码,尤其是 ModuleFederationPlugin
的部分。
4.2 用法
- 配置 Webpack:启用
ModuleFederationPlugin
插件,定义需要共享的模块。
// webpack.config.js in Project A
const ModuleFederationPlugin = require("webpack").container.ModuleFederationPlugin;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "projectA",
filename: "remoteEntry.js",
exposes: {
"./Button": "./src/Button",
},
}),
],
};
- 使用远程模块:在另一个模块中,你可以导入和使用这些暴露的模块,如同它们是本地模块一样。
// in Project B
import { Button } from 'projectA/Button';
function App() {
return <Button />;
}
5. DLL Bundling
- 含义: DLL Bundling 是 webpack 的一种优化技术,它将一些不常变动的依赖库预先打包成一个 DLL 文件,以提高构建速度。
- 背景: 为了解决大型项目构建慢的问题,webpack 提出了 DLL Bundling 技术。通过预先打包不常变动的依赖库,可以提高构建速度。
- 应用场景: DLL Bundling主要关注第三方库或模块的共享,而Monorepo、微前端和模块联邦等方式更注重项目内部的模块和应用之间的共享。
5.1 原理
其原理主要是利用了浏览器缓存机制和持久化存储。对于那些不经常变动的库,将其打包成 DLL 文件后,可以长时间地被浏览器缓存,从而提升了页面的加载速度。同时,由于这些库不需要在每次构建时都重新打包,因此也能提升构建速度。
然而,DLLPlugin 有一个缺点,就是需要手动维护和更新 DLL 文件。当第三方库有更新,或者需要添加新的库时,都需要重新构建 DLL 文件。这对于大型项目来说,可能会带来一些额外的维护成本。
所以,DLLPlugin 最佳的使用场景是,当项目中有大量的第三方库,并且这些库不经常更新时。在这种场景下,使用 DLLPlugin 可以显著地提升构建速度和页面加载速度。
5.2 用法
Step 1:创建一个 webpack.dll.config.js 文件,内容如下:
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
vendor: ['react', 'react-dom', 'lodash'] // 把 React, ReactDOM, lodash 这些第三方库提取出来
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].dll.js', // 输出的文件名
library: '[name]_library', // 输出的库的全局变量名
},
plugins: [
new webpack.DllPlugin({
name: '[name]_library',
path: path.join(__dirname, 'dist', '[name]-manifest.json'), // manifest 文件的输出路径
}),
],
};
Step 2:运行 DLL 构建命令:
webpack --config webpack.dll.config.js
这个命令会生成一个 vendor.dll.js
和一个 vendor-manifest.json
文件。
Step 3:在主 webpack 配置文件中,添加 DllReferencePlugin:
const path = require('path');
const webpack = require('webpack');
module.exports = {
// ...其他配置
plugins: [
// ...其他插件
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./dist/vendor-manifest.json'),
}),
],
};
Step 4:在 HTML 文件中引入 DLL 文件:
<script src="/dist/vendor.dll.js"></script>
以上就是使用 DLLPlugin 的步骤。其原理主要是利用了浏览器缓存机制和持久化存储。对于那些不经常变动的库,将其打包成 DLL 文件后,可以长时间地被浏览器缓存,从而提升了页面的加载速度。同时,由于这些库不需要在每次构建时都重新打包,因此也能提升构建速度。
然而,DLLPlugin 有一个缺点,就是需要手动维护和更新 DLL 文件。当第三方库有更新,或者需要添加新的库时,都需要重新构建 DLL 文件。这对于大型项目来说,可能会带来一些额外的维护成本。
5.3 和externals的区别
DLLPlugin:
DLLPlugin 是将公共模块(如:第三方库)进行单独打包,然后在使用这些库的地方直接链接已经预打包好的库,而不需要再次打包这些库。这样可以大大提高构建速度,因为常用的库只需要打包一次,之后的构建过程中都可以复用已经打包好的库。
Externals:
Externals 的方式则是在编译时,直接排除对某些外部库(如:CDN 引入的库)的打包,而将其标记为外部依赖。当代码中出现这些外部依赖时,Webpack 不会将它们打包到输出的 bundle 中,而是在运行时,从全局对象(如:window)中获取这些依赖。
以下是 DLLPlugin 和 externals 的主要区别:
-
打包方式: DLLPlugin 是预先将公共库打包为一个 DLL 文件,然后在需要的地方引用这个 DLL 文件。而 externals 是直接排除对某些库的打包,这些库需要在运行时从全局对象中获取。
-
使用场景: DLLPlugin 更适合于本地开发环境,可以大大提高开发环境的构建速度。而 externals 更适合于生产环境,可以减少输出 bundle 的大小,并利用 CDN 提高库的加载速度。
-
缓存: 使用 DLLPlugin 打包的库可以被浏览器缓存,提高页面的加载速度。而使用 externals 的方式,则需要依赖浏览器对 CDN 资源的缓存。
总的来说,DLLPlugin 和 externals 都是优化构建和加载速度的有效手段,但适用的场景不同。在选择使用哪种方式时,需要根据项目的实际情况和需求来决定。
6. 最佳实践
收录最佳实践相关文章
转载自:https://juejin.cn/post/7241835342897889341