vue3 模板编译
一、背景
项目需求中需要根据不同场景展示不同文案和 UI。其中 UI 总类繁多,并且文案内容均需后端组装拼接,UI 模板中还需要支持链接跳转。同时需求还有二期,可能会继续增加很多种新的场景和 UI,因此考虑到扩展性,思索是否可以通过动态渲染 UI 模板的方式来做,由后端下发具体的模版内容,前端仅仅负责展示。
二、目标
- 可动态渲染 UI 模板,具备快速支持新的 UI场景,无需重复开发。
三、方案调研
支持动态渲染模板主要有三种方案:
3.1、方案一:v-html
v-html:想到动态渲染模板,首先就会想到通过 v-html 方式,直接嵌入原生 html 内容。
<template>
<div v-html="template" @click="onClick"></div>
<p>{{ msg }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const template = `
<span class="label" style="color:red">原因</span>
<a class="link" href="#/xxx" target="_blank">点击跳转</a>
`;
const msg = ref('');
const onClick = (event) => {
if (event.target.nodeName === 'SPAN' && event.target.className === 'label') {
msg.value = `${event.target.nodeName} is clicked!`;
}
}
</script>
优点:
- 简单,原生支持,能支持动态 html 标签和样式。
- 实现成本低,快捷。
缺点:
- 只能使用原生 html 标签,无法使用项目中组件库。
- 绑定事件只能通过顶层标签代理来实现,较为繁琐。
- 存在 XSS 安全问题。
3.2、方案二:vue.compile
通过 compile 函数,动态编译模板,并返回一个渲染函数,能够在运行时生成动态组件。
<!-- compile.vue -->
<script lang="ts">
import { compile, defineComponent } from 'vue';
export default defineComponent({
props: {
template: {
type: String,
default: '',
}
},
data() {
return {
text: '这是data数据',
}
},
methods: {
onClick() {
alert("clicked");
}
},
setup(props) {
return compile(props.template);
}
})
</script>
App.vue
<!-- App.vue -->
<template>
<compileComp :template="template"></compileComp>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import compileComp from './components/compile.vue';
const template = `<div>
<el-button @click="onClick" type="danger">{{ text }}</el-button>
<el-link type="primary" href="https://element.eleme.io" target="_blank">主要链接</el-link>
<el-popover
placement="top-start"
title="标题"
:width="200"
trigger="hover"
content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">
<template #reference>
<el-button>hover 激活</el-button>
</template>
</el-popover>
</div>`
</script>
vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
...
resolve: {
alias: {
// 仅完整版 vue 才支持实时动态编译,默认的 runtime 版本不包含此功能,需要显式引用
vue: "vue/dist/vue.esm-bundler.js",
},
},
})
优点:
- 可以引用项目中全局导入的组件库。
- 绑定组件中数据和事件比较方便。
缺点:
-
vue runtime 版本不支持动态 compile,需导入带编译的完整版才可以,这样会导致 vue 包体积变大,影响性能。
-
存在 XSS 安全问题。
3.3、方案三:渲染函数 h & jsx
渲染函数 & jsx 可以处理复杂的业务逻辑,用于实现动态组件。
<script lang="ts">
import { h, compile } from "vue";
import { ElButton } from "element-plus";
export default {
props: {
template: {
type: String,
default: '',
}
},
render() {
return h(
ElButton, { type: 'danger' }, 'hihihi'
);
},
};
</script>
vite.config.js
// vite.config.js
import vueJsx from '@vitejs/plugin-vue-jsx'
export default {
plugins: [
// 若要支持 jsx 需要引入 @vitejs/plugin-vue-jsx 插件编译构建
vueJsx({
// options are passed on to @vue/babel-plugin-jsx
}),
],
}
优点:
- 非常的灵活,可以处理复杂业务逻辑,动态渲染组件。
缺点:
- 必须要实现每种 UI 场景,后续若有新增场景仍需要开发,无法达到减少前端开发成本的目标。
3.4、方案确认
当前需求中,仅仅只需展示一些文本 & 超链接,没有其他复杂的 UI 功能,以及没有任何点点击或悬浮事件,因此最终决定选择方案一。通过配置平台存放不同场景的UI模版,后端读取模板配置&填充模版数据,再将完整模版&数据下发给前端,最后页面通过 v-html 展示模板 UI。
至此这个需求其实就结束了,由于上面的方案中使用到了 vue.compile,因此就好奇 vue 是如何去编译模板,以及 vue SFC 文件的编译与实时编译的模板字符串过程有何不同,下面就为大家浅析 vue 模板编译的过程。
四、模板编译
4.1、vue 不同构建版本
前面方案中提到若要支持在运行时编译模板,必须要手动引入带编译版本的 vue 才行,打包构建时默认引用的是 runtime
版本,不包含编译功能。下面梳理了 vue
的所有构建版本,以及各个版本使用场景。
// cjs 服务端渲染,两个版本都是完整版,包含编译器。 通过 `require()` 在 Node.js 服务器端渲染使用。
vue.cjs.js
vue.cjs.prod.js
// bundler 两个版本没有打包所有的代码,只会打包使用的代码,需要配合打包工具来使用,会让 Vue 体积更小。
vue.esm-bundler.js 【完整版,包含编译器,体积大点】
vue.runtime.esm-bundler.js【默认】
// global 四个版本都可以在浏览器中直接通过 `<script src="...">` 标签导入,导入之后会增加一个全局的Vue对象。
vue.global.js
vue.global.prod.js
vue.runtime.global.js
vue.runtime.global.prod.js
// browser 四个版本都包含 esm,浏览器的原生 ES 模块化方式,可以直接通过<script type="module" />的方式来导入模块
vue.esm-browser.js
vue.esm-browser.prod.js
vue.runtime.esm-browser.js
vue.runtime.esm-browser.prod.js
// 项目中通过下面方式导入的是 vue.runtime.esm-bundler.js 版本
import { createApp } from 'vue'
官方文档上对于这部分解释在这里,对于不同版本的使用是这样区分的:
-
通过 CDN 导入,使用 vue(.runtime).global(.prod).js 版本
-
通过 ES6 模块导入,使用 vue(.runtime).esm-browser(.prod).js 版本
-
通过构建工具进行模块化开发使用 vue(.runtime).esm-bundler.js 版本
-
用于 Node.js 的服务器端渲染使用 vue.cjs(.prod).js 版本
完整版和运行时版本的区别
- 运行时版本(runtime):名字中带有 runtime 的是运行时版本,不包含编译器,所有模板都要预先编译。通过构建工具使用的 Vue 版本是 vue.runtime.esm-bundler.js 也是默认版本。
- 完整版:名字中不带 runtime 的是完整版本,多导出
compileToFunction
函数,即 compile 方法。能在打包编译后,还能识别模板代码,拥有编译模板的能力,支持在运行时编译模板,体积会更大些。查看包体积 (517B vs 2.01KB)
4.2、vue 编译包
vue 的模板编译核心是通过下面三个包来处理的:
-
@vue/compiler-core:是 Vue.js 编译器的核心包,与平台无关,提供了编译器的核心逻辑和 AST 抽象语法树的数据结构。
-
@vue/compiler-dom:该包提供了特定于浏览器的编译器,将 AST 抽象语法树转换为浏览器可执行的代码,以生成 DOM 元素。相对于 @vue/compiler-core,它包含浏览器特定的逻辑和属性处理,例如事件处理和样式绑定等。
-
@vue/compiler-sfc:该包是用于处理单文件组件(SFC)的编译器。它依赖于 @vue/compiler-core 和 @vue/compiler-dom,可以将 SFC 中的模板、脚本和样式分别编译成渲染函数、模块和 CSS 代码。此外,它还提供了自定义块处理和 HMR(热重载)支持等功能。
4.3、单文件组件编译
从 vue 官方文档 中知道,Vue 组件挂载时会发生如下几件事:
- 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
- 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
- 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
这里我们要讲的则是第一步,编译:将 vue 模板编译为渲染函数。
在 vue 项目中,通过 vite 构建时会使用 @vitejs/plugin-vue 插件去编译 SFC 模板文件,而这个插件实际是引用 @vue/compiler-sfc 库去编译 vue sfc 文件的。
@vitejs/plugin-vue 解析过程如下:
// compiler-sfc 解析过程
function transformVueSFC(source, filename) {
// 解析 sfc 文件,构建为 descriptor 对象
const { descriptor, errors } = compiler.parse(source, {filename});
// 编译 script, 返回的 script.content 即为 script 模块代码
const script = compiler.compileScript(descriptor, {id, templateOptions, sourceMap:true});
// 编译 template, 返回的template.code 即为 render 渲染函数
// 此方法调用的实际是 @vue/compiler-dom 的 compile 方法,与 vue.compile 效果一致
const template = compiler.compileTemplate({...templateOptions, sourceMap: true});
// 编译 style,返回的 style.code 即为 css 代码
cont style = compiler.compileStyle({
source: descriptor.styles[0].content,
scoped: true,
id: 'data-v-123'
});
举个例子:
<template>
<div>{{ name }}</div>
<input type="text" v-model="name">
</template>
<script lang='ts' setup>
import { ref } from 'vue';
const name = ref('jack');
</script>
<style lang="scss">
input {
color: #333;
}
</style>
parse
这一步做的是解析,并没有对代码进行编译,把一个 Vue 文件,拆分成不同的块,然后分别对每个部分进行编译,其中最最终的 3 个部分如下:
- template 块
- script 和 scriptSetup 块
- 多个 style 块
// 用来表示 .vue 文件中每个代码块的对象
export interface SFCDescriptor {
// 模板
template: SFCTemplateBlock | null;
// 脚本
script: SFCScriptBlock | null
// 使用<script setup> 定义的脚本
scriptSetup: SFCScriptBlock | null
// 样式
styles: SFCStyleBlock[]
}
下图即为调用 parse 后的结果,即 SFCDescriptior 结构:
compileTemplate
目的是将 template 转换为 render 函数,实际上 compiler-sfc 就是调用 compiler-dom 的 compiler 方法来编译的。上面 demo 通过 compileTemplate 编译后内容如下:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, vModelText as _vModelText, withDirectives as _withDirectives, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n\n
export function render(_ctx, _cache) {\n
return (_openBlock(), _createElementBlock(_Fragment, null, [\n _createElementVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */),\n _withDirectives(_createElementVNode("input", {\n type: "text",\n "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.name) = $event))\n }, null, 512 /* NEED_PATCH */), [\n [_vModelText, _ctx.name]\n ])\n ], 64 /* STABLE_FRAGMENT */))\n}
compileScript
这个步骤实际上就是把 script 代码编译成可以让 vue runtime 执行的 js 代码。其中 script setup 代码不能直接运行,需要进行转换,最后通过 babel 来解析的。上面 demo 通过 compileScript 编译后得到的 content 内容如下:
import { defineComponent as _defineComponent } from 'vue'
\n
import { ref } from 'vue';
\n\n
export default /*#__PURE__*/_defineComponent({\n
__name: 'demo1',\n
setup(__props, { expose: __expose }) {\n
__expose();\n\n
const name = ref('jack');\n\n
const __returned__ = { name }\n
Object.defineProperty(__returned__, '__isScriptSetup', {
enumerable: false, value: true
})\n
return __returned__
\n
}
\n\n
})
compileStyle
编译 style,编译后产物还是style,不是 js,目的是编译 vue 的特殊能力,如 style scope, v-bind, :deep等。上面 demo 通过 compileStyle 编译后得到的 code 内容:
\n
input[data-v-123] {\n color: #333;\n}\n
组合
最后 @vitejs/plugin-vue 将 sfc 编译后的 template code、script code 和 style code ,合并成新的文件内容输出。
// vite-plugin-vue/packages/plugin-vue/src/main.ts
function transformMain() {
// 利用 compiler-sfc parse 方法将sfc 文件解析为 SFCDesciptor
const { descriptor, errors } = createDescriptor(filename, code, options)
// 利用 compileScript 编译 script 代码,生成code
const { code: scriptCode, map: scriptMap } = await genScriptCode(
descriptor,
options,
)
// 利用 compileTemplate 编译 script 代码,生成 code
const { code: templateCode, map: templateMap } = await genTemplateCode(
descriptor,
options,
))
// 利用 compileStyle 编译 script 代码,生成 code
const stylesCode = await genStyleCode(
descriptor,
pluginContext,
)
const output: string[] = [
scriptCode,
templateCode,
stylesCode,
customBlocksCode,
]
let resolvedCode = output.join('\n')
return {
code: resolvedCode,
}
}
4.4 、模板编译原理
上面讲述了整个单文件组件编译的大致流程,了解到 compiler-sfc 其实是依赖于 @vue/compiler-dom 去处理文件中的 template 模块,接下来主要介绍 compile template 的详细过程,compile script 和 style 具体细节不在本次分享范围内。
Vue 的编译分为三个阶段,分别是:parse、transform、generate。
parse 阶段将模板字符串解析为 AST。transform 阶段则是对 AST 进行了一些转换处理。generate 阶段根据 AST 生成对应的 render 函数字符串。精简源码如下:
// compiler-dom 入口
function compile(template) {
// 将 template 解析为 AST
const ast = parse(template, options);
// 对 AST 做一些处理
transform(ast, options);
// 通过 AST 生成代码字符串,并最终生成 render 函数字符串
const { ast, code } = generate(ast, options);
return { ast, code };
}
parse 解析器
解析器主要就是将 模板字符串 转换成 AST。
模板字符串
<div name="test">
文本节点
<p>{{ msg }}</p>
</div>
AST
AST是抽象语法树 和 Vnode 类似,都是使用JavaScript对象来描述节点的树状表现形式。
// AST
export interface BaseElementNode extends Node {
type: NodeTypes.ELEMENT // 类型
ns: Namespace // 命名空间 默认为 HTML,即 0
tag: string // 标签名
tagType: ElementTypes // 元素类型,ELEMENT, COMPONENT, SLOT, TEMPLATE
isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
children: TemplateChildNode[] // 子节点
}
截取规则
如何将模板字符串解析为 AST 节点的呢?
通过对字符串截取匹配一步步生成标签,文本,或属性节点的。在解析模板字符串时,截取规则可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。大致伪代码如下:
while(s.length) {
if (startsWith(s, '{{')) {
// 如果以 '{{' 开头
node = parseInterpolation(context, mode)
} else if (s[0] === '<') {
// 以 < 标签开头
if (s[1] === '!') {
if (startsWith(s, '<!--')) {
// 注释
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {
// 文档声明,当成注释处理
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
// 结束标签
parseTag(context, TagType.End, parent)
} else if (/[a-z]/i.test(s[1])) {
// 开始标签
node = parseElement(context, ancestors)
}
} else {
// 普通文本节点
node = parseText(context, mode)
}
}
每解析完一个标签、文本、注释等节点时,就会生成对应的 AST 节点,并且把已经解析完的字符串截断。对字符串进行截断使用的是 advanceBy(context, numberOfCharacters)
函数,context
是字符串的上下文对象,numberOfCharacters
是要截断的字符长度。
举个例子,来模拟每个阶段操作:
<div name="test">
文本节点
<p>{{ msg }}</p>
</div>
首先解析 <div
, 然后执行 advanceBy(context, 4)
进行截断(内部执行的是 s = s.slice(4)
),源字符串就会变成:
name="test">
文本节点
<p>{{ msg }}</p>
</div>
接下来,会解析属性 parseAttribute
,属性的匹配是通过 /^[^\t\r\n\f />][^\t\r\n\f />=]*/
空格,换行或分割符来匹配长度的,匹配完并截断。注意这里的属性可以是普通 html 属性,也可以是 vue 的指令 v-xxx ,不过他们的解析过程一致,唯一不同的是,返回的 AST结构中的节点类型不同。解析完属性,字符串就变成:
文本节点
<p>{{ msg }}</p>
</div>
注释文本和普通文本节点解析规则都很简单,直接截断,生成节点。注释文本调用parseComment()
函数处理,文本节点调用 parseText()
处理。文本节点截断位置判断是先找到下一个 < 字符字符位置,若没有就找 }} 字符位置,若都没有则认为直到字符串的结束都是文本节点。
变成:
<p>{{ msg }}</p>
</div>
同理继续截断:
>{{ msg }}</p>
</div>
此时<p>
标签没有属性,直接就是标签结束,并且不是自闭合标签,此时直接执行截断操作 advanceBy(context, 1)
,变成:
{{ msg }}</p>
</div>
此时遇到双花括号 {{ msg }},会调用 parseInterpolation
解析插值,具体步骤如下:
- 先将括号内的内容提取出来,即
msg
,再对它进行trim()
处理,去除前后空格。 - 然后会生成两个节点,一个节点是
INTERPOLATION
,表示是双花插值。 - 第二个节点是第一个节点的内容,即
msg
,会生成一个SIMPLE_EXPRESSION
节点,表示是一个表达式。
最后,上面的内容变成:
</p>
</div>
...
<!-- 所有字符串已经解析完 -->
每一个标签,元素,属性解析完后,都会生成一个对应的 AST 结构,AST的节点类型值如下:
// AST 节点类型
export const enum NodeTypes {
ROOT, // 根节点 0
ELEMENT, // 元素节点 1
TEXT, // 文本节点 2
COMMENT, // 注释节点 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 插值 {{ }} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7
}
// AST
export interface BaseElementNode extends Node {
type: NodeTypes.ELEMENT // 节点类型
ns: Namespace // 命名空间 默认为 HTML,即 0
tag: string // 标签名
tagType: ElementTypes // 元素类型,ELEMENT 0, COMPONENT 1, SLOT 2, TEMPLATE 3 用来表示是哪种节点
isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
children: TemplateChildNode[] // 子节点
}
上面 demo 中的完整 AST 结构如下:
{"type":0,"children":[{"type":1,"ns":0,"tag":"div","tagType":0,"props":[{"type":6,"name":"name","value":{"type":2,"content":"test","loc":{"start":{"column":11,"line":2,"offset":11},"end":{"column":17,"line":2,"offset":17},"source":""test""}},"loc":{"start":{"column":6,"line":2,"offset":6},"end":{"column":17,"line":2,"offset":17},"source":"name="test""}}],"isSelfClosing":false,"children":[{"type":2,"content":" 文本节点 ","loc":{"start":{"column":18,"line":2,"offset":18},"end":{"column":3,"line":4,"offset":28},"source":"\n 文本节点\n "}},{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":5,"content":{"type":4,"isStatic":false,"constType":0,"content":"msg","loc":{"start":{"column":9,"line":4,"offset":34},"end":{"column":12,"line":4,"offset":37},"source":"msg"}},"loc":{"start":{"column":6,"line":4,"offset":31},"end":{"column":15,"line":4,"offset":40},"source":"{{ msg }}"}}],"loc":{"start":{"column":3,"line":4,"offset":28},"end":{"column":19,"line":4,"offset":44},"source":"<p>{{ msg }}</p>"}}],"loc":{"start":{"column":1,"line":2,"offset":1},"end":{"column":7,"line":5,"offset":51},"source":"<div name="test">\n 文本节点\n <p>{{ msg }}</p>\n</div>"}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":1,"line":6,"offset":52},"source":"\n<div name="test">\n 文本节点\n <p>{{ msg }}</p>\n</div>\n"}}
transform
// transform
export function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options)
// 遍历 AST 节点
traverseNode(root, context);
if (options.hoistStatic) {
// 静态节点提升
hoistStatic(root, context)
}
// 生成 codegenNode
createRootCodegen(root, context)
}
在 transform 阶段,核心的逻辑就是识别一个个的 Vue 的语法,并且进行编译器的优化,我们经常提到的静态标记就是这一步完成的,最后生成一个带 codegenNode 字段的 AST。
- codegenNode:是生成代码阶段需要用到的数据,也是一个节点。只不过节点 type 类型与 parse 阶段的不同,此时的类型对应着生成代码时需要调用的 vue 方法,如 openBlock 或 createElementBlock 等,具体结构如下:
// codegenNode
export interface VNodeCall extends Node {
type: NodeTypes.VNODE_CALL; // 创建该节点时需要调用的 vue 方法,如 OPEN_BLOCK
tag: string; // 标签名称
props: PropsExpression | undefined; // 属性
children // 子节点
patchFlag: string | undefined
dynamicProps: string | SimpleExpressionNode | undefined; // 动态属性
directives: DirectiveArguments | undefined; // 指令
isBlock: boolean; // 是否为外层块元素,需要
isComponent: boolean; // 是否为组件
}
- hoistStatic: hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render() 函数外面生成,并被命名为 _hoisted_x 变量,这些静态节点都存储在 hoists 中,无论静态节点多深,都会被提升到 hoists 中。
<!-- hoistStatic=true-->
<div>
<div>foo</div> <!-- hoisted -->
</div>
import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
// 静态节点被提升到外部,变成一个变量
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1,
_createCommentVNode(" hoisted ")
]))
}
<!-- hoistStatic=false-->
<div>
<div>foo</div>
</div>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("div", null, "foo"),
_createCommentVNode(" hoisted ")
]))
}
- helpers:存储的是创建 VNode 的函数名称(其实是 Symbol)。
// runtimeHelpers.ts
export const helperNameMap: Record<symbol, string> = {
[FRAGMENT]: `Fragment`,
[TELEPORT]: `Teleport`,
[SUSPENSE]: `Suspense`,
[KEEP_ALIVE]: `KeepAlive`,
[BASE_TRANSITION]: `BaseTransition`,
[OPEN_BLOCK]: `openBlock`,
[CREATE_BLOCK]: `createBlock`,
[CREATE_ELEMENT_BLOCK]: `createElementBlock`,
[CREATE_VNODE]: `createVNode`,
[CREATE_ELEMENT_VNODE]: `createElementVNode`,
[CREATE_COMMENT]: `createCommentVNode`,
[CREATE_TEXT]: `createTextVNode`,
[CREATE_STATIC]: `createStaticVNode`,
[RESOLVE_COMPONENT]: `resolveComponent`,
}
- PatchFlags:transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。这是 vue3 与 vue2 模版编译不同的地方。 patchFlags 类型如下:
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
// 动态文本节点
TEXT = 1,
// 动态 class
CLASS = 1 << 1, // 2
// 动态 style
STYLE = 1 << 2, // 4
// 动态属性,但不包含类名和样式
// 如果是组件,则可以包含类名和样式
PROPS = 1 << 3, // 8
...
// 静态节点
HOISTED = -1,
// 指示在 diff 过程应该要退出优化模式
BAIL = -2
}
举个例子,来看看经过 tranform 后的结果与 parse 后有何不同:
<p>test</p>
parse 后的 ast 结构:
{"type":0,"children":[{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}}],"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}
transform 后的 ast 结构:
{"type":0,"children":[{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}}],"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"},"codegenNode":{"type":13,"tag":""p"","children":{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}},"isBlock":true,"disableTracking":false,"isComponent":false,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"codegenNode":{"type":13,"tag":""p"","children":{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}},"isBlock":true,"disableTracking":false,"isComponent":false,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}},"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"},"filters":[]}
generate 代码生成器
代码生成器的作用是通过 AST 语法树生成代码字符串,代码字符串被包装进渲染函数,执行渲染函数后,可以得到一份 vnode。
// generate 伪代码
export function generate(
ast: RootNode,
options: CodegenOptions & {
onContextCreated?: (context: CodegenContext) => void
} = {}
): CodegenResult {
// 用来存放最终生成的 code 字符串
const context = { code: '' };
// 定义 render 函数,以及函数入参
const functionName = `render`
const args = ['_ctx', '_cache']
const signature = args.join(', ')
// 将内容填充到 context.code 中
push(`function ${functionName}(${signature}) {`)
push(`return `)
// 根据 tranform 阶段生成的 codegenNode 生成对应的 code 代码
if (ast.codegenNode) {
genNode(ast.codegenNode, context)
}
push(`}`)
return {
ast,
code: context,
}
}
还是上面的例子:
<p>test</p>
经过 transform 得到的 codegenNode 如下:
{
"codegenNode": {
"type": 13,
"tag": ""p"",
"children": {
"type": 2,
"content": "test",
},
"isBlock": true,
"disableTracking": false,
"isComponent": false,
}
}
通过 generate生成的code 如下:
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("p", null, "Test"))
}
generate
拼接代码字符串时,遍历 transform
后的 ast
,根据每个节点 type
找到对应的创建虚拟 DOM 的函数名称,再根据 codegenNode
中的 tag
, content
等填充对应函数的入参。
上面 demo 中的 type = 13
,同时这里的 isBlock
为 true, isComponent
为 false,所以会同时生成调用 _openBlock()
和 createElementBlock ()
代码字符串。
最后在 vue 项目中导出的是 compileToFunction
函数,将 generate
生成的 code
字符串通过 new Function
方式转换为 render 函数:
// packages/vue/src/index.ts
function compileToFunction(
template: string | HTMLElement,
options?: CompilerOptions
): RenderFunction {
// 调用 compile-dom 的 compile 方法,返回 render 函数代码字符串
const { code } = compile(template, opts)
// 通过 new Function 将代码字符串转换为真正的 render 函数
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
return render;
}
五、总结
前面我们大致了解了 vue
模板编译的过程,知道了 AST
,tranform
,parse
,你肯定好奇,了解了这些编译原理,到底应该怎么应用到项目中呢?
编译的核心本质就是实现代码之间的转换,譬如 babel
把代码中的 ES6 语法转换浏览器支持的语法,如 vite
插件,在每一个 vue 组件中,我们都需要手动的去 import { ref, computed } from 'vue';
这个过程是不是很繁琐,这里就可以通过编译的思想来解决,目前已有 auto-import 实现了,具体的原理大家可以查看源码,Eslint
, preitter
检测等等。
六、参考资料
-
vue 在线编译:template-explorer.vuejs.org
-
Which dist file to use:github.com/vuejs/core/…
-
vue CDN files:cdn.jsdelivr.net/npm/vue@3.3…
-
New Function语法: zh.javascript.info/new-functio…
-
Template Compilation:medium.com/glovo-engin…
-
vite 插件:cn.vitejs.dev/plugins/
转载自:https://juejin.cn/post/7238541527058939959