Skip to content
字数
6634 字
阅读时间
26 分钟

在开发 Rspack 之前,我们已经尝试开发了 n 款构建工具和框架,并在实际的生产环境下重度使用了 Webpack、Vite、esbuild、rollup 等构建工具,对各个工具的优劣处和设计取舍深有体会。

先介绍下团队背景,我们是公司的前端公共 Infra Team,负责维护(过)公司的前端通用构建工具和框架(有一些是开源的,有一些并没有),包含:

  • 通用的前端应用构建引擎(Modernjs Builder)
  • 通用的微前端解决方案(Garfish & Vmok)
  • 渐进式的 React 框架(Modernjs Framework)
  • 高性能的 H5 研发框架(PIA)
  • 通用的库构建方案(Module Tools)
  • 文档解决方案(Rspress)
  • 跨平台框架 Lynx 的构建工具(Lynx Speedy)
  • 构建诊断分析工具(Web Doctor)

我们会发现所有这些工具和框架的包含一个很复杂的部分就是底层构建工具,实际上我们日常 Oncall 处理最多的用户问题也是关于构建的疑问。

作为公司内部的 Infra 团队,和开源社区的运维方式的差异主要体现在:

  • 社区上的一些开源团队更聚焦于一个单点的解决方案(如 Next.js、React-Native), 我们团队的职责更为宽泛,更需要综合考虑如何以最小成本维护各种解决方案,减小用户在不同的框架和工具的切换成本,以及各个方案的配合与融合(如 SSR 和微前端的混合支持)
  • 团队有着给业务团队 Oncall 的义务(迅速的业务响应): Oncall 和 Issue 的区别在于,我们需要快速的解决业务侧的问题(大部分问题都在 24hr 内解决,绝大多数问题在 1 周内解决),这一方面要求团队的方案能够保持在比较高速的迭代,另一方面要求我们可以以比较低成本的方式解决业务侧反应的问题。

我们第一个大规模使用的构建工具就是 Webpack,包括目前开源的 Modern.js 仍然在重度使用 Webpack,Webpack 的最大优点就是扩展能力极强,能够支持我们几乎所有的构建场景,但是缺点也比较明显。

  • 黑盒化严重,调试能力很差,业务碰到构建相关的问题,几乎都很难自行排查,都需要 Infra Team Oncall 协助解决,构建问题的排查成本也比较大(这点给 Infra 团队带来非常大的 Oncall 压力),这也是我们开发 Web Doctor 的初衷,用来缓解团队自身的 Oncall 压力。
  • 性能始终是 Webpack 绕不开的话题,虽然我们尝试了各种 Webpack 的优化方式,如 swc-loader、esbuild-loader、thread-loader、cache-loader、MFSU、Persistent Cache 等等,但是最终就是这些方案虽然可能缓解一部分的性能问题,面对大型项目仍然捉襟见肘,另一方面这些方案导致构建过程更加黑盒化,如 Persistent Cache 依赖业务配置良好的 build dependencies,esbuild-loader 不支持 es5 的降级,cache-loader 忘记清理 cache 导致产物没更新。

在 Webpack 上对性能进行缝缝补补也难以解决性能后,业务团队也尝试使用了 Vite,我们 Infra 团队也尝试接入了 Vite,采用的方案也是社区上较为流行的双引擎方案,即上层框架侧是统一的配置和插件,但是底层的引擎是可以在 Vite 和 Webpack 进行切换的,这虽然解决了一部分问题,但是其实带来了更大的挑战

  • 插件的跨引擎复用非常困难,Rollup 和 Webpack 的插件机制是截然不同的,虽然有类似 unplugin 的上层封装,但是其 API 层仍然较为薄弱,难以提供较复杂的插件能力,尤其是对于 Modern.js 这种比较重型的框架,最终的效果往往是代码里充斥着各种 if else 判断,根据不同的配置选择载入不同的 rollup 和 webpack 插件。
  • Vite 在大型项目中的性能表现不够理想,一方面一些业务首屏有几千个模块,因此带来几千个网络请求,虽然 Vite 的 devServer 可以很快的启动,但是几千的网络请求带来的开销是非常巨大的,这有时会带来几分钟的延时,尤其是在 HMR 的 reload 情况下,另一方面 rollup 的性能在大型项目上仍然存在提升空间,并且性能在部分场景下低于 webpack,比如 webpack 命中 persistent cache 时,因此导致较长的部署时间
  • Rollup 的产物优化能力相比弱了不少,尤其是缺失 Bundle Splitting 等能力导致业务很难做精细的优化,因此内部有不少业务是 dev 下运行 Vite,生产环境用 Webpack,这导致开发和生产存在着较大的差异。

