前言
通过上篇文章我们知道 Vue 文件一般由 template、script、style 三部分组成,编译的时候在读取了 Vue 文件的内容之后会通过 @vue/compiler-sfc 把上述 Vue 文件的三部分内容解析出来,然后再去解析各自部分的内容,比如,template 部分会通过模板编译器编译成 render 函数,script 部分会判断是否是 script setup 类型,因为 script setup 类型还需要编译 defineProps、defineEmits、defineModel 等语法糖代码,即对所谓编译宏的部分进行编译,以及将 TS 编译成 JavaScript,style 部分则需要编译 sass、less、stylus 的 CSS 代码。处理完成后,插件将结果传递给 Rollup 或 Vite 进行后续的打包或开发服务器启动等操作。
本文将详细讲解 Rollup 插件是如何编译 vue 文件的。
准备配置文件
首先我们在 rollup-plugin 目录下来创建一个 App.vue 文件,内容如下:
html
<template>
<div>i am Cobyte ~</div>
</template>
<script setup>
console.log('我是掘金签约作者:Cobyte')
</script>
<style>
div {
color: red;
}
</style>
我们现在的目标就是把上述的 App.vue 文件的代码通过 Rollup 插件编译成 JavaScript 代码。接着我们创建一个 Rollup 插件,根据我们前一篇文件所学的 Rollup 插件知识,一个基础的 Rollup 插件结构如下:
javascript
// rollup-plugin-vue.js
export default function pluginVue() {
return {
name: 'rollup-plugin-vue'
}
}
接着我们修改 rollup.config.js 配置文件:
javascript
import pluginVue from './rollup-plugin-vue.js'
export default [
{
input: './App.vue',
output: {
file: 'dist/output.js',
format: 'esm',
},
plugins: [
pluginVue()
],
},
]
那么根据我们前一篇文章所学到的知识,可以知道 Rollup 插件对本地相对目录的文件是所有的钩子函数都会执行的,所以我们可以直接在 transform 钩子中直接编译 App.vue 文件,同时因为 Rollup 插件对本地目录的文件都会进行访问,所以我们需要判断一下只有 Vue 文件的时候才进行处理,Rollup 插件修改如下:
javascript
import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
transform(code, id) {
// 因为 Rollup 插件对本地目录的文件都会进行访问,所以我们要判断一下只有 vue 文件的时候才进行处理
if (id.endsWidth('.vue')) {
const result = compiler.parse(code);
console.log(result)
}
}
}
}
同时我们需要安装 @vue/compiler-sfc:
sql
pnpm add @vue/compiler-sfc -D
Vue 文件的初步编译
接下来我们先进行调试一下,看一下经过 @vue/compiler-sfc 的 parse 方法编译结果是什么。
我们先打个断点:

然后启动调试模式运行命令:

接着我们进到 rollup-plugin 目录运行 pnpm build,最终可以看到 @vue/compiler-sfc 的 parse 方法编译结果如下:

我们可以看到 @vue/compiler-sfc 的 parse 方法把一个 Vue 文件分成了:
- template 模块
- script 模块
- scriptSetup 模块 (script setup 模式)
- styles 模块(是一个数组,因为可能有多个)
我们知道 Vue 文件一般由 template、script、style 三部分组成,@vue/compiler-sfc 的 parse 方法先把 Vue 文件进行拆分,处理成方便我们进行下一步编译的数据结构。这一步并没有对代码进行编译,可以看到每个模块中的 content 字段的内容还是原来 Vue 文件的内容。
编译 script 模块
我们知道 script setup 类型还需要编译 defineProps、defineEmits、defineModel 等语法糖代码,同时还是需要处理 CSS 变量注入等,所以我们需要编译 script 部分的代码,而这个能力 @vue/compiler-sfc 也提供了 compileScript 方法进行处理。compileScript 方法的使用如下:
diff
import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
transform(code, id) {
if (id.endsWith('.vue')) {
const result = compiler.parse(code);
+ // 创建组件的唯一标识符,用于样式作用域等
+ const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
+ const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
+ console.log(scriptCode)
}
}
}
}
打印出来的结果如下:

也就是 content 字段中的内容就是编译后的 script 部分的代码,我们整理一下如下:
javascript
export default {
setup(__props, { expose: __expose }) {
__expose();
console.log('我是掘金签约作者:Cobyte')
const __returned__ = { }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}
}
接下来我们编译 template 部分的代码。
编译 template 模块
编译编译 template 部分的代码也很简单,只需要使用 compileTemplate 方法进行以下操作:
diff
import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
transform(code, id) {
if (id.endsWith('.vue')) {
const result = compiler.parse(code);
// 创建组件的唯一标识符,用于样式作用域等
const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
+ const templateCode = compiler.compileTemplate({ source: result.descriptor.template.content, id: scopeId })
+ console.log(templateCode)
}
}
}
}
编译结果如下:

也就是 code 字段中的内容就是编译后的 template 部分的代码,也就是 render 函数,我们整理一下如下:
javascript
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, "i am Cobyte ~"))
}
那么我们现在得到了 script 、template 部分的编译后的代码,我们怎么整合它们呢?通过上一篇的文章我们知道可以通过虚拟模块的方式进行整合。
组装 script 和 render 函数
我们上面提到了可以通过虚拟模块的技术整合 script 、template 部分的编译后的代码,上一篇中代码是这样的:

现在我们依然可以这样:
diff
import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
transform(code, id) {
if (id.endsWith('.vue')) {
const result = compiler.parse(code);
// 创建组件的唯一标识符,用于样式作用域等
const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
const templateCode = compiler.compileTemplate({ source: result.descriptor.template.content, id: scopeId })
+ // 脚本导入的虚拟模块
+ const scriptImport = `import script from '${id}?vue&type=script'\n`
+ // 模板导入的虚拟模块
+ const templateImport = `import { render } from '${id}?vue&type=template'\n`
+ // 组装
+ const renderReplace = `script.render = render\n`
+ const exportDefault = `export default script`
+ return {
+ code: `
+ ${scriptImport}
+ ${templateImport}
+ ${renderReplace}
+ ${exportDefault}
+ `
+ }
}
}
}
}
通过我们前面对 Rollup 插件的了解,我们知道经过 transform 钩子处理过的代码如果还存在 import 语句那么还会继续调用 resolveId 钩子处理模块 ID,而且只对第三方模块的 ID 进行处理。而我们上述构建的模块地址是这样的:import script from '${id}?vue&type=script'\n,其中 id 是一个绝对路径地址,例如:'D:\workspace\github\mini-vue-vapor\rollup-plugin\App.vue',最终会变成:'D:\workspace\github\mini-vue-vapor\rollup-plugin\App.vue?vue&type=script'。那么这个导入地址很明显不是一个本地的相对路径,所以它将被当作一个第三方库的路径进行处理。所以我们需要在 resolveId 钩子处理在模块路径,让我们写的 Rollup 插件后续可以继续处理该文件。
我们的插件改动如下:
diff
import compiler from '@vue/compiler-sfc'
+ import qs from 'querystring'
+ // 判断否是一个 Vue 单文件组件的请求
+ function parseVuePartRequest(id) {
+ const [filename, query] = id.split('?', 2)
+ if (!query) return { vue: false, filename }
+ // 使用 qs.parse 方法解析查询字符串,将其转换成一个对象
+ const raw = qs.parse(query)
+ // 如果解析后的对象中包含 vue 键,说明这是一个 Vue 组件请求
+ if ('vue' in raw) {
+ return {
+ ...raw,
+ filename,
+ vue: true
+ }
+ }
+ // 如果不包含 vue 键,返回表示这不是 Vue 组件请求的对象
+ return { vue: false, filename }
+ }
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
+ async resolveId(id) {
+ // 调用 parseVuePartRequest 函数解析模块 ID
+ const query = parseVuePartRequest(id);
+ // 解析结果如果 query.vue 为 true 表示这是一个 Vue 组件请求,则返回原始的模块 ID,这样该插件后续就会继续处理这个模块
+ if (query.vue) {
+ return id
+ }
+ // 如果不是 Vue 组件请求,返回 null,表示这个模块 ID 不应该由这个插件处理
+ return null
+ },
// 省略...
}
}
我们通过封装一个 parseVuePartRequest 函数来判断是否 Vue 组件请求,主要判断依据就是文件ID的 ? 后面部分的参数中是否存在 vue 键,这就是我们上述构建虚拟模块的时候为什么要设置 ?vue&type=script 部分的原因,就是为了后面可以进行区分。
同时我们需要安装 querystring 依赖模块: pnpm add querystring -D
这样我们再次运行测试的时候,结果如下:

当代码可以走到这一步的时候,说明我们的代码是没有问题的。
根据我们在前一篇文章中所学到的知识可以知道当在 resolveId 钩子中返回了一个虚拟模块的ID之后,那么接下来就轮到 load 钩子进行处理了,那么还是根据前面所学的知识知道可以在 load 钩子中返回虚拟模块的代码,所以我们就可以在 load 钩子中根据模块ID 中的 type 类型来判断是返回 script 部分的代码还是 template 部份的代码。
插件代码改动如下:
diff
// 省略...
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
+ load(id) {
+ // 调用 parseVuePartRequest 函数解析模块 ID
+ const query = parseVuePartRequest(id);
+ // 解析结果如果 query.vue 为 true 表示这是一个 Vue 组件请求,则返回原始的模块 ID,这样该插件后续就会继续处理这个模块
+ if (query.vue) {
+ if (query.type === 'script') {
+ // 返回 script 部分的代码
+ } else if (query.type === 'template') {
+ // 返回 template 部分的代码
+ }
+ }
+ },
// 省略...
}
}
那么问题来了,我们在 load 钩子中返回 script 和 template 部分的代码呢?通过上文我们知道 script 和 template 部分的代码已经在 transform 钩子中进行了编译,怎么可以在 load 钩子中将 transform 钩子中编译好的代码获取到呢?我们可以在 transform 钩子中将 script 和 template 部分的编译结果缓存起来,然后在 load 钩子中就可以获取缓存中对应的编译结果了。
设置编译缓存
我们通过以下方式进行设置 script 和 template 部分的缓存。代码如下:
diff
+ const cache = new Map();
// 省略...
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
load(id) {
// 调用 parseVuePartRequest 函数解析模块 ID
const query = parseVuePartRequest(id);
// 解析结果如果 query.vue 为 true 表示这是一个 Vue 组件请求,则返回原始的模块 ID,这样该插件后续就会继续处理这个模块
if (query.vue) {
if (query.type === 'script') {
// 返回 script 部分的代码
+ const code = cache.get(id).content
+ return {
+ code
+ }
} else if (query.type === 'template') {
// 返回 template 部分的代码
+ const code = cache.get(id).code
+ return {
+ code
+ }
}
}
},
transform(code, id) {
if (/\.vue$/.test(id)) {
// 省略 ...
- const scriptImport = `import script from '${id}?vue&type=script'\n`
+ const scriptID = `${id}?vue&type=script`;
+ const scriptImport = `import script from '${scriptID}'\n`
- const templateImport = `import { render } from '${id}?vue&type=template'\n`
+ const templateID = `${id}?vue&type=template`;
+ const templateImport = `import { render } from '${templateID}'\n`
+ // 缓存编译结果
+ cache.set(scriptID, scriptCode)
+ cache.set(templateID, templateCode)
// 组装
const renderReplace = `script.render = render\n`
const exportDefault = `export default script`
return {
// 省略...
}
}
}
}
}
在上述代码中我们通过 Map 对象的实例 cache 来缓存编译后的结果,这个缓存是以我们自己构建的虚拟模块 ID 为键,然后设置对应的 script 或 template 部分的编译结果为键值。
我们运行上述代码的时候却报错了:

这个报错是说我们在读取缓存结果的时候读取不到,那么我们在相关的地方打断点进行测试,最后发现了以下问题。
我们初始设置缓存的键是下面这样的:

在 load 钩子中却变成了:

我们发现路径没有被正确地转义。这是 Rollup 内部处理的,我们可以通过 JSON.stringify 确保任何特殊字符(如路径中的空格、查询参数中的特殊符号等)都被正确地转义,从而生成一个合法的模块请求路径。所以我们修改如下:
diff
// 省略...
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
transform(code, id) {
if (/\.vue$/.test(id)) {
// 省略...
// 脚本导入的虚拟模块
const scriptID = `${id}?vue&type=script`;
+ const scriptRequest = JSON.stringify(scriptID);
- const scriptImport = `import script from '${scriptID}'\n`
+ const scriptImport = `import script from ${scriptRequest}\n`
// 模板导入的虚拟模块
const templateID = `${id}?vue&type=template`;
+ const templateRequest = JSON.stringify(templateID);
- const templateImport = `import { render } from '${templateID}'\n`
+ const templateImport = `import { render } from ${templateRequest}\n`
// 缓存编译结果
cache.set(scriptID, scriptCode)
cache.set(templateID, templateCode)
// 省略...
return {
// 省略...
}
}
}
}
}
我们再次运行代码,发现成功了。

