一、Webpack 现状
最初的 Webpack 主打 Bundle 并且支持「Code Splitting」;在经过一段爆发式增长后,最有用的功能之一:热更新 Hot Module Replacement (HMR)出现(你还可以在这里 Stack Overflow - What exactly is Hot Module Replacement in Webpack? 看到 2014 年 Dan 的提问及 Tobias 的回答);经过多年的发展到现在,强大的 Webpack 有了一个新的痛点,那就是「慢」。
为什么慢
通常,一个中大型项目的依赖会非常多,除了逐渐增长的项目体积,以及各种 Loader 与 Plugin 也会拖慢整体的构建速度。 其中最耗时应该属于压缩阶段一般会用到的 Terser ,其次是构建阶段的 Babel ,除此之外,Webpack 去构建 ModuleGraph 的过程也会导致性能瓶颈,而现在多数浏览器已经支持 ESM ,其它的 Vite/Snowpack/wmr 等构建工具使用 ESM 的 Bundleless 方案将这部分依赖分析的工作交由浏览器完成,速度当然就会快很多。
持久缓存
空间换时间是见效非常明显的提速方案,在 Webpack5 中通过配置 cache: 'filesystem'
来开启持久缓存,开启后命中缓存的情况下会直接反序列化缓存文件并跳过构建流程。
{
cache: {
type: "filesystem",
buildDependencies: {
config: [__filename, path.resolve('package.json')]
},
},
}
同样 babel-loader
也可以开启持久缓存:
module: {
rules: [{
test: /.(js|jsx|ts|tsx)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
...
},
},
...
},
],
}
命中缓存后,原本构建需要 16.63s 的项目只需要 2.65s :
持久缓存效果非常好,但是对于首次启动的项目来说没有任何提升。那么有没有办法直接提升首次构建速度?
ESBuild / SWC
使用 swc-loader
/ esbuild-loader
替换 babel-loader
也可以一定程度上减少构建耗时。下面是使用了一个拥有 5 个页面(包含 10+ 个 ArcoDesign 组件)的普通中后台项目构建测试的结果,虽然还远达不到中大型应用的标准,但也能明显体现出替换后的效果:
可以看到,即使替换成 ESBuild 后,依然会有 10s+ 的耗时,当引入的依赖模块过多时,即使没有配置任何 Loader 处理模块,直接加载 Vanilla JS 也会耗时较长。
对于不太追求压缩率的项目可以使用 ESBuild 替换 Terser 做压缩,但在开发阶段启动一般不会启用,这块不会影响到项目启动的构建耗时。
ESBuild/SWC 效果也非常不错,但对于部分项目来说无法完全脱离 Babel ,即使使用了 ESBuild 或 SWC 后也会受到来自 node_modules
模块数量过多的影响无法达到更快的构建速度。
二、按需编译(懒编译)
For Development Only
对于部分中大型应用来说,完整启动一次的时间太长了,可能出门 CTRL 玩一圈回来还没编译完。那么为了提升首次构建的速度,早在 Next.js 8.x 版本中就出现了按需编译:当应用启动时不会编译所有页面 ,而是在访问时即时编译。这样做的好处就是,在开发阶段减少了不必要模块的构建,无论应用后续增长多大,都能保持相对稳定的启动速度,但对于小型应用来说,体感上不会那么明显。
Next.js 的按需编译
在 Next.js 中基于 Webpack 实现了按需构建的能力,同时使用了 SWC 替换 Babel 实现 17 倍的速度提升(来自 Next.js 官方文档),使得 Next.js 在中大型应用开发中也能保持很可观的构建速度。
-
自定义 Loader 动态加载 Pages
通过自定义 Loader 劫持入口 ,对于不需要构建的模块返回劫持后的内容,该内容中的代码被运行时会触发一段激活逻辑,当前页面/模块会被标记为活跃状态。如下,window.__NEXT_P
记录了当前活跃页面:
import { stringifyRequest } from '../stringify-request'
export type ClientPagesLoaderOptions = {
absolutePagePath: string
page: string
isServerComponent?: boolean
}
// this parameter: https://www.typescriptlang.org/docs/handbook/functions.html#this-parameters
function nextClientPagesLoader(this: any) {
const pagesLoaderSpan = this.currentTraceSpan.traceChild(
'next-client-pages-loader'
)
return pagesLoaderSpan.traceFn(() => {
const { absolutePagePath, page, isServerComponent } =
this.getOptions() as ClientPagesLoaderOptions
pagesLoaderSpan.setAttribute('absolutePagePath', absolutePagePath)
const stringifiedPageRequest = isServerComponent
? JSON.stringify(absolutePagePath + '!')
: stringifyRequest(this, absolutePagePath)
const stringifiedPage = JSON.stringify(page)
// 记录当前页面到 window.__NEXT_P 中
return `
(window.__NEXT_P = window.__NEXT_P || []).push([
${stringifiedPage},
function () {
return require(${stringifiedPageRequest});
}
]);
if(module.hot) {
module.hot.dispose(function () {
window.__NEXT_P.push([${stringifiedPage}])
});
}
`
})
}
export default nextClientPagesLoader
-
转发至 Dev Server
上述的 window.__NEXT_P.push
方法在调用前已经被覆写,所以这里会通过对应的 routeLoader
触发对应页面的加载,如 /_next/static/chunks/pages/index.js
会被传递给 Dev Server 。
...
pageLoader = new PageLoader(initialData.buildId, prefix)
const register: RegisterFn = ([r, f]) =>
pageLoader.routeLoader.onEntrypoint(r, f)
if (window.__NEXT_P) {
// Defer page registration for another tick. This will increase the overall
// latency in hydrating the page, but reduce the total blocking time.
window.__NEXT_P.map((p) => setTimeout(() => register(p), 0))
}
window.__NEXT_P = []
;(window.__NEXT_P as any).push = register
...
-
判断是否需要触发构建
Dev Server 最后将路径交由 onDemandEntryHandler
判断是否需要构建,该模块未被记录则会被添加到 entries ,并触发 Webpack 构建(call invalidate()
)。
这里还包括一些优化逻辑,比如 25s 内没有使用的页面会被回收以减少内存占用。
-
构建完成返回页面
从下图中可以看出每当访问新的路由时,会触发构建并打印提示,编译完成后可访问。其次,与 SWC 深度融合也是 Next.js 构建这么快的原因之一。
Webpack 的按需编译
现在,我们可以直接使用 Webpack5 的实验特性 lazyCompilation
开启 Webpack 的按需编译。
某 Webpack5 项目开启后,本地首次启动耗时减少 100%+
Webpack 的实现与 Next.js 大同小异,也是劫持模块的加载并记录活跃状态,判断是否需要构建以返回对应模块真实内容。只不过他们实现的维度不同,Webpack 的 lazyCompilation 是以 Webpack 插件的形式的实现,并使用 LazyCompilationProxyModule 来代理原来的模块,当其 build
方法触发时,会判断当前的 active
状态,如果需要被构建,则返回 originalModule
即原有的模块。
...
/**
* @param {WebpackOptions} options webpack options
* @param {Compilation} compilation the compilation
* @param {ResolverWithOptions} resolver the resolver
* @param {InputFileSystem} fs the file system
* @param {function(WebpackError=): void} callback callback function
* @returns {void}
*/
build(options, compilation, resolver, fs, callback) {
this.buildInfo = {
active: this.active
};
/** @type {BuildMeta} */
this.buildMeta = {};
this.clearDependenciesAndBlocks();
const dep = new CommonJsRequireDependency(this.client);
this.addDependency(dep);
if (this.active) {
const dep = new LazyCompilationDependency(this);
const block = new AsyncDependenciesBlock({});
block.addDependency(dep);
this.addBlock(block);
}
callback();
}
…
具体流程不再赘述,可以查看 Github - Webpack: lib/hmr/LazyCompilationPlugin.js
三、依赖预构建 + Module Federation
For Development Only
既然在多数项目中,Webpack 在处理依赖模块(node_modules)的耗时远大于项目代码,那么我们可以将来自 node_modules 的模块使用 ESBuild/SWC 等巨快的构建工具构建后提供给 Webpack 消费,这里也就需要配合 Webpack5 的新特性 Module Federation 来连接彼此。
这块业界已经有了解决方案,也就是 umijs 的 MFSU (Module Federation Speed Up)。
MFSU 是如何做的?
-
转换 imports
当 Webpack 开始构建,基于 babel-loader
或 esbuild-loader
遍历 AST 修改对项目依赖的导入,映射到由 Module Federation 容器导出的模块。
import react from 'react';
// 转换后
import react from 'mf/react';
与此同时生成供 ESBuild 构建的入口文件,如动态生成的 react.js
文件,内容如下:
这里会兼容处理 CJS/ESM 的多种导出情况
import _ from 'react';
export default _;
export * from 'react';
所有依赖导入均被转为一个导出文件输出至临时文件夹中,待 ESBuild 构建后封装成 Module Federation Container 导出。
-
构建 ModuleGraph
在转换 imports 过程中已经完成了 ModuleGraph 的构建,主要用作缓存以及记录具体的依赖导入,以精确判断是否需要触发新一轮的依赖构建。
(早期 MFSU 每次依赖变化需要手动 rm -rf
缓存目录以触发重新构建)
最终会生成一份 ModuleGraph 的 JSON 文件,下次会通过该缓存 hydrate 以跳过 ModuleGraph 构建。
// .mfsu/MFSU_CACHE.json
{
"cacheDependency": {},
"moduleGraph": {
"roots": [
"/Users/***"
"/Users/***"
],
"fileModules": {
"/Users/***": {
"importedModules": [
"/Users/***"
],
"isRoot": true
},
...
},
"depModules": {
"react/jsx-runtime": {
"version": "18.2.0"
},
...
},
"depSnapshotModules": {
"react/jsx-runtime": {
"file": "react/jsx-runtime",
"version": "18.2.0"
},
...
}
}
}
-
生成 remoteEntry.js
属于 node_modules 的模块会由 ESBuild 构建,并在输出模块中声明 __webpack_require__
并附加 Module Federation Container 的初始化逻辑,通过 devServer 供主项目消费。
-
并行构建
依赖的构建与主项目构建会并行,在 Webpack 项目启动后同时触发基于 ESBuild 的依赖构建。
开启前后完整构建耗时对比
这里的依赖预构建的思路和 Vite 相似,只不过 Vite 是为了解决 CJS 和 UMD 兼容性以及 ES 子模块大量请求导致的性能问题,而 MFSU 是为了减少 Webpack 所需要处理的模块,将 node_modules
交给 ESBuild 构建,避开了 Webpack 「慢」的问题从而减少了构建耗时。
四、小结
十分钟快速提升我的 Webpack 项目速度:
- 开启持久缓存和按需编译(1分钟)
{
...
cache: {
type: "filesystem",
buildDependencies: {
config: [__filename],
},
},
experiments: {
lazyCompilation: true
}
}
- 使用
esbuild-loader
/swc-loader
替换babel-loader
(2分钟)
- 使用
ESBuildMinifyPlugin
替换TerserPlugin
(2分钟)
- 接入 MFSU (5分钟)