前言
之前在使用 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
还没有出匹配Vite
3.0 的版本,我们在本 demo 中使用的是 Vite 2.4 版本。虽然版本不是最新的,但原理是相通的。
总的来说,使用 vite-plugin-package-config
和 vite-plugin-optimize-persist
依旧无法解决第一次启动 dev server
时发生二次预构建
导致页面 reload
的问题。但如果项目中用到的第三方依赖没有变化的话,之后再启动 dev server
时就不会再发生二次预构建
了。这也是一种很好的优化策略了,👍🏻。
结束语
到这里关于预构建
和二次预构建
的话题就结束了。
写这篇文章的初衷,是看到 3.0
对二次预构建
的优化后,想深入了解一下内部原理,并把自己搞清楚的东西分享给同样对 Vite
感兴趣的小伙伴。整个研究和整理的过程,让自己学到了很多,对 Vite
的认识也更加深入了,成就感满满。另外,要特别感谢一下神三元大佬,他的掘金小册 - <<深入浅出 Vite>> 写的非常棒,给了我不少帮助,还没有看的小伙伴可以赶紧去看看了,点赞 👍🏻。
后续作者还会陆续推出一些 Vite
的学习所得,感兴趣的小伙伴可以时常关注哦。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。