说到 Rollup,我们在两个场景下深度使用了 Rollup,库构建方案和早期的 Lynx 构建方案,这期间也暴露了很多问题

Rollup 的优点非常明显,产物格式极为干净,产物结果对 TreeShaking 非常友好,但是同时其缺点也很明显

  • CommonJS 的支持: 虽然现在社区(Twitter)的趋势是推崇 ESM,但是在公司的实际项目中仍然存在着海量的 CommonJS 的依赖,这些依赖可能持续很长的时间,期望所有的业务去除 CommonJS 的依赖是一个不切实际的幻想,Rollup 对 CommonJS 的支持问题有很多,或者说在 Rollup 目前的架构下(将 CommonJS 转换成 ESM),实现对 CommonJS 的完全兼容几乎是一个不可能的事情(如一个非严格模式的 CJS 始终被转成了严格模式的 ESM),因此我们经常在 Oncall 中时不时的处理各种 CommonJS 的问题,不胜其烦(你不会想要每次业务碰到 CommonJS 问题的时候,给业务解释 rollup commonjs options 这里每个字段的意义)。
  • 羸弱的编译性能:Rollup 本身和 Webpack 比较类似,都是使用 JS 编写的 Bundler,因此本身构建性能相差不大,但是 Rollup 本身不支持 Persistent Cache,因此二次冷启动的性能相比 Webpack 更差,同时 Rollup 并不支持 HMR,因此如果需要用 Rollup 支持 HMR 将是一个非常困难的事情,所幸在库构建场景下并没有 HMR 的强需求,但是库场景下仍然有 watch 的需求,Rollup 的 watch 表现依然一般。

幸运的是 Rollup 的上述两个缺点,在 esbuild 下都得到了很好的解决,esbuild 把 CommonJS 当做一等公民,因此对 CommonJS 有着比较完美的支持,同时 esbuild 的性能极为出色,因此目前 esbuild 几乎是 Rollup 的一个很好的替代品,至少在库构建这个场景 esbuild 相比 Rollup 更为合适,这也是 tsup(底层为 esbuild) 成为 tsdx(底层为 rollup) 的一个更好的替代品的原因。Module Tool 目前的底层也采用的是 esbuild。

谈到 esbuild 解决了 Rollup 的 CommonJS 和性能两个最大的问题,我们基于此曾尝试将 esbuild 不仅用于库构建领域,还应用到应用构建领域(事实上,Remix 目前底层仍然使用 esbuild 进行构建),这期间同样暴露了非常多的问题。

  • 插件化问题: 众所周知,esbuild 的 API 极为精简,应用构建相比库构建需要更强的插件扩展能力,而 esbuild 难以满足这个需求,如缺失 onTransform hook 导致不同 transform 的扩展组合很难进行(如 sass -> postcss -> css),你只能将所有的 transform 逻辑写到一个 onLoad hook 里,这其实极难扩展,renderChunk hook 的缺失,也导致很难对 chunk 进行后处理(如使用自定义的 minifier 进行压缩,注入自己的 runtime),虽然你可以遍历产物进行压缩,但是 chunkId 的同步是个很复杂和难以处理的问题,在用户态非常难解决(修改 chunk 的时候需要同步修改引入 chunk 的 id),rollup hash dilemma 里介绍了这个复杂的场景 。
  • 产物性能问题: 在 C 端的场景下,对 chunk 数目和大小很敏感,大量的小 chunk 可能导致很差的加载性能,esbuild 缺乏像 webpack 对 chunk 的深度定制的能力(这里提一下,公司里加载文件的场景也非常多样,保留浏览器、跨平台容器,不同平台网络加载能力差异很大)。
  • Rebuild 问题:esbuild 的冷启动性能虽然十分优异,但是当你使用较多的 JS 插件后,其实 rebuild 的性能就变得堪忧,原因在于不同于 webpack 的 loader,webpack 在 rebuild 的时候只会触发变动模块的 Loader 的重复执行,而 esbuild 则会无条件对所有的 onLoad 和 onResolve 触发 rebuild,这在大型项目上其实是个 O(n) 的复杂度(n 为模块数目),因此 rebuild 性能难以得到保障,见 incremental build performance problem,我们虽然 fork esbuild 加了 incremental build 的缓存,但是 esbuild 这里的设计仍然存在一定问题。
  • HMR 问题:因为 esbuild 并没内置支持 HMR 的能力,因此如果要支持 HMR,就需要自行在 Load 阶段注入 HMR 的 runtime,我们通过这个方式实现了 esbuild 的 HMR(Remix 也通过类似方式支持了 HMR),但是这带来了不小的编译时性能开销。
  • Runtime 的扩展问题: 这也和 HMR 问题存在一定的相关性,另一个问题是这导致在 esbuild 上很难做到和 webpack 一样的 Module Federation 支持。

