渐进式 Unbundled 开发工具探索之路

简短摘要:得益于现代浏览器内置了模块处理系统(ESM), 业界新兴开发工具 Snowpack、WMR、Vite 等直接将模块解析加载过程直接交给浏览器, Dev Server 能够秒级启动。和传统开发工具编译时解析加载模块最终打包到 JS Bundle 中相比,本地开发体验提升明显

传统 Bundled Development

复杂项目构建太慢

业界主流的开发工具还是以 Webpack 为主,随着项目体积增大,开发阶段一次性将源代码和第三方依赖编译处理打包到一起的耗时会显著增加。在我们团队内部的 monorepo 仓库中,应用项目开发时,为了开发阶段调试方便,通常也会对一些公共库源码一起打包编译,成千上万个模块导致首次 dev server 启动耗时在几分钟甚至十几分钟,严重影响了开发效率与体验。

Webpack 打包慢的问题相信也是大家都会遇到的问题。随手一搜,各种 Webpack 配置优化以及最佳实践之类的文档数不胜数。大部分也都是遵循  Webpack[1] Build Performance Guide[2]。

粗略一看上面的一些优化方式很多, 但是在我们的场景中很多都是不够通用,比如 thread-loader 结合 babel-loader 的方式在业务项目中经常会遇到报错的情况,原因是业务项目通常会使用 babel-plugin-import 针对业务内部的组件库按需加载组件以及样式,配置大致如下:

[
  "babel-plugin-import",
    {
      "libraryName": "custom-ui-components",
      "style": (name: string, file: Object) => {
        return `${name}/style/2x`;
      }
    }
]

我们知道 JavaScript 中线程间共享的数据必须序列化,上述 style 函数在序列化时会直接报错。

esbuild 作为一个 bundle 工具性能很不错,但是针对应用生产环境打包还存在一些问题,如降级到 ES5,Code Split 、 CSS 处理等。社区内有提供替换 babel-loader 的 esbuild-loader, 通过 loader 的方式在 Webpack JS 运行时中编译单个文件的方式在速度上也不如单纯用 esbuild 一把梭快。对浏览器兼容性有要求的项目,平滑使用也比较困难。

减少处理的模块数量方面,针对 MPA,简单的做法是在 dev 时显式指定需要开发的页面,但是比较局限不够灵活, Webpack 5 提供的实验性特性 Lazy Compilation 在开发阶段能够做到真正的按需编译提升 dev server 启动速度:

渐进式 Unbundled 开发工具探索之路

首次 dev server 启动时, 会代理 Webpack 入口以及 dynamic import 导出的模块,打开浏览器页面后,代理模块在运行时通过 Server-Send-Events 与 Lazy Compilation Backend Server 通信决定需要真正编译处理的资源。但是作为实验功能,目前还不是很稳定,笔者在使用时,遇到过初次打开页面白屏必须手动刷新的问题。而且后续对需要编译的入口通过 babel-loader 或者 ts-loader 重新编译打包时,仍然会有慢的问题存在。

Webpack 4 中通过插件 lazy-compile-webpack-plugin[3] 也可以实现类似的效果。

更多的优化方式这里不一一列举, 总结下来,现有的一些 Webpack 打包优化方式或多或少都不够通用,或者存在一些问题。

打包工具之上的探索

沿着减少 Webpack 处理模块数量的思路,node_modules 下的第三方依赖如果能够从打包环节剔除,仅对业务代码打包,对构建速度肯定会有明显的提升。

比较常见的方法是将常用的第三方库在 Webpack 构建时配置 external, Html 中直接通过 script 标签引入 UMD 产物, 这种方式有以下问题:

  1. 每个依赖的 UMD 产物会增加额外的一段兼容代码,造成冗余。

  2. 浏览器中直接使用可能会污染全局变量,以及容易被修改覆盖。

  3. 很多公司内部包,并没有提供 UMD 产物。

既然 UMD 产物不太符合我们的场景,那么直接替换依赖为对应的 ESM 产物呢?业界也有类似的思路:

  1. Rollup 社区有 rollup-plugin-cdn[4] 支持代码中从 unpkg 引入依赖的 ESM 产物:

