Skip to content

实现编译器 - Transform 转换

本节对标 Vue 3 源码 @vue/compiler-core 中的 transform.ts 源码位置:packages/compiler-core/src/transform.ts

Transform 的作用

Parse 阶段生成的 AST 是模板的"原始"结构化表示,它忠实反映了模板的语法结构,但还不包含生成代码所需的语义信息。Transform 阶段的任务是:

  1. 语义分析:分析节点之间的关系(如相邻文本和插值的组合)
  2. 节点转换:为节点添加 codegenNode,指导后续代码生成
  3. 优化标记:标记静态节点、动态节点等(Vue 3 完整版)
Parse AST                    Transform AST
┌───────────┐               ┌───────────────────┐
│ ELEMENT   │               │ ELEMENT            │
│  tag: div │   transform   │  tag: div          │
│  children │ ────────────▶ │  children          │
│   TEXT    │               │   COMPOUND_EXPR    │
│   INTERP  │               │    TEXT + INTERP    │
└───────────┘               │  codegenNode: ...  │
                            └───────────────────┘

TransformContext 上下文对象

Transform 过程需要一个上下文来传递配置和共享状态:

ts
// 对标 packages/compiler-core/src/transform.ts - TransformContext
// 转换上下文接口:在整个 transform 过程中传递配置和共享状态
interface TransformContext {
  root: RootNode                     // AST 根节点引用
  nodeTransforms: NodeTransform[]    // 转换插件数组,每个插件处理一种转换逻辑
  helpers: Map<symbol, number>       // 记录代码生成所需的运行时辅助函数及其引用计数
  helper(key: symbol): symbol        // 注册辅助函数的方法
  currentNode: any                   // 当前正在处理的 AST 节点
  parent: any | null                 // 当前节点的父节点
  childIndex: number                 // 当前节点在父节点 children 中的索引
}

// 转换插件函数类型定义
// 可以返回 void(仅在进入阶段执行)或返回一个退出函数(在子节点处理完后执行)
type NodeTransform = (
  node: any,
  context: TransformContext,
) => void | (() => void)

// 创建转换上下文的工厂函数
function createTransformContext(root: any, options: any): TransformContext {
  const context: TransformContext = {
    root,
    nodeTransforms: options.nodeTransforms || [], // 从配置中获取转换插件列表
    helpers: new Map(), // 初始化辅助函数映射表
    helper(key) {
      // 注册辅助函数:累加引用计数,确保按需导入
      const count = context.helpers.get(key) || 0
      context.helpers.set(key, count + 1)
      return key // 返回 key 以便链式使用
    },
    currentNode: root,  // 初始时当前节点为根节点
    parent: null,       // 根节点没有父节点
    childIndex: 0,
  }
  return context
}

helpers 的作用

helpers 是一个 Map<symbol, number>,记录了代码生成阶段需要导入的运行时辅助函数:

ts
// 辅助函数标识:使用 Symbol 作为唯一标识符,避免命名冲突
export const TO_DISPLAY_STRING = Symbol('toDisplayString')       // 将值转为字符串显示(用于插值表达式)
export const CREATE_ELEMENT_VNODE = Symbol('createElementVNode') // 创建元素 VNode(用于元素节点)

// 辅助函数名称映射:Symbol → 实际的运行时函数名
// codegen 阶段通过此映射生成 import 语句
export const helperNameMap: Record<symbol, string> = {
  [TO_DISPLAY_STRING]: 'toDisplayString',
  [CREATE_ELEMENT_VNODE]: 'createElementVNode',
}

当 transform 过程中遇到插值表达式时,调用 context.helper(TO_DISPLAY_STRING),就会在 helpers 中注册这个辅助函数。Codegen 阶段会根据 helpers 生成对应的 import 语句。

transform 入口

