前言
我们在上一篇中通过 Vite 的插件 @vitejs/plugin-vue 实现了对自定义的 Vue 编译器的运行,并且成功实现了对 .vue 文件的编译。接下来我们将通过手写一个 Vite 插件来了解 .vue 文件具体是如何被转换并渲染到页面的,又因为 Vite 受 WMR 的启发采用了基于 Rollup 插件 API 的设计,所以要掌握 Vite 的插件开发必须掌握 Rollup 的插件运行机制和开发实践。
所以在学习 Vite 插件之前,我们先要对 Rollup 及其插件的工作原理进行了解,我们所学的知识,一切为了后面手写 Rollup 插件实现解析 vue 文件做准备而服务。
关于 Rollup
我们先手动实践 Rollup 具体是怎么工作的,再进行总结 Rollup 是什么。我们先在根目录下创建一个 rollup-plugin 的目录,接着在命令终端进到该目录中,然后初始化一个 Node 项目:
初始化:
csharp
npm init -y
安装 rollup
sql
pnpm add rollup -D
接着我们新建一个 a.js 文件,内容如下:
javascript
export function a() {
console.log('a')
}
接着我们新建一个 b.js 文件,内容如下:
javascript
export function b() {
console.log('b')
}
接着我们新建一个 index.js 文件,内容如下:
javascript
import { a } from './a'
import { b } from './b'
a();
b();
接着我们新建一个 Rollup 配置文件 rollup.config.js,内容如下:
javascript
export default [
{
input: './index.js', // 入口文件
output: {
file: 'dist/output.js', // 输出文件
format: 'esm', // 打包的方式为 ESM
}
}
]
接着我们修改 package.json 文件的 scripts 部分的脚本命令来启动我们的 Rollup 进行打包,内容如下:
diff
{
"name": "rollup-plugin-test",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
+ "build": "rollup -c"
},
// 省略...
}
这样我们就可以在命令终端通过 pnpm build 命令进行打包了。打包结果如下:

通过打包的结果我们可以非常具体地了解 Rollup 的作用了,现在我们来介绍一下:Rollup 是一个 JavaScript 模块打包器,它的基本原理就是通过读取入口文件解析 JavaScript 代码,找出其中的 import 语句,然后根据 import 语句构建一个模块之间的依赖关系图,最后将所有模块的代码合并在一起,处理好依赖关系进行打包,总结一句话就是解析模块、构建依赖图、打包代码。
关于 Rollup 插件
那么现在我们再新建一个 App.vue 的文件,内容如下:
html
<template>
<div>i am Cobyte ~</div>
</template>
<script setup>
console.log('我是掘金签约作者:Cobyte')
</script>
<style>
div {
color: red;
}
</style>
然后我们在 index.js 文件中引入它:
diff
import { a } from './a'
import { b } from './b'
+ import App from './App.vue'
+ console.log(App);
a();
b();
我们再运行打包命令:pnpm build,结果如下:

我们可以看到报错了,报错的意思是我们需要一个插件来解析加载 App.vue 文件,因为 Rollup 只能解析 JavaScript 代码。
对此,Rollup 通过插件机制提供了强大的功能扩展能力,Rollup 插件简单来说就是一个对象,这个对象要遵循 Rollup 插件的规范,比如说包含 属性、构建钩子 和 输出生成钩子 中的一个或多个。Rollup 的插件钩子函数非常多,我们初学者不用全部都学完,只需要把其中resolveId、load 和 transform 三个关键的构建钩子(hooks)学完就算是入门了,这样在后续开发中再慢慢去了解其他钩子会在模块处理的什么阶段被执行就可以了,因为经常使用到的 Hook 也并不多。
一个基本的 Rollup 插件的结构如下:
javascript
// ./rollup-plugin-test.js
export default function rollupPluginTest() {
return {
name: 'rollup-plugin-test',
resolveId(id) {
console.log('resolveId', id);
},
load(id) {
console.log('load', id);
},
transform(code, id) {
console.log('transform', id);
}
}
}
接着修改 rollup.config.js 文件配置:
diff
+ import TestPlugin from './rollup-plugin-test.js'
export default [
{
input: './index.js',
output: {
file: 'dist/output.js',
format: 'esm',
},
+ plugins: [
+ TestPlugin()
+ ],
},
]
同时为了方便测试,我们删掉 index.js 文件中对 App.vue 引用的相关代码,如何对 Vue 文件进行解析不是我们本文需要做的事情,我们将在下一篇文章中进行,我们需要先将前置基础相关的知识掌握。
diff
import { a } from './a'
import { b } from './b'
- import App from './App.vue'
- console.log(App);
a();
b();
接着我们我们运行打包命令 pnpm build,命令终端的显示结果如下:

通过命令终端的打印日志的显示结果,我们可以分析出 resolveId、load、transform 三个钩子的运行流程。从配置文件指定的入口文件开始就运行 resolveId 钩子了,接着运行 load 钩子,再接着运行 transform 钩子,同时在 load 钩子开始文件模块路径从相对路径变成了绝对路径。
下面是对这三个重点钩子的作用和执行顺序的详细解析:
-
resolveId
- 作用:用于解析模块 ID,即将模块的导入路径解析为一个确定的模块 ID。这个钩子允许插件修改或重写模块的解析方式。
- 常见用途:处理别名、解析第三方模块、动态替换模块路径等。
-
load
- 作用:用于加载模块内容,即根据解析后的模块 ID 读取模块的源代码。这个钩子允许插件从文件系统、数据库或其他地方加载模块。
- 常见用途:加载文件、编译代码(如将 TypeScript 编译为 JavaScript)、从远程源加载模块等。
-
transform
- 作用:用于转换模块内容,即在模块被 Rollup 处理之前修改其源代码。这个钩子允许插件对模块代码进行任意的转换,比如编译、优化、代码分割等。
- 常见用途:编译 ES6+ 语法、压缩代码、移除无用代码、插入调试信息等。
那么结合 Rollup 的运行原理来解析一下这三个钩子函数的运行流程,首先 Rollup 从配置文件读取了入口文件,然后就调用 resolveId 钩子解析入框文件路径,然后读取入口文件内容,然后解析入口文件内容,具体就是遇到 import 语句时,再次调用 resolveId 钩子来确定要导入的模块的位置,确定模块位置后,调用 load 钩子来读取模块的实际代码,读取到模块代码后,调用 transform 钩子来对代码进行转换和处理。而经过 transform 转换后的代码还会继续解析它的依赖,也就是转换后的模块代码如果还有 import 语句时,又会执行 resolveId 钩子,然后重复以上流程直到所有的 import 都解析完毕。
特别的 resolveId 钩子
想要更加充分地理解这些钩子函数的作用,我们可以通过一个小例子进行实践。
我们安装一个第三方库来看看 Rollup 插件的运行流程:
csharp
pnpm add loadsh-es -D
接着 index.js 修改测试代码如下:
javascript
import _ from 'loadsh-es'
console.log(_.last([1, 2, 3]));
接着运行打包命令 pnpm build,结果如下:

发现对于 loadsh-es 模块,rollup 插件只运行了 resolveId 钩子后面的就不再运行了。
接下来我们修改一下我们的 Rollup 插件:
diff
export default function rollupPluginTest() {
return {
name: 'rollup-plugin-test',
resolveId(id) {
console.log('resolveId', id);
+ if (id === 'loadsh-es') {
+ return id
+ }
},
load(id) {
console.log('load', id);
},
transform(code, id) {
console.log('transform', id);
}
}
}
测试结果如下:

我们发现虽然报错了,但 load 钩子函数运行了。接着我们再次修改我们的 Rollup 插件代码:
diff
export default function rollupPluginTest() {
return {
name: 'rollup-plugin-test',
resolveId(id) {
console.log('resolveId', id);
if (id === 'loadsh-es') {
return id
}
},
load(id) {
console.log('load', id);
+ if (id === 'loadsh-es') {
+ return {
+ code: `export default {
+ last() {
+ console.log('来自 Rollup 插件中的代码');
+ }
+ }`
+ }
+ }
},
transform(code, id) {
console.log('transform', id);
}
}
}
再次运行测试代码,终端显示如下:

