Vite源码学习(十三)——依赖预构建

350 阅读11分钟

前言

从这一篇文章开始,我们讲述VITE核心概念中比较重要的一个优化手段:依赖预构建

有的同学可能会有疑惑,为什么Webpack,Rollup这些打包构建工具没有这种技术特性呢?这主要还是取决于VITE的架构设计,VITE的设计是基于bundless的。

所谓bundless是指在开发阶段,不打包或少打包,这样就使得VITE有了非常快的冷启动(这就是冷启动冷字的真实含义)速度,而Webpack这种打包工具必须在启动前把我们的源码文件整体打包到内存中去,假设你的项目比较大的话,光是启动项目就得花费很久的时间才能完成。

我们是不是要说VITE的设计理念先进,Webpack的设计理念落后呢?其实没有必要这样黑(就好比总有人喜欢去对比Vue跟React孰优孰劣一样,大家都是搬砖的,比较谁搬的🧱更好看,真的没有什么意思),在Webpack诞生的年代,浏览器在当时还不支持原生ESM,所以webpack实现了一套文件加载系统。

而VITE作为后继者,在设计的时候就可以尽可能的利用现有的技术改进之前的一些问题,所以VITE选择bundless的架构还是离不开浏览器本身的技术进步。

bundless也不是银弹,虽然它解决了Webpack启动时必须把所有内容打包到内存的问题,但是仍然存在以下问题:

  • 源码是CommonJS规范,而浏览器只能识别ESM规范的代码,需要进行转换。
  • 源码未经打包,内容比较散乱,比如lodash-es,如果我们不做任何处理的话,导入它将会一下触发600多个的资源请求,这对浏览器的性能有着严峻的考验。
  • 一致性问题,由于我们在DEV阶段,由于不会有TreeShaking,当构建生产内容时,开启了TreeShaking,会有可能导致不一致的风险。

这个问题,我还在实际开发中遇到过一次,刚好可以给大家举个有说服力的例子。 20250222-092201.png 大家看一下上面这段代码有没有什么问题?

首先,renderFrame这个函数在调用的时候是没有传递参数的,在编译工具的视角里它认为你没有传t这个参数(它并不知道requestAnimationFrame这个函数有参数),那么整个if分支就不可能成立了,于是,经过TreeShaking之后的代码就成了这样:

const renderFrame = (t) => {
    // if分支已被树摇优化 
    requestAnimationFrame(renderFrame)
}

renderFrame();
  • 兼容问题,当我们需要考虑支持比较老的浏览器,就需要在构建生产环境代码时部署一些polyfill,为了解决这个问题,VITE官方提供了插件支持@vitejs/plugin-legacy

虽然VITE有这些问题,但是实在架不住VITE快啊,时间成本是优于一切其它因素,这也是VITE为什么这么受欢迎的原因,而依赖预构建就是为了解决我们前面提到的2个问题。

好了,废话不多说了,我们就开始进入今天的正题吧。

启动流程分析

依赖优化的入口不是特别好找,我是花费了一些时间找到的它的启动入口。

之前,我们在讲VITE的核心类的时候,已经向大家详细的阐述过DevEnvironment这个类了,依赖优化上下文是在这个类的构造器里面创建的。 image.png 在创建之后,它的初始化是在DevEnvironmentinit方法里面调用的。 image.png 接下来,我们就看一下DevEnvironmentinit方法是被谁调用的呢? image.png image.png 上面的调用链我们在前面已经讲过的创建DevServer的过程。

大家还需要注意一下上图哦,listen方法的return是在await initServer之后,所以,依赖预构建的处理顺序应该是在DevServer启动完成之前就进行了的

接下来,我们就开始看VITE是怎么去扫描哪些内容是需要做依赖预构建的。

调用discoverProjectDependencies方法获取到扫描到的依赖预构建内容: image.png 接下来,我们看一下这些方法里面它是如何扫描到待预构建的内容的。 image.png 这儿牵涉到esbuild的API,我就不带大家作关于esbuild处理部分详细逻辑的分析了,我会帮大家找到依赖是在哪个位置扫描到就结束。

在这个scanImorts方法中,首先初始化了一个esbuild的上下文,初始化的过程中调用了prepareEsbuildScanner这个方法。 image.png 点进去看关于这个prepareEsbuildScanner没有什么神秘的,就是创建esbuild的上下文,不过,这儿很关键的点就在于,看起来这段代码平平无奇,其实它的处理逻辑放到了VITE自定义的esbuild插件处理了。 image.png 关于esbuild的插件编写,思路跟VITE看起来也是大同小异的,只不过API风格不同而已。 image.png 我猜测esbuild应该是也通过词法分析去获取的引用吧(后文也用这个说法进行阐述,如果有问题,欢迎读者纠正),我没有更深入验证过这个过程,目前我只关心这个位置可以获取到依赖预构建的内容。 image.png 好了,在获取到了依赖预构建的内容列表之后,我们接下来就要开始对这些内容进行预构建了。 image.png 接下来,我们就看一下这个runOptimizeDeps方法的实现过程。 image.png 在上面这个代码中,创建预构建缓存目录,并且创建目录的package.json

接着,又使用esbuild创建一个用于构建的上下文。 image.png 以下是创建过程: image.png 我们把这段代码运行起来看一看,大家就明白了。

可以看到,我们目前断点命中的位置,此刻缓存目录里面还没有预构建的任何内容。 image.png 当执行了esbuild的构建方法之后,缓存目录已经生成了预构建的内容了。 image.png 假设构建是成功的,VITE就进行成功的后续处理。

