本地开发太慢了,试试增量构建?
背景
现代的前端项目开发已经离不开打包工具(webpack、rollup),整体的开发形式为项目开发时使用模块化机制开发,经过构建工具打包形成成品代码,成品代码最终在不支持模块化的浏览器中执行。虽然构建打包工具为前端提供了便利,但也因为在项目进行之前需要提前将代码构建打包成一个成品,这也导致在本地开发时引入了新的问题。
随着项目变得越来越大,全量构建整个项目就需要花费很长的时间,在本地开发时,无论每次修改多少的代码都需要重新全量构建,这大大降低了整体的开发效率。
目标
- 分享解决开发环境下构建问题的常见手段。
- 分析如何借助 Module Federation 实现项目的增量构建,提高开发效率。
为什么项目构建速度会慢?
就那最常用的 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 标签,那么浏览器就会按照 ES Module 规范来进行依赖加载,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包项目代码也能顺利的运行模块代码,构建工具只需要在加载模块代码时按需编译(语法降级、CSS 处理)。
// main.js
import {add} from "./utils.js";
add(1, 2);
// utils.js
export const add = (a, b) => {
return a + b
};
<!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 提供的丰富能力仍然相差很多。
增量编译
增量编译大多出现在 monorepo 项目中,通常一个 monorepo 项目会被拆分成一个 core 模块和多个 package 模块,core 是核心模块负责构建整合所有 package,产出最终的成品,每个 package 可以是业务中一个单独的功能模块,也都是一个通用组件库等,每个 package 都是可编译的,有自己独立的编译配置和流程。
增量编译功能可以通过跳过某些已经是最新的 package 来加速构建,已经是最新的
含义是:
- 项目已经在本地编译过。
- 其源码和 NPM 依赖没有发生变化。
- 如果该 package 依赖其他的 package,这些 package 都是最新的。
- 编译配置项及其命令行参数没有变化。
下面会通过一个实际的例子来详细讲解,接下来会用到一个 monorepo 工具——Nx,Nx 作为一个由 Angular 团队领导开发的项目,它已获得了广泛的认可和超过 21K+ GitHub stars 的支持,它提供了一套统一的开发模式,帮助开发团队高效构建现代化应用程序。
创建一个新 react 项目
npx create-nx-workspace@latest nx-monorepo-demo --preset=npm --packageManager=yarn
添加子模块
在 packages 目录下新建 package1、package2、shared-ui、utils 四个子模块,前两个属于独立的业务模块,后两个属于公共模块,在 package1 中引用了 shared-ui 中的公共组件,整体结构如下:
添加核心模块
nx generate @nx/react:application --name=core --directory=packages/core --e2eTestRunner=none --linter=none --projectNameAndRootFormat=as-provided --unitTestRunner=none
通过命令行创建核心模块 core 后,在其 package.json
中添加对 package1
、package2
、utils
的依赖,此时可以不用指明依赖的版本,因为这三个模块都是从 workspace 中加载的。
在根目录运行 npx run graph
后可查看整个项目中 package 的依赖关系:
编译缓存
为了提高模块二次编译的速度,需要在 nx.j son 文件中添加编译缓存配置,如果模块没有改动,那么再下次编译时会直接从缓存中取出上次一编译的产物:
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"] // 在构建某个模块之前先构建其依赖
},
"serve": {
"dependsOn": ["^build"] // 在启动某个模块的serve之前先构建其依赖
}
},
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build"]
}
}
},
}
项目运行
接下来在根目录下执行 npx nx run core:serve
将项目运行起来:
首次运行服务时,Nx 会按照依赖图依次执行所有依赖包的 build 命令,如果此时不对项目精心更改,再次执行 npx nx run core:serve
再来看下效果:
此时发现 Nx 会运行所有的依赖包的 build 命令,但是 Nx 发现这些依赖并没有发生更改,所以会直接使用上次编译的产物。
下面对 shared-ui 中的组件进行更改,将 CommonSelect 组件展示的内容改为 Update CommonSelect:
在增量编译模式下,当我们更改某一个 package 时,我们其实只需要将这个 package 及依赖这个 package 的其他模块重新编译即可,下面我么可以执行这行命令:npx nx affected -t build --base=main --exclude=core
,命令行的详细配置可查看文档。
我们可以看到 Nx 帮我们重新编译了 shared-ui、package1、package2 这三个模块,core 作为核心模块不需要重新编译,他只负责构架整合整个项目,此时刷新浏览器就能看到页面已发生变化。如果我们想查看我们的更改会影响哪些模块重新编译,可以运行 npx nx affected:graph
图中展示了本次更改直接或间接影响到的模块,并建立了依赖关系。
增量编译能够在源代码发生变化时,只编译那些被修改或受其影响的源文件及其相关模块,而不是对整个项目进行全量重新编译,这在一定程度上能够提高整个项目的构建速度,但是增量编译也有相应的缺点:
-
需要复杂的监听模式来检测代码的变化。
诸如 webpack 和 Jest 等流行工具都提供了“监听模式”功能,当启动本地服务后,这些工具会等待源代码发生变动,一旦监听到变动,就会重新执行构建任务更新输出,但这些工具的监听模式仅仅对单个项目有效,但是在 monorepo 模式下,我们需要能够一次性监听多项目的监听模式。
-
需要多次初始化 TypeScript 编译器,带来开销。
在进行TypeScript编译之前,编译器需要进行一些预处理工作,例如解析配置文件、建立类型检查环境,构建模块依赖图等,这个准备过程就称为编译器的初始化。在增量构建中,当有文件发生变化时,编译器需要重新初始化,以获取最新的模块依赖关系和类型信息,这意味着初始化过程需要重复执行多次。
-
WebPack 构建过程中的链接部分无法增量化。
WebPack 的构建过程大致分为几个阶段: 模块解析、模块编译、模块优化、输出代码块、代码块优化等,其中"输出代码块"这个阶段通常被称为链接阶段,在该阶段,WebPack 会将多个单独编译后的代码块链接合并为一个或多个包(bundle)。但是链接阶段无法进行增量化,即使代码变更很小,也需要重新执行整个链接过程。
什么是增量构建?
增量构建是一个更广泛的概念,它是指在进行软件构建时,只重新处理那些发生变化的部分,而不是对整个项目进行全量够重新构建。构建不仅包括编译,还包括链接、代码压缩优化等。
增量构建与增量编译的主要区别如下:
- 范围不同:增量编译专注于编译阶段,而增量构建覆盖了整个构建过程,包括编译、链接、代码压缩等多个阶段。
- 目标不同:增量编译的目标是加快编译速度,而增量构建的目标是加快整体构建速度,提高开发效率。
因此实现增量构建的关键点在于如何最小化构建的范围。
什么是 Module Federation?
模块联合是一种可以将代码分割成更小的可部署模块的方法,这些模块可以在应用程序之间在运行时共享和使用。为了实现这一目标,模块联合为构成模块联合架构的应用程序引入了三个术语:host
,remote
和federated modules
。
remote
remote 是一种能够独立构建部署的应用程序,内部暴露了可在运行时动态获得联合模块,这些联合模块可以是任何有效的 JavaScript 模块,包括组件、路由文件、普通 JavaScript 函数等。
Host
remote 是一种能够独立构建部署大的应用程序,它在运行时从 remote 应用程序消费(consume)联合模块。
在 host 应用中可以像导入本地模块一样导入 remote 中的模块,应用在构建时 Webpack 会识别出来这些模块只会在运行时被加载,并且在使用之前需要先向对应的 remote 应用发送网络请求以获取 JS Bundle。
federated modules
联邦模块是 remote 应用中公开的任何有效 JavaScript 模块,它能够被 host 应用使用。这意味着React 组件、Angular 组件、应用状态、函数、UI 组件等可以在应用程序之间共享和更新,而无需重新部署这些内容。
小结
我们发现 Module Federation 中 remote 的概念就恰好符合最小化构建范围的需求,我们可以将项目按功能模块划分成多个 remote 应用,这些 remote 应用单独构建,最终由 host 应用在运行时加载这些 remote 应用,从而实现项目的增量构建。
使用 Nx 搭建 Module Federation 项目
为什么选用 Nx?
- 代码生成器:Nx 为了帮助开发者快速搭建 Module Federation 项目,给开发者提供了非常全面的代码生成工具,并且帮助开发者内置了 Module Federation 配置,以减少开发者的心智负担。
- 代码执行器:Nx 为了让开发者拥有更好的开发体验,提供了系统的代码执行器,能够帮助开发者快速启动本地项目进行开发。
- 类型安全:Nx 提供了 TypeScript 类型检查机制,保证 host 应用和 remote 应用之间的类型安全。
- 依赖的版本管理:Module Federation 项目最关键的问题就是依赖版本的管理,如果同一依赖存在多个副本可能会造成版本冲突,因此 Nx 提供并内置了一套版本管理策略来帮助开发者防止因版本冲突造成的问题,详细策略可查看官方文档。
创建项目
npx create-nx-workspace@latest nx-module-federation --preset=apps --packageManager=yarn
创建完之后需要为项目添加代码生成工具 @nx/react
yarn nx add @nx/react
接下来就使用代码生成工具创建 host 应用 和 remote 应用:
yarn nx g @nx/react:host host --remotes=shop,cart,about --projectNameAndRootFormat=derived --directory=packages
创建完的项目结构如下:
创建完项目之后,为了提高项目二次构建的速度,需要在 nx.j son 文件中添加构建缓存配置,如果应用及其依赖没有发生改动,那么再下次构建时会直接从缓存中取出上次一构建的产物::
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"] // 在构建某一个模块之前,先构建其依赖
}
},
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build"]
}
}
}
}
如果此时只想开发 shop 模块,cart 和 about 保持功能不动,此时可以运行命令 nx serve host --open --devRemotes="shop"
此时我们看下 Nx 是怎么处理的:
Nx 首先会将 cart 和 about 这两个 remote 应用单独进行构建,产物会输出在 dist 目录,并且此次的构建产物会被缓存在本地:
最后给 dist/packages
起了一个本地服务。host 应用之后加载的就是这个目录下的构建产物。
接下来 Nx 会为 shop 和 host 应用单独执行构建流程并分别启动本地服务,host 应用的服务地址为 http://localhost:4200
此时打开这个服务地址就能看到整个项目。
在浏览器中可以查看每个 remote 应用入口文件的请求地址,之后如果我们更改 shop 应用或者当前 workspace 下他所依赖的其他模块(通用UI 组件、通用工具方法),Nx 只会重新构建 shop 应用,其他的 remote 应用及 host 应用都不会重新构建。
答疑
整个项目的构建产物体积是否会增加?
会,Module Federation 允许 host 应用和 remote 应用共享依赖,比如在 react 项目中,共享 react、react-dom 这些依赖是必不可少的,依赖共享意味着当 host 应用加载 remote 应用暴露的联邦模块时,不需要重新从 remote 应用下载这些共享依赖包的副本。
然而,当依赖包在 host 应用和 remote 应用之间共享时,webpack 无法对这些依赖包进行 tree-shaking(显示指定 externals 的包除外),这些没有被 tree-shaking 的依赖包会重复的打包到最终的产物中,导致最终的产物体积会增加。
如何管理依赖版本
每个 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 应用,使其重新构建,进而达到增量构建的效果。
转载自:https://juejin.cn/post/7370235634295111690