我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

lxf2023-03-18 16:51:01

本文正在参加「 . 」

一次偶然的机会,我将项目(基于 tdesign-vue-next-starter )由 Vite 2.7 升级Vite 3.x 后,发现首次运行 Vite dev 构建,页面首屏时间非常长,且一定会整个页面刷新一次。而第二次进入则不再刷新页面。

充满好奇心的我,决定研究一下为什么 Vite.3.x 会有这么一个负优化,于是我仔细研究源码,最终发现了问题的根源,并给 Vite 提交了修复的代码

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

大概测了一下,修复前的页面首屏时间为 1m06s,修复后为 45s,性能提升了 25%

问题详情

升级 Vite3.x 后的代码放到了该仓库,感兴趣的同学可以自行调试

项目升级 Vite3.x 后,首次进入页面,页面的首屏时间非常的长,且一定会刷新整个页面,这个问题只有在没有 Vite 缓存情况下出现。

因为我们可以通过以下方式复现:

vite --force

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

日志中,可以初步判断出,Vite 在运行过程中,发现了新的依赖,然后重新执行预构建,再刷新页面。

因此我们需要更多的信息,要打印更多的运行 log,以清楚 Vite 的运行状态。这里我们可以通过设置 DEBUG 环境变量,来输出更多的关于依赖构建相关的日志:

# vite:deps 是指过滤出依赖预构建的日志
# force 代表不使用之前构建的缓存,以确保每次都能复现问题
cross-env DEBUG=vite:deps vite --force

运行结果如下:我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

我们来仔细看一下日志信息:

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

仅仅从日志的字面意思,我们可以得出以下信息:

  1. Dev server 启动
  2. 依赖扫描,扫描出了项目中使用了哪些依赖。这里扫描到的依赖是不全的
  3. 访问页面后,发现新的依赖(lodash/union),重新执行依赖构建
  4. 发现新的依赖(echarts/charts、echarts/renderer 等),又重新执行依赖构建
  5. 刷新页面

看起来就是因为依赖扫描的时候,有很多依赖没有被扫描出来,那么这些依赖没有被预构建。导致运行代码时,多次发现新的依赖(没有进行预构建),导致又要重新执行预构建,最后还刷新了页面。

因此可能问题的根源是:Vite 的依赖扫描没有扫描到所有的依赖

Vite 的依赖扫描

这块涉及到 Vite 依赖扫描的相关知识,恰好之前就研究过这个内容,还写了一篇文章:《五千字深度解读 Vite 的依赖扫描》,这里总结一下:

  1. 用 esbuild 打包一遍整个项目
  2. 打包过程中遇到 import 语句,就把 import 的内容记录下来,例如 import Vue,就记录 Vue 到数组中
  3. 最后只留下实际路径为 node_module 中的依赖,这些代码就是第三方依赖。

假如有如下的模块依赖树,则扫描到的依赖就是 vueaxios

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

模块依赖树是利用模块中的 import 语句(静态 import、动态 import 均可),将各个模块连接起来的。

Vite 文档也同时指出,Vite 默认的依赖发现为启发式,可能并不总是可取

什么时候 Vite 的依赖发现不可靠呢?

当源代码中没有 import 语句,但经过代码编译转换后才有 import 语句,这种情况,Vite 无法依赖扫描。只能在浏览器请求模块,Vite 转换后,在运行时发现新依赖

提出和验证猜想

我们看看项目中的模块依赖树(节选):

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

router.ts 的部分代码如下:

// 自动导入modules文件夹下所有ts文件
// glob 和 globEager 作用相同,只是转化后,是动态引入还是静态引入的区别
const modules = import.meta.globEager('./modules/**/*.ts');

这是一种很常见的用法,所有的 vue-router 配置写到 modules 文件夹下,然后 router.ts 自动引入该文件的所有模块,然后传给 vue-router。

整个项目中,除了 router.ts 中使用 glob 特性进行引入模块外,其他模块均使用静态 import 或动态 import 语句引入模块。因此依赖扫描流程中,唯一可能出现问题的,就是在依赖扫描阶段 glob 没有进行转换

要想验证 Vite3.x 在依赖扫描阶段没有转换 glob,只需要在 Vite2.x 中找到转换代码,而在 Vite3.x 中找不到即可。

经过考证,我从这个 pull request 中得知,Vite3.x 重构了 import.meta.glob 的转换,但却删除对 JS 代码中 glob 的转换,从而导致依赖扫描不全。

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

知道问题之后,我们只要将 glob 的转换逻辑加上即可

如何修复,这个过程就不细说了,因为也不需要关心了,说多了反而让文章更难理解。

