前言
我们每天都在写vue
单文件组件(SFC),然后分别在<template>
, <script>
和<style>
块里写对应的代码。或许我们有时会有点好奇,vue
文件的内容是如何被处理并最终渲染到浏览器上的?
带着这个疑问,我们来探索一下它背后的原理。
注意: 对于插件
@vitejs/plugin-vue
,此次分析的版本为5.2.0
目录结构
这里使用了之前开发的小工具 treei (感兴趣的话欢迎✨star✨) 来生成该仓库的目录树形结构:
├──📁packages
| ├──📁plugin-vue
| | ├──📁dist
| | | ├──📄index.cjs
| | | ├──📄index.d.ts
| | | └──📄index.mjs
| | ├──📁src
| | | ├──📁utils
| | | | ├──📄descriptorCache.ts
| | | | ├──📄error.ts
| | | | └──📄query.ts
| | | ├──📄compiler.ts
| | | ├──📄handleHotUpdate.ts
| | | ├──📄helper.ts
| | | ├──📄index.ts
| | | ├──📄main.ts
| | | ├──📄script.ts
| | | ├──📄style.ts
| | | └──📄template.ts
| | ├──📄build.config.ts
| | ├──📄CHANGELOG.md
| | ├──📄LICENSE
| | ├──📄package.json
| | ├──📄README.md
| | └──📄tsconfig.json
| ├──📁plugin-vue-jsx // vue 的 jsx 插件
├──📁playground // 各种 examples
├──📁scripts
| ├──📄patchCJS.ts
| ├──📄publishCI.ts
| ├──📄release.ts
| ├──📄releaseUtils.ts
| └──📄tsconfig.json
├──📄.editorconfig
├──📄.eslintcache
├──📄.git-blame-ignore-revs
├──📄.gitattributes
├──📄.gitignore
├──📄.npmrc
├──📄.prettierignore
├──📄.prettierrc.json
├──📄CODE_OF_CONDUCT.md
├──📄eslint.config.js
├──📄LICENSE
├──📄package.json
├──📄pnpm-lock.yaml
├──📄pnpm-workspace.yaml
├──📄README.md
├──📄vitest.config.e2e.ts
└──📄vitest.config.ts
该仓库使用的是基于pnpm
的workspace
搭建的monorepo
多包项目,包含两个插件:
Package | Description |
---|---|
@vitejs/plugin-vue | 用于解析和转换.vue 文件的vite 插件 |
@vitejs/plugin-vue-jsx | 提供了 jsx / tsx 的支持 |
不过这里我们仅关注@vitejs/plugin-vue
,即packages/plugin-vue
目录。
插件的基本使用
当使用 create-vite 脚手架,即使用pnpm create vite
命令完成vue
项目的初始化后,得到vite.config.ts
的配置如下:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})
只需要将插件@vitejs/plugin-vue
传入到vite
的plugins
中,该项目就自动拥有了解析vue
文件的能力。
插件主函数结构
首先,我们知道 @vitejs/plugin-vue
作为一个 vite
插件,它需要符合 vite
插件的约定,下面是插件的主函数:
关于如何开发一个
vite
插件和插件的 API 含义这里不做赘述,详情看官方文档 Vite Plugin API
// packages/plugin-vue/index.ts
export default function vuePlugin(rawOptions: Options = {}): Plugin<Api> {
// ...忽略部分代码
const options = shallowRef<ResolvedOptions>({
isProduction: process.env.NODE_ENV === 'production',
compiler: null as any, // to be set in buildStart
include: /\.vue$/,
customElement: /\.ce\.vue$/,
...rawOptions,
root: process.cwd(),
sourceMap: true,
cssDevSourcemap: false,
})
return {
name: 'vite:vue',
api: {/*...*/},
handleHotUpdate(ctx) {/*...*/},
configResolved(config) {/*...*/},
configureServer(server) {/*...*/},
buildStart() {/*...*/},
async resolveId(id) {/*...*/},
load(id, opt) {/*...*/},
async transform(code, id, opt) {/*...*/},
}
}
插件的函数主体大概就是这样,这里我们主要关注 resolveId
、load
和 transform
三个 hooks
。
记得当时看这里代码的时候,发现尤大写了个错别字,还顺带水了一个 PR
将 vue
文件分割为多个子模块
总的来说,@vitejs/plugin-vue
会配合使用 @vue/compiler-sfc
将 .vue
文件处理成多个子模块,包括 script
、template
、style
和 CustomBlock,然后对每一个子模块应用不同的 transform
去处理。
在 @vue/compiler-sfc 文档中这样一段话:
The general idea is to generate a facade module that imports the individual blocks of the component. The trick is the module imports itself with different query strings so that the build system can handle each request as "virtual" modules:
+--------------------+
| |
| script transform |
+----->+ |
| +--------------------+
|
+--------------------+ | +--------------------+
| | | | |
| facade transform +----------->+ template transform |
| | | | |
+--------------------+ | +--------------------+
|
| +--------------------+
+----->+ |
| style transform |
| |
+--------------------+
Where the facade module looks like this:
// main script
import script from '/project/foo.vue?vue&type=script'
// template compiled to render function
import { render } from '/project/foo.vue?vue&type=template&id=xxxxxx'
// css
import '/project/foo.vue?vue&type=style&index=0&id=xxxxxx'
// attach render function to script
script.render = render
// attach additional metadata
// some of these should be dev only
script.__file = 'example.vue'
script.__scopeId = 'xxxxxx'
// additional tooling-specific HMR handling code
// using __VUE_HMR_API__ global
export default script
翻译过来的意思大概就是: 生成一个门面模块,导入组件的各个独立block。技巧就是在模块导入自身的时候,加上不同的query字符串,这样构建系统就能把每个请求处理为"虚拟"模块。
工作流程:
- 在
facade transform
中,会使用@vue/compiler-sfc
的parse
API 将.vue
文件的源代码解析成一个descriptor
描述符,并且会基于该描述符生成上面的门面模块代码。 - 在
script transform
中,会使用@vue/compiler-sfc
的compileScript
API 来处理该 script。这可以处理诸如像<script setup>
和 CSS 变量这样的功能。或者,也可以直接在 facade 模块中完成(使用内联代码而不是导入代码)。 - 在
template transform
中,使用compileTemplate
将原始模板编译为渲染函数代码 - 在
style transform
中,使用compileStyle
编译原始CSS来处理<style scoped>
,<style module>
和 CSS变量注入。
这里我们使用一个 Demo 来演示下效果。如下所示,通过 src imports 方式导入各个 block:
注意:大部分时候我们一般使用
<script>
或<script setup>
这种方式来写 vue 组件,而不是src imports
这种方式,但是背后原理还是类似的,仅仅转换规则会有所不同。这里为了方便演示模块的转换(模块导入自身的时候,加上不同的query字符串),我们在这里使用src imports
的方式。
index.vue
:
<template src="./template.html"></template>
<style src="./style.css"></style>
<script src="./script.js"></script>
transform
hook 代码如下:
// plugin-vue/src/index.ts
async function transform(code, id, opt) {
const { filename, query } = parseVueRequest(id)
if (!filter.value(filename) && !query.vue) {
return
}
if (!query.vue) {
// main request
return transformMain(...)
} else {
// ... sub block request
}
}
在第一次执行 transform
hook 解析 vue
文件时,显然此时是不带 vue 参数标识的,因此会调用transformMain
函数将对 vue
文件的请求转换为多个子模块请求的形式。
transformMain
函数的代码如下:
// plugin-vue/src/main.ts
async function transformMain(
code: string,
filename: string,
options: ResolvedOptions,
// ... other params
) {
// 创建 .vue 文件的描述符,包含了 script, template, style 等信息
const { descriptor } = createDescriptor(filename, code, options)
// 生成 script 代码
const { code: scriptCode } = await genScriptCode(...)
// 生成 template 代码
const { code: templateCode } = await genTemplateCode(...)
// 生成 style 代码
const stylesCode = await genStyleCode(...)
const output: string[] = [
scriptCode,
templateCode,
stylesCode,
]
// ... ignore other code
let resolvedCode = output.join('\n')
return {
code: resolvedCode,
// ...
}
}
// 创建 vue 组件的描述符
function createDescriptor(
filename: string,
source: string,
) {
// 调用 @vue/compiler-sfc 的 parse 函数来解析 .vue 文件的代码
const { descriptor, errors } = compiler.parse(source, {
filename,
// other options
})
return { descriptor }
}
而上面的 descriptor
描述符就是一个对象,下面是打印的 json
格式的数据:
{
"filename": "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue",
"source": "<template src=\"./template.html\"></template>\n<style src=\"./style.css\"></style>\n<script src=\"./script.js\"></script>\n",
"template": {
"type": "template",
"content": "",
"attrs": {
"src": "./template.html"
},
"src": "./template.html"
},
"script": {
"type": "script",
"content": "",
"attrs": {
"src": "./script.js"
},
"src": "./script.js"
},
"scriptSetup": null,
"styles": [
{
"type": "style",
"content": "",
"attrs": {
"src": "./style.css"
},
"src": "./style.css"
}
],
"customBlocks": [],
"id": "b2ef2ffb"
// 省略了部分属性
}
这里,重点关注下 script
, template
和 styles
属性,可以看到它们都有一个 src
属性用于引入外部文件。
而最终,这里的 transformMain
函数得到的 resolvedCode
如下:
// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
export * from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'
// ... ignore HMR code
import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
['render', _sfc_render],
[
'__file',
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
],
])
也就是说,当我们应用上面的index.vue
组件:
import IndexComponent from './index.vue'
在经过 transformMain()
函数处理后,会被转换成下面这种形式的代码:
// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'
// ...
也就得到了在前面所说的,将 .vue
组件分成多个子模块的效果 —— 导入自身的时候,加上不同的query字符串,这样构建系统就能把每个请求处理为"虚拟"模块。
解析子模块
首先来看下 rollup hooks 的执行流程图:
可以看到,在经过 transform
hook 转换后,下一步就是执行 moduleParsed hook,它会解析所有的静态 import
语句,因此上文的 resolvedCode
会继续被解析,然后再依次执行 resolveId
, load
和 transform
等钩子,如此反复,直到所有的静态 import
语句都被解析完毕。
resolveId
hook 代码如下:
// plugin-vue/src/index.ts
export const EXPORT_HELPER_ID = '\0plugin-vue:export-helper'
async function resolveId(id) {
// component export helper
if (id === EXPORT_HELPER_ID) {
return id
}
// serve sub-part requests (*?vue) as virtual modules
if (parseVueRequest(id).query.vue) {
return id
}
}
可以看到,当解析到:
import _export_sfc from 'plugin-vue:export-helper'
import xxx from *?vue
这种类似请求时,会被转换为 虚拟模块。
再来看看 load
hook 的代码:
// plugin-vue/src/index.ts
function load(id, opt) {
const ssr = opt?.ssr === true
if (id === EXPORT_HELPER_ID) {
return helperCode
}
// 解析文件名和查询参数
const { filename, query } = parseVueRequest(id)
// 为 sub-part 虚拟模块选择相应的块 (script, template, style, customBlock)
if (query.vue) {
if (query.src) {
// 如果是 src imports 的形式,则返回文件内容
// case 1: "*.js?vue&type=script&src=true&lang.js"
// case 2: "*.html?vue&type=template&src=true&lang.js"
// case 3: "*.css?vue&type=style&index=0&src=true&lang.css"
return fs.readFileSync(filename, 'utf-8')
}
// ...ignore other code
}
}
可以看到,当 id === EXPORT_HELPER_ID
,会直接返回 helperCode,即:
// plugin-vue/src/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;
}
`
再结合前面出现在 resolveCode
中的代码:
import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
['render', _sfc_render],
[
'__file',
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
],
])
等价于:
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc
for (const [key, val] of props) {
target[key] = val
}
return target
}
// 将渲染函数添加到组件上
_sfc_main.render = _sfc_render
_sfc_main.__file =
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue'
export default _sfc_main
再来看看 transform
hook 代码:
async function transform(code, id, opt) {
const ssr = opt?.ssr === true
const { filename, query } = parseVueRequest(id)
if (query.raw || query.url) {
return
}
if (!filter.value(filename) && !query.vue) {
return
}
if (!query.vue) {
// main request
return transformMain(...)
} else {
// sub block request
const descriptor = query.src
? getSrcDescriptor(filename, query) ||
getTempSrcDescriptor(filename, query)
: getDescriptor(filename, options.value)!
if (query.type === 'template') {
// case: "*.html?vue&type=template&src=true&lang.js"
return transformTemplateAsModule(
code,
descriptor,
options.value,
this,
ssr,
customElementFilter.value(filename),
)
} else if (query.type === 'style') {
// case: "*.css?vue&type=style&index=0&src=true&lang.css"
return await transformStyle(
code,
descriptor,
Number(query.index || 0),
options.value,
this,
filename,
)
}
}
}
可以看到,transform
hook 中,会对子模块的请求进行处理:
-
如果
query.type === 'template'
,则会调用transformTemplateAsModule
函数,该函数其实是调用了@vue/compiler-sfc
的compileTemplate
函数——将模板字符串编译成渲染函数字符串。 -
如果
query.type === 'style'
,则会调用transformStyle
函数,该函数其实是调用了@vue/compiler-sfc
的compileStyleAsync
函数——对 css 进行处理 (应用 css 预处理器,postcss 等转换成原生 css 格式)。 -
如果
query.type === 'script'
,因为已经在load
hook 中被处理了(直接返回文件内容)。
对于 template
:
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
转换后得到:
import {
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from 'vue'
const _hoisted_1 = { class: 'test' }
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
'div',
_hoisted_1,
_toDisplayString(_ctx.msg),
1 /* TEXT */,
)
)
}
对于 style
:
import './style.css?vue&type=style&index=0&src=true&lang.css'
转换后得到:
因为这里用的是原生 css ,所以没有做任何转换处理,如果使用的是 less, scss, stylus 等预处理器语言,则会被转换成原生的 css。
.test {
color: orange;
}
显然,对于上面的 css 字符串,浏览器是不认识的,因此还需要进一步处理将其写入到 html 中。
那它是如何被处理的呢?这就需要靠 vite 内置的插件来处理了,它依次被 vite:css
, 和 vite:css-post
插件处理,最终得到的结果如下:
import {
updateStyle as __vite__updateStyle,
removeStyle as __vite__removeStyle,
} from '/@vite/client'
const __vite__id =
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css'
const __vite__css = '.test {\n color: orange;\n}\n'
__vite__updateStyle(__vite__id, __vite__css)
// ...ignore HMR code
即:
const __vite__updateStyle = updateStyle
const __vite__id =
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css'
const __vite__css = '.test {\n color: orange;\n}\n'
__vite__updateStyle(__vite__id, __vite__css)
// `vite\packages\vite\src\client\client.ts#updateStyle`
export function updateStyle(id: string, content: string): void {
let style = sheetsMap.get(id)
if (!style) {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.setAttribute('data-vite-dev-id', id)
style.textContent = content
// insert into html
document.head.appendChild(style)
} else {
style.textContent = content
}
sheetsMap.set(id, style)
}
对于 script
, transform
hook 并没有对它做任何处理,而是在 load
hook 中:
function load(id, opt) {
// ...
const { filename, query } = parseVueRequest(id)
if (query.vue) {
if (query.src) {
// 如果是 src imports 的形式,则返回文件内容
// case: "*.js?vue&type=script&src=true&lang.js"
return fs.readFileSync(filename, 'utf-8')
}
}
// ...
}
可以看到,script
的内容直接从文件中读取并返回了。
所以,对于 index.vue
:
<template src="./template.html"></template>
<style src="./style.css"></style>
<script src="./script.js"></script>
在第一次 transform
hook中,会被 transformMain
函数处理,得到的 resolvedCode
如下:
// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
export * from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'
// ... ignore HMR code
import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
['render', _sfc_render],
[
'__file',
'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
],
])
然后,后续的每个子模块请求都会被 transform
hook 处理,最终得到大致如下的代码:
// script
const _sfc_main = {
name: 'Test',
setup() {
return {
msg: 'Hello App',
}
},
}
// template
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "test" }
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.msg), 1 /* TEXT */))
}
// style
const __vite__id = "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css"
const __vite__css = ".test {\n color: orange;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
function __vite__updateStyle(id: string, content: string): void {
let style = sheetsMap.get(id)
if (!style) {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.setAttribute('data-vite-dev-id', id)
style.textContent = content
// insert into html
document.head.appendChild(style)
} else {
style.textContent = content
}
sheetsMap.set(id, style)
}
// ... ignore HMR code
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
}
_sfc_main.render = _sfc_render
_sfc_main.__file = "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue"
export default _sfc_main
可以看到,一个 vue
组件最终会被处理成上面这样类似的 js
代码,而这些代码也是可以被支持 ES6+ 的现代浏览器直接执行的。
总结
简单总结下,我从中收获到了哪些东西:
- 学会了如何去调试和开发一个
vite
插件。比如以前在写 vue2 项目的时候用过一个将 vue template 的行内样式 px 转换为 rem 单位的 webpack 插件,然后就突然想到是不是可以写一个类似的 vite 插件 vite-plugin-pxtorem - 理解了
<style sceopd>
实现样式隔离的原理 - 理解了一个
vue
组件是如何被转换并最终被处理成 js 的过程的原理