我们发现不报错了,而且 resolveId、load、transform 三个钩子函数都运行了。我们再看看打包结果的文件内容,如下:

我们可以看到打包的内容是我们在 load 钩子中返回的内容。
接着我们再修改我们的 Rollup 插件:
diff
export default function rollupPluginTest() {
return {
name: 'rollup-plugin-test',
// 省略...
transform(code, id) {
console.log('transform', id);
+ if (id === 'loadsh-es') {
+ return {
+ code: code.replace('Rollup', '掘金签约作者:Cobyte')
+ }
+ }
}
}
}
再次运行测试代码,查看打包结果文件,内容如下:

我们可以看到原来的 Rollup 已经被替换了,也就是被转换了,从一种代码转换成了另一种代码,这也是 transform 钩子做的工作 ------ 编译。
至此我们又可以得出一个结论,如果是第三方模块,也就是从 node_modules 中加载的模块,Rollup 插件需要在 resolveId 钩子函数中进行确认,如果返回该模块ID,则后面的钩子函数都会依次执行,否则后面的钩子函数不再执行解析该模块,而这个规则对本地模块,也就是相对路径的模块则不遵循此规则。
对于 resolveId 钩子函数的作用,我们再通过一个例子进行测试一下,我们现在再创建一个 Rollup 插件,代码如下:
javascript
export default function rollupPluginTest2() {
return {
name: 'rollup-plugin-test2',
resolveId(id) {
console.log('resolveId2', id);
},
load(id) {
console.log('load2', id);
},
transform(code, id) {
console.log('transform2', id);
}
}
}
接着修改 rollup.config.js 文件配置:
diff
import TestPlugin from './rollup-plugin-test.js'
+ import TestPlugin2 from './rollup-plugin-test2.js'
export default [
{
input: './index.js',
output: {
file: 'dist/output.js',
format: 'esm',
},
plugins: [
TestPlugin(),
+ TestPlugin2()
],
},
]
再运行代码,查看打印日志:

我们发现对于本地模块,两个插件的全部钩子函数都运行了,而对于第三方模块,则第二个 Rollup 插件只运行了 transform 钩子。而我们第二个 Rollup 插件的 resolveId 钩子是没有返回结果的,但它的 transform 钩子也运行了。
这里我们就可以得到一条规则,只要前面插件确定加载过的第三方模块代码,后面的插件的 transform 钩子也会运行,也就是可以对该模块的代码作转换处理。
关于 Vite & Vite 插件
首先从 Vite 官方网站的介绍中,我们可以知道 Vite 包含两部分内容:
一、它是一个开发服务器,它利用浏览器原生的ES模块(ESM)支持来提供更快的开发体验;
二、它是一套构建指令,生产环境会利用 Rollup 进行打包。
按尤雨溪的说法就是:你可以把它理解为一个开箱即用的开发服务器 + 打包工具的组合,但是更轻更快。
在前端开发领域,一个工具的繁荣发展,必须是生态的繁荣。那么如果 Vite 要自建生态的话,肯定会发展得很慢,所以 Vite 受 WMR 的启发采用了基于 Rollup 插件 API 的设计。这意味着 Vite 用户可以利用 Rollup 插件的强大生态系统,且有相当数量的 Rollup 插件可以直接作为 Vite 插件工作(例如:@rollup/plugin-alias 或 @rollup/plugin-json)。并且有 Rollup 插件开发基础,学习 Vite 插件开发也很快。
我们在根目录下创建一个 vite-plugin 的目录,接着创建一个 vite-plugin-test.js Vite 插件文件,内容直接把我们上面写的 Rollup 插件复制粘贴进来,内容如下:
javascript
export default function vitePluginTest() {
return {
name: 'vite-plugin-test',
resolveId(id) {
console.log('vite-resolveId', id);
},
load(id) {
console.log('vite-load', id);
},
transform(code, id) {
console.log('vite-transform', id);
}
}
}
接着我们在 vite.config.js 中配置我们上述写的 Vite 插件。
diff
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import * as CompilerVapor from './compiler-vapor/src/index'
+ import vitePlugin from './vite-plugin/vite-plugin-test'
export default defineConfig({
plugins: [
Vue({
template: {
compiler: CompilerVapor
}
}),
+ vitePlugin()
]
})
接着在根目录启动我们的项目:pnpm dev,我们可以在控制台看到打印的日志了。

