模版编译流程
Vue3模版编译就是把template字符串编译成渲染函数
// template
<div><p>{{LH_R}}</p></div>
// render
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("p", null, _toDisplayString(_ctx.LH_R), 1 /* TEXT */)
]))
}
我会按照编译流程分3步分析
- parse:将模版字符串转换成模版AST
- transform:将模版AST转换为用于描述渲染函数的AST
- generate:根据AST生成渲染函数
export function baseCompile( template: string | RootNode, options: CompilerOptions = {} ): CodegenResult { // ... const ast = isString(template) ? baseParse(template, options) : template const [nodeTransforms, directiveTransforms] = getBaseTransformPreset( prefixIdentifiers ) transform( ast, extend({}, options, { prefixIdentifiers, nodeTransforms: [ ...nodeTransforms, ...(options.nodeTransforms || []) // user transforms ], directiveTransforms: extend( {}, directiveTransforms, options.directiveTransforms || {} // user transforms ) }) ) return generate( ast, extend({}, options, { prefixIdentifiers }) ) }
parse
parse对模版字符串进行遍历,然后循环判断开始标签和结束标签把字符串分割成一个个token,存在一个token列表,然后扫描token列表并维护一个开始标签栈,每当扫描一个开始标签节点,就将其压入栈顶,栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有Token扫描完成后,即可构建成一颗树形AST
以下是简化版parseChildren源码,是parse的主入口
function parseChildren( context: ParserContext, mode: TextModes, ancestors: ElementNode[] // 节点栈结构,用于维护节点嵌套关系 ): TemplateChildNode[] { // 获取父节点 const parent = last(ancestors) const ns = parent ? parent.ns : Namespaces.HTML const nodes: TemplateChildNode[] = [] // 存储解析出来的AST子节点 // 遇到闭合标签结束解析 while (!isEnd(context, mode, ancestors)) { // 切割处理的模版字符串 const s = context.source let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined if (mode === TextModes.DATA || mode === TextModes.RCDATA) { if (!context.inVPre && startsWith(s, context.options.delimiters[0])) { // 解析插值表达式{{}} node = parseInterpolation(context, mode) } else if (mode === TextModes.DATA && s[0] === '<') { if (s[1] === '!') { // 解析注释节点和文档声明... } else if (s[1] === '/') { if (s[2] === '>') { // 针对自闭合标签,前进三个字符 advanceBy(context, 3) continue } else if (/[a-z]/i.test(s[2])) { // 解析结束标签 parseTag(context, TagType.End, parent) continue } else { // 如果不符合上述情况,就作为伪注释解析 node = parseBogusComment(context) } } else if (/[a-z]/i.test(s[1])) { // 解析html开始标签,获得解析到的AST节点 node = parseElement(context, ancestors) } } } if (!node) { // 普通文本节点 node = parseText(context, mode) } // 如果节点是数组,就遍历添加到nodes中 if (isArray(node)) { for (let i = 0; i < node.length; i++) { pushNode(nodes, node[i]) } } else { pushNode(nodes, node) } } return nodes }
就拿
<div><p>LH_R</p></div>
流程举例
- div开始标签入栈,context.source =
<p>LH_R</p></div>
,ancestors =[div]
- p开始标签入栈,context.source =
LH_R</p></div>
,ancestors =[div, p]
- 解析文本
LH_R
- 解析p结束标签,p标签出栈
- 解析div结束标签,div标签出栈
- 栈空,模版解析完毕
transform
- transform采用深度优先的方式对AST进行遍历,在遍历过程中,对节点的操作与转换采用插件化架构,都封装为独立的函数,然后转换函数通过
context.nodeTransforms
来注册 - 转换过程是优先转换子节点,因为有的父节点的转换依赖子节点
- 以下是AST遍历
traverseNode
核心源码/* 遍历AST节点树,通过node转换器对当前节点进行node转换 子节点全部遍历完成后执行对应指令的onExit回调退出转换 */ export function traverseNode( node: RootNode | TemplateChildNode, context: TransformContext ) { // 记录当前正在遍历的节点 context.currentNode = node /* nodeTransforms:transformElement、transformExpression、transformText... transformElement:负责整个节点层面的转换 transformExpression:负责节点中表达式的转化 transformText:负责节点中文本的转换 */ const { nodeTransforms } = context const exitFns = [] // 依次调用转换工具 for (let i = 0; i < nodeTransforms.length; i++) { /* 转换器只负责生成onExit回调,onExit函数才是执行转换主逻辑的地方,为什么要推到栈中先不执行呢? 因为要等到子节点都转换完成挂载gencodeNode后,也就是深度遍历完成后 再执行当前节点栈中的onExit,这样保证了子节点的表达式全部生成完毕 */ const onExit = nodeTransforms[i](node, context) if (onExit) { if (isArray(onExit)) { // v-if、v-for为结构化指令,其onExit是数组形式 exitFns.push(...onExit) } else { exitFns.push(onExit) } } if (!context.currentNode) { // node was removed 节点被移除 return } else { // node may have been replaced // 因为在转换的过程中节点可能被替换,恢复到之前的节点 node = context.currentNode } } switch (node.type) { case NodeTypes.COMMENT: if (!context.ssr) { // inject import for the Comment symbol, which is needed for creating // comment nodes with `createVNode` // 需要导入createComment辅助函数 context.helper(CREATE_COMMENT) } break case NodeTypes.INTERPOLATION: // no need to traverse, but we need to inject toString helper if (!context.ssr) { context.helper(TO_DISPLAY_STRING) } break // for container types, further traverse downwards case NodeTypes.IF: // 对v-if生成的节点束进行遍历 for (let i = 0; i < node.branches.length; i++) { traverseNode(node.branches[i], context) } break case NodeTypes.IF_BRANCH: case NodeTypes.FOR: case NodeTypes.ELEMENT: case NodeTypes.ROOT: // 遍历子节点 traverseChildren(node, context) break } // 当前节点树遍历完成,依次执行栈中的指令退出回调onExit context.currentNode = node let i = exitFns.length while (i--) { exitFns[i]() } }
generate
generate
生成代码大致分为3步
- 创建代码生成上下文,因为该上下文对象是用于维护代码生成过程中程序的运行状态,如:
code
:最终生成的渲染函数push
:拼接代码indent
:代码缩进deindent
:减少代码缩进- …
- 生成渲染函数的前置预设部分
- module模式下:
genModulePreamble()
- function模式下:
genFunctionPreamble
- 还有一些函数名,参数,作用域…
- module模式下:
- 生成渲染函数
- 通过调用
genNode
,然后在genNode
内部通过switch语句来匹配不同类型的节点,并调用对应的生成器函数
- 通过调用
参考资料
- Vue3 模板编译原理
- 《Vue.js设计与实现》