import hyper from 'https://unpkg.com/hyperhtml@latest/esm/index.js';
hyper(document.body)`
  <h1>Hello ESM</h1>`;
  1. Pika(Skypack 前身)提供了 @pika/cdn-webpack-plugin[5] 支持生产环境构建时将 package.json 中的依赖替换为 Pika CDN 上对应的链接,同时 html 中通过 script type=module 加载打包后的 js 产物, 以 React 为例在最终 JS Chunk 中大致如下:

 import __mun2tz2a_default, * as __mun2tz2a_all from "https://cdn.skypack.dev/react";
    window["https://cdn.skypack.dev/react"] = Object.assign((__mun2tz2a_default || {}), __mun2tz2a_all);
(window["webpackJsonptask_activity"] = window["webpackJsonptask_activity"] || []).push([["vendors"],{
 /***/ "50ab":
/*!****************************************!*\
  !*** ./node_modules/__pika__/react.js ***!
  \****************************************/
/*! no static exports found */
/***/ (function(module, exports) {
module.exports = window["https://cdn.skypack.dev/react"];
/***/ }),
}])

可以看到利用浏览器对 ESM 的原生支持,直接从 Skypack CDN import 导入 React ,之后以全局变量的方式挂到 window 变量上。其他模块中导入 React ,最终会是 window 上存在的变量。

考虑到我们主要是想提升 dev server 启动时构建的速度,并且很多依赖都是公司内部包,笔者在 @pika/cdn-webpack-plugin[6] 的基础上,结合团队内部的 CJS 转 ESM 服务,在 dev 环节将用到的第三方依赖改成从线上 import 导入。最终在实际业务项目中测试效果并不理想,主要有以下一些问题:

  1. 第三方依赖以及依赖的依赖都会直接在浏览器中请求,请求数量成指数增长,浏览器中白屏时间过长,甚至请求数量会直接爆炸,运行报错。

  2. 速度上面,一些项目确实能够在 6s 左右 dev 完成,但是在一些比较大的内部 monorepo 中,仍然是需要几十秒的时间。

  3. CJS 转 ESM 中间的坑太多: 大部分依赖都还是只提供了 CJS 版本,在转换过程中不论是用 rollup-plugin-commonjs 还是 esbuild ,一些问题都避免不了。

  4. 同样会污染全局变量。

在 Webpack 生态基础上将第三方依赖以 ESM 形式直接加载看起来也不太能满足我们的场景,那么如何更好地提升 dev server 启动速度呢?

迈入 Unbundled Development

业界现状

基于 ESM 的现代 unbundled 开发工具在社区如雨后春笋不断发展,es-dev-server、Snowpack 、Wmr 、Vite 等工具直接直接抛弃 Webpack,通过内部的 Dev Server 接收请求后实时对 JS、TS、CSS 等资源实时编译处理。

渐进式 Unbundled 开发工具探索之路

图片来自 Snowpack 官网: https://www.snowpack.dev/concepts/how-snowpack-works

和 Webpack 等打包工具相比,这类的 unbundled 开发工具有如下优点:

  1. 飞快的启动速度:dev server 启动时仅需要对依赖预处理编译成 ESM 格式,一次处理之后,后续依赖没有变化时,可以做到秒级启动,

  2. 实时按需编译:运行时浏览器第一次请求对应模块时,dev server 本地实时编译处理返回对应内容。

  3. 更快的热更新:针对具体修改的文件,根据模块的依赖关系图, 逐步向上寻找 accept 该模块 HMR 更新的文件,重新请求文件内容。和 Webpack 的热更新需要重新整体构建相比会更快。

这种新兴 Unbundled Development 模式看着能够满足我们对 dev server 启动速度的需求,下面的问题就是针对使用我们内部应用开发工具的业务项目来说如何平滑的接入这些工具。

由于我们的应用开发工具提供了一*代 Web 项目开发范式,从应用入口和各种资源的处理使用,以及服务端 API 的一体化调用上都有内部一些标准。直接在项目中使用上述工具会有很多问题,免不了对上面的工具进行二次开发,这就要求这些工具提供足够灵活的扩展自定义方式,这里就业界用的比较多的 Snowpack 和 Vite 2.0 简单对比如下:

渐进式 Unbundled 开发工具探索之路(点击查看大图)