ts
// 对标 packages/compiler-core/src/transform.ts - transform
// Transform 阶段的入口函数:对 AST 进行语义分析和转换
export function transform(root: any, options: any) {
  const context = createTransformContext(root, options) // 创建转换上下文

  traverseNode(root, context) // 深度优先遍历并转换所有节点

  createRootCodegen(root) // 为根节点设置 codegenNode

  // 将收集到的辅助函数 key 挂载到根节点上,供 codegen 生成 import 语句
  root.helpers = [...context.helpers.keys()]
}

// 为根节点设置 codegenNode
// render 函数只返回一个根 VNode,所以将第一个子节点的 codegenNode 提升为根节点的
function createRootCodegen(root: any) {
  const child = root.children[0] // 获取根节点的第一个子节点
  // 如果子节点是元素且有 codegenNode,使用其 codegenNode
  if (child.type === NodeTypes.ELEMENT && child.codegenNode) {
    root.codegenNode = child.codegenNode
  } else {
    // 否则直接使用子节点本身(如文本节点、插值节点)
    root.codegenNode = child
  }
}

createRootCodegen 将根节点的第一个子节点的 codegenNode 提升为根节点的 codegenNode,这是因为 render 函数只返回一个根 VNode。

traverseNode —— 深度优先遍历

ts
// 对标 packages/compiler-core/src/transform.ts - traverseNode
// 深度优先遍历 AST 节点,对每个节点执行所有转换插件
function traverseNode(node: any, context: TransformContext) {
  context.currentNode = node // 更新上下文中的当前节点引用

  const { nodeTransforms } = context
  const exitFns: Array<() => void> = [] // 收集所有转换插件返回的退出函数

  // 进入阶段:依次执行所有 transform 插件
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context) // 调用插件,可能返回退出函数
    if (onExit) {
      exitFns.push(onExit) // 保存退出函数,等子节点处理完后再执行
    }
  }

  // 根据节点类型决定后续处理
  switch (node.type) {
    case NodeTypes.INTERPOLATION:
      // 插值节点需要 toDisplayString 辅助函数来将值转为字符串
      context.helper(TO_DISPLAY_STRING)
      break

    case NodeTypes.ROOT:
    case NodeTypes.ELEMENT:
      // 根节点和元素节点包含子节点,需要递归遍历
      traverseChildren(node, context)
      break

    default:
      // 文本节点等叶子节点无需进一步处理
      break
  }

  // 退出阶段:逆序执行退出函数(后注册的先执行,实现洋葱模型)
  // 此时所有子节点已经处理完毕,父节点可以安全地访问子节点的转换结果
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

退出函数模式(onExit callbacks)

这是 transform 最精妙的设计之一。每个 transform 插件可以返回一个"退出函数",该函数在子节点全部处理完之后才执行。

执行顺序:

traverseNode(div)
  ├── 进入 transformElement(div)     → 返回 onExit_element
  ├── 进入 transformText(div)        → 返回 onExit_text

  ├── traverseChildren(div)
  │   ├── traverseNode(text)         → 进入 + 退出
  │   └── traverseNode(interpolation) → 进入 + 退出

  ├── 退出 onExit_text()            ← 子节点已处理完
  └── 退出 onExit_element()         ← 最后执行

先进后出的顺序保证了:

  1. 子节点的 transform 先于父节点完成
  2. 后注册的 transform 先于先注册的执行退出函数
  3. 父节点的退出函数可以访问子节点 transform 后的结果

这类似于中间件的"洋葱模型":

transformElement 进入 →
  transformText 进入 →
    处理子节点
  transformText 退出 ←
transformElement 退出 ←

traverseChildren —— 遍历子节点

ts
// 对标 packages/compiler-core/src/transform.ts - traverseChildren
// 遍历父节点的所有子节点,依次对每个子节点执行 traverseNode
function traverseChildren(parent: any, context: TransformContext) {
  const children = parent.children

  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    context.parent = parent    // 记录父节点,供子节点的 transform 使用
    context.childIndex = i     // 记录子节点索引,便于定位
    traverseNode(child, context) // 递归处理每个子节点
  }
}