写入元数据JSON: image.png 并且将之前的临时文件目录命名成正式目录。 image.png 好了,到这个位置为止,依赖预构建的处理几乎就已经完成了。

在上面整个过程中,是发生在我们启动VITE的DEV服务器就已经进行了(也就是说现在还没有任何客户端访问服务器),可以看到在上述流程中,都还是调用esbuild的API进行分析与构建,因为esbuild比较快,所以倒也无所谓。

下图就是我打印出来的信息,VITE是使用esbuild将我的测试项目所有的内容都扫描了一遍。 image.png 可以看到,这个调用栈的来源包含esbuild的包,所以可以证明代码是esbuild执行的,因此速度就会非常快。 image.png

依赖重新构建

在上面有一个问题,依赖扫描的分析过程可能存在遗漏,为什么是这样呢?因为在这个时候,VITE的插件系统还没有工作,之前的扫描都还是基于esbuild的词法分析。

我给大家举个形象一点的例子,比如我们编写一个Vue文件,里面就是一个纯静态的文案展示,也就是这种场景下,我们在源码里面肯定是不会从vue里面导入什么内容的。

但是,经过@vitejs/plugin-vue这个插件处理之后,肯定就要从vue里面导入内容了,否则就无法正常运行了。

源码: image.png 编译结果: image.png 所以,这个时候,我们就应该再次进行依赖预构建,接下来,我们就来看一下这个过程是如何处理的。

之前我们曾经简单的分析过VITE的内置插件vite:resolve,在这个插件中,VITE可以对导入的路径进行路径重写。

当我们引入的资源是来自node_modules的时,VITE会对其进行路径重写。

只有从node_modules导入的资源,才会匹配到这个ifimage.png 路径重写: image.png 然后,开始准备重新依赖预构建: image.png 把缺失的依赖加入到依赖预构建的列表里面去: image.png 以下是运行起来的内存数据,大家可以直观的看到: image.png 接下来,就执行重新构建就可以了: image.png image.png image.png 剩下的步骤,就跟我们之前分析的依赖预构建是一模一样了的,只不过此刻不需要再去扫描依赖预构建选项了,直接拿到结果,就可以进行构建了。 image.png 最后,只需要把最新的内容写回磁盘就可以了,大家需要注意一下,这个地方VITE是直接调用的runOptimizeDeps方法,所以之前的预构建内容直接完全扔掉,用新的内容来覆盖,对于esbuild这种处理速度非常快的构建工具,这点儿性能消耗几乎可以忽略不计。

使用依赖预构建内容

跳过依赖预构建

很明显,如果我们现在只是把DevServer重启一下的话,这些预构建的内容是还存在磁盘上的,那么,我们就不需要再次进行预构建了。 image.png 在运行使用缓存的情况下,直接读取已经处理过的依赖预构建内容: image.png 反序列化依赖预构建的元数据: image.png 如果有缓存的元数据,初始化就直接完成了: image.png

使用依赖预构建资源

当依赖预构建处理好了之后,现在最大的意义就是要把它用起来,否则一切就是白忙活了。

现在我们把目光切回到vite:resolve这个内置插件上,我们看它是如何使用依赖预构建的路径替换源码中的资源路径标识符。 image.png 获取到依赖预构建的缓存信息: image.png image.png 于是,经过插件处理之后,编译结果里面包含的路径就已经是依赖预构建的缓存路径了,从而避免了请求一次资源,会级联请求几百个资源的问题,也处理了CommonJs风格的包不能在浏览器中直接使用的问题。 image.png image.png 这儿需要向大家提一个注意事项,VITE的依赖预构建仅仅只能处理CommonJS风格的node包,如果说你在源码中需要引入一个CommonJS的文件,在不做额外插件的配置的情况下,是不行的

能正常加载资源: image.png 但是一运行就报错了: image.png

依赖预构建总结

VITE的依赖预构建主要解决了2个问题(这儿我就直接给大家截官方文档上的描述,如图): image.png

VITE的依赖预构建是发生在DevServer启动之前就进行的,VITE会使用esbuild访问项目中所有的内容,并且找到是从node_modules目录中导入的资源(VITE的依赖预构建只处理Node包,不会处理源码),把它添加到待构建信息中。因为VITE使用的是esbuild扫描整个项目,因此速度很快。

当扫描得到了待构建信息之后,VITE会使用esbuild构建这些内容,默认会把它写入到当前node_modules下面的.vite/deps目录里面(这个路径可以自己配置,大家可以参考文档),同时里面还包含了已构建的元信息。

因为在依赖预构建的时候,VITE还仅仅只是利用esbuild分析项目中的nod_modules引用,因此在初次预构建时有可能分析不全(因为插件系统中,有可能有额外的内容导入,而在依赖预构建的时候,插件系统是还没有完全工作的),因此,当客户端发送请求到VITE的DevServer时,可能触发重新的依赖预构建,当重新构建时,VITE会直接丢弃旧的预构建内容再生成新的预构建内容。

当VITE读取到有预构建缓存时,则会跳过依赖预构建的过程。

当我们访问资源时,如果源码中引用的内容来自node_modules时,VITE在内置插件vite:resolve中则会尝试从依赖预构建的信息中读取,若能匹配成功则可以使用依赖预构建的路径替换源码中的路径,就达到了在DEV阶段优化的效果。

以上就是我通过学习VITE源码依赖预构建整体流程之后的总结,由于我的水平有限,总结可能存在纰漏或错误,如有问题,请各位读者指正。

最后,感谢大家的阅读,如果你能够坚持看到这个位置并理解的话,恭喜你,你几乎已经完全掌握了VITE的核心设计,我们下期将会阐述VITE的最后一个命令Preview,未完待续......