通过上面的简单对比,以及文档丰富程度上来看, Vite 2.0 扩展起来会更加灵活方便。因此在给我们的应用开发工具提供 Unbundled Development 模式前期,也是果断选择了 Vite 2.0 作为底层, 但是最终实现版本里面我们选择借鉴 Vite、Wmr 等工具自主开发实现 dev server 部分,主要有以下几个角度的考虑:

  1. 依赖预处理过程需要深度定制:一些公司内部包需要特殊处理,这部分我们最终决定放到内部的 CJS 转 ESM 服务上,同一个 package 的具体版本,只会处理一次,后续使用时直接下载转换后的产物即可。

  2. 深度定制需求:在我们的设计体系里面,用户并不会直接接触到具体底层工具的配置,比如 babel.config.js、postcss.config.js 等,而是集中在我们提供的配置文件进行自定义的需求。Vite 2.0 本身是支持这些文件的加载,另一方面具体资源文件处理的标准也有不同的地方,比如 CSS Modules 处理上,我们默认支持文件后缀 .module.css 的形式,也支持关闭文件后缀的约定。这些有的可以通过配置实现,但是大部分还是要接管 Vite 内部的资源的编译处理过程,如果通过 Vite 提供的插件可以完成,这样对 Vite 利用不是很充分,也显得比较冗余。

  3. 后续维护与发展:初期我们打算提供基本的 dev server 解决开发环节慢的问题,后期需要结合已有 SSR、SSG 等建设补齐对应的能力。

综上,实现 Unbundled Dev Server 对我们来说更容易维护以及后续添加一些能力更方便。

Unbundled Dev Server 原理

为了更好的理解 Unbudled 开发工具的细节,我们从下面几个方面分别介绍:

依赖预处理

上文我们提到,业务项目中使用到的依赖很多只提供了 CJS 产物, 首先,我们需要将第三方依赖转换成 ESM 格式。业界常用的工具主要是 Rollup 和 esbuild 等构建工具。

基本思路是分析项目源码中使用到的依赖, 这些依赖作为构建工具的入口整体打包,好处是整体将依赖打包得到 common chunks,浏览器中打开页面加载第三方依赖的请求数量会少很多。缺点是添加依赖或者删除一些依赖改动了 package.json 或者 lock 文件时, 需要重新对依赖编译打包,在一些比较大的中后台项目中,依赖预处理耗时还是存在的。

这里我们目前采用的方案是,沿用之前内部已有的 CJS 转 ESM 服务,直接下载线上依赖已经转换好的的 ESM 产物。后续针对下载的 ESM 文件,用 esbuild 做一次 bundle 减少浏览器中运行的请求数量。

渐进式 Unbundled 开发工具探索之路

借助 CJS 转ESM 服务和直接本地编译转换相比,有以下好处:

  1. 首次对项目依赖预处理之后,后续添加依赖只需要从云端下载新添加的依赖 ESM 产物,之后借助 esbuild 重新 bundle 耗时也不会很多。

  2. 一些需要特殊处理的第三方依赖,统一在云端处理修复,业务项目不依赖开发工具的发版升级。针对一些通用的处理场景,我们会提供 UI 界面引导用户输入依赖对应信息,自动修复。

  3. CJS 到 ESM 转换的产物可以在本地全局缓存,跨项目复用已经编译好的产物, 也就是说,随着使用项目增加,云端和本地双重缓存级联,能够大幅度减少 CJS 转 ESM 的时间。

同时我们针对内部模块比较多的依赖,如 antd,在线上 CJS 转 ESM 时,会将内部模块打包到单个产物中,这样能减少成百上千的网络请求。

以 React 为例, 直接从云端下载的 ESM 产物内容截图如下:

渐进式 Unbundled 开发工具探索之路

可以看到依赖项 object-assign 会额外带有版本号信息。在递归下载第三方依赖 ESM 文件后,能够得到如下的 json 文件,存储某个版本依赖实际 ESM 文件的路径, 如下:

{
   "react?16.14.0": "/Library/Caches/__web_modules__/react@16.14.0.js",
   "react-dom?16.14.0": "/Library/Caches/__web_modules__/react-dom@16.14.0.js",
   "object-assign?4.1.1": "/Library/Caches/__web_modules__/object-assign@4.1.1.js",
}

版本号是直接在 node_modules 中解析对应依赖位置获取的,也就是说,项目下存在某个依赖多个版本也是支持的。

之后通过 esbuild 打包时借助 onResolve hook 从上面的 json 文件中匹配具体的 ESM 文件路径, 示例代码如下:

const bundleResult = await require('esbuild').build({
    entryPoints: ['react', 'react-dom'],
    bundle: true,
    splitting: true,
    chunkNames: 'chunks/[name]-[hash]',
    metafile: true,
    outdir: webModulesDir,
    format: 'esm',
    treeShaking: 'ignore-annotations',
    plugins: [
      {
         name: 'resolve-deps-plugin',
         setup(build) {
            build.onResolve({filter: /^/}, async args => {
               const { kind, path } = args;
               if (['import-statement', 'entry-point', 'dynamic-import'].includes(kind)) {
                 if (kind === 'entrypoint') {
                   //  针对 bundle 入口, 直接本地 resolve 得到版本号
                   // 在上面的 json 文件中拿到具体路径返回
                 } else if (path.startsWith('/esm/bv')) {
                   // 针对依赖的依赖,比如 object-assign^4.1.1
                   // 通过 semver 在文件中匹配获取实际的 ESM 文件路径返回
                 }
               }
            })
         }
      }
    ]
})

到这里,依赖预处理已经完成了,最终通过 bundleResult.metafile 能够得到我们最终的 import-map.json 后续在请求模块时用来解析模块依赖路径到转换后的 esm 文件, 目录结构和内容大致如下:

渐进式 Unbundled 开发工具探索之路

针对 monorepo 中某些 package 并不发布,在应用中直接使用源码统一构建的场景, 在分析使用到的依赖时,也会收集这些 package 用到的依赖,统一预处理成 ESM 格式。某些 package build 后提*物在应用中使用时,会根据当前 package 最新代码, 本地实时编译转换成 ESM,这里本地编译转换和云端会复用底层代码,效果上也类似。

资源文件处理

整体 Server 的实现部分借鉴了 WMR 和 Vite 的 插件系统设计, Plugin 作为 Rollup Plugin 的超集,通过 Plugin Container 提供统一的插件接口, 和 Snowpack、es-dev-server 在 Server 中间件中处理请求、文件转换不同,WMR 、Vite 的插件体系将文件转换以及 Server 中间件分离开来,概念上比较清晰, 也比较易于维护。WMR、Vite 这种插件系统也有利于 dev 和 build 时复用文件转换相关的逻辑。

从浏览器发出请求到 Server 返回对应资源的流程如下图所示:

渐进式 Unbundled 开发工具探索之路

在 resolveId hook 中根据 url 解析出具体文件路径。load hook 主要加载文件内容。transform hook 是编译转换各种类型资源文件的核心。下面对几种资源文件在 Server 内部的处理展开描述:

JSX/TSX 编译转换

我们知道 JSX或 TSX 不能直接在浏览器中运行,这里因为 dev 环节对浏览器兼容性没有要求以及追求更快的实时编译速度,直接使用 esbuild transform 即可:

const result = await esbuild.transform(code, {
  loader: 'tsx',
  sourcefile: importer,
  sourcemap: true,
  target: 'chrome63'
})

在业务项目中实践时,遇到的一些问题举例如下:

  1. esbuild 不支持 React 17 jsx transformer,inject 配置选项 transform api 也没有提供,当业务项目使用 React 版本为 17 时,并且没有显示导入 React 时, 我们在 esbuild transform 的结果上自动注入 import React from 'react'

  2. 业务 TS 项目中有很多使用了 const enum 导致 esbuild 无法处理,只能切换到 typescript api 处理。

  3. 老项目业务代码中依赖 babel 生态的插件, 比如 babel-plugin-macros[7], 这就导致我们额外需要提供 babel 编译的流程。

基础的语法转换完成后,接下来就是 Bare Import 的处理问题,我们的业务代码中直接通过包名导入依赖的方式经过打包工具处理能够正常运行, 如下:

import React from 'react'

但是在浏览器中直接运行会直接报错, import-maps[8] 提案可以解决这个问题,但是只有最新版本 Chrome 支持。这里我们采用的方案和业界的做法一致,编译完成后改写 import 语句, 以 React 为例, 最终返回的内容如下:

import React from "/node_modules/.web_modules/react.js";

node_modules/.web_modules 目录就是我们在依赖预处理时生成的第三方依赖 ESM 文件目录。

CSS、JSON、图片等资源处理

浏览器中直接 import 导入资源,要求返回的类型是 application/javascript,因此这些文件在对应的插件中最终都会被处理成 JavaScript。

CSS 默认会用 PostCSS 处理之后直接创建 style 标签插入 head 节点就能生效,以 import ./App.css 为例,返回的内容大致如下:

// src/App.css
const code = "body {\n  margin: 0;\n}\n"
const filename = "/Users/songzhenwei/Documents/test/full-demo/src/App.css"
const styleEl = document.createElement('style');
const codeEl = document.createTextNode(code);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);