插件化 nodeTransforms 架构

Transform 采用插件化设计,每个转换逻辑都是一个独立的函数,通过 nodeTransforms 数组注入。这种设计的好处:

  • 关注点分离:每个 transform 只负责一种转换
  • 可组合:不同场景可以使用不同的 transform 组合
  • 可测试:每个 transform 可以独立测试
ts
// 使用 transform 时通过 nodeTransforms 注入不同的转换插件
transform(ast, {
  nodeTransforms: [
    transformExpression,  // 处理表达式(如给变量加 _ctx 前缀)
    transformElement,     // 处理元素节点(生成 codegenNode)
    transformText,        // 处理文本 + 插值组合(合并为复合表达式)
  ],
})

注意插件的顺序很重要——transformElementtransformText 都使用退出函数,所以实际的退出执行顺序是 transformText → transformElement(后进先出)。

transformExpression —— 处理表达式

ts
// 对标 packages/compiler-core/src/transforms/transformExpression.ts
// 处理插值表达式中的变量引用
// 在 Vue 3 完整实现中会将 msg 转为 _ctx.msg,使得 render 函数能从组件实例上访问数据
export function transformExpression(node: any, context: TransformContext) {
  // 只处理插值节点,其他类型的节点跳过
  if (node.type === NodeTypes.INTERPOLATION) {
    node.content = processExpression(node.content) // 处理插值内部的表达式
  }
}

// 处理表达式节点:在 mini-vue 简化版中直接返回原节点
// 完整 Vue 3 中会做前缀转换,如 msg → _ctx.msg
function processExpression(node: any) {
  return node
}

在 Vue 3 完整实现中,transformExpression 会将模板中的 {{ msg }} 转换为 _ctx.msg,通过分析表达式内容并添加上下文前缀。这使得 render 函数可以从组件实例上正确访问数据。

transformElement —— 处理元素节点

ts
// 对标 packages/compiler-core/src/transforms/transformElement.ts
// 处理元素节点:为其生成 codegenNode,描述如何调用 createElementVNode
export function transformElement(node: any, context: TransformContext) {
  // 只处理元素类型的节点
  if (node.type !== NodeTypes.ELEMENT) {
    return
  }

  // 返回退出函数,确保子节点先完成转换
  // 这样在生成 codegenNode 时,子节点(如复合表达式)已经准备就绪
  return () => {
    // 注册 createElementVNode 辅助函数
    context.helper(CREATE_ELEMENT_VNODE)

    // 将标签名包装为字符串字面量格式(如 'div')
    const vnodeTag = `'${node.tag}'`

    // 处理 props:将属性数组转换为对象字面量的 JSON 字符串
    const vnodeProps = node.props.length > 0
      ? JSON.stringify(
          node.props.reduce((result: any, prop: any) => {
            result[prop.name] = prop.value // 将 { name, value } 转换为 { [name]: value }
            return result
          }, {}),
        )
      : null // 没有属性时传 null

    // 处理 children:根据子节点数量决定 children 的表示形式
    const vnodeChildren = node.children
    let childrenNode: any
    if (vnodeChildren.length === 1) {
      childrenNode = vnodeChildren[0] // 单个子节点直接使用
    } else if (vnodeChildren.length > 1) {
      childrenNode = vnodeChildren // 多个子节点使用数组
    }

    // 创建 VNODE_CALL 类型的 codegenNode,供 codegen 阶段生成代码
    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      childrenNode,
    )
  }
}

transformElement 使用退出函数的原因:它需要访问子节点经过 transform 后的结果(比如经过 transformText 处理后的复合表达式)。

transformText —— 处理文本和插值的组合