编译结果如下:

运行编译结果代码
在前面一小节,我们实现了对 Vue 文件的编译,因为我们的打包输出格式是 ESM 的,所以我们可以通过以下的方式进行测试。
我们回到根目录的 src/main.js 中修改测试代码如下:
javascript
import { createApp } from 'vue'
import App from '../rollup-plugin/dist/output'
const app = createApp(App)
app.mount('#app')
接着修改根目录的 vite.config.js 的文件内容:
javascript
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
Vue(),
]
})
如果你还没要安装 Vue3 的依赖包,那么先安装一下 Vue3 的依赖包。
我们在根目录启动 Vite 开发模式,最后我们可以看到 Vite 项目正常启动了。结果如下:

在浏览器的网络窗口,我们也可以看到打包的 Vue 文件也被加载了,结果如下:

日志打印窗口也打印了组件中 setup 方法的日志输出:

编译 style 模块
对于 style 模块,跟 script 和 template 模块一样,需要通过 @vue/compiler-sfc 包将 Vue 本身赋予 style 模块的能力代码进行编译,比如是否使用了 scoped 属性,然后需要对 scoped 属性的处理,总的来说需要处理以下的问题:
- 如果样式使用了
scoped属性的,编译器会生成唯一的 CSS 类名,确保样式不会影响其他组件; - 如果 style 中使用了
v-bind指令来绑定 script 模块中的响应式变量,那么需要将v-bind指令转换为 CSS 变量; - 同时使用了
scoped样式无法穿透到子组件,那么需要使用:deep()指令,所以当遇到:deep()指令时,编译器会特殊处理这些选择器,确保它们能够应用到子组件的 DOM 结构中。 - 如果使用了 CSS 模块化 (module),也需要进行编译处理
经过 @vue/compiler-sfc 包编译之后,可能还需要通过 sass、less、stylus 编译器处理成普通的 CSS 代码。这样处理后 Vue 文件中 style 部分的样式就会被转换成标准的 CSS,并插入到最终打包后的 JavaScript 代码中,或者在单独的 CSS文件中引用。
而我们这里暂时只讲插入打包后的 JavaScript 代码中的情况,那么怎么将编译后的普通 CSS 代码插入打包后的 JavaScript 代码中呢?
比如我们上述的 App.vue 文件的 style 部分的 CSS 代码最终会编译成:
css
div {
color: red;
}
很明显这段代码是字符串,是不能直接放到 JavaScript 文件中的,那么我们可以通过一个变量把它存起来,这样它就可以放到 JavaScript 文件中了。
javascript
const cssBlock = `
div {
color: red;
}
`
那么我们再通过 JavaScript 代码动态地添加到HTML页面上即可,实现也很简单,直接用 createElement 创建 style 标签元素,然后插入到页面 head 元素内即可,代码如下:
javascript
const cssBlock = `div {color: red;}`
function styleInject(css) {
// 获取页面 head 标签元素
const head = document.getElementsByTagName('head')[0]
// 创建 style 元素
const style = document.createElement('style')
style.type = 'text/css'
// 插入到页面 `head` 元素内
head.appendChild(style)
// 添加 style 元素的 CSS 内容
style.appendChild(document.createTextNode(css))
}
styleInject(cssBlock)
这个就是实现编译后的 CSS 代码添加到页面上的实现原理。接下来我们来进行具体的实现。
跟 script 和 template 模块一样,style 部分我们也需要通过虚拟模块来处理,而 style 部分可能存在多个,所以我们的代码实现如下:
diff
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
transform(code, id) {
if (/\.vue$/.test(id)) {
// 省略...
+ // 编译 style 模块
+ let stylesImport = ``
+ if (result.descriptor.styles.length) {
+ result.descriptor.styles.forEach((style, i) => {
+ // 样式导入的虚拟模块
+ const query = `?vue&type=style&index=${i}&lang.css`
+ const styleRequest = id + query
+ stylesImport += `\nimport ${JSON.stringify(styleRequest)}`
+ // 编译 style 模块
+ const styleCode = compiler.compileStyle({
+ id: `data-v-${scopeId}`,
+ source: style.content,
+ scoped: style.scoped // 是否使用 scoped
+ })
+ // 缓存 style 模块
+ cache.set(styleRequest, styleCode)
+ })
+ }
// 省略...
return {
code: `
+ ${stylesImport}
`
}
}
}
}
}
值得注意的是我们在加载 style 文件路径参数后面加了 lang.css 的部分,以此来区分是哪种 style 预处理语言,如果是 scss 那么就以 lang.scss 结尾,以此类推,因为我们在 App.vue 文件中是只使用了普通的 CSS,所以我们是以 .css 结尾。
接着我们需要在 load 钩子里从缓存中加载 style 部分的代码,代码实现如下:
diff
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
load(id) {
// 调用 parseVuePartRequest 函数解析模块 ID
const query = parseVuePartRequest(id);
// 解析结果如果 query.vue 为 true 表示这是一个 Vue 组件请求,则返回原始的模块 ID,这样该插件后续就会继续处理这个模块
if (query.vue) {
if (query.type === 'script') {
// 省略...
+ } else if (query.type === 'style') {
+ // 返回 style 部分的代码
+ const code = cache.get(id).code
+ return {
+ code,
+ }
+ }
}
},
// 省略...
}
}
我们知道目前在 load 中加载的 CSS 代码是不能直接放到 JavaScript 文件中的,所以我们需要在 transform 中进行处理,当然也可以在 load 中处理好再进行返回,我们现在的处理方案是放到 transform 中进行处理,而方案就是我们在上面已经提到的动态添加 CSS 代码到页面的方案。
diff
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
transform(code, id) {
if (/\.vue$/.test(id)) {
// 省略...
+ } else if (/\.css$/.test(id)) {
+ const styleCode = `
+ const cssBlock = '${JSON.stringify(code)}';\n
+ function styleInject(css) {\n
+ const head = document.getElementsByTagName('head')[0]\n
+ const style = document.createElement('style')\n
+ style.type = 'text/css'\n
+ head.appendChild(style)\n
+ style.appendChild(document.createTextNode(css))\n
+ }\n
+ styleInject(cssBlock)\n
+ `
+ return {
+ code: styleCode
+ }
+ }
}
}
}
这时我们在 ./rollup-plugin/ 目录下再执行 pnpm build,我们就可以看到 ./rollup-plugin/dist/output.js 文件,已经把我们的 CSS 代码编译进去了。