这里只是简单展示返回文件的内容,真正可用还需要考虑热更新时 Style 标签的删除、CSS 文件中通过 [url function](<https://developer.mozilla.org/en-US/docs/Web/CSS/url( "url function")>) 加载字体图片等场景。

JSON 文件比较简单,这里我们只需要读取文件 default 导出即可:

export default { "name": "example" }

业务代码中使用图片等资源时, 我们会在 import 语句后面添加 ?assets query 表示为资源请求需要编译处理, 如下:

import logo from './logo.png';
// => rewrite to 
import logo from '/src/logo.png?assets'

/src/logo.png?assets 直接返回对应资源的实际 url 即可:

export default '/src/logo.png'

对于图片资源,实际业务场景中我们还需要支持根据文件尺寸决定是否通过 base64 编码内联、svgr 等特性。

到这里通过不同的插件完成了一些文件类型的编译转换,页面已经可以在浏览器中正确渲染。

热更新功能

在 Webpack 等打包工具里面,热更新相关代码通常写在入口文件内如下:

// src/index.jsx
import App from './App';

module.hot.accept('./App', () => {
   renderApp();
})

App 根组件中引用到的文件修改时,会触发入口文件中注册的 accept 回调函数重新渲染 App 组件。

ESM 场景下的 HMR API, 业界也有一些规范:Snowpack 联合 Vue、Preact 提出了 ESM-HMR Spec[9],类似 module.hot, unbundled 开发工具需要提供 import.meta.hot 对象,比如常用的 accept 函数使用如下:

import.meta.hot.accept();

import.meta.hot.accept(['./dep1', './dep2'], ()=> {})

这部分实现参考了 Snowpack 和 Vite , 文件更新时,通过内部建立好的依赖关系,上溯至 accept 该文件或自身的文件节点,重新在浏览器请求该文件, 如下图:

渐进式 Unbundled 开发工具探索之路

修改 dep-b.js 向上遍历依赖树时,找到 accept 的文件节点 App.tsx , 同时会依赖路径上文件节点的编译缓存失效,之后通过 HMR client api 重新请求 App.tsx ,为了保证返回的内容是最新的,重新请求时会加上时间戳。

和 Webpack 等打包工具热更新相比,Unbundled Development 开发工具热更新只会重新编译加载依赖路径上的文件, 因此速度也会更快。同时也能结合 React Fast Refresh 做组件级别的热更新。

总结

上面通过一些小点,介绍了我们内部解决 Webpack 打包慢的一些探索以及最终实现 Unbundled Development 模式的一些做法, 最终实现的版本和我们应用开发的标准范式一一对齐。在业务项目中使用 Unbundled Development 模式后开发体验有很大提升:

渐进式 Unbundled 开发工具探索之路

生产环境现阶段还是通过 Webpack 打包出 JS Bundle ,在一些基础编译能力以及使用方式上尽最大努力抹平 Unbundled Development 模式和生产环境 Webpack 打包的差异。

通过实际业务项目接入 Unbundled Development 模式实践,我们这边也针对 CJS 转 ESM 积累了一定的解决方案。通过云端统一处理的方式,后续也能发挥出更大的作用。一些新的方案如免依赖安装也在持续探索中,最后,我们也希望能对 Unbundled Development 生态添砖加瓦,最后反哺生态。

参考资料

[1]

Webpack: https://webpack.js.org/guides/build-performance/

[2]

Build Performance Guide: https://webpack.js.org/guides/build-performance/

[3]

lazy-compile-webpack-plugin: https://github.com/liximomo/lazy-compile-webpack-plugin

[4]

rollup-plugin-cdn: https://github.com/WebReflection/rollup-plugin-cdn

[5]

@pika/cdn-webpack-plugin: https://www.npmjs.com/package/@pika/cdn-webpack-plugin

[6]

@pika/cdn-webpack-plugin: https://www.npmjs.com/package/@pika/cdn-webpack-plugin

[7]

babel-plugin-macros: https://github.com/kentcdodds/babel-plugin-macros

[8]

import-maps: https://github.com/WICG/import-maps#the-basic-idea

[9]

ESM-HMR Spec: https://github.com/snowpackjs/esm-hmr

上一篇:计算机体系结构的庖丁解牛


下一篇:Cytoscape.js——(试图学习)数据分析与可视化插件的使用