15.4.2 转换上下文与节点操作
context是转换过程中的「全局变量」,所有转换函数靠它共享数据;节点的替换与删除也必须借助它提供的replaceNode/removeNode。
一、什么是转换上下文
- 上下文不是一个具象的东西,它依赖于具体的使用场景。
- 上下文对象其实就是程序在某个范围内的「全局变量」;反过来说,全局变量也可以看作全局上下文。
- 所有 AST 转换函数都可以通过
context来共享数据。
二、context 上的数据
| 字段 | 含义 | 设置时机 |
|---|---|---|
currentNode | 当前正在转换的节点 | 每次进入 traverseNode 时置为 ast |
parent | 当前节点的父节点 | 递归转换子节点之前设为当前节点 |
childIndex | 当前节点在父节点 children 中的位置索引 | 递归前设为循环下标 i |
nodeTransforms | 注册的转换函数数组 | 在 transform 内初始化 |
replaceNode | 替换当前节点的函数 | 同上 |
removeNode | 删除当前节点的函数 | 同上 |
helpers | 生成代码时需要注入的辅助函数(如 h),通过 addHelper 收集 | 完整实现中使用 |
sourceLocation | 节点在源码中的位置信息,用于报错时定位 | 解析阶段写入节点,转换阶段读取 |
function traverseNode(ast, context) {
// 设置当前转换的节点信息 context.currentNode
context.currentNode = ast
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context)
}
// 递归转换子节点
const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
// 递归之前,把当前节点设为父节点,并记录位置索引
context.parent = context.currentNode
context.childIndex = i
traverseNode(children[i], context) // 透传 context
}
}
}
function transform(ast) {
const context = {
currentNode: null, // 当前正在转换的节点
childIndex: 0, // 当前节点在父节点 children 中的索引
parent: null, // 当前转换节点的父节点
nodeTransforms: [transformElement, transformText]
}
traverseNode(ast, context)
console.log(dump(ast))
}
易错点:
parent与childIndex必须在递归子节点之前设置;若放在递归之后,转换函数里拿到的就是上一轮遗留的值。
三、replaceNode:为什么不能直接赋值
需求:转换时希望把某些节点替换为其他类型的节点。
// 用于替换节点的函数,接收新节点作为参数
replaceNode(node) {
// 替换的本质是修改 AST:
// 找到当前节点在父节点 children 中的位置(context.childIndex),用新节点替换
context.parent.children[context.childIndex] = node
// 当前节点已被替换,同步把 currentNode 指向新节点
context.currentNode = node
}
- 为什么要改
context.parent.children[childIndex]:父子关系靠父节点children数组中的引用维持。只让局部变量currentNode指向新节点,父节点children里指向的仍是旧节点,后续的遍历与代码生成拿到的还是旧节点,替换等于没生效。 - 替换之后要同步更新
context.currentNode,否则后续转换函数操作的还是那个已被丢弃的旧节点。
function transformText(node, context) {
if (node.type === 'Text') {
// 把文本节点替换为元素节点
context.replaceNode({ type: 'Element', tag: 'span' })
}
}
// 测试:遍历是深度优先的,若「文本外再套一层标签」,
// 会导致后续节点的 parent 变化、替换失败,所以用结构简单的模板测试
const ast = parse(`<div>hello<p>vue</p></div>`)
transform(ast)
四、removeNode:删除后必须中断后续处理
// 用于删除当前节点
removeNode() {
if (context.parent) {
// 根据当前节点的索引,从父节点的 children 中删除当前节点
context.parent.children.splice(context.childIndex, 1)
// 将 currentNode 置空,作为「已被删除」的标记
context.currentNode = null
}
}
- 任何转换函数都可能移除当前访问的节点,因此每个转换函数执行完毕后都要检查
context.currentNode是否还存在,不存在则traverseNode直接返回,不做后续处理:
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context)
// 当前节点被移除了 → 直接返回
if (!context.currentNode) return
}
function transformText(node, context) {
if (node.type === 'Text') {
// 直接移除文本节点
context.removeNode()
}
}
// 测试
const ast = parse(`<div>hello<p>vue</p></div>`)
transform(ast)
易错点:
splice会改变原数组的长度,若用for循环遍历children,删除后索引会错乱(与hello相邻的p可能被跳过),循环需要做相应修正。 补充:转换函数可以返回退出阶段的回调(见 15.4.3),节点被移除时应直接返回,这些回调也不会再执行。
相关
- 15.4.1 节点的访问:先能遍历到节点,才有「当前节点」可替换/删除
- 15.4.3 进入与退出:转换函数返回值(退出阶段回调)的来龙去脉