前言
之前在使用 Vite 的时候,经常会遇到这种情况:项目启动以后,不管是首屏还是页面懒加载,如果发现有未进行预构建的第三方依赖,那么 Vite 就会重新预构建,然后触发页面的 reload。重复的页面load 操作,给开发人员带来了很不友好的体验。
最新发布的 3.0 版本对此问题做了部分优化,即首屏期间,即使有未进行预构建的第三方依赖,也不会发生页面 reload。
那 3.0 版本是怎么做到的呢?今天我们就通过本文,和大家一起聊一聊 3.0 版本针对二次预构建做了什么优化。
本文的目录结构如下:
初探 Vite 预构建
使用过 Vite 的同学都知道,开发阶段 Vite 会对项目中使用的第三方依赖如 react、react-dom、lodash-es 等做预构建操作。
之所以要做预构建,是因为 Vite 是基于浏览器原生的 ESM 规范来实现 dev server 的,这就要求整个项目中涉及的所有源代码必须符合 ESM 规范。
而在实际开发过程中,业务代码我们可以严格按照 ESM 规范来编写,但第三方依赖就无法保证了,比如 react。这就需要我们通过 Vite 的预构建功能将非 ESM 规范的代码转换为符合 ESM 规范的代码。
另外,尽管有些第三方依赖已经符合 ESM 规范,但它是由多个子文件组成的,如 lodash-es。如果不做处理就直接使用,那么就会引发请求瀑布流,这对页面性能来说,简直就是一场灾难。同样的,我们可以通过 Vite 的预构建功能,将第三方依赖内部的多个文件合并为一个,减少 http 请求数量,优化页面加载性能。
综上,预构建,主要做了两件事情:
-
将非
ESM规范的代码转换为符合ESM规范的代码; -
将第三方依赖内部的多个文件合并为一个,减少
http请求数量;
关于上面提到的几种情形,我们可以通过几个简单的 demo 来为大家演示一下:
-
react的入口文件代码如下:'use strict'; if (process.env.NODE_ENV === 'production') { module.exports = require('./cjs/react.production.min.js'); } else { module.exports = require('./cjs/react.development.js'); }我们可以通过配置
optimizeDeps.exclude为["react"], 使得dev server在工作过程中不对react进行预构建。当浏览器请求
react时,拿到的是commonjs类型的代码,无法执行,直接报错。 -
lodash-es的入口文件如下:export { default as add } from './add.js'; export { default as after } from './after.js'; export { default as ary } from './ary.js'; ... export { default as zipWith } from './zipWith.js'; export { default } from './lodash.default.js';我们通过配置
optimizeDeps.exclude为["lodash-es"], 使得dev server在工作过程中不对lodash-es进行预构建。当我们打开浏览器的
network面板时,我们发现首屏渲染时居然发起了多达659个http请求,导致页面性能非常差。 -
在本示例中,我们不对
optimizeDeps.exclude做任何配置,Vite会在dev server启动以后,默认对项目react、lodash-es等第三方依赖做预构建。默认情况下,
预构建结果会保存到node_modules的.vite/deps目录下。当我们再次启动
dev server时,如果项目的第三方依赖(.lock 文件内容没有变)和vite.config没有改变,那么Vite会复用上一次预构建的结果。如果不想让Vite复用上一次预构建的结构,我们可以配置optimizeDeps.force为true,使得dev server每次启动的时候都强制进行预构建。
二次预构建
预构建,最关键的一步就是找到项目中所有的第三方依赖。 那 Vite 是怎么做到快速获取项目中所有的第三方依赖呢?
在解释这个问题之前,我们先来聊一聊 Webpack、Rollup、Parcel 这一类静态打包器是如何打包代码的。以 Webpack 为例,整个打包过程可以分为构建模块依赖图 - module graph 和将 module graph 分离为多个 bundles 两个步骤。其中,构建 module graph 是重中之重。
Webpack 构建 module graph 的过程,可以拆解成下面几个步骤:
- 找到入口文件
entry对应的url, 这个url一般为相对路径; - 将
url解析为绝对路径,找到源文件在本地磁盘的位置,并构建一个module对象; - 读取源文件的内容;
- 将源文件内容解析为
AST对象,分析AST对象,找到源文件中的静态依赖(import xxx from 'xxx') 和动态依赖(import('xx'))对应的url, 并收集到module对象中; - 遍历第 4 步收集到的
静态依赖、动态依赖对应的url,重复 2 - 5 步骤,直到项目中所有的源文件都遍历完成。
在构建 module graph 过程中,我们就可以知道整个项目涉及的所有源文件对应的 url,然后就可以从这些 url 中过滤出第三方依赖。
同样的,Vite 在预构建的时候也是基于类似的机制去找到项目中所有的第三方依赖的。和 Webpack 不同, Vite 另辟蹊径,借助了 Esbuild 比 Webpack 更快的打包能力,对整个项目做一个全量打包。打包的时候,通过分析依赖关系,得到项目中所有的源文件的 url,然后分离出第三方依赖。
这样,Vite 就可以对找到的第三方依赖做转化、合并操作了。
预构建功能非常棒,但在实际的项目中,并不能保证所有的第三方依赖都可以被找到。如果出现下面的这两种情况, Esbuild 也无能为力:
-
plugin在运行过程中,动态给源码注入了新的第三方依赖; -
动态依赖在代码运行时,才可以确定最终的url;
当出现这两种情况时,Vite 会触发二次预构建。
我们可以通过几个 demo,为大家演示一下上面提到的两种情况:
-
首先是
demo演示,使用的Vite版本是2.9:在控制台上,我们可以清楚的看到打印信息:
new dependencies optimized: lodash-es,optimized dependencies changed. reloading。打开performance面板,也能清晰的看到reload操作。在这个
demo中,我们通过一个自定义插件 -customePlugin,在加载util.1.ts文件时,手动注入了lodash-es依赖。const customePlugin = { name: 'custome-plugin', async transform(code, id) { if (id.includes('util.1')) { code = `import { throttle } from 'lodash-es'; console.log(throttle); ${code}`; } return code; } } export { customePlugin };lodash-es在初次预构建的时候,无法被获取,也就无法进行预构建优化。dev server启动后,浏览器向dev server发送util.1.ts请求,dev server收到请求以后,需要对util.1.ts做下面几个处理:-
将
url解析为的绝对路径; -
根据文件绝对路径去加载源代码;
-
对源代码做转换(如 ts -> js, tsx -> js 等);
-
分析转换以后的源代码,收集源文件依赖的其他模块的
url; -
对收集到的
url重复步骤 1 到步骤 5,直到没有需要处理的url;
customePlugin在步骤 3 起作用,给源文件添加lodash-es依赖。在步骤 4,lodash-es会被收集到util.1.ts的依赖列表中。之后lodash-es开始步骤 1 的时候,dev server会判断出该url属于未进行预构建的第三方依赖,然后触发二次预构建。二次预构建完成以后,通知浏览器去reload页面。 -
-
将 plugin-inject-demo-2.9 的
Vite版本换成3.0,效果如下:控制台上没有打印发现新的依赖、重新
load页面的信息。打开preformance面板,我们可以看到只发生了一次load操作。相比
2.9版本,3.0版本对首屏期间的二次预构建做了优化,不再需要浏览器进行reload操作。这一块儿我们将在第三节 Vite 3.0 对预构建的优化 中重点讲解。 -
demo演示,使用的Vite版本是3.0:在演示
demo中,当我们切换到page3时,可以很明显的看到页面发生了reload操作。回到Vscode,我们也可以看到控制台上打印了new dependencies optimized: lodash-es、optimized dependencies changed. reloading信息。在
demo中,我们给Page3组件添加了下述代码:// Page3.tsx import React from 'react'; const importModule = (m: string) => import(`../../utils/${m}.ts`); importModule("util.1").then(res => { res.func1(); }); const Page = () => { return <div>页面3</div> } export default Page; // util.1.ts import { throttle } from 'lodash-es'; export const func1 = () => { console.log(throttle); console.log('func1'); }Page3进行懒加载时,会动态加载util.1.ts。util.1.ts中包含第三方依赖lodash-es。在首次
预构建时,util.1.ts无法被解析,导致lodash-es也无法被esbuild扫描到,没有被预构建优化。当我们切换到
Page3时, 先懒加载Page3.tsx,再懒加载util.1.ts, 然后解析lodash-es。此时,由于lodash-es属于未进行预构建的第三方依赖,dev server会触发二次预构建,然后通知浏览器做reload操作。
Vite 3.0 对预构建的优化
在开发过程中,如果频繁的触发二次预构建,导致重复的 reload 操作,对开发人员来说简直就是一种折磨。
针对这个问题, Vite 3.0 做了相关优化。在 plugin-inject-demo-3.0 中我们可以看到, 首屏期间,即使有未进行预构建的第三方依赖,也不会发生页面 reload。那它是怎么做到的呢?
其实,原理也非常简单。
首先需要声明一点,首屏期间,如果发现有未预构建的第三方依赖,还是会触发二次预构建。
3.0 版本对第三方依赖的请求和业务代码的请求有不同的处理逻辑。当浏览器请求业务代码时,dev server 只要完成源代码转换并收集到依赖模块的 url,就会给浏览器发送 response。而第三方依赖请求则不同,dev server 会等首屏期间涉及的所有模块的依赖关系全部解析完毕以后,才会给浏览器发送 response。这就导致了,如果发现有未预构建的第三方依赖,第三方依赖的请求会一直被阻塞,直到二次预构建完成为止。
有了这种操作,当然就不需要 reload 操作了。
这么一说,是不是非常简单呢!😄。
通过 plugin-inject-demo-2.9 和 plugin-inject-demo-3.0 两个 demo 首屏期间的 performance 面板,我们可以看到 react 在请求过程中被阻塞的情况。
在 plugin-inject-demo-2.9 中,在初次预构建时,react 已经完成了优化,首屏期间关于 react 的请求会快速响应。
在 plugin-inject-demo-3.0 中,尽管 react 已经在初次预构建的时候被优化,但在首屏期间解析模块依赖关系时,发现有未预构建的 lodash-es,触发二次预构建,react 请求一直被阻塞,直到二次预构建完成以后才被响应,耗时较长。
总结一下, 3.0 对二次预构建的优化,其实是以消耗首屏性能来优化 reload 交互体验。只能说鱼与熊掌,二者不可兼得吧。
遗憾的是,Vite 3.0 并没有解决懒加载二次预构建导致的 reload 的问题。这个问题,目前只能通过社区提供的 vite-plugin-optimize-persist、vite-plugin-package-config 来解决了。
vite-plugin-package-config & vite-plugin-optimize-persist
老规矩,我们还是先通过一个 demo 为大家做一下演示:
在本 demo 中,我们做了两次 dev server 的启动。第一次,由于懒加载 Page3 时发现有未进行预构建的第三方依赖 lodash-es,触发二次预构建,导致页面 reload。第二次启动时,懒加载 Page3 时没有发生二次预构建,也没有发生 reload。
如果仔细看演示 demo,你会发现第一次启动 dev server 并发生二次预构建时,package.json 文件会新增一个 vite.optimizeDeps.include 字段,里面包含项目中用到的所有第三方依赖。正是有了这个字段,第二次启动 dev server 以后,就不在发生二次预构建了。
package.json 文件中这个新增的 vite.optimizeDeps.include 字段,是 vite-plugin-package-config 和 vite-plugin-optimize-persist 这两个插件的功劳。
其中 vite-plugin-package-config 提供 config hook,在 dev server 初始化 vite.config 时提供 vite.optimizeDeps.include 配置项,告诉 dev server 要预构建指定的第三方依赖。
vite-plugin-optimize-persist 提供 configureServer hook,在发生二次预构建时将新的第三方依赖写入 package.json 的 vite.optimizeDeps.include 字段中永久保存起来,供下一次启动 dev server 时使用。
由于
vite-plugin-package-config和vite-plugin-optimize-persist还没有出匹配Vite3.0 的版本,我们在本 demo 中使用的是 Vite 2.4 版本。虽然版本不是最新的,但原理是相通的。
总的来说,使用 vite-plugin-package-config 和 vite-plugin-optimize-persist 依旧无法解决第一次启动 dev server 时发生二次预构建导致页面 reload 的问题。但如果项目中用到的第三方依赖没有变化的话,之后再启动 dev server 时就不会再发生二次预构建了。这也是一种很好的优化策略了,👍🏻。
结束语
到这里关于预构建和二次预构建的话题就结束了。
写这篇文章的初衷,是看到 3.0 对二次预构建的优化后,想深入了解一下内部原理,并把自己搞清楚的东西分享给同样对 Vite 感兴趣的小伙伴。整个研究和整理的过程,让自己学到了很多,对 Vite 的认识也更加深入了,成就感满满。另外,要特别感谢一下神三元大佬,他的掘金小册 - <<深入浅出 Vite>> 写的非常棒,给了我不少帮助,还没有看的小伙伴可以赶紧去看看了,点赞 👍🏻。
后续作者还会陆续推出一些 Vite 的学习所得,感兴趣的小伙伴可以时常关注哦。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。