上篇文章提到,Vue 文件通常由 template、script、style 三部分构成。在编译时,系统会读取 Vue 文件内容,并借助 @vue/compiler-sfc 将其拆解为三部分,分别处理。template 部分通过模板编译器编译成 render 函数;script 部分需要判断是否为
上篇文章提到,Vue 文件通常由 template、script、style 三部分构成。在编译时,系统会读取 Vue 文件内容,并借助 @vue/compiler-sfc 将其拆解为三部分,分别处理。template 部分通过模板编译器编译成 render 函数;script 部分需要判断是否为 script setup 类型——如果是,还需编译 defineProps、defineEmits、defineModel 等语法糖(即编译宏),并将 TypeScript 转换为 JavaScript;style 部分则处理 sass、less、stylus 等 CSS 预处理器。处理完成后,插件会将结果交给 Rollup 或 Vite,用于后续打包或启动开发服务器。
本文将详细解析 Rollup 插件如何编译 .vue 文件。从零开始,逐步构建一个可用的插件,带你了解完整的流程。
长期稳定更新的攒劲资源: >>>点此立即查看<<<
首先在 rollup-plugin 目录下创建 App.vue 文件,内容如下:
<template>
<div>i am Cobyte ~div>
template>
<script setup>
console.log('我是掘金签约作者:Cobyte')
script>
<style>
div {
color: red;
}
style>
目标明确:将 App.vue 文件通过 Rollup 插件编译成 JavaScript 代码。首先搭建一个基础的 Rollup 插件结构,根据前文知识,框架如下:
// rollup-plugin-vue.js
export default function pluginVue() {
return {
name: 'rollup-plugin-vue'
}
}
然后修改 rollup.config.js:
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 文件才处理:
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:pnpm add @vue/compiler-sfc -D
先调试一下,看看 @vue/compiler-sfc 的 parse 方法编译结果是什么。打个断点:

