主题
实现编译器 - Transform 转换
本节对标 Vue 3 源码
@vue/compiler-core中的transform.ts源码位置:packages/compiler-core/src/transform.ts
Transform 的作用
Parse 阶段生成的 AST 是模板的"原始"结构化表示,它忠实反映了模板的语法结构,但还不包含生成代码所需的语义信息。Transform 阶段的任务是:
- 语义分析:分析节点之间的关系(如相邻文本和插值的组合)
- 节点转换:为节点添加
codegenNode,指导后续代码生成 - 优化标记:标记静态节点、动态节点等(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() ← 最后执行先进后出的顺序保证了:
- 子节点的 transform 先于父节点完成
- 后注册的 transform 先于先注册的执行退出函数
- 父节点的退出函数可以访问子节点 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, // 处理文本 + 插值组合(合并为复合表达式)
],
})注意插件的顺序很重要——transformElement 和 transformText 都使用退出函数,所以实际的退出执行顺序是 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 做了三件事:
transformExpression:处理插值中的表达式(简化版不做实际转换)transformText:将 TEXT + INTERPOLATION 合并为 COMPOUND_EXPRESSIONtransformElement:为<p>和<div>添加codegenNode(VNODE_CALL)
对比 React
| 维度 | Vue 3 Transform | React(Babel Plugin) |
|---|---|---|
| 时机 | 编译时 | 编译时(Babel 转换) |
| 输入 | Vue 模板 AST | JSX AST(Babel AST) |
| 转换方式 | 插件化 nodeTransforms | Babel visitor pattern |
| 遍历方式 | 自定义 traverseNode | Babel traverse |
| 退出机制 | onExit 回调函数 | visitor 的 exit 钩子 |
| 优化 | 静态提升、PatchFlags | React 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
// 如果模板中没有元素,就不会导入 createElementVNode4. 分离 AST 结构与生成逻辑
Transform 通过 codegenNode 字段将代码生成逻辑"附着"在 AST 节点上,而不是修改原始 AST 结构。这意味着原始的 type/tag/children 等信息保持不变,codegen 只需要读取 codegenNode 即可。
本节小结
- Transform 的职责 — 在 AST 上进行语义分析和转换,为 codegen 做准备
- TransformContext — 上下文对象,传递配置和共享状态(helpers/currentNode 等)
- traverseNode — 深度优先遍历,支持进入和退出两个阶段
- 退出函数模式 — 先进后出,保证子节点先处理完再处理父节点
- 插件化架构 — nodeTransforms 数组,灵活组合转换逻辑
- transformText — 合并相邻文本和插值为 COMPOUND_EXPRESSION
- transformElement — 为元素添加 codegenNode(VNODE_CALL)
- createVNodeCall — 描述 createElementVNode 调用的数据结构
下一节实现 codegen 阶段,将转换后的 AST 生成 render 函数代码字符串。