我们可以看到一个 Rollup 插件也可以在 Vite 插件中进行运行的。
虚拟模块插件实践
虚拟模块是一种很实用的模式,在解析 Vue 文件的 Rollup 插件和 Vite 插件中都有大量使用,所以我们直接来学习虚拟模块插件的使用。
什么是虚拟模块呢?简单来说就是你在代码中导入一个不存在的模块,你可以通过插件根据该不存在的模块ID返回任意你想返回的代码。
例如我们在 JavaScript 文件中引入一个不存在的模块,例如: import Test from 'virtual-module' 中的 virtual-module 是不存在的,但我们可以通过插件解析它,然后返回任意你想返回的代码,这就是虚拟模块的本质。
例如我们在 src/main.js 中添加如下代码:
javascript
import Test from 'virtual-module'
console.log('Test:', Test)
这个时候我们运行代码很明显是会报错的,因为 virtual-module 模块不存在。

接着我们对上文创建的 rollup-plugin-test.js 文件进行修改。实现一个简单的虚拟模块解析和加载机制,以及对该虚拟模块内容的转换。
其实在我们上述创建的 Vite 插件的 resolveId 钩子已经执行了。

那么根据我们上文对 Rollup 插件的了解,可以知道 import 加载的模块需要 Rollup 插件在 resolveId 钩子函数中进行确认,如果返回该模块ID,则后面的钩子函数都会依次执行,否则后面的钩子函数不再执行解析该模块,因为我们还没在 resolveId 钩子函数中进行确认,所以就只执行 resolveId 钩子一个函数,插件后面的函数都不执行了。
所以我们现在需要在 resolveId 钩子函数中进行确认,确定该插件是处理该模块的。
diff
export default function vitePluginTest() {
return {
name: 'vite-plugin-test',
resolveId(id) {
console.log('vite-resolveId', id);
+ if (id === 'virtual-module') {
+ return id
+ }
},
// 省略...
}
}
执行结果如下:

我们发现 load 钩子函数执行了,但报没发加载 virtual-module 模块,因为 virtual-module 模块是虚拟的,本身就不存在的,那么我们怎么让它不报错呢?我们只需要在 load 钩子中返回你想返回的代码即可。例如:
diff
export default function vitePluginTest() {
return {
name: 'vite-plugin-test',
resolveId(id) {
console.log('vite-resolveId', id);
if (id === 'virtual-module') {
return id
}
},
load(id) {
console.log('vite-load', id);
+ // 如果模块 ID 是 'virtual-module',则加载特定内容
+ if (id === 'virtual-module') {
+ return 'export default "我是 {{Cobyte}} ~";';
+ }
},
transform(code, id) {
console.log('vite-transform', id);
}
}
}
这时我们发现代码不再报错了:

而且 resolveId、load、transform 三个钩子都运行了。
在浏览器的控制台上也打印了以下内容:

打印的内容就是我们在 load 钩子中返回的代码。
我们从上文可以了解到 transform 钩子是进行代码编译的,所以我们对上面返回的代码中的 {{Cobyte}} 部分进行一个转换。
代码如下:
diff
export default function vitePluginTest() {
return {
// 省略...
transform(code, id) {
console.log('vite-transform', id);
// 如果模块 ID 是 'virtual-module',则转换其内容
+ if (id === 'virtual-module') {
+ return {
+ code: code.replace('{{Cobyte}}', 'Cobyte 掘金签约作者'),
+ };
+ }
}
}
}
这时在浏览器的控制台上打印了以下内容:

到此我们来详细解析上述 Rollup 插件的运行流程:
模块解析(resolveId 钩子) :
- 当遇到导入路径为
'virtual-module'时,会将该模块ID返回,表示该插件是专门处理该模块的。
模块加载(load 钩子) :
- 当需要加载模块ID为
'cobyte-module'的模块时,该插件会返回一个字符串,这个字符串是模块的源码,内容为export default "我是 Cobyte ~";。这相当于在构建时动态地创建了一个模块。
模块转换(transform 钩子) :
- 当处理模块ID为
'cobyte-module'的模块代码时,该插件会将其内容中的'Cobyte ~'替换为'Cobyte ~ 掘金签约作者'。这可以理解为对模块代码进行了编译处理。 - 转换后的代码会作为一个对象返回,其中包含修改后的代码(
code属性)
至此,我们在 Vite 环境下了解了 Vite 的插件的运行过程,可以看到 Vite 插件跟 Rollup 插件流程是相似的,此外虚拟模块在 Vite 以及 Rollup 中都以 virtual: 为前缀,作为面向用户路径的一种约定,这是我们在实际开发过程中需要注意的。
虚拟模块进阶实践
我们上文中说到了在解析 vue 文件的 Rollup 插件和 Vite 插件中都有大量使用虚拟模块,那么在这一小节我们就来深入理解一下虚拟模块的进阶实践。
此外我们在上文也说了虚拟模块在 Vite 以及 Rollup 中都以 virtual: 为前缀,作为面向用户路径的一种约定,但在直接从真实文件派生出来的模块,像单文件组件中的脚本模块(如.vue 或 .svelte SFC)不需要遵循这个约定。
接下来我们模拟解析 Vue 文件,我们知道 Vue 文件一般由 template、script、style 三部分组成,在读取了 Vue 文件的内容之后会通过 @vue/compiler-sfc 把上述 Vue 文件的三部分解析出来,然后再去解析各自部分的内容,比如,template 部分会通过模板编译器编译成 render 函数,script 部分会判断是否是 script setup 类型,因为 script setup 类型需要编译 defineProps、defineEmits、defineModel 等语法糖代码,style 部分则需要编译 sass、less、stylus 的 CSS 代码,还有可能是需要异步加载的。
更具体一点,比如 template 部分,我们可以通过 Vue 3 Template Explorer 这个网站看到 template 部分代码编译后的结果,例如模板:
html
<div>Hello Cobyte</div>
编译后的结果为:
javascript
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, "Hello Cobyte"))
}
那么我们在模拟中可以将上述代码简化为:
javascript
export function render() {
console.log('template 的编译结果');
}
同样 script 部分的编译结果简化后是:
javascript
export default {
name: 'App',
setup() {
console.log('我是 script 部分')
}
}
如果我们将上述代码分别写在 template.js 和 script.js 中,再分别导入再将它们组合在一起可以是下面这样的过程:
javascript
import script from './script.js'
import { render } from './template.js'
// 组合 script 和 render
script.render = render
// 导出
export default script
而在 Rollup 或者 Vite 插件中我们可以将上述的 script 部分的编译结果和 template 部分的编译结果通过虚拟模块来加载。
还有一个 Vue 文件的内容还有可能是这样的:
html
<template src="./template.html"></template>
<script src="./script.js"></script>
<style scr="./style.css"></style>
那么在 Vite 中为了实现高性能的热更新,那么这三部分也需要通过虚拟模块来实现。
接下来我们编写 Vite 插件来模拟 Vue 文件的解析过程。我们在 vite-plugin 的目录,然后创建一个 vite-plugin-virtual-vue.js 文件,初步代码实现如下:
javascript
export default function vitePluginVirtualVue() {
return {
name: 'vite-plugin-virtual-vue',
resolveId(id) {
},
load(id) {
},
transform(code, id) {
// 模拟如果是 Vue 文件则返回编译后结果,在实际过程中这里会调用 @vue/compiler-sfc 解析 vue 文件,我们目前这里是模拟,我们就不调用 @vue/compiler-sfc 进行解析了,这部分内容将在下一章节详细讲解。
if (/\.vue$/.test(id)) {
return {
code: `
import script from 'virtual:script'\n
import render from 'virtual:template'\n
script.render = render\n
export default script
`,
};
}
}
}
}
我们在 transform 钩子中模拟经过 @vue/compiler-sfc 解析后的代码,再通过虚拟模块进行加载。
接着我们在 vite.config.js 中配置我们上述写的 Vite 插件。
javascript
import { defineConfig } from 'vite'
import vitePluginVirtualVue from './vite-plugin/vite-plugin-virtual-vue'
export default defineConfig({
plugins: [
vitePluginVirtualVue()
]
})
然后 src/main.js 内容为:
javascript
import App from './App.vue'
console.log('App', App)
也就是说我们现在要通过上述手写的 Vite 插件模拟实现 App.vue 文件的解析,但现在我们的代码运行报错了。