为了进一步了解 Vite 的运行机制,我们研究一下这个问题:

为什么依赖扫描不全,会导致后面的一系列问题(依赖重新构建、页面刷新)

依赖扫描不全后的运行过程

我们需要对照运行日志和模块依赖树,来解析依赖扫描不全后的 Vite 的整个运行过程:

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

  1. import.meta.glob 没有被转换,Vite 认为 router.ts 下只有 Login.vue,Login.vue 下的依赖被 Vite 发现,但 base.ts 等模块及其嵌套使用的依赖,并没有被扫描到
  2. 第一次依赖预构建完成
  3. 访问页面,执行时,请求 router.ts 页面,router.ts 被 Vite 转换
  4. 浏览器执行 router.ts 代码,动态 import base.ts,在浏览器运行时才知道有 base.ts 模块
  5. 请求 base.ts,Vite 转换 base.ts 并返回
  6. 执行 base.ts 代码,请求静态 import Layout.vue ,Vite 发现新依赖 echarts/charts 等, 重新执行依赖预构建
  7. 第二次依赖预构建完成
  8. 浏览器执行 base.ts 的代码,发现有动态 import dashboard.vue 模块
  9. 请求 dashboard.vue 及其嵌套的模块,发现新依赖 echart/charts,重新执行依赖预构建
  10. 第三次依赖预构建完成

以下是这一过程的图示,从第 3 点开始画的

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

静态 import 和动态 import 的区别?

静态 import:阻塞代码执行,必须要等 import 的模块加载完成,才会执行当前模块的代码

动态 import:异步加载模块,不阻塞当前模块代码执行。

我们来看下面这个片段。

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

base.ts 是静态 import Layout.vue 的,因此 base.ts 必须要等它嵌套的依赖加载完成,才会执行。但由于嵌套的 SiderNav 依赖了 lodash/unionlodash/union 又必须等构建完成,才能返回。

因此 base.ts、Layout.vue、SiderNav.vue 三个模块都被阻塞了。

再来看这个片段:

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

当 base.ts 代码运行时,才发现有动态的 import dashboard.vue,在请求 dashboard.vue 过程中,又发现了新的依赖 echart/charts,又需要重新预构建。

结合这两个片段,我们会发现这两次发现新依赖,并没有办法合成一次构建,即使 Vite 有延迟执行重新构建的能力

因为发现新依赖 lodash/union,base.ts 是被阻塞的,无法执行代码,这就无法知道需要请求 dashboard.vue,也就无法知道有新的依赖 echart/charts

这就是依赖扫描不全导致的严重后果:由于静态 import 阻塞代码执行,导致运行过程中多次发现新依赖,多次重新预构建。

因此这次的修复,其实对性能提升远远大于 25%,原因有以下两点:

  1. 运行过程中还会发现新的依赖,导致重新预构建
  2. 依赖扫描完整后,扫描出非常多的依赖,所有的这些依赖构建时间为 40s;而没修复前,仅仅扫描出少量的依赖,构建时间仅仅不到 10s。两者构建的依赖数量本身就相差较大的。

每次发现新的依赖,必须重新构建吗?

必须重新构建

官方文档提到了, Vite 构建的两个目的:

  1. CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
  2. 性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

因此新的依赖,必须要等构建完成才能返回,期间会造成阻塞

为什么只有最后一次依赖预构建才会刷新页面?

我们来看看三次构建的产物(节选):

我修复了一个 Vite Bug,让我的项目首屏性能提高了 25%

  1. 第一次构建,有 echart/corelodash/keys
  2. 第二次构建,新发现了 lodash/union,该依赖跟原有依赖,没有任何公共代码,因此打包的产物也不会相互依赖
  3. 第三次构建,新发现了 echart/charts,它与 echart/core 有公共的依赖,打包产物会多了一份公共的代码,它们都依赖这份公共代码。

第三次构建与第二次构建对比, echart/core 的模块文件已经被改变(原来自己所有代码都在一个模块,现在公共代码被抽离),原先浏览器拉取的 echart.core 代码已经是失效的代码,这时候只能刷新页面,让浏览器重新拉取最新的 echart/core

Vite 实际上会根据打包前后的 file hash,来决定是否需要刷新页面,如果所有依赖的构建前后文件 hash 没有被改变,则不会刷新页面,例如第二次构建,只新增了 lodash/union,其他模块没有被改变。

总结

文章就写到这了,第一次给 Vite 贡献代码,的确有点小激动。虽然是一个小小的 bug,但实际上过程是充满坎坷的,每一个小小的问题都能研究几天,但最后回顾起来,这个过程学到了很多收获还是非常大的。

如果这篇文章对您有所帮助,可以点赞加收藏