启动调试模式运行 pnpm build,可以看到 parse 方法将一个 Vue 文件分成了:
Vue 文件由 template、script、style 三部分组成,parse 先拆分,得到便于后续编译的数据结构。这一步并未编译代码,每个模块的 content 字段仍是原始内容。
script setup 类型需要编译 defineProps、defineEmits、defineModel 等语法糖,以及处理 CSS 变量注入。这个能力 @vue/compiler-sfc 也提供了 compileScript 方法:
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 部分代码,整理后如下:
export default {
setup(__props, { expose: __expose }) {
__expose();
console.log('我是掘金签约作者:Cobyte')
const __returned__ = { }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}
}
接下来编译 template 部分。
使用 compileTemplate 方法:
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 函数:
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, "i am Cobyte ~"))
}
现在得到了 script 和 template 编译后的代码,如何整合呢?通过虚拟模块的方式。
虚拟模块技术在前面的文章中已经介绍过,这里直接复用思路:
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 = rendern`
+ const exportDefault = `export default script`
+ return {
+ code: `
+ ${scriptImport}
+ ${templateImport}
+ ${renderReplace}
+ ${exportDefault}
+ `
+ }
}
}
}
}
经过 transform 处理后的代码如果还有 import 语句,Rollup 会继续调用 resolveId 钩子处理模块 ID。上面构建的模块地址是 import script from '${id}vue&type=script'n,其中 id 是绝对路径,例如 D:workspace...App.vue,最终变成 D:workspace...App.vuevue&type=script。这个路径不是本地相对路径,会被当作第三方库处理。因此需要用 resolveId 钩子拦截,让插件继续处理。
插件改动如下:
import compiler from '@vue/compiler-sfc'
+ import qs from 'querystring'
+ function parseVuePartRequest(id) {
+ const [filename, query] = id.split('', 2)+ if (!query) return { vue: false, filename }
+ const raw = qs.parse(query)
+ if ('vue' in raw) {
+ return { ...raw, filename, vue: true }
+ }
+ return { vue: false, filename }
+ }export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
+ async resolveId(id) {
+ const query = parseVuePartRequest(id);
+ if (query.vue) {
+ return id
+ }
+ return null
+ },
// 省略...
}
}
parseVuePartRequest 判断文件 ID 中 后面的参数是否存在 vue 键——这就是在构建虚拟模块时设置 vue&type=script 的原因,便于后续区分。
安装 querystring:pnpm add querystring -D。再次运行测试,结果如下:

代码能走到这一步说明没问题。根据前文,resolveId 返回虚拟模块 ID 后,接下来会触发 load 钩子。可以在 load 钩子中根据模块 ID 的 type 返回对应的代码:
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
// 省略...
+ load(id) {
+ const query = parseVuePartRequest(id);
+ if (query.vue) {
+ if (query.type === 'script') {
+ // 返回 script 部分的代码
+ } else if (query.type === 'template') {
+ // 返回 template 部分的代码
+ }
+ }
+ },
}
}
问题来了:在 load 钩子中如何获取 transform 钩子里编译好的代码?可以在 transform 中缓存编译结果,然后在 load 中读取缓存。
用 Map 缓存:
+ const cache = new Map();
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
load(id) {
const query = parseVuePartRequest(id);
if (query.vue) {
if (query.type === 'script') {
+ const code = cache.get(id).content
+ return { code }
} else if (query.type === '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)
// 组装...
return { code: ... }
}
}
}
}
运行时报错:读取缓存不到。断点调试发现,初始设置缓存的键和 load 中得到的键不一致——路径转义出了问题。Rollup 内部处理了路径,使用 JSON.stringify 可以确保特殊字符正确转义:
export default function pluginVue() {
return {
// ...
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)
// ...
}
}
}
}
再次运行,成功。编译结果如下:


打包输出格式是 ESM,可以这样测试:修改根目录 src/main.js:
import { createApp } from 'vue'
import App from '../rollup-plugin/dist/output'const app = createApp(App)
app.mount('#app')
修改 vite.config.js:
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'export default defineConfig({
plugins: [
Vue(),
]
})
安装 Vue3 依赖后,启动 Vite 开发模式,项目正常启动,浏览器网络窗口能看到 Vue 文件被加载,日志也打印了 setup 中的输出。



style 模块的处理与 script、template 类似,需要借助 @vue/compiler-sfc 编译 Vue 赋予 style 的特殊能力,例如 scoped 属性、v-bind 指令、:deep() 穿透、CSS 模块化等。编译后可能还需要通过 sass/less/stylus 处理器转成普通 CSS。这里只讨论如何将 CSS 插入打包后的 JavaScript 代码中——如何将编译后的 CSS 字符串塞进 JS 中呢?
例如 App.vue 的 style 最终编译成:
div {
color: red;
}
这段字符串不能直接放入 JS 文件,但可以用变量存储:
const cssBlock = `
div {
color: red;
}
`
然后通过 JavaScript 动态添加到页面:用 createElement 创建 style 标签,插入到 head 中:
const cssBlock = `div {color: red;}`
function styleInject(css) {
const head = document.getElementsByTagName('head')[0]
const style = document.createElement('style')
style.type = 'text/css'
head.appendChild(style)
style.appendChild(document.createTextNode(css))
}styleInject(cssBlock)
这是实现原理。接下来具体实现:style 部分也通过虚拟模块处理,可能有多个 style,代码如下:
export default function pluginVue() {
return {
name: 'rollup-plugin-vue',
transform(code, id) {
if (/.vue$/.test(id)) {
// 省略...
+ 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)}`
+ const styleCode = compiler.compileStyle({
+ id: `data-v-${scopeId}`,
+ source: style.content,
+ scoped: style.scoped
+ })
+ cache.set(styleRequest, styleCode)
+ })
+ }
// 省略...
return { code: `+ ${stylesImport}` }
}
}
}
}
注意路径参数后面加了 lang.css,用于区分预处理语言。如果是 scss 就以 lang.scss 结尾,以此类推。
在 load 钩子中从缓存加载 style 代码:
export default function pluginVue() {
return {
// ...
load(id) {
const query = parseVuePartRequest(id);
if (query.vue) {
if (query.type === 'script') { ... }
+ } else if (query.type === 'style') {
+ const code = cache.get(id).code
+ return { code }
+ }
}
},
// ...
}
}
目前 load 中加载的 CSS 代码不能直接放入 JS,需要在 transform 中处理,采用上面提到的动态注入方案:
export default function pluginVue() {
return {
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,可以看到 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(尤其是 )中的编译宏和 TypeScript 被转换为标准 JavaScript,style 则经过作用域处理(scoped)和 CSS 变量绑定(v-bind)后生成普通 CSS 字符串。这个分步编译的过程,展示了 Vue 官方编译工具链的威力,也揭示了现代前端构建工具处理组件化框架时的底层逻辑。
整个实现中,虚拟模块技术和缓存机制的巧妙结合是保证 Rollup 插件正确工作的关键。为每个编译好的部分(script、template、style)构造了带有查询参数的模块 ID(如 App.vuevue&type=script),并在 resolveId 和 load 钩子中拦截这些请求,返回缓存中的编译结果。这种设计让 Rollup 能够像处理普通 JavaScript 模块一样处理 Vue 的不同组成部分,同时避免了重复编译,提升了构建性能。特别是对于 style 模块,通过注入运行时的 styleInject 函数,将 CSS 字符串动态添加到页面中,实现了样式模块的按需加载,这一策略在开发环境和生产打包中都具有实用价值。
最终,成功将编译后的代码整合成一个完整的 Vue 组件对象,并能在真实项目中运行验证。这个插件的实现不仅涵盖了 Vue 单文件组件编译的核心路径,也为后续扩展其他特性(如 CSS 预处理器支持、自定义块处理、热更新等)预留了清晰的接口。通过这篇实战,可以深入理解 Rollup 插件的钩子执行顺序、虚拟模块的用法以及 Vue 官方编译工具的内部工作原理,从而具备自主定制和优化 Vue 构建流程的能力,为复杂的工程化场景打下坚实基础。