作者 | 鲲尘
snowpack / vite 等基于 ESM 的构建工具出现,让项目的工程构建不再需要构建一个完整的 bundle。很多人都觉得我们不再需要打包工具的时代即将到来。借助浏览器 ESM 的能力,一些代码基本可以做到无需构建直接运行。对于 webpack 而言,社区掀起的这一波 ESM 热潮,将 webpack 编译的速度推到了风口浪尖。webpack 在 v5 版本中也是针对编译的性能做出了不少努力,除了提供了物理缓存的优化之外,还提供 Module Federation 的方案,这给我们上层的应用实践带来了很多想象的空间。
以前 webpack 大有一统构建工具的趋势,而现在我们可以结合业务的特点有更多的选择。
为什么需要打包
JavaScript 编程过程中很多时候,我们都在修改变量,在一个复杂的项目开发过程中,如何管理函数和变量作用域,显得尤为重要。而 JavaScript 的模块化提供了我们更好的方式来组织和维护函数以及变量。大家熟知的 JavaScript 模块除了上述的 ESM 之外,还有 CJS、AMD、CMD、UMD 等等规范。而在 npm 生态开发的背景下,CJS 模块是开发过程中接触最多也是无法避免的。但由于浏览器并不能直接执行基于 CJS 打包的模块,因此类似 webpack 等打包工具便应运而生。
对于早期的 web 应用而言,打包模块既能够处理 JS 模块化,又能将多个模块打包合并网络请求。使用这类构建工具打包项目的确是个不错的选择。时至今日基本上主流的浏览器版本都支持 ESM,并且并发网络请求带来的性能问题,在 HTTP/2 普及下不像以前那么凸显的情况下,大家又将目光转向了 ESM。就目前的体验而言,基于原生 ESM 在开发过程中的构建速度似乎远远优于 webpack 之类的打包工具的。
初探 ESM 构建工具
使用 ESM
<script src="index.js" type="module"></script>
通过 type="module"
告诉浏览器,当前脚本使用 ESM 模式,浏览器会构建一个依赖关系图,借助浏览器原生的 ESM 能力完成模块的查找、解析、实例化到执行的过程。
为什么快
为什么基于 ESM 的构建工具 snowpack / vite 会比 webpack 在构建的时候要快很多,借用 snowpack 官网的一张图片来说明:
最核心的两个特点:
- 首先它们的构建复杂度非常低,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1)
- 借助 ESM 的能力,模块化交给浏览器端,不存在资源重复加载问题,如果不是涉及到 jsx 或者 typescript 语法,甚至可以不用编译直接运行
构建流程
如果仅仅是把源码交给浏览器执行,是满足不了大部分项目的诉求,源码中通常我们是直接 import 第三方模块,除此之外还会导入样式、资源,包括源码开发过程中使用最新 es 语法、jsx、ts 语法,这些在浏览器中都无法直接运行。
构建工具针对上述的问题,根据不同类型和需求针对性进行了处理,以下便是简化版的流程概览:
import 语句处理
对于 esm 模块首先需要处理的便是 import 语句,而项目开发过程中常见以下几种情况:
import 第三依赖,比如 import React from 'react';
import 资源,比如图片的导入 import img from './img.png';
import css import './index.css'
import 依赖
snowpack 中将三方依赖的语句均转化为 /web_modules/*.js
import React from 'react';
// 转化为
import React from '/web_modules/react.js';
如果 npm 下所有的依赖都能够打出标准 ESM 模块,那这一步的处理其实可以简单得将所有 web_modules 的模块请求拦截并返回其 node_modules 下的 ESM 模块就行。但现实是很多 npm 生态下的依赖还不支持,所以目前大部分做法会在初次启动项目时进行预打包,完成 CJS / UMD 转化为 ESM 的操作。
配置逻辑上一般也会支持主动筛选已生成 ESM 的模块,降低预先打包成本
import 图片
开发过程中 import 图片资源的时候,实际上是希望最终返回其静态资源的 URI,snowpack 内部首先将此类资源的 import 语句进行改写:
import img from './img.png';
// 转化为
import img from './img.png.proxy.js';
而在 img.png.proxy.js 文件中则可以默认导出对应的文件地址:
// img.png.proxy.js
export default '/dist/assets/img.png';
工具在生成 *.proxy.js
的时候可以对应将资源拷贝到指定输出路径即可。
import css
样式导入的改写规则同图片类似,区别上仅仅是生成 *.css.proxy.js
中的内容。
思路上也是将导入的 css 变成 JS 模块,对于 css 而言,处理通过 link 的方式引入之外,还可以通过 style 标签注入,那 css 模块的代理规则也变得相当清晰了:
// code 便是 css 文件中读取的内容
const code = ${JSON.stringify(code)};
const styleEl = document.createElement("style");
const codeEl = document.createTextNode(code);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);
如果想启动 css module,在 snowpack 中约定了 *.module.css 文件将启用 CSS Module 功能。相比上述插入 标签的方式,增加了样式 class 名称对应类名的导出,即:
...
// let json = ${JSON.stringify(moduleJson)};
let json = { "test": "App--Test--3kX9Z4E"}
export default json;
除了上述提到的改写 import 类型之外,还有 json、less、sass 等文件的处理,本质上处理逻辑大同小异,均是通过将 import 代理到新生成的文件,并在中加入特定脚本,完成最终一个 ESM 模块的转化,以支持浏览器处理加载。
Module Federation 的应用
ESM 给我们带来的体验就是快,因为它不需要像 webpack 除了要处理编译之外,还需要分析源码中的 node_module 依赖关系,并将它们合并、拆分,打包。
不管是官方还是社区也都不遗余力地去设计各种各样的方案来优化性能,比如 cache-loader、thread-loader、dllPlugin、babel cacheDirectory、hard-source-webpack-plugin 等等优化方式,但实际的效果并没有那么令人惊艳,并且都有一定的使用成本。
直到 webpack 5 的出现,它带来的长效缓存能力可以在检测到文件变更的时候,根据依赖关系仅对依赖树上相关文件进行编译,并且通过优化后的构建缓存及 resolver 缓存大大提升构建速度。而另一个特性 Module Federation(以下简称 MF)的出现,带来了基于 webpack 开发的全新协作方式,它让不同构建任务间的模块复用变的更加简单。
MF 的设计动机是为了能够让不同的团队协作开发一个或者多个应用。这种协助的模式跟去年如火如荼的微前端开发模式如出一辙。
MF 方案能够将一个应用拆分并导出不同的模块,并且模块依赖的底层三方库,同样能够被共享。借助这个能力我们可以为应用提前构建一个公共依赖,来减少编译内容和打包体积。
核心用法
首先来认识下 MF 中的两个核心概念:
- Host:提供了
remotes
选项,可以应用其他 Remote 应用中 expose 的模块 - Remote:提供了
exposes
选项,为其他应用提供模块
使用方式:
// webpack
const { ModuleFederationPlugin } = require("webpack").container;
...
plugins: [
new ModuleFederationPlugin({
name: 'remoteRuntime', // 必须,唯一 ID,作为输出的模块名,使用的时通过 ${name}/${expose} 的方式使用
remotes: ['remote'], // 可选,表示作为 Host 时,去消费哪些 Remote
exposes: { // 可选,表示作为 Remote 时,export 哪些属性被消费
'./ComponentA': './src/components/A',
},
shared: ['react', 'react-dom'], // 可选,优先用 Host 的依赖,如果 Host 没有,再用自己的
})
]
消费模块的应用使用 exposes 时,除了引入 Host 生成的 remoteEntry (模块依赖关系)脚本之外,代码层面需要对应修改:
// 消费形式 import ${name}/${expose}
import ComponentA from 'remoteRuntime/ComponentA';
执行逻辑
如何希望使用 webpack 5 module federation 的能力,除了工程上的配置之外,源码层面也需要做对应的修改:
// index.js
import('./bootstrap');
// bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import ComponentA from 'ComponentA';
...
在渲染逻辑执行前,需要增加一层 bootstrap 的 import 逻辑,本质是为了能够让异步加载的 runtime 依赖,完成加载后再执行主逻辑,核心流程如下:
代码核心执行流程变更如下:
- 首先先加载 index.js,这一般是应用的主bundle
- 主 bundle 中会去加载,
__webpack_require__(“./boostrap.js”)
而 boostrap 模块中将会包含各种依赖 chunk 的信息
- 通过 overridables 逻辑,内部会判断这个 chunk 依赖的 shared 信息,并根据信息进行加载
- 通过 remotes 逻辑,加载外部依赖模块,信息已经在 remoteEntry 中提供
- 完成所以应用依赖加载完后,执行最终应用逻辑
依赖抽离模式
理解上述的原理之后,我们回过头来看有没有可能让 webpack 项目也像 ESM 模式一样不打包依赖,将所有的三方依赖都预先准备好,在主应用启动的时候,直接依赖这些提前编译的模块。
借助 MF 的能力,可以将所有的三方依赖都作为 remote 依赖引入。在 ICE 的实践过程中,利用 MF 方案 和 webpack 5 的物理缓存的确给项目开发带来了很大构建速度的提升,方案的核心实现逻辑如下:
- 首先通过 exposes 能力,将项目运行时依赖去全部导出,并完成 remoteEntry 和相关 remote 模块的构建
- 项目开启 MF 能力,并依赖已构建好的 remote
- 将项目中运行时依赖通过 babel 能力转化为 remote 模块加载模式,即 import xx from {expose} 的形式
完成上述的处理后,项目启动后的 bundle 将不会再将远程已打包的 runtime 相关依赖打包在一起。
优化前:
启用方案优化后:
上述方案已在不同类型业务中实践,欢迎👏大家联系探讨使用场景
同类方案对比
dll
应用的三方依赖编译成 dll,每次应用依赖改变的时候,应用都需要重新构建,并且无法进行按需加载,多个应用间基本无法共用 dll,缺乏动态性的特点。
externals
与之对应的 externals 方案,依赖被构建成一个个文件,应用在打包的时候需要声明有哪些外部模块被引用,externals 无法按需加载,并且需要自己维护相应的依赖关系和scripts 加载顺序。
小结
去年的时候 snowpack / vite 的工程生态和构建的定制能力同 webpack 还是有着不小的差距,而随着国内对 ESM 生态的关注,越来越多的构建工具开始去尝试 ESM 的方式进行开发。
ESM 的开发模式很大程度上解决了在 dev 开发的启动速度的问题,对于目前很多模块未导出 ESM 的情况,也提供了预编译的方式。同时像 snowpack 等工具在生产构建模式下提供了基于 webpack 打包的插件,让开发者没有太大负担的去应用最终的产物。这的确是一种渐进式的方式,但肯定不是长久方案。
webpack 的慢除了需要分析依赖并打包成一个 bundle 的影响之外,使用 babel 编译和 sass-loader 等能力的耗时也是非常的长。这也是为什么现在社区主流 ESM 模块方案在做源码编译的时候,会选择 esbuild 作为默认的编译工具,它编译的速度足够快。webpack 同样利用优化物理缓存的方式来提升构建的性能,特别是二次构建和热更新都能够得到很大的编译速度提升。
webpack 提供的能力更像是一套企业级的解决方案,对源码 / 构建的任意节点都提供了充分的钩子和能力,方便开发者进行定制。而 ESM 则是利用浏览器的模块加载能力,不去解析模块依赖,内部实现逻辑以快为首要考虑条件,能交给浏览器直接运行的就不编译。针对 ICE 或者社区的一些应用研发框架而言,大部分方案为了降低开发者的认知和开发成本,实现上会结合工程和运行时能力来简化开发成本,逻辑处理上对 webpack 的生态都有一定程度的依赖。那这部分能力怎么去结合 ESM 的开发模式,来帮助开发者在工程构建体验和源码开发体验上都能够得到极致的提升,将是接下来又一个着重发力的风口。