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

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

本文正在参加「金石计划 . 瓜分6万现金大奖」

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

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

image-20221115223200740

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

问题详情

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

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

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

vite --force
复制代码

image-20221106203410506

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

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

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

运行结果如下:image-20221106212253161

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

image-20221106212637079

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

  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

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

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

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

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

提出和验证猜想

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

image-20221113102302363

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 的转换,从而导致依赖扫描不全。

image-20221115225401671

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

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

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

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

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

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

image-20221106212637079image-20221113102302363

  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 点开始画的

image-20221113202945953

静态 import 和动态 import 的区别?

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

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

我们来看下面这个片段。

image-20221115232028452

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

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

再来看这个片段:

image-20221115232423683

当 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 依赖关系转换为单个模块,以提高后续页面加载性能。

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

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

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

image-20221115233619112

  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,但实际上过程是充满坎坷的,每一个小小的问题都能研究几天,但最后回顾起来,这个过程学到了很多收获还是非常大的。

如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)

关联阅读

更多内容可以查看我的专栏:《Vite 设计与实现》

收藏成功!
已添加到「」, 点击更改