一次偶然的机会,我将项目(基于 tdesign-vue-next-starter )由 Vite 2.7
升级成 Vite 3.x
后,发现首次运行 Vite dev 构建,页面首屏时间非常长,且一定会整个页面刷新一次。而第二次进入则不再刷新页面。
充满好奇心的我,决定研究一下为什么 Vite.3.x 会有这么一个负优化,于是我仔细研究源码,最终发现了问题的根源,并给 Vite 提交了修复的代码
大概测了一下,修复前的页面首屏时间为 1m06s,修复后为 45s,性能提升了 25%
升级 Vite3.x 后的代码放到了该仓库,感兴趣的同学可以自行调试
项目升级 Vite3.x
后,首次进入页面,页面的首屏时间非常的长,且一定会刷新整个页面,这个问题只有在没有 Vite 缓存情况下出现。
因为我们可以通过以下方式复现:
vite --force
从日志中,可以初步判断出,Vite 在运行过程中,发现了新的依赖,然后重新执行预构建,再刷新页面。
因此我们需要更多的信息,要打印更多的运行 log,以清楚 Vite 的运行状态。这里我们可以通过设置 DEBUG
环境变量,来输出更多的关于依赖构建相关的日志:
# vite:deps 是指过滤出依赖预构建的日志
# force 代表不使用之前构建的缓存,以确保每次都能复现问题
cross-env DEBUG=vite:deps vite --force
我们来仔细看一下日志信息:
仅仅从日志的字面意思,我们可以得出以下信息:
看起来就是因为依赖扫描的时候,有很多依赖没有被扫描出来,那么这些依赖没有被预构建。导致运行代码时,多次发现新的依赖(没有进行预构建),导致又要重新执行预构建,最后还刷新了页面。
因此可能问题的根源是:Vite 的依赖扫描没有扫描到所有的依赖。
这块涉及到 Vite 依赖扫描的相关知识,恰好之前就研究过这个内容,还写了一篇文章:《五千字深度解读 Vite 的依赖扫描》,这里总结一下:
import
语句,就把 import
的内容记录下来,例如 import Vue,就记录 Vue
到数组中node_module
中的依赖,这些代码就是第三方依赖。假如有如下的模块依赖树,则扫描到的依赖就是 vue
和 axios
模块依赖树是利用模块中的 import
语句(静态 import、动态 import 均可),将各个模块连接起来的。
Vite 文档也同时指出,Vite 默认的依赖发现为启发式,可能并不总是可取
什么时候 Vite 的依赖发现不可靠呢?
当源代码中没有 import
语句,但经过代码编译转换后才有 import
语句,这种情况,Vite 无法依赖扫描。只能在浏览器请求模块,Vite 转换后,在运行时发现新依赖。
我们看看项目中的模块依赖树(节选):
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
的转换,从而导致依赖扫描不全。
知道问题之后,我们只要将 glob
的转换逻辑加上即可
如何修复,这个过程就不细说了,因为也不需要关心了,说多了反而让文章更难理解。
为了进一步了解 Vite 的运行机制,我们研究一下这个问题:
为什么依赖扫描不全,会导致后面的一系列问题(依赖重新构建、页面刷新)?
我们需要对照运行日志和模块依赖树,来解析依赖扫描不全后的 Vite 的整个运行过程:
import.meta.glob
没有被转换,Vite 认为 router.ts 下只有 Login.vue,Login.vue 下的依赖被 Vite 发现,但 base.ts 等模块及其嵌套使用的依赖,并没有被扫描到echart/charts
,重新执行依赖预构建以下是这一过程的图示,从第 3 点开始画的
静态 import 和动态 import 的区别?
静态 import:阻塞代码执行,必须要等 import 的模块加载完成,才会执行当前模块的代码
动态 import:异步加载模块,不阻塞当前模块代码执行。
我们来看下面这个片段。
base.ts 是静态 import Layout.vue 的,因此 base.ts 必须要等它嵌套的依赖加载完成,才会执行。但由于嵌套的 SiderNav 依赖了 lodash/union
,lodash/union
又必须等构建完成,才能返回。
因此 base.ts、Layout.vue、SiderNav.vue 三个模块都被阻塞了。
再来看这个片段:
当 base.ts 代码运行时,才发现有动态的 import dashboard.vue,在请求 dashboard.vue 过程中,又发现了新的依赖 echart/charts
,又需要重新预构建。
结合这两个片段,我们会发现这两次发现新依赖,并没有办法合成一次构建,即使 Vite 有延迟执行重新构建的能力
因为发现新依赖 lodash/union
,base.ts 是被阻塞的,无法执行代码,这就无法知道需要请求 dashboard.vue,也就无法知道有新的依赖 echart/charts
这就是依赖扫描不全导致的严重后果:由于静态 import 阻塞代码执行,导致运行过程中多次发现新依赖,多次重新预构建。
因此这次的修复,其实对性能提升远远大于 25%,原因有以下两点:
每次发现新的依赖,必须重新构建吗?
必须重新构建
官方文档提到了, Vite 构建的两个目的:
因此新的依赖,必须要等构建完成才能返回,期间会造成阻塞
为什么只有最后一次依赖预构建才会刷新页面?
我们来看看三次构建的产物(节选):
echart/core
和 lodash/keys
lodash/union
,该依赖跟原有依赖,没有任何公共代码,因此打包的产物也不会相互依赖echart/charts
,它与 echart/core
有公共的依赖,打包产物会多了一份公共的代码,它们都依赖这份公共代码。第三次构建与第二次构建对比, echart/core
的模块文件已经被改变(原来自己所有代码都在一个模块,现在公共代码被抽离),原先浏览器拉取的 echart.core
代码已经是失效的代码,这时候只能刷新页面,让浏览器重新拉取最新的 echart/core
Vite 实际上会根据打包前后的 file hash,来决定是否需要刷新页面,如果所有依赖的构建前后文件 hash 没有被改变,则不会刷新页面,例如第二次构建,只新增了 lodash/union
,其他模块没有被改变。
文章就写到这了,第一次给 Vite 贡献代码,的确有点小激动。虽然是一个小小的 bug,但实际上过程是充满坎坷的,每一个小小的问题都能研究几天,但最后回顾起来,这个过程学到了很多收获还是非常大的。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)
更多内容可以查看我的专栏:《Vite 设计与实现》
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。