首页>国内 > 正文

前端历史项目的 Vite 迁移实践总结

2022-05-09 10:45:59来源:前端加加

当前,前端社区用 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 之间的区别,在其文档中也已经展示得很清楚,一图胜千言:

Webpack 式的经典 bundler 示意图

Vite 式的 No-bundler 示意图

基于这个差异我们就可以知道,要让 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  --ConardLiconst { 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 引擎移植上去

所以最后,非常鼓励大家多做兴趣驱动的技术尝试。没准未来的哪天,折腾它们的经验就能帮助你找到抓手,赋能业务,形成闭环,打出一套组合拳呢

关键词: 秘密花园 解决问题 实际问题 环境变量

相关新闻

Copyright 2015-2020   三好网  版权所有