首页 > 网页制作 >手写Rollup插件解析Vue文件

手写Rollup插件解析Vue文件

来源:互联网 2026-07-31 17:00:15

上篇文章提到,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-sfcpnpm add @vue/compiler-sfc -D

Vue 文件的初步编译

先调试一下,看看 @vue/compiler-sfcparse 方法编译结果是什么。打个断点:

手写Rollup插件解析Vue文件

启动调试模式运行 pnpm build,可以看到 parse 方法将一个 Vue 文件分成了:

  • template 模块
  • script 模块
  • scriptSetup 模块(script setup 模式)
  • styles 模块(数组,因为可能有多个)

Vue 文件由 template、script、style 三部分组成,parse 先拆分,得到便于后续编译的数据结构。这一步并未编译代码,每个模块的 content 字段仍是原始内容。

编译 script 模块

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 部分。

编译 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 编译后的代码,如何整合呢?通过虚拟模块的方式。

组装 script 和 render 函数

虚拟模块技术在前面的文章中已经介绍过,这里直接复用思路:

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。再次运行测试,结果如下:

手写Rollup插件解析Vue文件

代码能走到这一步说明没问题。根据前文,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)
                // ...
            }     
        }
    }
}

再次运行,成功。编译结果如下:

手写Rollup插件解析Vue文件

手写Rollup插件解析Vue文件

运行编译结果代码

打包输出格式是 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 中的输出。

手写Rollup插件解析Vue文件

手写Rollup插件解析Vue文件

手写Rollup插件解析Vue文件

编译 style 模块

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 代码编译进去。

手写Rollup插件解析Vue文件

回到根目录运行 ./src/main.js,CSS 代码成功生效:

手写Rollup插件解析Vue文件

至此,Vue 文件中的 CSS 编译也实现了。

总结

通过逐步推导,完整实现了一个 Rollup 插件对 Vue 单文件组件的编译流程。从插件基础结构出发,利用 @vue/compiler-sfc 提供的 parsecompileScriptcompileTemplatecompileStyle 等核心 API,将 Vue 文件中的 template、script 和 style 三部分分别提取并编译成独立的中间产物。template 编译为 render 函数,script(尤其是