哈喽,很高兴你能点开这篇博客,本博客是针对 Vite
源码的解读系列文章,认真看完后相信你能对 Vite
的工作流程及原理有一个简单的了解。
Vite
是一种新型的前端构建工具,能够显著提升前端开发体验。
我将会使用图文结合的方式,尽量让本篇文章显得不那么枯燥(显然对于源码解读类文章来说,这不是个简单的事情)。
如果你还没有使用过 Vite
,那么你可以看看我的前两篇文章,我也是刚体验没两天呢。(如下)
本篇文章是 Vite
源码解读系列的第三篇文章,往期文章可以看这里:
本篇文章解读的主要是 vite
源码本体,在往期文章中,我们了解到:
vite
在本地开发时通过connect
库提供开发服务器,通过中间件机制实现多项开发服务器配置,没有借助webpack
打包工具,加上利用rollup
(部分功能)调度内部plugin
实现了文件的转译,从而达到小而快的效果。vite
在构建生产产物时,将所有的插件收集起来,然后交由rollup
进行处理,输出用于生产环境的高度优化过的静态资源。
本篇文章,我会针对贯穿前两期文章的 vite
的插件 —— @vitejs/plugin-vue
来进行源码解析。
好了,话不多说,我们开始吧!
vite:vue
vite:vue
插件是在初始化 vue
项目的时候,就被自动注入到 vite.config.js
中的插件。(如下)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue()
]
});
该插件导出了几个钩子函数,这几个钩子函数,部分是用于 rollup
,部分是 vite
专属。(如下图)
在开始阅读源码之前,我们需要先了解一下 vite
和 rollup
中每一个钩子函数的调用时机和作用。
字段 | 说明 | 所属 |
---|---|---|
name | 插件名称 | vite 和 rollup 共享 |
handleHotUpdate | 执行自定义 HMR(模块热替换)更新处理 | vite 独享 |
config | 在解析 Vite 配置前调用。可以自定义配置,会与 vite 基础配置进行合并 | vite 独享 |
configResolved | 在解析 Vite 配置后调用。可以读取 vite 的配置,进行一些操作 | vite 独享 |
configureServer | 是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件。 | vite 独享 |
transformIndexHtml | 转换 index.html 的专用钩子。 | vite 独享 |
options | 在收集 rollup 配置前,vite (本地)服务启动时调用,可以和 rollup 配置进行合并 | vite 和 rollup 共享 |
buildStart | 在 rollup 构建中,vite (本地)服务启动时调用,在这个函数中可以访问 rollup 的配置 | vite 和 rollup 共享 |
resolveId | 在解析模块时调用,可以返回一个特殊的 resolveId 来指定某个 import 语句加载特定的模块 | vite 和 rollup 共享 |
load | 在解析模块时调用,可以返回代码块来指定某个 import 语句加载特定的模块 | vite 和 rollup 共享 |
transform | 在解析模块时调用,将源代码进行转换,输出转换后的结果,类似于 webpack 的 loader | vite 和 rollup 共享 |
buildEnd | 在 vite 本地服务关闭前,rollup 输出文件到目录前调用 | vite 和 rollup 共享 |
closeBundle | 在 vite 本地服务关闭前,rollup 输出文件到目录前调用 | vite 和 rollup 共享 |
在了解了 vite
和 rollup
的所有钩子函数后,我们只需要按照调用顺序,来看看 vite:vue
插件在每个钩子函数的调用期间都做了些什么事情吧。
config
config(config) {
return {
define: {
__VUE_OPTIONS_API__: config.define?.__VUE_OPTIONS_API__ ?? true,
__VUE_PROD_DEVTOOLS__: config.define?.__VUE_PROD_DEVTOOLS__ ?? false
},
ssr: {
external: ['vue', '@vue/server-renderer']
}
}
}
vite:vue
插件中的 config
做的事情比较简单,首先是做了两个全局变量 __VUE_OPTIONS_API__
和 __VUE_PROD_DEVTOOLS__
的替换工作。然后又设置了要为 SSR 强制外部化的依赖。
configResolved
在 config
钩子执行完成后,下一个调用的是 configResolved
钩子。(如下)
configResolved(config) {
options = {
...options,
root: config.root,
sourceMap: config.command === 'build' ? !!config.build.sourcemap : true,
isProduction: config.isProduction
}
},
vite:vue
中的 configResolved
钩子,读取了 root
和 isProduction
配置,存储在插件内部的 options
属性中,以便提供给后续的钩子函数使用。
然后,判断当前命令是否为 build
,如果是构建生产产物,则读取 sourcemap
配置来判断是否生成 sourceMap
,而本地开发服务始终会生成 sourceMap
以供调试使用。
configureServer
在 configureServer
钩子中,vite:vue
插件只是将 server
存储在内部 options
选项中,并无其他操作。(如下)
configureServer(server) {
options.devServer = server;
}
buildStart
在 buildStart
钩子函数中,创建了一个 compiler
,用于后续对 vue
文件的编译工作。(如下)
buildStart() {
options.compiler = options.compiler || resolveCompiler(options.root)
}
该 complier
中内置了很多实用方法,这些方法负责按照规则对 vue
文件进行庖丁解牛。
load
在运行完了上述几个钩子后,vite
本地开发服务就已经启动了。
我们打开本地服务的地址,对资源发起请求后,将会进入下一个钩子函数。(如下图)
打开服务后,首先进入的是 load
钩子,load
钩子主要做的工作是返回 vue
文件中被单独解析出去的同名文件。
vite
内部会将部分文件内容解析到另一个文件,然后通过在文件加载路径后面加上 ?vue
的 query
参数来解析该文件。比如解析template
(模板)、script
(js 脚本)、css
(style
模块)...(如下图)
而这几个模块(template
、script
、style
)都是由 complier.parse
解析而来(如下)
const { descriptor, errors } = compiler.parse(source, {
filename,
sourceMap
});
transform
在 load
返回了对应的代码片段后,进入到 transform
钩子。
transform
主要做的事情有三件:
- 转译
vue
文件 - 转译以
vue
文件解析的template
模板 - 转译以
vue
文件解析的style
样式
简单理解,这个钩子对应的就是webpack
的loader
。
这里,我们以一个 TodoList.vue
文件为例,展开聊聊 transform
所做的文件转译工作。
下面是 TodoList.vue
源文件,它做了一个可供增删改查的 TodoList
,你也可以通过 第二期文章 - Vite + Vue3 初体验 —— Vue3 篇 了解它的详细功能。
<script setup lang="ts">
import { DeleteOutlined, CheckOutlined, CheckCircleFilled, ToTopOutlined } from '@ant-design/icons-vue';
import { Input } from "ant-design-vue";
import { ref } from "vue";
import service from "@/service";
import { getUserKey } from '@/service/auth';
// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
id: string;
title: string;
is_completed: boolean;
is_top: boolean;
}[]>([]);
// 初始化 todo list
const getTodoList = async () => {
const reply = await service.get('/todo/get-todo-list', { params: { key: getUserKey() } });
todoList.value = reply.data.data;
}
getTodoList();
// 删除、完成、置顶的逻辑都与 todoList 放在同一个地方,这样对于逻辑关注点就更加聚焦了
const onDeleteItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/delete', { id });
todoList.value.splice(index, 1);
}
const onCompleteItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/complete', { id });
todoList.value[index].is_completed = true;
// 重新排序,将已经完成的项目往后排列
const todoItem = todoList.value.splice(index, 1);
todoList.value.push(todoItem[0]);
}
const onTopItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/top', { id });
todoList.value[index].is_top = true;
// 重新排序,将已经完成的项目往前排列
const todoItem = todoList.value.splice(index, 1);
todoList.value.unshift(todoItem[0]);
}
// 新增 Todo Item 的逻辑都放在一处
// 创建一个引用变量,用于绑定输入框
const todoText = ref('');
const addTodoItem = () => {
// 新增一个 TodoItem,请求新增接口
const todoItem = {
key: getUserKey(),
title: todoText.value
}
return service.post('/todo/add', todoItem);
}
const onTodoInputEnter = async () => {
if (todoText.value === '') return;
await addTodoItem();
await getTodoList();
// 添加成功后,清空 todoText 的值
todoText.value = '';
}
</script>
<template>
<section class="todo-list-container">
<section class="todo-wrapper">
<!-- v-model:value 语法是 vue3 的新特性,代表组件内部进行双向绑定是值 key 是 value -->
<Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="请输入待办项" />
<section class="todo-list">
<section v-for="(item, index) in todoList"
class="todo-item"
:class="{'todo-completed': item.is_completed, 'todo-top': item.is_top}">
<span>{{item.title}}</span>
<div class="operator-list">
<CheckCircleFilled v-show="item.is_completed" />
<DeleteOutlined v-show="!item.is_completed" @click="onDeleteItem(index)" />
<ToTopOutlined v-show="!item.is_completed" @click="onTopItem(index)" />
<CheckOutlined v-show="!item.is_completed" @click="onCompleteItem(index)" />
</div>
</section>
</section>
</section>
</section>
</template>
<style scoped lang="less">
.todo-list-container {
display: flex;
justify-content: center;
width: 100vw;
min-height: 100vh;
box-sizing: border-box;
padding-top: 100px;
background: linear-gradient(rgba(219, 77, 109, .02) 60%, rgba(93, 190, 129, .05));
.todo-wrapper {
width: 60vw;
.todo-input {
width: 100%;
height: 50px;
font-size: 18px;
color: #F05E1C;
border: 2px solid rgba(255, 177, 27, 0.5);
border-radius: 5px;
}
.todo-input::placeholder {
color: #F05E1C;
opacity: .4;
}
.ant-input:hover, .ant-input:focus {
border-color: #FFB11B;
box-shadow: 0 0 0 2px rgb(255 177 27 / 20%);
}
.todo-list {
margin-top: 20px;
.todo-item {
box-sizing: border-box;
padding: 15px 10px;
cursor: pointer;
border-bottom: 2px solid rgba(255, 177, 27, 0.3);
color: #F05E1C;
margin-bottom: 5px;
font-size: 16px;
transition: all .5s;
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 10px;
.operator-list {
display: flex;
justify-content: flex-start;
align-items: center;
:first-child {
margin-right: 10px;
}
}
}
.todo-top {
background: #F05E1C;
color: #fff;
border-radius: 5px;
}
.todo-completed {
color: rgba(199, 199, 199, 1);
border-bottom-color: rgba(199, 199, 199, .4);
transition: all .5s;
background: #fff;
}
.todo-item:hover {
box-shadow: 0 0 5px 8px rgb(255 177 27 / 20%);
border-bottom: 2px solid transparent;
}
.todo-completed:hover {
box-shadow: none;
border-bottom-color: rgba(199, 199, 199, .4);
}
}
}
}
</style>
进入到 transformMain
函数,可以发现 transformMain
内部主要做了几件事情:
- 解构
vue
文件的script
、template
、style
- 解析
vue
文件中的script
代码; - 解析
vue
文件中的template
代码; - 解析
vue
文件中的style
代码; - 解析
vue
文件中的自定义模块
代码; - 处理 HMR(模块热重载)的逻辑;
- 处理
ssr
的逻辑; - 处理
sourcemap
的逻辑; - 处理
ts
的转换,转成成es
;
接下来,我们将深入源码,将每一项任务深入解析。
解构 script
、template
、style
vue
文件中包含 script
、template
、style
三大部分,transformMain
内部先通过 createDescriptor
中的 compiler
将这三大块分离解析出来,作为一个大对象,然后方便后面的解析。(如下图)
在 compiler
中,会先使用 parse
方法,将源码 source
解析成 AST
树。(如下图)
在下图中可以看出,解析后的 AST
树有三个模块,主要就是 script
、template
、style
。
接下来,就是将各个模块的属性、代码行数记录起来,比如 style
标签,就记录了 lang: less
的信息,以供后面的解析。
解析 Template
vue
文件中的 template
写了很多 vue
的语法糖,比如下面这行
<Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="请输入待办项" />
像这种语法,浏览器是无法识别并将事件绑定到 vue
的内部函数中的,所以 vite
对这类标签先做了一遍内部转换,转换成可执行的函数,再通过浏览器执行函数生成一套 虚拟 DOM
,最后再由 vue
内部的渲染引擎将 虚拟 DOM
渲染成 真实 DOM
。
现在我们就可以看看 vite
内部对 template
语法的转译过程,vite
内部是通过 genTemplateCode
函数来实现的。
在 genTemplateCode
内部,首先是将 template
模板语法解析成了 AST
语法树。(如下图)
然后再通过不同的转译函数,对对应的 AST 节点进行转换。
下面我们以 Input
节点为例来简单解释一下转译的过程。
将这个步骤重复,直到整棵 template
树都解析完成。
解析 script 标签
下面,我们来看看对 script
标签的解析部分,对应的内部函数是 genScriptCode
这个函数所做的事情主要是下面几件事情:
- 解析
script
标签中定义的变量; - 解析
script
标签中定义的引入import
,后面将会转换成相对路径引入; - 将
script
标签编译成一个代码片段,该代码片段导出_defineComponent
(组件)封装的对象,内置setup
钩子函数。
我们用图来说明以上三个步骤。(如下图)
解析 style 标签
style
标签解析的比较简单,只是将代码解析成了一个 import
语句(如下)
import "/Users/Macxdouble/Desktop/ttt/vite-try/src/components/TodoList.vue?vue&type=style&index=0&scoped=true&lang.less"
随后,根据该请求中 query
参数中的 type
和 lang
,由 vite:vue
插件的 load
钩子(上一个解析的钩子)中的 transformStyle
函数来继续处理样式文件的编译。这部分我就不做展开了,感兴趣的同学可以自行阅读代码。
编译 ts
到 es
在 script
、template
、style
部分的代码都解析完毕后,接下来还做了下面几个处理:
- 解析
vue
文件中的自定义模块
代码; - 处理 HMR(模块热重载)的逻辑;
- 处理
ssr
的逻辑; - 处理
sourcemap
的逻辑; - 处理
ts
的转换,转成成es
;
由于篇幅原因,这里只对 ts
到 es
的转换做个简单介绍,这一步主要是在内部通过 esbuild
完成了 ts
到 es
的转换,我们可以看到这个工具有多快。(如下图)
输出代码
在 ts
也转译成 es
后,vite:vue
将转换成了 es
的 script
、template
、style
代码合并在一起,然后通过 transform
输出,最终输出为一个 es
模块,被页面作为 js
文件加载。(如下图)
handleHotUpdate
最后,我们来看看对于文件模块热重载的处理,也就是 handleHotUpdate
钩子。
我们在启动项目后,在 App.vue
文件的 setup
中加入一行代码。
console.log('Test handleHotUpdate');
在代码加入并且保存后,被 vite
内部的 watcher
捕获到变更,然后触发了 handleHotUpdate
钩子,将修改的文件传入。
vite:vue
内部会使用 compiler.parse
函数对 App.vue
文件进行解析,将 script
、template
、style
标签解析出来。(也就是上面解析的编译步骤)(如下图)。
然后,handleHotUpdate
函数内部会检测发生变更的内容,将变更的部分添加到 affectedModules
数组中。(如下图)
然后,handleHotUpdate
将 affectedModules
返回,交由 vite
内部处理。
最后,vite
内部会判断当前变更文件是否需要重新加载页面,如果不需要重新加载的话,则会发送一个 update
消息给客户端的 ws
,通知客户端重新加载对应的资源并执行。(如下图)
好了,这样一来,模块热重载的内容我们也清楚了。
小结
本期对 @vitejs/plugin-vue
的解析就到这里结束了。
可以看出,vite
内部结合了 rollup
预设了插件的多个生命周期钩子,在编译的各个阶段进行调用,从而达到 webpack
的 loader
+ plugin
的组合效果。
而 vite/rollup
直接使用 plugin
就替代了 webpack
的 loader
+ plugin
功能,可能也是为了简化概念,整合功能,让插件的工作更简单,让社区的插件开发者也能更好的参与贡献。
vite
的快不仅仅是因为运行时不编译原生 es
模块,还有在运行时还利用了 esbuild
这类轻而快的编译库来编译 ts
,从而使得整个本地开发时变得非常地轻快。
下一章,我们将对 vite
插件进行实战练习:实现一个 vite
插件,它的功能是通过指定标签就能加载本地 md
文件。
最后一件事
如果您已经看到这里了,希望您还是点个赞再走吧~
您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!
如果觉得本文对您有帮助,请帮忙在 github 上点亮 star
鼓励一下吧!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。