ts
// 对标 packages/compiler-core/src/transforms/transformText.ts
// 处理相邻的文本和插值节点,将它们合并为复合表达式节点
// 这样 codegen 可以用 + 将它们连接起来生成表达式
export function transformText(node: any, context: TransformContext) {
  // 只处理元素和根节点(它们才有子节点需要合并)
  if (node.type !== NodeTypes.ELEMENT && node.type !== NodeTypes.ROOT) {
    return
  }

  // 返回退出函数,确保子节点的转换已完成
  return () => {
    const children = node.children
    let currentContainer: any = undefined // 当前的复合表达式容器

    for (let i = 0; i < children.length; i++) {
      const child = children[i]

      if (isText(child)) {
        // 当前节点是文本或插值,尝试与后续相邻的文本/插值合并
        for (let j = i + 1; j < children.length; j++) {
          const next = children[j]

          if (isText(next)) {
            if (!currentContainer) {
              // 首次遇到相邻文本,创建复合表达式节点替换当前位置的节点
              currentContainer = children[i] = {
                type: NodeTypes.COMPOUND_EXPRESSION,
                children: [child], // 先放入第一个文本/插值节点
              }
            }

            // 在节点之间添加 + 连接符(codegen 时用于字符串拼接)
            currentContainer.children.push(' + ', next)
            // 将已合并的节点从原数组中移除,避免重复处理
            children.splice(j, 1)
            j-- // 调整索引,因为数组长度减少了
          } else {
            // 遇到非文本节点,停止合并
            currentContainer = undefined
            break
          }
        }
      }
    }
  }
}

// 判断节点是否为"文本类"节点(纯文本或插值表达式)
function isText(node: any): boolean {
  return node.type === NodeTypes.TEXT || node.type === NodeTypes.INTERPOLATION
}

为什么需要 transformText?

考虑模板 <p>hello {{ name }} !</p>,parse 后的子节点是:

children: [
  { type: TEXT, content: "hello " },
  { type: INTERPOLATION, content: { content: "name" } },
  { type: TEXT, content: " !" }
]

在生成代码时,我们需要把它们组合成一个表达式:"hello " + _toDisplayString(name) + " !"transformText 就是把相邻的文本和插值合并为一个 COMPOUND_EXPRESSION 节点:

children: [
  {
    type: COMPOUND_EXPRESSION,
    children: [
      { type: TEXT, content: "hello " },
      " + ",
      { type: INTERPOLATION, content: { content: "name" } },
      " + ",
      { type: TEXT, content: " !" }
    ]
  }
]

createVNodeCall 辅助函数

ts
// 对标 packages/compiler-core/src/ast.ts - createVNodeCall
// 创建 VNODE_CALL 节点:描述一个 createElementVNode(tag, props, children) 调用
// 该节点在 codegen 阶段会被转换为实际的函数调用代码
export function createVNodeCall(
  context: TransformContext,
  tag: string,       // 标签名字符串,如 "'div'"
  props: any,        // 属性对象的 JSON 字符串,或 null
  children: any,     // 子节点(单个节点、数组或 undefined)
) {
  context.helper(CREATE_ELEMENT_VNODE) // 确保注册了辅助函数

  return {
    type: NodeTypes.VNODE_CALL, // 标记为 VNode 调用类型
    tag,
    props,
    children,
  }
}

createVNodeCall 创建的节点描述了一个 createElementVNode(tag, props, children) 调用。Codegen 阶段会根据这个节点生成对应的代码。

完整 Transform 流程示例

输入模板

html
<div><p>hi,{{ message }}</p></div>

Parse 后的 AST

ROOT
└── ELEMENT (div)
    └── ELEMENT (p)
        ├── TEXT "hi,"
        └── INTERPOLATION {{ message }}

Transform 后的 AST

