obsidian-computer
前端识记mini-vue-reading

06-compiler-三段式

第 6 章 · compiler-core:把 {{ message }} 变成 render 函数

最后更新 2026年8月29日

第 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__/ 目录——每个用例的输入(模板)和输出(生成的代码)并排躺着,不用跑代码就能建立输入输出直觉。这招对一切"输入→产物"型代码(编译器、序列化、代码生成器)通用。


自测

  1. 三段各自的输入输出是什么?为什么改写逻辑不放进 codegen?
  2. parser 的 context 里唯一的状态是什么?advanceBy 在干嘛?ancestors 栈防的是什么?
  3. 退出函数解决什么问题?为什么 transformElement 必须后序而 transformExpression 不用?
  4. 'hi, ' 和 {{message}} 之间的 + 是谁在哪个阶段决定的?
  5. runtime 和 compiler 如何做到互不 import 又能协作?(两个机制)
  6. (手撕)写一个只支持插值和单层元素的 baseParse,产出本章开头那棵 AST。

反向链接(1)

评论

关系图谱

00-总览与阅读路线01-reactivity-effect与依赖收集02-reactivity-reactive-ref-computed03-runtime-挂载流程04-runtime-组件系统05-runtime-更新与diff06-compiler-三段式07-方法论-如何啃大型开源项目README

On this page