原文来源于:ByteDance Web Infra ,作者:杨兴元
如有侵权,联系删除
随着 Snowpack、Vite 等利用提倡 no-bundle 的构建工具逐渐兴起,同时现代浏览器对原生 ESM 的普遍支持,Bundle-less 的概念席卷前端圈,那么我们如何理解 Bundle-less?究竟是炒概念还是能够真正地给业界带来收益?下面就来分享一下我对于 Bundle-less 的理解以及在这个方向上做过的一些探索。
如何理解 Bundle-less?
Bundle-less,也被叫做 Bundless,我觉得可以从这几个角度去理解:
- 首先是拆包,弱化传统意义上的打包概念,由单 bundle 拆分为数十或者上百个 bundle,这样可以更好地利用 HTTP2 的多路复用优势和提升缓存命中率。
- 然后是对于项目源代码不进行 bundle(no-bundle),在开发阶段可以省略 bundle 的开销,如 Vite、Snowpack、WMR,这层含义相信大家都比较熟悉了。
- 再者是依赖产物的模块化分发。对于庞大的外部依赖,一方面打包成本比较高,另一方面文件数量可能非常多,打包几乎是一个必选项,甚至需要多个 NPM 包合并打包。因此,针对依赖的打包也是非常重要的优化点,一般可以通过预打包 + 模块化缓存来进行优化,目前也有一些优化案例,如 Vite 中基于 Esbuild 的预打包器、基于 ESM 的 CDN 服务,如 Skypack、esm.sh、jspm 等,当然也有一些新的自研方案,后文会详细介绍。
一、拆多少包更合适?
对于究竟拆多少包这个问题,大家的概念都一直比较模糊,打的包太多或者太少都可能出现加载性能的问题,比如过多的嵌套 import 导致网络瀑布流的产生、bundle 太少不能充分利用 HTTP2 下并发请求的优势。
针对这个问题,我们曾做过一系列的性能测试,最后得出的结论如下:
- 对于总产物资源大小相同的情况,资源加载分成的 chunk 数量在 10 - 25 之间进行并行加载性能最佳。
- 一次资源加载需要的依赖引用深度尽量等于 1 时加载性能最好。
- 由于不打包的情况下项目的请求数量和请求深度问题都不可控,因此不适合在生产构建中采用 no-bundle 方案。
二、No-bundle 服务
1.代表方案 Vite
在开发环境中,Vite 主要做了两件事情:
- 基于 Esbuild 打包外部依赖
- 创建 HTTP Server 以响应浏览器中
浏览器的 import map 兼容性: https://caniuse.com/?search=importmap
相关 Polyfill: es-module-shims(地址) 、SystemJS
2. 模块合并
首先基于 Esbuild 将项目进行预打包(性能考虑),需要开启 metafile 配置,在 onEnd 钩子或者 build API 的返回值中可以获取构建元信息,即 meta 对象,由 inputs 字段可以解析出模块依赖图。
// meta 对象 { "inputs": { // 当前模块路径 "../node_modules/.pnpm/object-assign@4.1.1/node_modules/object-assign/index.js": { // 模块大小 "bytes": 2108, // 依赖模块数组 "imports": [] }, "../node_modules/.pnpm/react@17.0.2/node_modules/react/cjs/react.development.js": { "bytes": 72141, "imports": [ { "path": "../node_modules/.pnpm/object-assign@4.1.1/node_modules/object-assign/index.js", "kind": "require-call" } ] }, } }
根据当前的模块依赖图信息,我们可以将项目中用到的依赖进行分组,通过特定的依赖分组算法产出一些依赖组的信息,算法细节比较复杂,篇幅原因就不展开了。
3. 多包(combo)模式打包
Combo 模式打包即把多个依赖包打包到一起,主要会产生如下的问题:
- 多包出现导出名称冲突
- Subpath 问题大量出现的问题
3.1不同包的导出名冲突问题
首先需要解决命名导出的问题,整体由两部分构成,在构建时阶段注入一些带有包名前缀的 specifier,运行时根据包名取出这些 specifier,从根本上解决导出名冲突问题。
3.1.1 构建时注水
导入导出检测
在构建之前需要探测 NPM 包所有的导出,包含以下的情况:
- ESM
- Named/Default export,通过 es-module-lexer 扫描
- export * from 'xxx',通过 esbuild 预打包,开启 metafile 模式,在 metafile 中获取所有的 export 名
- CJS
- 尝试通过直接 require 拿到所有的导出名
- 若 require 失败,降级到 AST 解析,分析所有的导出名
构造入口模块
在拿到所有导出名的基础之上,构建虚拟模块,交由 bundler 进行打包,格式如下:
export { 包名_导出字段名 } from '包名'
也就是在 NPM 包每个导出名前面加上包名_,完成注水过程,以防止重名。
3.1.2 运行时脱水 (Hydrate)
如上包名_导出字段名 的这种导出在业务中是直接使用的,我们需要在模块系统中进行运行时拦截(脱水过程),把真正的导出字段名取出。
如以下的导入:
// 在以 esm 的方式对依赖进行 external 后,webpack 产物中的引入代码 import * as __WEBPACK_EXTERNAL_MODULE_react_router_ from 'react-reouter'
我们将会改写成以下的代码:
// 1. 换成临时变量 import * as __WEBPACK_EXTERNAL_MODULE_react_router_$0 from "react-router"; // 2. 对原来的变量重新赋值,通过 __EDEN_COMBO_HYDRATE__ 工具函数将导出名去掉包名前缀 // 如 react_router_Router => Router var __WEBPACK_EXTERNAL_MODULE_react_router_ = __EDEN_COMBO_HYDRATE__( __WEBPACK_EXTERNAL_MODULE_react_router_$0, "react_router" );
3.2大量 subpath 问题
做实际落地项目的过程中发现第三方包中使用大量的 subpath,如下图的依赖分组结果所示:
这种情况下 import map 的体积会很大,因为每个 subpath 都会对应一个远程地址。解决方案是进行 subpath 合并打包,如@
babel/runtime/helper/esm/assertThisInitialized、@
babel/runtime/helpers/esm/inheritsLoose,原始产物中的引入代码如下:import * as __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_inheritsLoose from '@babel/runtime/helpers/esm/inheritsLoose'; import * as __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_assertThisInitialized from '@babel/runtime/helpers/esm/assertThisInitialized';
改写成如下的形式:
import * as _babel_runtime_ from '@babel/runtime'; var __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_inheritsLoose = __EDEN_COMBO_HYDRATE__( _babel_runtime_, '_babel_runtime_helers_esm_inheritsLoose' ); var __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_assertThisInitialized = __EDEN_COMBO_HYDRATE__( _babel_runtime_, '_babel_runtime_helers_esm_assertThisInitialized' );
这样对于 @babel/runtime 只留下一个 importmap 的 key-value 对,有效减少 import map 的体积。
4. 产物 Tree Shaking
在 Esbuild 预打包阶段扫描源文件所用到的 specifier,然后构建对应的虚拟模块交给打包器进行打包:
export { cloneDeep } from 'lodash-es' export { Spin } from '@douyinfe/semi-ui'
这样可以做到一定程度的 Tree Shaking。
5. Polyfill 安全
根据不同的 runtimeTarget 要求(Eden 默认自带以下前三种 runtimeTarget,包括 Modern、PCLegacy、MobileLegacy)和产物模块格式,注入不同的 Polyfill 内容:
- 现代浏览器: 注入 import map 的 polyfill 即可
- PC 端老旧浏览器: PC 端默认 browserlist 下所有第三方包所需的 Polyfill + Systemjs 产物
- 移动端老旧浏览器: 移动端默认 browserlist 下所有第三方包所需的 Polyfill + Systemjs 产物
- 自定义 runtimeTarget 或模块格式: 指定 browserlist 下所有第三方包所需的 Polyfill,如果是 Systemjs 格式,则加入 Systemjs 的 Polyfill
Polyfill 内容在所有第三方包编译完成后进行累计去重,然后统一打包成一个 Chunk,通过 script 标签注入到页面中:
6. 产物本地化
依赖产物本地化有两个好处:
- 可以在本地调试依赖的产物代码
- 可以将依赖产物代码同业务代码一同部署(私有化部署)
具体的做法如下:
在使用编译服务将依赖包打包完成并上传 CDN 后,构建插件会重新请求 CDN 的资源,并将资源写入到本地磁盘中。
然后,构建插件通过在本地 Dev Server 加入中间件来对本地临时目录启用静态资源服务。
与此同时,插入到 HTML 中的 import map 的远程 CDN 产物被改写为本地静态资源服务的地址。
因此,最后项目中访问到的第三方包资源即为临时目录中的产物代码。
小结
本文从拆包数量、no-bundle 服务和依赖产物的模块化分发三个角度介绍了 Bundle-less 目前的具体概念及应用,不知道你对 Bundle-less 有没有新的理解呢?