背景
现代的前端项目开发已经离不开打包工具(webpack、rollup),整体的开发形式为项目开发时使用模块化机制开发,经过构建工具打包形成成品代码,成品代码最终在不支持模块化的浏览器中执行。虽然构建打包工具为前端提供了便利,但也因为在项目进行之前需要提前将代码构建打包成一个成品,这也导致在本地开发时引入了新的问题。
随着项目变得越来越大,全量构建整个项目就需要花费很长的时间,在本地开发时,无论每次修改多少的代码都需要重新全量构建,这大大降低了整体的开发效率。
为什么项目构建速度会慢?
就拿最常用的 webpack 来说,在大型项目中使用 webpack 时,光项目的启动就需要花几分钟,项目的热更新也经常需要等待几十秒,这主要是因为:
- 项目冷启动时,需要递归遍历所有的依赖,将项目打包成一个成品。
- JavaScript 语言本身的性能限制,导致构建性能出现瓶颈,直接影响开发效率。
这样一来,代码改动后不能立马看到效果,自然开发体验也越来越差。而其中,最占用时间的就是代码打包和文件编译。
缓解开发环境下构建时间长的常见手段
构建缓存
构建缓存旨在能够将首次构建的过程与结果数据持久化保存到本地文件系统,在下次执行构建时会跳过解析、链接、编译等一系列非常消耗性能的操作,直接服用上次构建的结果,迅速构建出产物。
比较有代表性的就是 webpack,在 webpack5 中推出了持久化缓存这一特性,它将构建结果保存到文件系统中,在下次编译时对比每一个文件的内容哈希或时间戳,未发生变化的文件跳过编译操作,直接使用缓存副本,减少重复计算。
但是构建缓存如果使用不当就会存在很大的安全隐患,这也是 webpack 为什么默认情况下不启用持久化存的原因。构建缓存最大的问题就在于如何设置缓存失效,因为 webpack 需要弄清楚缓存的数据何时不再有效并停止使用它们进行构建,比如说你要考虑:
- 使用 npm 升级 loader 或者 plugin 时
- 当你改变配置文件时
- 当你更改配置文件中正在读取的文件时
- 当你使用 npm 升级配置文件中使用的依赖项时
- 当你将不同的命令行参数传递给构建脚本时
- ...
这些问题会使得构建缓存变得棘手,webpack 无法开箱即用地处理所有这些案例,官方更多使用建议可查看此文档。
no-bundle
项目中的代码分为两部分,一部分是业务代码,另一部分是第三方依赖代码,即 node_modules 中的代码,所谓 no-bundle 只是针对业务代码而言,no-bundle 能够做到开发时模块按需编译,而不用先编译打包完再加载。
no-bundle 最有代表性的就是 Vite,当前 ES Module 模块化规范已经得到了现在浏览器的内置支持,只要开发者在 HTML 中加入具有 type="module"
属性的 script 标签,那么浏览器就会按照 ESModule 规范来进行依赖加载,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包项目代码也能顺利的运行模块代码,构建工具只需要在加载模块代码时按需编译(语法降级、CSS 处理)。
flow
// main.js
import {add} from "./utils.js";
add(1, 2);
flow
// utils.js
export const add = (a, b) => {
return a + b
};
flow
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
但是 Vite 在大型项目中的性能表现不够理想,一方面一些业务首屏可能有几千个模块,每个模块都会产生一个请求,因此带来几千个网络请求,虽然 Vite 的 devServer 可以很快的启动,但是几千的网络请求带来的开销是非常巨大的,这有时会带来几分钟的延时,尤其是在 HMR 的 reload 情况下。
语言优势
大多数前端打包工具都是基于 JavaScript 实现的,比如 Rollup、Webpack,但是在资源打包这种 CPU 密集场景下 JavaScript 的性能是比较差的,原因有以下几点:
- JavaScript 本质上依然是一门解释型语言,JavaScript 程序每次执行都需要先由解释器一边将源码翻译成机器语言,一边调度执行,因此相比于其他基于编译型语言实现的打包工具(比如 ESbuild、Respack、Turbopack等)在编译阶段就已经将打包工具的源码转译为机器码,启动时只需要直接执行这些机器码即可。因此当编译型打包工具忙于解析您的 JavaScript 时,JavaScript 解释器正忙于解析基于 JavaScript 实现的打包工具的源码。
- JavaScript 本质上是一门单线程语言,直到引入 WebWorker 规范之后才有可能在浏览器、Node 中实现多线程操作,但是像 Rollup、Webpack 他们都没有使用 WebWorker 提供的多线程能力,而反观 ESbuild、Respack、Turbopack 这些基于 Go 或者 Rust 实现的打包工具,这些打包工具能够充分利用语言的多线程能力,这使得其编译性能随着 CPU 核心数的增长而增长,充分挖掘 CPU 的多核优势。
虽然像 ESbuild、Respack、Turbopack 这些打包工具都发挥了语言的优势,但他们自身也存在一定的局限性,比如 esbuild 它的工程化特性非常少(不支持 HMR、Module Federation,缺乏像 webpack 对 chunk 的深度定制的能力),还不足以支撑一个大型项目的开发需求, 而 Rspack 目前只支持了 Loader API,和较少的 Webpack Plugin API,相比 Webpack 提供的丰富能力仍然相差很多。
什么是增量构建?
增量构建是一个更广泛的概念,它是指在进行软件构建时,只重新处理那些发生变化的部分,而不是对整个项目进行全量够重新构建。构建不仅包括编译,还包括链接、代码压缩优化等,实现增量构建的关键点在于如何最小化构建的范围。
什么是 Module Federation?
Module Federation 是一种 JavaScript 应用分治的架构模式,它允许你在多个 JavaScript 应用程序(或微前端)之间共享代码和资源,详细介绍可查看官方文档。在本章节中我们最需要关注的点在于如何借助 MF 提高大型应用程序开发效率。
对于大型应用程序开发场景,可以将应用程序拆分为多个子应用,每个子应用可独立开发、构建和部署,最终使用 Module Federation 在它们之间共享代码和资源。在这之前先介绍下 Module Federation 引入的三个术语:host
,remote
和 federated modules
。
remote
remote 是一种能够独立构建部署的应用程序,内部暴露了可在运行时被集成的联合模块,这些联合模块可以是任何有效的 JavaScript 模块,包括组件、路由文件、普通 JavaScript 函数等。
Host
host 是一种能够独立构建部署的应用程序,它在运行时从 remote 应用程序消费(consume)联合模块。
在 host 应用中可以像导入本地模块一样导入 remote 中的模块,应用在构建时构建工具 会识别出来这些模块只会在运行时被加载,并且在使用之前需要先向对应的 remote 应用发送网络请求以获取 JS Bundle。
federated modules
联邦模块是 remote 应用中公开的任何有效 JavaScript 模块,它能够被 host 应用使用。这意味着React 组件、应用状态、函数、UI 组件等可以在应用程序之间共享和更新,而无需重新构建这些内容。
小结
我们发现 Module Federation 中 remote 的概念就恰好符合最小化构建范围的需求,我们可以将项目按功能模块划分成多个 remote 应用,这些 remote 应用单独构建,最终由 host 应用在运行时加载这些 remote 应用,从而实现项目的增量构建。
使用 Nx 搭建 Module Federation 项目
创建项目
jsx
npx create-nx-workspace@latest nx-module-federation --preset=react-monorepo --packageManager=pnpm
这会创建一个 monorepo 项目并生成一个应用,这个应用不需要的话可以删掉。接下来就使用代码生成工具创建 host 应用:

在 NX Console 中选择 @nx/react-host 生成器,配置如下:

这里构建工具可以选 rspack 和 webpack,remotes 可以后面单独生成也可以在创建 host 的同时一起创建。下面再通过生成器创建几个 remote 应用,首先在 Nx Console 中选择 @nx/react - remote生成器:

配置如下:

同时在生成cart 和 about 两个 package。生成完之后可以在 host 应用的 mf 配置中查看引用的 remote:

除此之外默认会在 host 应用中添加相应的路由配置:

启动 host 应用
接下来就可以通过 nx serve host
命令来启动 host 应用:

这行启动命令看似简单,其实他背后做和很多工作,下面列举的是其背后的详细过程:
-
查找 host 应用依赖的所有 remote 应用。
-
确定哪些 remote 应用需要为其静态产物提供本地服务,哪些需要通过 dev-server 启动项目。
对于有代码变动的 remote 可以启动 dev-server,对于没有代码变动的 remote 只需要为其构建产物提供本地服务,这样会有更好的开发体验,例如
nx serve host --devRemotes=shop
命令会使用 dev-server 启动 shop 应用,cart 和 about 则只会为其静态产物提供本地服务。 -
对于静态 remote 应用(没有代码变动的应用),nx 将调用
nx run-many -t build --projects={listOfStaticRemotes}
进行打包构建。 -
在每个静态 remote 应用的构建产物目录下启动 http-server。
-
对于动态 remote 应用(有代码变更的应用),将通过 dev-server 启动应用以支持应用的 HMR 和实施重新加载。
-
为 host 启用 dev-server。
开发 remote 应用
在本地开发时,开发者一般只会开发某一个 remote 应用,但是又想在 host 里实时看到效果,在 Nx 版本小于 21 时开发者需要手动启动 host 并告诉他需要联动哪个 remote 的的开发服务,以 Webpack Module Federation 为例:
jsx
npx nx serve host --devRemotes=shop
这样 host 会把对 shop
的请求指向正在本地跑的 dev-server ,但是从"**只想开发remote"**这个角度来说,这条命令不直观,明明是在改 remote,却要去 serve host,还得记 --devRemotes
配置。
因此在 Nx 21 版本中引入了 Continuous Tasks
的概念,简单理解就是 Nx 知道 host 和 remote 之间的关系,能够帮助开发者把需要一起跑的任务联动起来。同样的例子,如果只想本地开发 shop,只需要运行下面这行命令:
jsx
npx nx serve shop
Nx 会自动启动 shop 和 host 应用,也就是说从"我要改哪个 remote"出发,一条命令就能把所需的一切跑起来,这个功能的实现也比较简单,依赖了 Nx 内部的任务编排机制:

