06-compiler-三段式
第 6 章 · compiler-core:把 {{ message }} 变成 render 函数
第 6 章 · compiler-core:把 {{ message }} 变成 render 函数
编译器听起来吓人,但 mini-vue 的 compiler-core 是全项目结构最规整的包——严格的三段流水线,每段独立可测。
本章以贯穿案例 <div>hi, {{ message }}</div> 走完全程,最终产出:
const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode } = Vue
return function render(_ctx, _cache) {
return _createElementVNode('div', null, 'hi, ' + _toDisplayString(_ctx.message))
}三段分工铁律般分明,任何一段都不知道另外两段的存在:
parse transform codegen
字符串 → AST → AST → 加工后的 AST → AST → 代码字符串
(理解语法) (理解语义/准备信息) (纯机械拼接)6.1 parse:手写一个递归下降解析器
先看产物长什么样(AST 就是普通对象树)
"hi, {{ message }}" parse 后:
{
type: NodeTypes.ROOT,
children: [
{ type: NodeTypes.TEXT, content: 'hi, ' },
{
type: NodeTypes.INTERPOLATION, // 插值
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message',
},
},
],
}骨架:context + 分发循环
// parse.ts
export function baseParse(content: string) {
const context = createParserContext(content)
return createRoot(parseChildren(context, []))
}
function createParserContext(content) {
return { source: content } // ★ 整个 parser 唯一的状态:还没消费的剩余字符串
}
function parseChildren(context, ancestors) {
const nodes = []
while (!isEnd(context, ancestors)) {
let node
const s = context.source
if (s.startsWith('{{')) {
node = parseInterpolation(context) // 插值分支
} else if (s[0] === '<') {
if (/[a-z]/i.test(s[1])) {
node = parseElement(context, ancestors) // 元素分支
}
}
if (!node) {
node = parseText(context) // 兜底:文本分支
}
nodes.push(node)
}
return nodes
}parser 的全部世界观就是这个循环:看剩余字符串的开头几个字符,决定进哪个分支;每个分支负责"消费"掉自己那段字符串(把 context.source 向前推进),然后回到循环看下一段。"当前位置 + 向前看几个字符做决定 + 消费 + 循环"——这就是递归下降,和你按字符扫描处理字符串的算法题同构,只是产物从数值变成了树。
消费的动作统一封装成:
function advanceBy(context, length) {
context.source = context.source.slice(length)
}真实编译器不会真的每次 slice 字符串(O(n) 拷贝),而是维护一个 offset 指针——mini-vue 用 slice 换直观,又一个"教学从简,真源码见"的标记点。
三个分支逐个看
parseInterpolation:定界符之间取内容
function parseInterpolation(context) {
const openDelimiter = '{{'
const closeDelimiter = '}}'
// 从 '{{' 之后找 '}}' 的位置
const closeIndex = context.source.indexOf(closeDelimiter, openDelimiter.length)
advanceBy(context, openDelimiter.length) // 消费 '{{'
const rawContentLength = closeIndex - openDelimiter.length
const rawContent = context.source.slice(0, rawContentLength)
const content = rawContent.trim() // '{{ message }}' 里的空格
advanceBy(context, rawContentLength + closeDelimiter.length) // 消费内容和 '}}'
return {
type: NodeTypes.INTERPOLATION,
content: { type: NodeTypes.SIMPLE_EXPRESSION, content },
}
}为什么表达式外面还要包一层 INTERPOLATION 节点?因为 {{ message }} 和 :id="message" 里都有表达式——SIMPLE_EXPRESSION 是可复用零件,INTERPOLATION 是"出现在文本位置的表达式"这个具体语境。AST 节点类型的设计颗粒度,取决于"后续 transform/codegen 需要区分到什么程度"。
parseElement:递归发生的地方
function parseElement(context, ancestors) {
const element = parseTag(context, TagType.Start) // 消费 '<div>'
ancestors.push(element)
element.children = parseChildren(context, ancestors) // ★ 递归:元素内部又是一段"children"
ancestors.pop()
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End) // 消费 '</div>'
} else {
throw new Error(`缺少结束标签: ${element.tag}`) // 语法错误在这里报
}
return element
}
function parseTag(context, type) {
const match = /^<\/?([a-z]*)/i.exec(context.source)
const tag = match[1]
advanceBy(context, match[0].length) // 消费 '<div' 或 '</div'
advanceBy(context, 1) // 消费 '>'
if (type === TagType.End) return
return { type: NodeTypes.ELEMENT, tag }
}parseChildren 调 parseElement,parseElement 又调 parseChildren——互递归,树形结构就是这么长出来的,和第 3 章 patch↔mountChildren 的互递归一个骨架。
ancestors 栈是干嘛的:处理 <div><span></div>(span 没闭合)这类残局。isEnd 判断"该停了吗"时,除了源串耗尽,还要检查"当前开头是不是祖先栈里任何一个标签的结束标签"——是的话提前结束本层,让外层去报"span 缺少结束标签"而不是死循环。解析器里的栈几乎总是在维护"我现在身处哪些嵌套结构之内",和括号匹配题的栈同源。
function isEnd(context, ancestors) {
const s = context.source
if (s.startsWith('</')) {
for (let i = ancestors.length - 1; i >= 0; i--) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) return true
}
}
return !s // 源串耗尽
}parseText:一直吃到下一个"特殊开头"
function parseText(context) {
let endIndex = context.source.length
// 文本的终点 = '{{' 或 '<' 中先出现的那个
for (const token of ['<', '{{']) {
const index = context.source.indexOf(token)
if (index !== -1 && index < endIndex) endIndex = index
}
const content = context.source.slice(0, endIndex)
advanceBy(context, content.length)
return { type: NodeTypes.TEXT, content }
}文本分支是"兜底"分支,它的边界由其他所有分支的开始符定义——写 parser 时兜底分支永远最后写,且要在别的分支每次新增语法时同步更新终点符列表。
6.2 transform:插件架构 + 退出函数
为什么需要中间这一段
parse 的产物忠实于模板语法,codegen 需要的是贴近目标代码的结构。两者之间有鸿沟,举两例:
- 插值里的
message,生成代码时得写成_ctx.message——谁来加前缀? - 生成
createElementVNode('div', null, children)这行代码,需要把 tag/props/children 汇总成"一次函数调用"的描述——谁来汇总?
把这些改写逻辑塞进 codegen?codegen 会同时承担"理解"和"拼字符串"两件事,膨胀失控。Vue 的选择:中间加一个纯 AST→AST 的加工段,codegen 保持无脑。
骨架:DFS 遍历器 + 插件列表
// transform.ts
export function transform(root, options = {}) {
const context = {
root,
nodeTransforms: options.nodeTransforms || [], // ★ 插件从外面注入
helpers: new Map(), // 记录用到了哪些运行时函数
helper(key) { this.helpers.set(key, 1) },
}
traverseNode(root, context)
createRootCodegen(root) // 约定:root.codegenNode = 入口节点
root.helpers = [...context.helpers.keys()]
}
function traverseNode(node, context) {
// ① 进入阶段:依次执行每个插件,收集它们返回的"退出函数"
const exitFns = []
for (const transform of context.nodeTransforms) {
const onExit = transform(node, context)
if (onExit) exitFns.push(onExit)
}
// ② 分类型递归孩子
switch (node.type) {
case NodeTypes.INTERPOLATION:
context.helper(TO_DISPLAY_STRING) // 用到了 toDisplayString,记账
break
case NodeTypes.ROOT:
case NodeTypes.ELEMENT:
for (const child of node.children) traverseNode(child, context)
break
}
// ③ 退出阶段:★ 倒序执行退出函数
let i = exitFns.length
while (i--) exitFns[i]()
}逐段解释
插件架构的动机:改写逻辑会随功能无限增长(处理指令、静态提升、v-if/v-for……Vue 真源码里有几十个 transform)。遍历器只负责"走遍每个节点",每种改写是一个独立函数,从外部注入。变化的部分(改写规则)与稳定的部分(遍历)分离——这也是 Babel 插件、ESLint 规则的架构,学会一个通吃一片。
退出函数是本段的精华。插件不在"进入节点时"干活,而是返回一个函数,等该节点的所有孩子处理完之后倒着执行。为什么?看 transformElement 的需求:它要为 div 生成"函数调用描述"(codegenNode),其中 children 参数需要孩子们已经加工完的形态——进入 div 时孩子还没处理,信息不全。先序位置信息不全的活,挪到后序做——和树形 DP"先递归儿子、回溯时汇总"完全同构。倒序执行则保证插件间嵌套顺序对称(先进后出)。
三个具体插件
// transformExpression.ts —— 给表达式加 _ctx 前缀(先序就能做,不需要退出函数)
export function transformExpression(node) {
if (node.type === NodeTypes.INTERPOLATION) {
node.content.content = '_ctx.' + node.content.content
}
}
// transformElement.ts —— 为元素生成 codegenNode(必须后序 → 返回退出函数)
export function transformElement(node, context) {
if (node.type === NodeTypes.ELEMENT) {
return () => { // ★ 返回函数,延迟到退出时
context.helper(CREATE_ELEMENT_VNODE)
const vnodeTag = `'${node.tag}'`
const vnodeProps = node.props
const vnodeChildren = node.children[0] // (简化:取汇总后的孩子)
node.codegenNode = createVNodeCall(context, vnodeTag, vnodeProps, vnodeChildren)
}
}
}
// transformText.ts —— 把相邻的 文本+插值 合并成"复合表达式"(也是后序)
// 'hi, ' 和 {{message}} 相邻 → COMPOUND_EXPRESSION: ['hi, ', ' + ', 插值节点]
// 否则 codegen 没法知道该在它们之间拼 '+'
export function transformText(node) {
if (node.type === NodeTypes.ELEMENT) {
return () => {
let currentContainer
const children = node.children
for (let i = 0; i < children.length; i++) {
if (!isText(children[i])) continue
for (let j = i + 1; j < children.length; j++) {
if (!isText(children[j])) break
// 相邻的两个文本类节点 → 合并进 COMPOUND_EXPRESSION,中缝插 ' + '
if (!currentContainer) {
currentContainer = children[i] = {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [children[i]],
}
}
currentContainer.children.push(' + ', children[j])
children.splice(j, 1); j--
}
currentContainer = undefined
}
}
}
}helpers 记账:transform 过程顺手记下"这段模板用到了哪些运行时函数"(toDisplayString? createElementVNode?),codegen 据此生成头部的按需导入。产物只 import 用到的东西——按需生成的信息在加工阶段收集,输出阶段消费,两段又一次通过 context 咬合。
6.3 codegen:纯机械的字符串拼接
经过 transform,AST 上已经挂好 codegenNode、helpers。codegen 只做一件事:按节点类型递归拼字符串,没有任何"理解"成分:
// codegen.ts
export function generate(ast) {
const context = createCodegenContext() // { code: '', push(s){...} }
const { push } = context
genFunctionPreamble(ast, context) // 头部:const { xx } = Vue
push('return function render(_ctx, _cache) {')
push('return ')
genNode(ast.codegenNode, context) // ★ 主体:递归下降拼接
push('}')
return { code: context.code }
}
function genNode(node, context) {
switch (node.type) {
case NodeTypes.TEXT: genText(node, context); break
case NodeTypes.INTERPOLATION: genInterpolation(node, context); break
case NodeTypes.SIMPLE_EXPRESSION: genExpression(node, context); break
case NodeTypes.ELEMENT: genElement(node, context); break
case NodeTypes.COMPOUND_EXPRESSION: genCompoundExpression(node, context); break
}
}
function genText(node, { push }) { push(`'${node.content}'`) }
function genExpression(node, { push }) { push(node.content) } // _ctx.message
function genInterpolation(node, context) {
const { push, helper } = context
push(`${helper(TO_DISPLAY_STRING)}(`) // _toDisplayString(
genNode(node.content, context)
push(')')
}
function genElement(node, context) {
const { push, helper } = context
const { tag, children, props } = node
push(`${helper(CREATE_ELEMENT_VNODE)}(`)
genNodeList([tag, props ?? 'null', children], context) // 逗号分隔生成参数
push(')')
}结构上和 parse 是镜像:parse 是"字符串 → 按开头分发 → 建树",codegen 是"树 → 按类型分发 → 拼字符串"。中枢分发函数 genNode 对应 parseChildren 的分支循环,也对应 runtime 的 patch——整个项目三个包,骨架是同一个:递归下降 + 类型分发。认出这一点,mini-vue 就"读薄"了。
最后一步在编译入口把三段串起来,并用 new Function 把字符串变成真函数:
// compiler-core/compile.ts
export function baseCompile(template) {
const ast = baseParse(template)
transform(ast, { nodeTransforms: [transformExpression, transformElement, transformText] })
return generate(ast)
}
// vue 入口:runtime 与 compiler 的唯一交点
function compileToFunction(template) {
const { code } = baseCompile(template)
return new Function('Vue', code)(runtimeDom) // 把运行时作为参数喂给生成的代码
}
registerRuntimeCompiler(compileToFunction) // 注入给 runtime(finishComponentSetup 里用)registerRuntimeCompiler 用的还是依赖注入:runtime 不 import compiler(否则生产包甩不掉编译器),而是留一个注册口,有编译器就注册进来,finishComponentSetup 发现组件只有 template 没有 render 时调用它。两大系统的解耦靠"约定产物格式 + 运行时注册",第 0 章的伏笔在此收口。
编译器怎么测试:snapshot
三段的产物(AST、代码字符串)都是结构化数据,逐字段断言太啰嗦。mini-vue 用快照测试:
it('element with interpolation', () => {
const ast = baseParse('<div>hi, {{ message }}</div>')
transform(ast, { nodeTransforms: [...] })
const { code } = generate(ast)
expect(code).toMatchSnapshot() // 产物整体存档,变了就报 diff
})读编译器最快的入口是直接翻 __snapshots__/ 目录——每个用例的输入(模板)和输出(生成的代码)并排躺着,不用跑代码就能建立输入输出直觉。这招对一切"输入→产物"型代码(编译器、序列化、代码生成器)通用。
自测
- 三段各自的输入输出是什么?为什么改写逻辑不放进 codegen?
- parser 的 context 里唯一的状态是什么?advanceBy 在干嘛?ancestors 栈防的是什么?
- 退出函数解决什么问题?为什么 transformElement 必须后序而 transformExpression 不用?
'hi, '和{{message}}之间的+是谁在哪个阶段决定的?- runtime 和 compiler 如何做到互不 import 又能协作?(两个机制)
- (手撕)写一个只支持插值和单层元素的 baseParse,产出本章开头那棵 AST。