根据我们前面所学的 Rollup 插件运行流程的知识,我们知道在经过 transform 钩子转换后的代码中,如果还存在 import 语句,还会重新解析 import 语句导入的模块,即继续调用 resolveId 钩子来确定模块导入的位置,而我们上述所写的插件还没确定是否导入这些模块,所以我们需要在 resolveId 钩子中进行确认需要导入的模块ID。
diff
export default function vitePluginVirtualVue() {
return {
name: 'rollup-plugin-virtual-vue',
resolveId(id) {
+ if(id === 'virtual:script' || id === 'virtual:template') {
+ return id
+ }
},
// 省略...
}
}
上述代码的作用是允许插件识别并处理这两个特定的虚拟标识符 'virtual:script' 和 'virtual:template',根据我们前面所学的知识,知道在 resolveId 钩子中确认了模块标识符,那么后续的 load 钩子也将会运行,那么根据我们前面所学的知识可以知道在 load 钩子中,我们就可以返回我们想返回的代码了。代码实现如下:
diff
export default function vitePluginVirtualVue() {
return {
name: 'rollup-plugin-virtual-vue',
// 省略...
load(id) {
+ // 返回 script 部分的代码
+ if (id === 'virtual:script') {
+ return {
+ code: `
+ export default {
+ name: 'App',
+ setup() {
+ console.log('我是 script 部分')
+ }
+ }
+ `}
+ } else if (id === 'virtual:template') {
+ // 返回 template 部分的代码
+ return {
+ code: `
+ export function render() {
+ console.log('template 的编译结果');
+ }
+ `
+ }
+ }
},
// 省略...
}
}
这时我们代码便运行正常了,同时可以看到控制台打印了我们期待的结果::

这是一个正常的 Vue 组件的配置对象,说明我们上述的伪代码是正确的。
接下来我们详细了解编译的流程。
例如我们上述的 App.vue 文件在 Vite 开发模式下最终会被编译成:

script 部分的模拟编译结果:

template 部分的模拟编译结果:

至此我们初步了解了 Vue 文件是如何被编译的。首先会由 Vue 官方提供的编译工具 @vue/compiler-sfc 把 Vue 文件初步分离文件中的 script、template、style 三部分,然后再分别编译各自部分的代码,最后再组合成一个 Vue 组件的配置对象。当然实际当中会更复杂,我们将在下一篇文章中详细讲解具体的实现。
总结
通过本文我們大概了解了 Rollup 的插件的运行过程,而 Vite 也是基于 Rollup 插件 API 进行设计的,在开发环境中,Vite 开发服务器会创建一个插件容器来调用 Rollup 构建钩子,与 Rollup 如出一辙,所以 Vite 的插件开发过程跟 Rollup 插件也有很多相同的部分,当然 Vite 在 Rollup 插件的钩子函数基础上设计了一些 Vite 独有的钩子函数来服务于特定的 Vite 目标,而这些我们在初学的时候,可以不用关注太多,先把插件的整体流程运行起来再说,这也是一种学习方法。