ROOT (codegenNode → div 的 VNODE_CALL)
├── helpers: [toDisplayString, createElementVNode]
└── ELEMENT (div)
    └── ELEMENT (p)
        └── COMPOUND_EXPRESSION
            ├── TEXT "hi,"
            ├── " + "
            └── INTERPOLATION {{ message }}
    └── codegenNode: VNODE_CALL('div', null, ...)

Transform 做了三件事:

  1. transformExpression:处理插值中的表达式(简化版不做实际转换)
  2. transformText:将 TEXT + INTERPOLATION 合并为 COMPOUND_EXPRESSION
  3. transformElement:为 <p><div> 添加 codegenNode(VNODE_CALL)

对比 React

维度Vue 3 TransformReact(Babel Plugin)
时机编译时编译时(Babel 转换)
输入Vue 模板 ASTJSX AST(Babel AST)
转换方式插件化 nodeTransformsBabel visitor pattern
遍历方式自定义 traverseNodeBabel traverse
退出机制onExit 回调函数visitor 的 exit 钩子
优化静态提升、PatchFlagsReact Compiler(实验性)
架构洋葱模型(进入→子节点→退出)访问者模式(enter / exit)

两者的 transform 都采用了"访问者"思想——不修改遍历逻辑,而是通过注入不同的"访问者"(transform 函数 / visitor)来实现不同的转换。Vue 3 的退出函数模式本质上和 Babel 的 exit 钩子是等价的。

测试用例

ts
import { baseParse, NodeTypes } from '../src/parse'
import { transform } from '../src/transform'
import { transformElement } from '../src/transforms/transformElement'
import { transformText } from '../src/transforms/transformText'
import { transformExpression } from '../src/transforms/transformExpression'
import { TO_DISPLAY_STRING, CREATE_ELEMENT_VNODE } from '../src/runtimeHelpers'

describe('transform', () => {
  // 测试:插值表达式应该自动注册 toDisplayString 辅助函数
  it('should add helpers for interpolation', () => {
    const ast = baseParse('{{ message }}')
    transform(ast, { nodeTransforms: [transformExpression] })

    // 断言:helpers 中包含 TO_DISPLAY_STRING,因为插值需要将值转为字符串
    expect(ast.helpers).toContain(TO_DISPLAY_STRING)
  })

  // 测试:元素节点经过 transform 后应该生成 codegenNode
  it('should transform element with codegenNode', () => {
    const ast = baseParse('<div>hello</div>')
    transform(ast, {
      nodeTransforms: [transformElement],
    })

    const element = ast.children[0]
    // 断言:codegenNode 存在且类型为 VNODE_CALL
    expect(element.codegenNode).toBeDefined()
    expect(element.codegenNode.type).toBe(NodeTypes.VNODE_CALL)
    // 断言:标签名被包装为字符串字面量
    expect(element.codegenNode.tag).toBe("'div'")
  })

  // 测试:相邻的文本和插值应被合并为复合表达式
  it('should merge adjacent text and interpolation into compound expression', () => {
    const ast = baseParse('<p>hi,{{ msg }}</p>')
    transform(ast, {
      nodeTransforms: [transformExpression, transformElement, transformText],
    })

    const p = ast.children[0]
    const compound = p.children[0] // 合并后只有一个复合表达式子节点

    // 断言:类型为 COMPOUND_EXPRESSION,包含3个子元素(文本 + " + " + 插值)
    expect(compound.type).toBe(NodeTypes.COMPOUND_EXPRESSION)
    expect(compound.children.length).toBe(3)
    expect(compound.children[0].type).toBe(NodeTypes.TEXT)
    expect(compound.children[0].content).toBe('hi,')
    expect(compound.children[1]).toBe(' + ') // 连接符
    expect(compound.children[2].type).toBe(NodeTypes.INTERPOLATION)
  })

  // 测试:根节点应该被设置 codegenNode
  it('should set root codegenNode', () => {
    const ast = baseParse('<div></div>')
    transform(ast, {
      nodeTransforms: [transformElement],
    })

    // 断言:根节点的 codegenNode 已设置(从第一个子节点提升而来)
    expect(ast.codegenNode).toBeDefined()
  })

  // 测试:所有用到的辅助函数都应被收集到 helpers 中
  it('should collect all helpers', () => {
    const ast = baseParse('<div>{{ msg }}</div>')
    transform(ast, {
      nodeTransforms: [transformExpression, transformElement, transformText],
    })

    // 断言:同时包含 toDisplayString(用于插值)和 createElementVNode(用于元素)
    expect(ast.helpers).toContain(TO_DISPLAY_STRING)
    expect(ast.helpers).toContain(CREATE_ELEMENT_VNODE)
  })

  // 测试:嵌套元素的每一层都应生成 codegenNode
  it('should handle nested elements', () => {
    const ast = baseParse('<div><span>hello</span></div>')
    transform(ast, {
      nodeTransforms: [transformElement, transformText],
    })

    const div = ast.children[0]
    const span = div.children[0]

    // 断言:外层 div 和内层 span 都有 codegenNode
    expect(div.codegenNode).toBeDefined()
    expect(span.codegenNode).toBeDefined()
    expect(span.codegenNode.tag).toBe("'span'")
  })

  // 测试:纯文本子节点不应被转换为复合表达式
  it('should handle text only children without compound expression', () => {
    const ast = baseParse('<p>hello world</p>')
    transform(ast, {
      nodeTransforms: [transformElement, transformText],
    })

    const p = ast.children[0]
    // 断言:只有纯文本时保持 TEXT 类型,不会创建 COMPOUND_EXPRESSION
    expect(p.children[0].type).toBe(NodeTypes.TEXT)
    expect(p.children[0].content).toBe('hello world')
  })
})