这时我们再回到根目录运行我们 ./src/main.js 中的代码,我们发现我们 CSS 代码已经成功运行了:

至此我们的 Vue 文件中的 CSS 代码也实现了编译。
总结
通过本文的逐步推导,我们完整实现了一个 Rollup 插件对 Vue 单文件组件的编译流程。从插件的基础结构出发,我们利用 @vue/compiler-sfc 提供的 parse、compileScript、compileTemplate 和 compileStyle 等核心 API,将 Vue 文件中的 template、script 和 style 三部分分别提取并编译成独立的中间产物。其中 template 被编译为 render 函数,script(尤其是 <script setup>)中的编译宏和 TypeScript 被转换为标准 JavaScript,style 则经过作用域处理(scoped)和 CSS 变量绑定(v-bind)后生成普通 CSS 字符串。这个分步编译的过程,不仅展示了 Vue 官方编译工具链的威力,也揭示了现代前端构建工具处理组件化框架时的底层逻辑。
在整个实现中,虚拟模块技术和缓存机制的巧妙结合是保证 Rollup 插件正确工作的关键。我们为每个编译好的部分(script、template、style)构造了带有查询参数的模块 ID(如 App.vue?vue&type=script),并在 resolveId 和 load 钩子中拦截这些请求,返回缓存中的编译结果。这种设计让 Rollup 能够像处理普通 JavaScript 模块一样处理 Vue 的不同组成部分,同时避免了重复编译,提升了构建性能。特别是对于 style 模块,我们通过注入运行时的 styleInject 函数,将 CSS 字符串动态添加到页面中,实现了样式模块的按需加载,这一策略在开发环境和生产打包中都具有实用价值。
最终,我们成功将编译后的代码整合成一个完整的 Vue 组件对象,并能够在真实项目中运行验证。这个插件的实现不仅涵盖了 Vue 单文件组件编译的核心路径,也为后续扩展其他特性(如 CSS 预处理器支持、自定义块处理、热更新等)预留了清晰的接口。通过这篇实战,读者可以深入理解 Rollup 插件的钩子执行顺序、虚拟模块的用法以及 Vue 官方编译工具的内部工作原理,从而具备自主定制和优化 Vue 构建流程的能力,为复杂的工程化场景打下坚实基础。