上述的这些问题,导致我们渐渐放弃了 esbuild 的方案,专向自研 Rust Bundler 的之路。

How We Build Rspack ​

我们自研 Rust Bundler 并不是一开始就选择走 Rust Webpack 这条路,实际上我们最开始的路径更加简单,解决 esbuild 的目前的一些问题,因此最开始的路径其实是 Rust 版本的 esbuild | rollup 加上内置 HMR、CommonJS 和 Bundle splitting 的支持,因为我们已经在 esbuild 的基础上封装了一套完整的框架,在生产环境也跑了一年了,我们并不想重新来过,而是希望接口和 esbuild 兼容,同时解决掉 esbuild 的 HMR 和 bundle splitting 问题。实际上 Rspack 的 legacy 分支仍然保留了当初兼容 esbuild 接口的设计。

但随着我们继续深入做下去,完成了第一版基于 Rollup 架构的 Rust Bundler,并完成项目的 POC 后,暴露了很多的问题,这导致我们重新审视架构,最终导致转向了 Webpack 架构。

一等公民(Language Agnostic) ​

  • Rollup 的核心架构是只支持 ESM 作为一等公民,其他的模块系统(CommonJS)或者非 JS 模块系统都需要转换成 ESM 才能工作,这导致了非常多的问题,一个最为常见的问题就是不同的模块系统的 resolve 逻辑是不一致的(还有更多的不一致,如 sideEffects 的默认值,chunk 的生成逻辑等),在 Rollup 下并不能很好的感知到不同模块的差异(因为所有的模块都被转换成了 ESM 模块),因为 Rollup 在核心层并没对不同模块进行区分,这导致只能依赖在插件侧依赖非常的 hack 逻辑来实现该功能(每个 CommonJS 转换成 ESM 的时候需要给对应模块标注原始的 CommonJS 标记,以便于后续的 resolve 逻辑进行区分。

与很多人的直觉可能相违背的是,Webpack 和 Parcel 一样都是 language agnostic,而 Rollup 则是只有 Javascript 才是一等公民。这可能也是 Webpack 5 最为人忽视的一点,Webpack 5 支持了更多的一等公民模块。

插件 API 的设计 ​

Rust Bundler 自从立项开始,自始至终就有一个核心需求,那就是支持通过 JavaScript 写 Plugin,这点始终未变,因为我们在业务支持中,深知业务的扩展性是非常必要的,一个业务不可扩展的 Bundler 是难以落地的,因此如何设计插件 API 就成了我们的一个核心问题。

当我们设计插件的时候考虑的两个核心问题就是性能和可组合性,我们发现 Rollup 的 API 并不能满足我们的需求。

Note

Simple API is useful for adoption but maybe hard for scaling.

模块转换 ​

所有 Bundler 的插件都要考虑的一个核心功能就是如何处理模块的转换,所有的 Bundler 都提供了插件支持该功能,但是不同插件的支持方式不同(rollup 对应的是 transform 而 webpack 对应的是 loader)。

我们综合分析了模块转换的功能,实际上发现这实际上是三个维度的需求

  • 过滤器(filter): 即过滤哪些模块进行转换。
  • 转换器(transformer): 即对过滤模块进行怎样的转换。
  • 模块类型转换(change module type): 即我们可能需要将一个模块从 A 类型转换成 B 类型。

我们以 svgr 这个插件为例,来说明模块转换逻辑的复杂之处,svgr 的插件的作用是将一个 svg 文件转换为一个 React 的组件。我们来提炼下这里的三个要素:

  • 过滤器(filter): 即 /.svg$/,只处理 svg 结尾的文件
  • 转换器: 即通过 @svgr/core 将 svg 内容转换为对应的 jsx 组件
  • 模块转换: 处理完转换后,我们需要将 svg 的内容视为 jsx 来处理

在 Rollup 中实际将这三个维度揉进了一个 transform hook 里,这导致了两个比较严重的问题

  • 高频的 callback 通信: 因为 rollup 的 transform hook 的 filter 逻辑写在了 hook 内,因此我们只有执行了该 hook 才能执行过滤操作,这意味着我们所有的模块都需要进行一次 Rust 和 JS 的通信开销,才能进行过滤操作,实际上 Rust 和 JS 的通信开销在上万的模块通信场景下将会十分巨大,尤其是在 HMR 下,这将更难以接受。esbuild 则很好的解决了该问题,esbuild 会先通过 filter 进行过滤,命中过滤逻辑的才会真正的执行 JS 的 callback,这里值得注意的是这里的 filter 使用的是 golang 的 regex 而非 JS 的 regex,因为其为了避免 golang 和 JS 的调用开销,但是这牺牲了一定的直观性(用户可能分不清 golang 和 JS 的 regex,而导致误用)
build.onLoad({ filter: /.txt$/ }, async (args) => {
  let text = await fs.promises.readFile(args.path, 'utf8');
  return {
    contents: JSON.stringify(text.split(/\s+/)),
    loader: 'json',
  };
});
  • 用户灵活性的丧失: 因为 rollup 的 filter 的逻辑写在了 transform 内部,用户难以更改从外部修改 filter 的逻辑,如我们后期想用这个插件不仅用来处理 svg 结尾的文件,还希望用来处理其他后缀结尾的文件(这个文件内容仍然是 svg,如 .svg2,类似的还有我可能想将某种文件后缀的文件视为 css module 等),这导致我们只有修改 rollup 的插件才能做到,而在 esbuild 或者 webpack 上只需要直接修改 filter 的逻辑即可。
  • 丧失了模块转换逻辑的组合性: 因为 Rollup 只支持 JS 类型,这导致我们需要将 @svgr/core 生成的 jsx 文件,进一步转换为 js 文件才能进行处理,因此我们需要在插件里进一步处理 jsx 转 js 的逻辑,如 vite-plugin-svgr 这里使用 esubild 将 jsx 转成了 js,而无法将这部分转换工作移交出去,但是这里的 jsx 转 js 的逻辑大概率在其他插件里已经做了类似的事情,但是 vite-plugin-svgr 也只能重复再实现该功能,还为此引入了 esbuild,最终可能导致 A 插件用了 esbuild,B 插件用了 swc,C 插件用 babel,明明是同一个转换逻辑,却在不同的插件里难以进行复用。

我们调研了几乎市面上所有关于模块转换的插件 API web-infra-dev/rspack#315 ,最终发现只有 Parcel 和 Webpack 的 API 能够很好的解决该问题。

Parcel ​
  • 过滤器:用 pipeline 来定义过滤逻辑
{
  "transformers": {
    "icons/*.svg": ["@company/parcel-transformer-svg-icons", "..."],
    "*.svg": ["@parcel/transformer-svg"]
  }
}
import {Transformer} from '@parcel/plugin';

export default new Transformer({
  async transform({asset}) {
    // Retrieve the asset's source code and source map.
    let source = await asset.getCode();
    let sourceMap = await asset.getMap();

    // Run it through some compiler, and set the results 
    // on the asset.
    let {code, map} = compile(source, sourceMap);
    asset.setCode(code);
    asset.setMap(map);

    // Return the asset
    return [asset];
  }
});
  • 模块类型转换:使用 asset.type = xxx 来修改模块类型
import {Transformer} from '@parcel/plugin';

export default new Transformer({
  async transform({asset}) {
    let code = await asset.getCode();

    let result = compile(code);
    asset.type = 'js'; // change asset type
    asset.setCode(result);

    return [asset];
  }
});
Webpack ​
  • 过滤器:rule.test
module: {
  rules: {
    test: /.svgr/,
    use: ['@svgr/webpack']
  }
}
  • 转换器: @svgr/webpack loader
  • 类型转换: inlineMatchResource(inlineMatchResource 相比于直接修改 asset.type,直观度差了很多,虽然有一个 更直观的 virtual resource 的提案,但是迟迟未推进)

AST 复用 ​

另一个对性能影响很大的设计就是如何在不同的模块转换之间复用 AST,因为 parse 的开销通常非常巨大且常常成为性能的瓶颈,如果能尽可能的复用 AST 则可以大大的优化性能。我们看看各个工具是如何处理 AST 的复用的。

Esbuild ​

esbuild 的处理方式最为直接,不支持模块转换操作,因此就不存在 AST 的复用问题,esbuild 的 parse、transform 和 minify 都是共享同一个 AST 的,这也是 esbuild 的性能远远快于其他所有 bundler 的一个重要原因,缺点很明显扩展性很差。

Rollup ​

rollup 可以在 load 和 transform hook 里返回 AST,这里要求的 AST 是标准的 ESTree AST,如果返回了标准的 ESTree AST 那么内部的 parse 就可以复用返回的 AST 避免重复 parse。

Note

rollup 在复用 AST 的一大优势是 rollup 只支持 JavaScript,这意味着只需要考虑标准 ESTree AST 一种数据结构即可,但是这对于 Parcel 和 Webpack 却不适用。

Parcel ​

Parcel 的 transform plugin 显然是经过深思熟虑的,Reusing ASTs 里详细讲述了如何实现 AST 的复用,Rspack 的早期版本也也借鉴了该设计。Parcel 的设计里解决了 AST 的复用的一大难题,即如何处理 string transform 和 AST transform 交叉的场景,对于一般的 transformer 实际存在四种情况。即 string -> string, string -> AST, AST -> AST, AST -> string,如何处理这些 transformer 的交叉执行是个难题,但是 Parcel 很好的解决了该难题。

Webpack ​

webpack 同样支持在 loader 里返回 AST 来支持 AST 的复用,但是 webpack 这里存在几个限制导致这个功能在社区并没流行起来。

首先 webpack 里返回的 AST 只能是符合 ESTree 标准的 AST,但是不幸的是,社区的各种 JS 转换的 loader 返回的基本都不是标准的 ESTree AST,包括 babel-loader(babel/babel-loader#539) 和 swc-loader(https://github.com/webpack/webpack/issues/13425),%EF%BC%8C) 这也导致即使其返回了 AST,也难以在 Webpack 里进行复用,另一个问题是 webpack 虽然内部 parser 支持多种 AST(CSS AST 和 JavaScript AST),但是 webpack 目前也只支持 JavaScript 的 AST,这导致虽然 Webpack 支持这个功能很长时间,但是其实也没有大规模应用。

除了模块转换和 AST 复用的问题,我们还考量了很多插件设计的问题,如怎样减小 Rust 和 JS 的通信频率,如何在不同的转换器之间进行 AST 的复用,避免重复 Parse 开销,如何处理 Virtual Module 等等。最后综合考虑下来感觉 Webpack 的架构更适合我们开发 Rust Bundler 对于定制化和性能的诉求,因此后面决定了走向 Rust Webpack 的道路(没有使用 Parcel 的原因是因为团队中几乎没人有 Parcel 相关的应用经验,Webpack 是个风险系数更低的选项).

Webpack 之路的探索 ​

虽然我们整体路线上决定采用了 Webpack 的架构,但是在实施过程中还是走了很多弯路,

一等公民支持 ​

我们在早期开发过程中,一方面收到了 esbuild 的影响,另一方面则是当时的 loader 尚未完全支持,因此我们决定扩展了对一等公民的支持(如支持 jsnext、ts、tsx、jsx 等 js 扩展语言作为一等公民),这虽然帮助我们快速的在业务侧进行了落地,但是也导致了较多问题

  • bundle splitting 的结果不够准确,因为 AST 转换后没有立即进行 codegen 导致无法拿到转换后的代码,只能使用源码大小进行 bundle splitting 的 minSize | maxSize 分析,但是源码后转换后的代码可能存在巨大的代码差异(如注入 babel 和 swc 的 runtime),这导致 bundle splitting 的结果不够准确
  • 默认对 ts 和 js 文件使用 swc 进行转换,导致一些不能进行 transform 的模块会出错(如 core.js)
  • 当用户使用 swc-loader 进行 transform 的时候,会导致模块出现二次转换的问题
  • TypeScript 一些语法的编译行为不可控制,如 decorator 在 ts 下有多重编译可能,默认的编译配置可能不符合用户需求,用户可能部分模块 decorator 选择 A 语义,部分模块 decorator 选择 B 语义。

因此在未来 Rspack 考虑放弃对 ts、tsx、jsx 等模块的一等公民支持,而让用户通过 swc-loader 来进行编译处理,这保证了一等公民的处理和 Webpack 对齐,同时保障核心层的稳定。

Codegen architecture ​

如早期的 codegen 的方案采用的是基于 AST 的 codegen 的方案,而 Webpack 本身则是采用基于 dependency 的 string replacement 的方案,codegen 的方案是性能更优(避免了重复的 parse 开销),但是导致和 webpack 的架构有了偏离,实际上 webpack 的 Runtime 和 treeshaking 等逻辑都强依赖 string replacement 的依赖的信息,另一方面 codegen 也导致了后续的 persistent cache 的实现增加了不少难度,因此我们在 0.3 版本完成了 codegen architecture 从 AST 到 string replacement 的迁移,并发现性能并没有发生太大的衰减。

TreeShaking ​

因为我们早期选择了 ast based codegen 的方案,因此 treeshaking 也借鉴 esbuild 选择了对 AST 更为友好的方案,但是后来也暴露了该方案的不少问题,如 treeshaking 对于 reexport 和 multi entry 的优化(esbuild 的 treeshaking 优化相比 webpack 仍然有不小的差距 evanw/esbuild#2049 ,因此我们后续逐渐将 TreeShaking 的实现过度到 Webpack 的实现,以实现更深入的 TreeShaking 优化策略。

类似的问题仍然有很多,但是我们仍然会继续探索下去。

Beyond Webpack ​

虽然 Rspack 的定位是 Webpack 的 Drop in Replacement,但是我们也深知 Webpack 也有诸多局限,我们也会在未来和 Webpack 合作,一起提升 Webpack 生态的用户体验。

Out of Box Solution ​

Webpack 最令人诟病的一点应该就是开发体验较差,对新人不够友好,给一个项目从头配置 Webpack 并非易事,相较于 Vite 提供的开销即用的体验,Webpack 的体验就差的很多,另一方面随着 creact-react-app 的弃坑,社区上对于 Webpack 的上层封装选择已经不多,我们深知开箱即用对于新用户的重要性,我们在未来也会和 Modern.js 一起优化这块的体验。

Diagnostics ​

正如前面所说,Webpack 对于普通用户过于黑盒,缺乏必要的调试工具,一方面我们会继续深度优化 Web Doctor 来提升 Rspack | Webpack 的调试体验,另一方面我们也会在 Rspack 里加上更多的调试信息,做到调试体验的开箱即用。

Optimization ​

虽然 Webpack 对于产物的优化已经算是同类的佼佼者,但是仍然存在着不少的优化空间,如 Webpack 的 bundle splitting | code splitting | tree shaking 都是模块粒度的,这限制了很多的优化方式,我们未来会探索基于 function 粒度的优化手段,来进一步优化产物的运行时性能。

Portable cache + Remote build cache ​

我们公司内部有着大量的巨石应用和 Monorepo,Webpack 目前缺乏对 portable cache 能力的支持导致其很难实现分布式的构建缓存共享,但是分布式构建缓存共享对于巨石应用和 monorepo 的提速至关重要,我们在未来会重点关注这块能力的建设。

reference ​

Bundler的设计取舍 · web-infra-dev · Discussion #4 · GitHub

贡献者

The avatar of contributor named as jiechen jiechen

页面历史

用 ♥ 撰写