前言
在前面14篇文章,我们将VITE核心的内容大致学习了一下,通过学习我已经对VITE的架构和整体处理流程非常清楚了,为了方便一些学习能力相对较弱的同学总结和提高,我编写这篇文章将前面14篇文章中所提到的内容进行全面的总结。
本文的内容,可能在前面的文章中出现过,大家不喜勿喷。
VITE整体流程概述
VITE主要提供3个命令用于我们项目日常的开发和构建,这3个命令主要是dev,build,preview。
首先是简单的preview命令,它的目的仅仅是提供一个临时部署构建产物的能力,不用再自己准备服务器部署产物才能预览构建产物的困境。
然后是build命令,VITE对于构建处理没有过多啰嗦的处理,VITE直接了当的调用了Rollup的JS API进行构建,只不过VITE对Rollup进行了一些友好的默认配置,再加上VITE内置了一些插件,使得我们不再需要额外配置插件处理对于文件的转换,因此提升了我们的使用体验。所以,从这个角度来说,要比直接去使用Rollup体验感要好很多。
最后,就是dev命令了,VITE在DEV阶段处理的逻辑是比较复杂的,VITE在服务正式启动之前就会进行依赖预构建,通过使用esbuild扫描项目中来源于node_modules中资源的导入,然后将其生产到其缓存目录里面去。
VITE的Dev服务是冷启动的,即bundless(不打包或者少打包)的架构思想。它不会在项目启动时就所有的资源都处理一遍,它只会在你需要用到它的时候去处理。
在VITE服务启动后,我们就可以从浏览器发送资源请求到DevServer,服务端则是通过中间件进行分发,比如静态资源和publicDir下面的资源直接通过sirv这个包处理不会走编译处理。
对于非静态资源,VITE则首先会尝试根据url读取缓存,有缓存直接返回结果给客户端。若没有读取到缓存,VITE就会调用插件系统,根据插件系统的url处理规则,生成预期的产物,在生成产物的过程中,如果命中依赖预构建的产物,VITE会替换产物中的路径,从而使得读取的是依赖预构建的产物,客户端在使用时就可以得到一些性能提升。生成产物后VITE会将结果缓存下来,然后再返回给客户端,方便下次快速取用。
VITE在处理资源时,会对源码文件进行词法分析,能够知道不同资源文件之间的引用与被引用关系。VITE使用图这个数据结构描述资源文件的引用关系,其中每个资源文件代表的就是图结构中的一个顶点。
VITE在启动DevServer时,会同步启动WebSocket服务,VITE在服务器端会开启文件变更的监听,当文件发生变更时,VITE会寻找依赖链中的热更新边界,然后将待更新列表通过WS消息发送给客户端。
VITE在客户端注入了一些辅助处理WS消息进行热更新的辅助代码,客户端在打开页面的时候就会和服务器进行WebSocket连接。让服务器将更新列表信息发送给客户端之后,客户端完成相应资源的热更新。
CLI命令与JS API
公共参数
这个,大家可能没有实际体会过,在某些场景可能会用到,我给大家举个例子,大家就明白它的使用场景了。
这是我之前的某个项目,在打Docker镜像的时候内存不够了,所以需要配置Node的参数。
dev
可以直接在package.json里面配置:
{
"scripts": {
"dev": "vite"
}
}
其中,dev或serve是vite的别名。
参数如下:
我们也可以使用JS API来实现同样的效果,这种场景一般是我们需要基于VITE进行自己的团队的基建开发,直接调用VITE的CLI命令的话,处理起来不如JS API直接:
import { createServer } from 'vite'
function bootstrap() {
const server = await createServer({
// 一些VITE的配置
})
// 在控制台打印当前的服务器信息
server.printUrls();
// 绑定控制台的快捷键
server.bindCLIShortcuts({
print: true
})
}
createServer的方法签名如下:
async function createServer(inlineConfig?: InlineConfig): Promise<ViteDevServer>
各位读者如果有兴趣的话,请查看VITE的类型定义。
build
可以在package.json里面配置
{
"scripts": {
"build": "vite build"
}
}
参数如下:
也可以使用JS API来实现同样的效果:
import { build } from 'vite'
(async function () {
await build({
// 一些配置参数
})
}())
最后,各位请注意一下mode和process.env.NODE_ENV的区别
preview
可以在package.json里面配置
{
"scripts": {
"preview": "vite preview"
}
}
参数如下:
也可以使用JS API来实现同样的效果:
import { preview } from 'vite'
;(async () => {
const previewServer = await preview({
// 任何有效的用户配置项,将加上 `mode` 和 `configFile`
preview: {
port: 8080,
open: true
}
})
previewServer.printUrls()
})()
内置插件
我在这小节不会再将前面我们花费4篇文章的内容在阐述一遍,在这小节,我们主要阐述一下这些内置插件给我们提供了什么能力。
VITE内置了很多插件,所以我们在0配置就可以处理json,ts,css,其它静态资源(图片,视频等),对于css的预处理器,VITE也是集成了调用方式,我们只需要安装对应css预处理器的实现包就可以了,简直为开发者考虑的太周到了。
另外我觉得尤其比较好用的一个点就是,对于一些资源,你可以在导入资源时拼上一个?url。
比如:
// svga是一种动画格式
import svgaUrl from '@/assets/animation.svga?url'
最后它就会给我们当成一个普通的路径资源处理,最终生成到dist目录下,像我之前在使用Webpack开发时,还需要自己配置Loader来处理它。
插件管理
VITE的插件管理思路还是基于Rollup的,因为VITE的插件管理的代码fork自Rollup,并进行了一些重构,可读性变得更好了。
对于插件的处理,Rollup是将这些插件放在一个集合里面,在构建的过程中,根据当前的时机触发对应的生命周期。
插件有不同的顺序,VITE内置插件的顺序是最靠前的,这个我们是无法控制的,我们自己定义的插件顺序就是我们在编写代码的时候的先后顺序,不过我们可以通过API来调整(仍然无法调整VITE内置插件的顺序)。
// vite.config.js
import example from 'rollup-plugin-example'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
{
...example(),
enforce: 'post',
apply: 'build',
},
],
})
// ======================================
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
(function MyPlugin() {
return {
resolveId: {
order: 'post',
handler(id) {
// 处理逻辑
}
}
}
}())
]
})
对于生命周期,主要有几种类型:
first: 如果有多个插件实现此钩子,则钩子按顺序运行,直到钩子返回一个不是null或undefined的值。sequential:如果有多个插件实现此钩子,当前插件的返回值是一个Promise(若不是则包裹成一个Promise),需要在当前的这个Promise的状态变更为fulfilled之后,下一个实现这个钩子的插件才会执行,就是一个Promise串。parallel:如果多个插件实现此钩子,各个插件之间的钩子互相独立没有依赖关系。
下面,我们就简单用代码来模拟一下这几张生命周期的调度实现:
首先是first类型的钩子:
// resolveId是first类型的钩子
const plugins = getSortedPlugins('resolveId')
async function runner(args) {
while(plugins.length) {
const plugin = plugins.shift();
const result = await plugin(args)
if(result !== null || result !== undefined) {
return result;
}
}
return null;
}
然后是sequential类型的钩子:
// transform是sequential类型的钩子
const plugins = getSortedPlugins('transform')
async function runner() {
let transformResult = undefined;
while(plugins.length) {
const plugin = plugin.shift();
transformResult = await plugin(transformResult)
}
return transformResult;
}
最后是parallel类型的钩子:
// buildStart是parallel类型的钩子
const plugins = getSortedPlugins('buildStart')
function runner() {
plugins.map(fn => {
fn();
})
}
资源管理
VITE只处理了Dev阶段的资源管理问题,对于构建阶段完全交给Rollup处理。
在Dev阶段,对于一个VITE项目来说,整个项目就是一个依赖图(Dependencies Graph),这是一个有向,可能有环,无权图,并且一定是一个连通图(为什么说是连通图,我们接下来解释),这个图中的顶点就是源码文件(包括图片、视频等)。
所谓连通图,就是你从一个节点出发,在不考虑方向的前提下,总是可以访问到所有的节点。
为什么说我们的VITE项目是一个连通图呢,如果说你放进去的资源,源代码不引用的话,它除了增加项目源代码的体积是没有任何意义的,对于一个没有意义的东西,我们把它考虑进来也是没有任何价值的,不过,正是对于这个点,我们可以编写检测程序来检测项目中哪些文件是没有引用到。
有向,是指我们的一个源码文件里面可能有多个引用,它引用的这些资源又需要知道这个源码文件引用着它,所以它是一个有向图。
如果我们编写的代码出现循环引用的话,那就是有环图,否则就是无环图。在前面我们讲Rollup的时候就聊过这个问题了,存在循环引用不可怕,可怕的是把这个环里面的代码拆分到了不同的Chunk里面去了,这样构建的产物直接初始化就报错了。
以下就是在之前的文章里面出现过的例子,大家凑合着再看一下:
热更新
关于热更新,在之前的文章中,我们花费了3篇文章来阐述其中的原理,在最后这篇文章中,我们再总结一下VITE热更新的全流程。
当我们在控制台执行vite dev命令时,VITE在创建资源服务器的同时,还创建了WebSocket服务。
我们的入口文件,即index.html文件中,VITE会为我们注入一些操作WebSocket的方法,并且当DevServer启动之后,我们打开网页就会连接WebSocket服务。
VITE在热更新的过程中,是以它所谓的热更新边界来处理的,当我们的源码里面包含代码import.meta.hot.accept时,VITE在自己的内置插件分析到有这样的代码,才会给我们的这个文件注入import.meta.hot的实现,有了这个才能真正调用客户端的热更新处理逻辑。
当源码里面包含import.meta.hot.accept时,VITE就会认为这个文件是热更新的边界。
VITE在服务器端启动时就会监听项目文件的变化,当监听到文件的变化时,VITE会尝试更新依赖图,还会更新资源的最后修改时间戳。通过依赖图的引用关系,找到带有热更新边界的文件信息。这个过程中,VITE就可以分析到哪些资源是不再需要了的,哪些是需要更新的,会通过WebSocket消息告诉客户端。
客户端拿到这些消息时,如果是简单的资源更新,就会触发import.meta.hot.accept回调,重新获取资源时,因为当前文件会级联获取它的依赖文件,所以就完成了所有依赖资源的更新,这些资源都有最后的修改时间戳,在转换的过程中会被VITE处理,附加在URL上,因此总是可以保证已更新的资源是最新的。
如果是待删除列表,就会触发import.meta.hot.prune钩子,以删除不再需要的资源,比如在VITE的官方插件@vitejs/plugin-vue处理css就是这样的逻辑。
如果监听到的文件修改不包含热更新边界,那么VITE就会通知客户端整页刷新
依赖预构建
VITE的依赖预构建主要解决了2个问题(这儿我就直接给大家截官方文档上的描述,如图):
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对于源码中所应用到的词法分析手段也加强了我对于前端编译的一些认知,另外就是VITE对于热更新边界的处理,它的实现是真的很吸引我,即保证了所有的内容都得到了更新,又控制了更新的粒度,真的是让我叹为观止!!!
关于VITE的源码,我的这个专栏目前计划更新到这里了,如果各位读者对某个我在这个专栏中没有提及到的知识点感兴趣的话,可以私信联系我。
最后,感谢大家的阅读,谢谢各位的捧场,嘻嘻嘻。