vite-plugin-vue 实现分析
最近在编写vue-jit项目,直接在浏览器中编译 Vue 组件并运行,因此需要了解在 Vite 中,Vue 插件是如何工作的。
插件架构
graph TD
subgraph vite-plugin-vue
A[plugin-vue]
B[plugin-vue-jsx]
end
subgraph plugin-vue核心功能
A --> C[Vue SFC处理]
A --> D[HMR热更新]
A --> E[SSR支持]
C --> C1[template编译]
C --> C2[script处理]
C --> C3[style处理]
C --> C4[custom blocks]
D --> D1[文件变更监听]
D --> D2[组件重载]
E --> E1[SSR模块注册]
E --> E2[SSR上下文]
end
subgraph plugin-vue-jsx核心功能
B --> F[JSX/TSX转换]
B --> G[HMR支持]
B --> H[SSR支持]
F --> F1[Babel转换]
F --> F2[TypeScript支持]
G --> G1[组件热重载]
H --> H1[SSR模块注册]
end
subgraph 公共功能
I[源码映射]
J[自定义元素支持]
K[开发工具集成]
end
A --> I
A --> J
A --> K
B --> I
B --> K
只提取 SFC 部分的架构:
graph TD
A[plugin-vue]
A --> C[Vue SFC处理]
C --> C1[template编译]
C --> C2[script处理]
C --> C3[style处理]
C --> C4[custom blocks]
Vue SFC 处理
入口位于/packages/plugin-vue/src/main.ts
。
流程如下
- Vite 通过 plugin-vue 插件拦截 .vue 文件
- 将 SFC 解析为 descriptor (描述符)
- 分别处理 template/script/style 块
- 生成最终的 JavaScript 代码
- 处理 HMR、Sourcemap 等
接下来试试编译后的结果长什么样,以下面这个 .vue 文件为例
<template>
<div class="container">
<span>{{ name }}</span>
<span>{{ age }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
name: {
type: String,
default: 'Ray',
},
})
const age = ref(18)
</script>
<style scoped>
.container {
color: red;
}
</style>
编译后:
// 导入 VDOM API
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "container" }
import { ref } from 'vue'
// 主模块
const _sfc_main = {
__name: 'test',
props: {
name: {
type: String,
default: 'Ray',
},
},
setup(__props) {
const props = __props
const age = ref(18)
// 返回一个函数,用于渲染组件
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("span", null, _toDisplayString(__props.name), 1 /* TEXT */),
_createElementVNode("span", null, _toDisplayString(age.value), 1 /* TEXT */)
]))
}
}
}
// 样式模块
import "test.vue?vue&type=style&index=0&scoped=5e63b55d&lang.css"
import _export_sfc from '�plugin-vue:export-helper'
export default /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-5e63b55d"]])
最后的 import 和 export default 是插件自动生成的,用于转换主模块后导出。
转化相关的代码在 helper.ts
中
export const EXPORT_HELPER_ID = '\0plugin-vue:export-helper'
export const helperCode = `
export default (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
}
`
上面的样例代码,通过 _export_sfc
函数为组件添加了 __scopeId 属性,值为 “data-v-5e63b55d”。这个 ID 用于 scoped CSS 的实现。
这样确保样式只影响当前组件的元素。
比如:
.container {
color: red;
}
会被编译为:
.container[data-v-5e63b55d] {
color: red;
}
具体分析每一步的实现
解析 SFC
为了防止对同一个.vue文件重复解析,Vite 插件包含缓存机制,在没有缓存的情况下,会调用 createDescriptor
函数解析 SFC。
createDescriptor 函数内部调用 compiler.parse(@vue/compiler-sfc) 函数,解析 SFC 文件。
解析后的 descriptor 的主要数据结构如下:
interface SFCDescriptor {
template: SFCTemplateBlock | null;
script: SFCScriptBlock | null;
// <script setup>
scriptSetup: SFCScriptBlock | null;
styles: SFCStyleBlock[];
}
descriptor 会作为后续步骤的参数。
脚本处理
脚本处理通过 main.ts 中的genScriptCode
函数完成,主要流程如下:
初始化
let scriptCode = `const ${scriptIdentifier} = {}`
let map: RawSourceMap | undefined
解析脚本
const script = resolveScript(descriptor, options, ssr, customElement)
在 resolveScript 内部,调用了 compiler.compileScript 方法(@vue/compiler-sfc),将脚本编译为 JavaScript 代码后返回。
解析脚本后,有两种情况:
- 当脚本包含导入内容时:
- 处理外部源文件
- 生成查询参数
- 通过 import 语句导入脚本
- 当脚本不包含导入内容,可以直接使用时:
- 判断编译器版本
- 处理 TypeScript 和装饰器
- 使用 rewriteDefault 重写默认导出
- 或直接使用脚本内容
一些主要的判断逻辑:
- 是否可以内联(canInlineMain)
if (canInlineMain(descriptor, options)) {
// 内联处理
} else {
// 外部处理
}
- 编译器版本
if (!options.compiler.version) {
// 旧版本编译器处理
scriptCode = options.compiler.rewriteDefault(...)
} else {
// 新版本编译器处理
scriptCode = script.content
}
- TypeScript 支持
const defaultPlugins =
script.lang === 'ts'
? userPlugins.includes('decorators')
? (['typescript'] as const)
: (['typescript', 'decorators-legacy'] as const)
: []
最终,scriptCode 会传入output
数组,作为主模块的一部分。
模板编译
main.ts 通过 genTemplateCode
函数处理模板编译。
({ code: templateCode, map: templateMap } = await genTemplateCode(
descriptor,
options,
pluginContext,
ssr,
customElement,
))
在 genTemplateCode 内部,调用了位于packages/plugin-vue/src/template.ts
的两个方法。
transformTemplateAsModule
将模板编译为独立模块transformTemplateInMain
将模板直接编译到主模块中
这两个方法本质都是调用 compiler.compileTemplate 方法(@vue/compiler-sfc),将模板编译为 JavaScript 代码。
最终编译完成的 templateCode 会传入output
数组,作为主模块的一部分。
样式处理
样式处理通过genStyleCode
函数完成,主要内容是遍历所有的样式块,根据不同的情况进行处理。
最终生成的是一堆 import 语句,如果使用了 CSS Modules,还会生成一个 cssModulesMap 对象。
// 普通样式
import "style.css?vue&type=style&index=0&scoped=xxx"
// CSS Modules
import style0 from "style.css?vue&type=style&index=0&module"
const cssModules = {
"className": style0["className"]
}
// 包含作用域
import "style.css?vue&type=style&index=0&scoped=data-v-xxx"
这些包含查询条件的导入会交由 Vite 处理。
外部样式流程:
- 链接外部文件到 descriptor
- 生成带有作用域的查询参数
CSS Modules 流程:
- 检查是否支持(自定义元素模式不支持)
- 生成导入代码和名称映射
- 将样式代码添加到 stylesCode
最终,stylesCode 会传入output
数组,作为主模块的一部分。
output 数组
output 数组是插件处理后的结果,包含以下几个部分:
const output: string[] = [
// 脚本
scriptCode,
// 模板
templateCode,
// 样式
stylesCode,
// 自定义块 (在本文中被忽略)
customBlocksCode,
]
在 output 数组构建完成后,会进一步处理:
- 处理作用域样式
if (hasScoped) {
attachedProps.push([`__scopeId`, JSON.stringify(`data-v-${descriptor.id}`)])
}
- 添加开发工具相关信息
if (devToolsEnabled || (devServer && !isProduction)) {
attachedProps.push([
`__file`,
JSON.stringify(isProduction ? path.basename(filename) : filename),
])
}
- 处理热更新
- 添加 HMR ID
- 创建 HMR 记录
- 监听文件变化
- 处理模板更新
- 实现热重载逻辑
- 处理 SSR
- 包装用户的 setup 函数
- 注册 SSR 模块
- 处理 SSR 上下文
- 导出组件
// 如果没有任何附加属性,则直接导出主模块
if (!attachedProps.length) {
output.push(`export default _sfc_main`)
} else {
// 否则,导出带有附加属性的主模块
output.push(
`import _export_sfc from '${EXPORT_HELPER_ID}'`,
`export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
.map(([key, val]) => `['${key}',${val}]`)
.join(',')}])`,
)
}
将 output 数组拼接后,就得到开头的编译结果:
// 导入 VDOM API
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "container" }
import { ref } from 'vue'
// 主模块
const _sfc_main = {
__name: 'test',
props: {
name: {
type: String,
default: 'Ray',
},
},
setup(__props) {
const props = __props
const age = ref(18)
// 返回一个函数,用于渲染组件
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("span", null, _toDisplayString(__props.name), 1 /* TEXT */),
_createElementVNode("span", null, _toDisplayString(age.value), 1 /* TEXT */)
]))
}
}
}
// 样式模块
import "test.vue?vue&type=style&index=0&scoped=5e63b55d&lang.css"
import _export_sfc from '�plugin-vue:export-helper'
export default /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-5e63b55d"]])
一些额外内容
生成实际的 css 文件
先前提到,插件会为组件生成类似下面的样式导入:
import "style.css?vue&type=style&index=0"
但这个文件实际上并不存在,当 Vite 遇到这些带查询参数的导入时,会:
- 将样式提取到单独的文件
- 应用必要的转换(如 scoped、CSS Modules)
- 在开发时通过
<style>
标签注入 - 在生产构建时通过 CSS 提取插件合并