Vite 源码剖析:plugin-vue
如何炼丹,让 SFC 既美味又兼容 Rollup?
大家好,我是今天的炼丹师,不对,是 Vite 源码解读师。今天我们来聊聊 Vite 中 plugin-vue
这个神奇的插件,看看它是如何把 .vue
文件这种“特殊食材”(Single File Component,SFC)烹饪成浏览器能直接享用的美味佳肴,并且还能完美兼容 Rollup 这位老饕的口味。
首先,咱们得先认识一下 plugin-vue
的核心职责:
- SFC 解析: 把一个
.vue
文件拆解成 template、script、style 三个部分(当然,还有 customBlocks)。 - 模板编译: 将 template 部分编译成渲染函数 (render function),让 Vue 能够高效地更新 DOM。
- 脚本处理: 处理 script 部分,包括 TypeScript 编译、ES Modules 转换等等。
- 样式处理: 处理 style 部分,通常是 CSS 预处理器(如 Sass、Less)的编译,以及 CSS Modules 的支持。
- HMR(热模块替换): 在修改
.vue
文件时,能够快速、无刷新地更新页面,提升开发体验。 - Rollup 兼容: 确保编译后的代码能够被 Rollup 正确处理,方便构建生产环境版本。
好了,废话不多说,我们直接撸代码,深入 plugin-vue
的源码,看看它到底是怎么炼丹的!
一、plugin-vue
的初始化:炼丹炉的搭建
plugin-vue
本身就是一个 Vite 插件,它导出一个函数,这个函数接受一个可选的配置对象作为参数,并返回一个标准的 Vite 插件对象。
// packages/plugin-vue/src/index.ts
import { Plugin } from 'vite'
import { createDescriptor, parse } from './utils/descriptorCache'
import { transformMain } from './transformMain'
import { handleHotUpdate } from './handleHotUpdate'
export interface Options {
include?: string | RegExp | (string | RegExp)[]
exclude?: string | RegExp | (string | RegExp)[]
// ... 其他配置项
}
export function createVuePlugin(options: Options = {}): Plugin {
// 插件的配置项,合并用户配置和默认配置
const mergedOptions = {
include: [/.vue$/],
...options,
}
let isProduction = false
return {
name: 'vite:vue', // 插件名称,必须唯一
configResolved(config) {
// 在 Vite 配置解析完成后调用
isProduction = config.command === 'build'
},
resolveId(id) {
// 解析模块 ID
if (id.startsWith('vue:')) {
return id
}
},
load(id) {
// 加载模块内容
if (id.startsWith('vue:')) {
return {
code: `export default {}`, // 占位符,实际内容在 transform 中生成
}
}
},
transform(code, id) {
// 转换模块内容
if (mergedOptions.include && !Array.isArray(mergedOptions.include) && !mergedOptions.include.test(id)) {
return null
}
if (mergedOptions.include && Array.isArray(mergedOptions.include) && !mergedOptions.include.some(item => {
if (typeof item === 'string') return new RegExp(item).test(id)
if (item instanceof RegExp) return item.test(id)
return false
})) {
return null
}
if (mergedOptions.exclude && !Array.isArray(mergedOptions.exclude) && mergedOptions.exclude.test(id)) {
return null
}
if (mergedOptions.exclude && Array.isArray(mergedOptions.exclude) && mergedOptions.exclude.some(item => {
if (typeof item === 'string') return new RegExp(item).test(id)
if (item instanceof RegExp) return item.test(id)
return false
})) {
return null
}
if (id.endsWith('.vue')) {
// 处理 .vue 文件
return transformMain(code, id, this, isProduction)
}
},
handleHotUpdate(ctx) {
// 处理热模块替换
if (ctx.file.endsWith('.vue')) {
return handleHotUpdate(ctx)
}
},
}
}
export default createVuePlugin
这个插件对象包含以下几个关键的钩子函数:
name
: 插件的名称,必须是唯一的。configResolved
: 在 Vite 配置解析完成后调用,可以获取到 Vite 的配置信息,例如是否是生产环境。resolveId
: 用于解析模块 ID,这里主要处理vue:
开头的虚拟模块 ID。load
: 用于加载模块内容,这里主要处理vue:
开头的虚拟模块 ID,返回一个占位符,实际内容在transform
钩子中生成。transform
: 用于转换模块内容,这是最核心的钩子,负责处理.vue
文件的编译。handleHotUpdate
: 用于处理热模块替换,在.vue
文件发生变化时触发。
二、SFC 解析:庖丁解牛,化整为零
当 Vite 遇到一个 .vue
文件时,transform
钩子会被触发。transformMain
函数是处理 .vue
文件的核心逻辑。
// packages/plugin-vue/src/transformMain.ts
import { parse as vueParse } from '@vue/compiler-sfc'
import { createDescriptor, getDescriptor } from './utils/descriptorCache'
import { transformTemplate } from './transformTemplate'
import { transformScript } from './transformScript'
import { transformStyle } from './transformStyle'
export async function transformMain(
code: string,
id: string,
pluginContext: any, // PluginContext
isProduction: boolean
) {
// 1. 获取或创建 SFC 的描述对象 (descriptor)
const { descriptor, errors } = createDescriptor(id, code, pluginContext.server?.moduleGraph)
if (errors.length) {
errors.forEach(error => pluginContext.error(error))
return null
}
// 2. 转换 template 部分
const templateResult = descriptor.template ? await transformTemplate(descriptor, pluginContext) : null
// 3. 转换 script 部分
const scriptResult = descriptor.script || descriptor.scriptSetup ? await transformScript(descriptor, id, pluginContext, isProduction) : null
// 4. 转换 style 部分
const stylesResult = descriptor.styles.length ? await Promise.all(descriptor.styles.map((style, index) => transformStyle(descriptor, id, index, pluginContext, isProduction))) : []
// 5. 拼接最终的代码
let output = ''
if (scriptResult) {
output += scriptResult.code
} else {
output += `const __script = {};n`
}
if (templateResult) {
output += templateResult.code
output += `n__script.render = render;`
}
if (stylesResult.length) {
stylesResult.forEach(styleResult => {
output += styleResult.code
})
}
output += `nexport default __script;`
return {
code: output,
map: { mappings: '' }, // 暂时忽略 sourcemap
}
}
transformMain
函数的主要流程如下:
-
获取或创建 SFC 的描述对象 (descriptor):
createDescriptor
函数使用@vue/compiler-sfc
提供的parse
函数来解析.vue
文件,将其拆解成 template、script、style 等部分,并将这些信息存储在一个描述对象中。这个描述对象会被缓存起来,方便后续使用。 -
转换 template 部分: 如果 SFC 中包含 template 部分,则调用
transformTemplate
函数进行转换。transformTemplate
函数会将 template 代码编译成渲染函数 (render function)。 -
转换 script 部分: 如果 SFC 中包含 script 部分,则调用
transformScript
函数进行转换。transformScript
函数会处理 script 代码,例如 TypeScript 编译、ES Modules 转换等等。 -
转换 style 部分: 如果 SFC 中包含 style 部分,则调用
transformStyle
函数进行转换。transformStyle
函数会处理 style 代码,例如 CSS 预处理器(如 Sass、Less)的编译,以及 CSS Modules 的支持。 -
拼接最终的代码: 将转换后的 template、script、style 代码拼接在一起,生成最终的 JavaScript 代码。
三、模板编译:化腐朽为神奇,DOM 的高效生成器
transformTemplate
函数负责将 template 代码编译成渲染函数 (render function)。它使用了 @vue/compiler-sfc
提供的 compileTemplate
函数。
// packages/plugin-vue/src/transformTemplate.ts
import { compileTemplate } from '@vue/compiler-sfc'
export async function transformTemplate(descriptor: any, pluginContext: any) {
const { id, template } = descriptor
const result = compileTemplate({
source: template.content,
id: id,
filename: id,
transformAssetUrls: true, // 是否转换资源 URL
})
if (result.errors.length) {
result.errors.forEach(error => pluginContext.error(error))
return null
}
const code = `
import { render as _render } from '${id}?vue&type=template&lang.js'
export function render(_ctx, _cache) {
return _render(_ctx, _cache)
}
`
return {
code: code,
map: result.map,
}
}
transformTemplate
函数的主要流程如下:
- 使用
compileTemplate
函数编译 template 代码:compileTemplate
函数会将 template 代码编译成渲染函数的 AST (抽象语法树),然后将 AST 转换成 JavaScript 代码。 - 生成渲染函数代码: 将编译后的渲染函数代码包装在一个 JavaScript 函数中,并导出该函数。
四、脚本处理:锦上添花,让代码更上一层楼
transformScript
函数负责处理 script 代码,它主要做了以下几件事:
- TypeScript 编译: 如果 script 代码是 TypeScript 代码,则使用 TypeScript 编译器将其编译成 JavaScript 代码。
- ES Modules 转换: 将 script 代码转换成 ES Modules 格式,方便浏览器加载。
- 处理
setup
语法糖: 如果 script 代码使用了setup
语法糖,则将其转换成标准的 JavaScript 代码。
// packages/plugin-vue/src/transformScript.ts
import { compileScript } from '@vue/compiler-sfc'
import { transform } from 'esbuild'
export async function transformScript(descriptor: any, id: string, pluginContext: any, isProduction: boolean) {
const { script, scriptSetup } = descriptor
let content = ''
let bindings
if (scriptSetup) {
const compiled = compileScript(descriptor, { id, isProd: isProduction })
content = compiled.content
bindings = compiled.bindings
} else if (script) {
content = script.content
}
if (!content) {
return null
}
const result = await transform(content, {
loader: 'ts', // 假设是 TypeScript 代码
format: 'esm', // 转换成 ES Modules 格式
sourcemap: true,
})
const code = result.code
return {
code: code,
map: result.map,
}
}
transformScript
函数的主要流程如下:
- 编译 script setup 如果存在: 如果存在scriptSetup 的话,就使用
compileScript
编译,然后拿到content 和 bindings - 使用
esbuild
转换 script 代码: 使用esbuild
将 script 代码转换成 ES Modules 格式,并生成 sourcemap。
五、样式处理:画龙点睛,让页面更漂亮
transformStyle
函数负责处理 style 代码,它主要做了以下几件事:
- CSS 预处理器编译: 如果 style 代码使用了 CSS 预处理器(如 Sass、Less),则使用相应的编译器将其编译成 CSS 代码。
- CSS Modules 支持: 如果 style 代码使用了 CSS Modules,则生成 CSS Modules 的 classname。
- 样式注入: 将编译后的 CSS 代码注入到页面中。
// packages/plugin-vue/src/transformStyle.ts
import { compileStyleAsync } from '@vue/compiler-sfc'
export async function transformStyle(descriptor: any, id: string, index: number, pluginContext: any, isProduction: boolean) {
const { styles } = descriptor
const style = styles[index]
const result = await compileStyleAsync({
source: style.content,
filename: id,
id: `data-v-${id}`,
scoped: style.scoped,
modules: style.module,
})
if (result.errors.length) {
result.errors.forEach(error => pluginContext.error(error))
return null
}
const code = `
import { updateStyle } from '${id}?vue&type=style&index=${index}&lang.css'
updateStyle(${JSON.stringify(result.code)})
`
return {
code: code,
map: result.map,
}
}
transformStyle
函数的主要流程如下:
- 使用
compileStyleAsync
函数编译 style 代码:compileStyleAsync
函数会将 style 代码编译成 CSS 代码,并处理 CSS Modules 和 scoped CSS。 - 生成样式注入代码: 生成 JavaScript 代码,用于将编译后的 CSS 代码注入到页面中。
六、HMR:妙手回春,开发体验的飞跃
HMR (Hot Module Replacement) 是 Vite 的一个重要特性,它可以在修改 .vue
文件时,快速、无刷新地更新页面,提升开发体验。plugin-vue
使用 handleHotUpdate
函数来处理 HMR。
// packages/plugin-vue/src/handleHotUpdate.ts
export function handleHotUpdate(ctx: any) {
// ... HMR 相关的逻辑
}
handleHotUpdate
函数的主要流程如下:
- 获取修改的模块: 获取到修改的
.vue
文件对应的模块。 - 重新编译 SFC: 重新编译修改的
.vue
文件。 - 更新页面: 将重新编译后的代码发送到客户端,客户端会更新页面。
七、Rollup 兼容:老少皆宜,生产环境的保障
为了确保 plugin-vue
编译后的代码能够被 Rollup 正确处理,plugin-vue
在设计时考虑了以下几点:
- ES Modules 格式:
plugin-vue
将所有代码都转换成 ES Modules 格式,这是 Rollup 能够理解的格式。 - 虚拟模块 ID:
plugin-vue
使用虚拟模块 ID (例如vue:
开头的 ID) 来标识一些特殊的模块,例如 template 和 style 模块。Rollup 可以通过配置rollupOptions.plugins
来处理这些虚拟模块。 - Sourcemap:
plugin-vue
生成 sourcemap,方便在生产环境中调试代码。
总的来说,plugin-vue
通过以下方式与 Rollup 兼容:
兼容方式 | 描述 |
---|---|
ES Modules | 将 template、script、style 都编译成 ES Modules 格式的代码,Rollup 可以直接处理。 |
虚拟模块 ID | 使用 vue: 开头的虚拟模块 ID 来标识 template 和 style 模块,需要在 Rollup 的 rollupOptions.plugins 中配置相应的插件来处理这些虚拟模块。 例如,需要一个 Rollup 插件来处理 vue&type=template 的请求,并返回相应的渲染函数代码。同样,需要一个插件来处理 vue&type=style 的请求,并将 CSS 代码注入到页面中。 |
Sourcemap | 生成 sourcemap,方便在生产环境中调试代码。 |
八、总结:plugin-vue
的炼丹之道
plugin-vue
是一个非常复杂的插件,它涉及到 SFC 解析、模板编译、脚本处理、样式处理、HMR 和 Rollup 兼容等多个方面。通过深入分析 plugin-vue
的源码,我们可以更好地理解 Vite 的工作原理,以及如何开发自己的 Vite 插件。
plugin-vue
的核心思想是将 SFC 拆解成多个小的模块,然后分别处理这些模块,最后将处理后的模块拼接在一起。这种模块化的设计思想可以提高代码的可维护性和可扩展性。
希望今天的讲解对大家有所帮助,如果有什么问题,欢迎随时提问。下次有机会我们再一起深入研究 Vite 的其他部分!