【总结】1319- 前端历史项目的 Vite 迁移实践总结
当前,前端社区用 Vite
替代 Webpack
的呼声正日趋高涨。但对于长期维护的业务项目,很多同学可能仍然对上车存有疑虑——Vite 真的足够支撑非玩具级的项目吗?为此本文会分享一个实际案例,介绍我们是如何(比较轻松地)在公司核心业务中落地 Vite
的。
稿定 Web 端业务中的平面编辑器已经有五年以上的历史。作为一个历经多人主导维护的前端项目,它有这么一些复杂度:
编辑器使用基于 Yarn workspace 和 Lerna 的宏仓库来管理源码,其中有近 20 个 package,初始化时会加载超过 400 个模块,并有 2GB 以上的 node_modules 依赖。 编辑器模块最早使用 Vue 0.8 和 AMD 模块语法 ,历经 Vue 1.x 和 2.x 时代维护至今。Webpack 也是从无到有,再从 1.x 一路升级到了现在的 4.x 版本。 编辑器内的部分高级渲染功能,用到了 Worker 和 WASM 的能力。 编辑器整体作为单个 NPM 包发布到公司私有仓库上供业务接入,有独立的打包和发版流程。
编辑器在 2016 年的第一次提交,基于 Vue 0.8 和 AMD 语法
我们不敢说这就是所谓的「大型企业级」项目,但这至少肯定不是个玩具项目。然而超乎预期的是,「Vite 的迁移成本甚至比升级 Webpack 和 Babel 大版本还要低」。只花了一个下午的时间,基于 Vite 的编辑器最小可用 MVP 就跑起来了。下面分几点介绍相关的实践经验:
如何规划基本的迁移思路,以及一些基础的知识储备。 如何通过编写插件来解决一些 Webpack loader 的问题。 如何迁移常见的 Webpack 配置。 如何处理上游依赖问题。
知识背景与思路
我们知道,以 Webpack 为代表的主流前端 bundler 之所以慢,根源在于它们冷启动时必须递归打包出整个项目的依赖树,并受限于 JavaScript 的天性(解释执行与单线程模型)而存在吞吐量上的瓶颈。为了解决这两个痛点,Vite 另起炉灶切换了路线:
对于项目中的业务模块,Vite 利用现代浏览器内置的 ES Module 支持,由浏览器直接向 dev server 逐个请求加载这些模块——因此你往往可以看到本地环境下大量的 HTTP 请求刷屏,这也是 Vite 最鲜明的特征。 对于项目中的 node_modules 依赖,Vite 借助 esbuild 这类由原生语言开发的高性能 bundler,将这些库中非 ESM 标准(CommonJS 或 UMD)的模块整体打包为 ESM,即所谓的 Dependency Pre-Bundling。这个过程的打包结果具备缓存,并且冷启动重建缓存的效率也极高。
Vite 的这个设计与 webpack-dev-server 之间的区别,在其文档中也已经展示得很清楚,一图胜千言:
基于这个差异我们就可以知道,要让 Vite 支持原有的 Webpack 项目,需要保证的无非两件事:
确保业务模块源码均符合 ESM 规范。 确保依赖均可正确被 esbuild 处理。
当然这只是最简单的思维模型。实际的前端项目中往往还会引入一些奇怪的东西,比如 CSS、JSON、Worker、WASM、HTML 模板……虽然 Vite 对这些需求已经内建了良好的支持,但确实谁也不敢保证能一键开箱即用——这并不是 Vite 或 Webpack 的问题,而是移植代码构建环境时的共通难点。对这类任务,「最难的地方总在于从零到一的「点亮」」。因此这里对此的建议是这样的:「充分熟悉从项目入口到各组件渲染完成之间所经历的代码(子)树,确保这一个最小的子集能够在新环境下正常运作」。其他代码都可以大刀阔斧地暂时移除掉。
对于架构设计合理的软件项目,一般都可以容易地实现模块的精简和扩展。例如在这个编辑器中,我们就支持了可配置并按需加载的元素类型。对于现有的 20 余种业务元素,它们对应的模块都已经支持了按需加载,只会在遇到相应数据时 import()
导入。因此在迁移时,只需保留若干基础元素模块实现用于测试即可。类似地,在业务项目中也可以通过精简路由配置等方式,定制出一个用于走通主流程的最小可用版本。
自定义插件实现
上述的代码精简过程,其实不外乎是建立一个干净的 example 页面来导入项目,注释掉部分代码然后反复执行 vite
命令测试,这里不再赘述。对于 Vite 迁移,很多同学最担忧的可能还是 Webpack 插件兼容性方面的问题。我们恰好也遇到了类似的问题,这里简单分享一下。
在前面 2016 年的编辑器上古版本代码截图中有一个细节,那就是其中引入了 editor.html
作为组件的 HTML 模板。这个行为历经多年一直保留到了现在——也就是说这里没有使用 SFC 单文件组件,而是对 text-element.js
等组件配套放一个 text-element.html
作为其模板,像这样:
// 导入 HTML 源码 --code秘密花园
import TextElementTpl from './text-element.html'
// Vue 2.0 的经典配置 --code秘密花园
export default {
template: TextElementTpl,
methods: {
// ...
},
created() {
// ...
}
}
在 Webpack 配置中,我们一般会用 HTML loader 来支持它,那么 Vite 呢?这类需求似乎并没有内置,而现在社区的 vite-plugin-html 是为 EJS 模板设计的,star 数量好像也不多……但真的就要等社区做现成的给你吗?
其实,Vite 的插件系统是直接依赖 rollup 的。对于这个需求,只要这样在 vite.config.js
里写个几行的插件就够了:
// 使用 rollup 附带的 plugin utils --ConardLi
const { createFilter, dataToEsm } = require('@rollup/pluginutils');
function createMyHTMLPlugin() {
// 建立一个用于筛选模块的 filter
const filter = createFilter(['**/*.html']);
return {
name: 'vite-plugin-my-html', // 起个名字 --ConardLi
// 根据 id 来筛选模块,并在遇到匹配的模块时变换其 source
transform(source, id) {
if (!filter(id)) return;
// 这样 HTML 字符串就能被 export default 给其他 JS 模块了
return dataToEsm(source);
},
};
}
// 这样就可以按照 Vite 的标准 API 来使用插件了
module.exports = {
plugins: [createMyHTMLPlugin()],
}
这个 createMyHTMLPlugin
不就是个非常简单的函数而已吗?但它却切实地解决了一个实际问题。个人认为对用户友好的构建系统应该做到在大多数时候能开箱即用,并能通过简单的逻辑自行扩展。在这一点上,可以说 Vite 还是做得相当出色的。另外 Vite 相比 Snowpack 的一个主要区别,就是它的插件系统与 Rollup 有更深的集成,由此实现了在 dev 和 build 两种模式下通用的插件 API。因此在业务中,也有机会自行「套壳」一些成熟的 Rollup 插件来实现需求。
常见 Webpack 配置迁移
在这次实践中用到的 Vite 配置相当少,值得一提的主要是这么几条:
通过 resolve.alias
配置,可以覆写(或者说劫持)掉模块路径。注意最好尽量让这个配置少一点,滥用它容易降低代码模块结构对工具链的友好性。通过 define
配置,可以支持process.env.__DEV__
这样的环境变量注入。注意 Vite 会把字符串直接注入成产物代码中的 raw expression,所以如果只想传递true
这种简单常量,要额外JSON.stringify
包一层。通过 vite-plugin-vue2
可以支持 Vue 2.0 的 SFC。这里的理由在于虽然编辑器内的主要组件没有使用 SFC,但测试页面的 demo 入口是个app.vue
。通过这个插件,可以让它们良好地共存。Less 和 CSS 依赖了 Vite 的内置支持,没有引入额外的配置。当然另一种变通方案是先执行独立打包 CSS 的命令,然后 import "./dist.css"
即可。通过 import Worker from "worker.js?worker"
的语法,可以支持 Web Worker。另外也可以进一步将其配合resolve.alias
配置,来继续兼容 Webpack。对于 WASM,除了形如 import init from "./a.wasm"
的内置支持以外,还有一种实践是让 WASM 的 JS 适配层支持传入可配置的 WASM 路径,这方面比较典型的例子可以参考 CanvasKit 等包。
上游依赖问题处理
基于上面介绍的这些实践,应当已经足够解决 Vite 对各类业务模块的加载问题了。但最后还有一个比较头疼的地方:如果 node_modules 中的依赖不能被 esbuild 正确打包,又该怎么办呢?
在这次迁移中,这样的问题我们有遇到两处,各自的原因有所不同:
图片重采样库 Pica 依赖了一个简易的 Web Worker 转换库,它会直接在模块代码顶层读取 arguments
数据,导致 esbuild 报错。字体解析库 OpenType.js 为了同时兼容浏览器端和 Node,在 ESM 源码中封装了若干 require('fs')
的函数。这也会导致报错。
对于这两个问题,其实都有一种通用的 workaround 手法:「建立一个 third_party 目录,把存在问题的上游模块拷贝一份进去,在这里修复问题并调整模块依赖即可」。如 Pica 库内 require('./a.js')
的代码,就可以复制到 third_party 目录后,将模块导入路径改为 require('pica/src/a.js')
,这样并不需全量复制整个上游依赖。而对于这里遇到的两个 CommonJS 问题,具体的修复也都很容易,例如把对 arguments
的读取放到 export default
的函数体内,并直接移除在浏览器环境下用不到的 Node 文件读取逻辑等。这样的 third_party 模式实际上倒也不算什么 hack,在很多语言的工程中有很广泛的使用,但也有些地方值得注意:
建议在改动位置添加 // FIXME
之类的注释,方便接受者确认修改之处。如果需要集成很大的上游依赖,那么不建议直接放到代码库里,可以使用 git submodule 或 CDN 等形式。 理想情况下应当向上游反馈 patch,解决问题后移除相应的本地版本。
以上就是全部值得列出的问题了,最后放一张基于 Vite 启动本地环境成功时的截图:
上图的日志有个问题,即加载了两个不同的 Vue 版本。这是因为 SFC 部分和依赖 HTML 模板的代码误用了不同的 Vue 依赖。这个问题后来通过 alias
配置将 vue
全部重写到 vue/dist/vue
而解决了。
由于编辑器 SDK 原本就使用 Babel 独立发版,因此原有的 NPM 发布过程不受影响,Vite 整体的侵入性也并不高。至于最终效果上也没有什么别的,就是油门踩到底加速了一下:
Webpack 40 秒以上的 dev server 冷启动时间缩短到了 1.5 秒内,在建立 .vite
目录缓存后,启动vite
命令的时间仅需约 300 毫秒。修改单个文件后 2 秒左右的增量编译时间被完全优化掉了,同时浏览器中加载页面的效率并没有明显差异。
这样一来,这个历史项目就重新获得了即时反馈级别的开发体验,同时也让更高效的 CI 集成成为了可能。这里的想象空间还很大,我们很期待让 Vite 在未来发挥出更大的作用。
总结
Vite 做到了以低接入代价换取开发体验上的大幅提升,有望引领前端构建工具领域的下一波 paradigm shift 浪潮。按 ROI 的话说,「其落地的潜在收益远大于成本」。 实际业务中的代码应当尽量贴合标准,少使用需依赖工具链黑魔法的特性,以换取更好的后向兼容性。 对于代码移植,实践中其实还有很多(未必上得了台面的)奇技淫巧,比如正则替换、编写 codemod 和为下游业务提供 deprecated API 检测脚本等等——扪心自问,把抄来代码里的 var
全部查找替换成let
这种事你干过没有?这些手段并没有什么高下之分,能简单方便地解决问题就好。JavaScript 本身哪怕作为编译后的产物,仍然是易读、易修改,且易向上游 backport 反馈的。主流的编译型语言都不容易做到这一点——类似于你把 DLL 里函数符号的机器码或 Java class 文件里的字节码改完,马上就能照着 diff 直接去给上游库提 PR。这是黑魔法的源头,可能也是种前端的「道路自信」吧。
实际上作为本文的作者,之前个人还尝试过一些类似的代码移植。这类工作就像是一个破解密室逃脱游戏的过程,非常有趣。个人感觉像这次的 Vite 迁移,在实践手段上其实和之前的经历都是相当共通的:
将 1995 年世界上最早的 JS 引擎源码编译回 JavaScript 将 Dart VM 从 Flutter 中抽离出来,单独在 iOS 原生项目中使用 为国产掌机搭建嵌入式 Linux 工具链,把 QuickJS 引擎移植上去
所以最后,非常鼓励大家多做兴趣驱动的技术尝试。没准未来的哪天,折腾它们的经验就能帮助你找到抓手,赋能业务,形成闭环,打出一套组合拳呢(
参考
作者:doodlewind
https://zhuanlan.zhihu.com/p/391077878
https://vitejs.dev/guide/why.html https://vitejs.dev/guide/dep-pre-bundling.html https://vitejs.dev/guide/api-plugin.html https://vitejs.dev/config/