设计分析

1. 插件化的威力

通过 nodeTransforms 数组注入不同的 transform 函数,可以灵活地组合转换逻辑:

ts
// 客户端编译:使用标准的转换插件组合
transform(ast, {
  nodeTransforms: [transformExpression, transformElement, transformText],
})

// SSR 编译:可以替换某些插件来生成不同的代码
// 例如用 ssrTransformElement 替代 transformElement,生成服务端渲染所需的代码
transform(ast, {
  nodeTransforms: [transformExpression, ssrTransformElement, transformText],
})

2. 退出函数保证正确性

如果 transformElement 在进入阶段就生成 codegenNode,此时子节点还未经过 transformText 处理,可能会遗漏复合表达式的信息。退出函数确保了自底向上的处理顺序。

3. helpers 实现按需导入

不是所有 render 函数都需要所有辅助函数。通过 helpers 机制,codegen 只会导入实际用到的函数:

ts
// 如果模板中没有插值,就不会导入 toDisplayString
// 如果模板中没有元素,就不会导入 createElementVNode

4. 分离 AST 结构与生成逻辑

Transform 通过 codegenNode 字段将代码生成逻辑"附着"在 AST 节点上,而不是修改原始 AST 结构。这意味着原始的 type/tag/children 等信息保持不变,codegen 只需要读取 codegenNode 即可。

本节小结

  1. Transform 的职责 — 在 AST 上进行语义分析和转换,为 codegen 做准备
  2. TransformContext — 上下文对象,传递配置和共享状态(helpers/currentNode 等)
  3. traverseNode — 深度优先遍历,支持进入和退出两个阶段
  4. 退出函数模式 — 先进后出,保证子节点先处理完再处理父节点
  5. 插件化架构 — nodeTransforms 数组,灵活组合转换逻辑
  6. transformText — 合并相邻文本和插值为 COMPOUND_EXPRESSION
  7. transformElement — 为元素添加 codegenNode(VNODE_CALL)
  8. createVNodeCall — 描述 createElementVNode 调用的数据结构

下一节实现 codegen 阶段,将转换后的 AST 生成 render 函数代码字符串。

用心学习,用代码说话 💻