可以查看 shop 应用的 project.json 文件,在 targets 中可以添加每个命令的配置项,以 serve 命令为例,在 options 中定义了服务的端口号,在 dependsOn 中定义了依赖的前置任务,详细配置可查看官方文档。
🚨 这里要注意目前该功能只支持Rspack Module Federation,Webpack Module Federation 目前不支持 Continuous Tasks。
答疑
整个项目的构建产物体积是否会增加?
Module Federation 本身不会必然让整个项目产物更大,但它引入的共享机制和运行时加载逻辑,很容易导致**整体构建产物比未启用 MF 时更大。**因为 remote 通常会把 shared 的依赖打包进产物,并在运行时决定使用使用依赖的哪一个版本,除非你在构建层显式把这个依赖标为 externals / 手动外置。
如何管理依赖版本
每个 remote 应用是单独构建打包的,并且最终的产物中包含了联邦模块运行所需要的所有依赖,这就意味着假如一个 remote 应用暴露出来的联邦模块依赖了一个第三方库,那这个库将会与这个联邦模块捆绑在一起,这种独立性提供了很大的灵活性,允许各个联合模块在不依赖外部资源的情况下运行。
但是当这些联邦模块集成到其他的 host 应用中就会出现一个问题,假入每个联合模块都有自己的依赖项,host 应用可能无意中就下载了同一依赖的多个副本,为了缓解这些问题,Module Federation 提供了一个共享的API。它的主要功能是充当看门人,开发者通过配置确保只下载依赖项的一个副本,而不管有多少联邦模块请求它。

尽管共享 API 是一个强大的工具,但它的管理可能具有挑战性,Nx 为了帮助开发者简化这一操作,提供了一套版本管理策略并且已经内置了共享 API 的配置,开发者不需要手动再去配置。
Nx 推荐使用单一版本策略(SVP)来管理库版本,这意味着,如果有一个由多个 remote 应用和 host 应用使用的依赖库(npm 库或者 workspace 包),那么它在所有 remote 应用和 host 应用中应该只有一个版本。使用这一策略的原因有以下几点:
-
一致性
确保所有联合模块都依赖于同一版本的共享依赖项,从而在整个应用程序中提供一致的行为,不同的库版本可能有不同的行为或错误,从而导致意外或不一致的结果。
-
依赖冲突
在同一运行时混合库或模块的多个版本可能会导致冲突,对于维护内部状态或有副作用的库来说,这尤其成问题。
-
API 兼容性
随着库的发展,函数和方法会被添加、删除或更改,通过确保单一版本,可以消除在一个版本中使用不兼容 API 而在另一个版本中使用不兼容 API 的风险。
-
单例库
有些库被设计为单例(React、Angular、Redux 等),这些库旨在实例化一次并在整个应用程序中共享。此类库的多个版本可能会破坏预期行为,甚至导致运行时错误。
小结
模块联合允许您将单个构建进程拆分为多个进程,这些进程可以并行运行,每个构建过程的结果都可以独立缓存,开发者在开发时可以只运行某一个或多个 remote 应用进行本地开发,当开发者对某一个 remote 应用进行更改,只会影响当前更改的 remote 应用,使其重新构建,进而达到增量构建的效果。