用导游的例子来理解 Visitor 模式,实现AST 转换

Visitor 模式是一种行为设计模式 ,它允许你在不修改对象结构的情况下,为对象添加新的操作。核心思想是分离操作与结构,让操作可以独立变化。

(本文是在理解编译器第3步骤,转换代码的时候接触到访问者模式,简单研究了下)

Visitor 模式核心概念

  • 被访问者(Element):具有固定结构的对象(如 AST 节点)
  • 访问者(Visitor):定义对每种对象类型的操作
  • 遍历器(Traverser):控制访问顺序,遍历节点
  • 访问者模式:不同访问者在访问节点时,可以做不同的操作

生活化理解:导游与游客

想象你是一个导游 ,带着游客参观故宫

  • 故宫:结构固定,有固定的景点和路线
  • 游客:每个游客有不同的需求(拍照、购物、听讲解)
  • 导游:按照固定路线带游客参观
  • 访问者模式:不同游客在参观过程中做自己的事情

怎么遍历一棵树?

这边使用深度优先遍历(DFS)遍历一棵树,举个例子。

js 复制代码
// 故宫结构(固定)
const palace = {
  name: '故宫',
  children: [
    { name: '钟粹宫', children: [{ name: '钟粹宫大殿' }] },
    { name: '珍宝馆', children: [{ name: '珍宝馆大殿' }] },
    { name: '乾清宫', children: [{ name: '乾清宫大殿' }] },
  ],
};

用深度优先遍历(DFS)来遍历这棵树,代码如下:

js 复制代码
function traverse(node) {
  console.log('开始参观', node.name);
  if (node.children) {
    node.children.forEach((child) => traverse(child));
  }
  console.log('结束参观', node.name);
}

运行结果如下:

复制代码
开始参观 故宫
开始参观 钟粹宫
开始参观 钟粹宫大殿
结束参观 钟粹宫大殿
结束参观 钟粹宫
开始参观 珍宝馆
开始参观 珍宝馆大殿
结束参观 珍宝馆大殿
结束参观 珍宝馆
开始参观 乾清宫
开始参观 乾清宫大殿
结束参观 乾清宫大殿
结束参观 乾清宫

从结果可以看出,深度优先遍历(DFS)的顺序是:故宫 -> 钟粹宫 -> 钟粹宫大殿 -> 珍宝馆 -> 珍宝馆大殿 -> 乾清宫 -> 乾清宫大殿。

带着 visitor 来遍历这棵树

现在我们带着 visitor 来遍历这棵树,visitor 是一个对象,它包含多个方法,每个方法对应一个操作。

js 复制代码
// 游客A的行为(可变化)
const visitorA = {
  故宫: {
    enter(node) {
      const action = '拍照';
      console.log('A游客进入', node.name, action);
    },
    exit(node) {
      const action = '收起相机';
      console.log('A游客离开', node.name, action);
    },
  },
  钟粹宫大殿: {
    enter(node) {
      const action = '听讲解';
      console.log('A游客进入', node.name, action);
    },
    exit(node) {
      const action = '还讲解机器';
      console.log('A游客离开', node.name, action);
    },
  },
};

修改下 traverse 函数,添加 visitor 参数。

js 复制代码
function traverse(node, visitor) {
  console.log('开始参观', node.name);
  // 获取游客在这个景点要干的事
  const doThingsInThisPlace = visitor[node.name];
  // 进入景点开始的时候,看看有没有要干的事
  doThingsInThisPlace?.enter?.(node);
  // 这块就是寻常的遍历逻辑
  if (node.children) {
    node.children.forEach((child) => traverse(child, visitor));
  }
  // 离开景点结束的时候,看看有没有要干的事
  doThingsInThisPlace?.exit?.(node);
  console.log('结束参观', node.name);
}

现在导游带着 A 游客来参观故宫,代码如下:

js 复制代码
traverse(palace, visitorA);

运行结果如下:

css 复制代码
开始参观 故宫
A游客进入 故宫 拍照
开始参观 钟粹宫
开始参观 钟粹宫大殿
A游客进入 钟粹宫大殿 听讲解
A游客离开 钟粹宫大殿 还讲解机器
结束参观 钟粹宫大殿
结束参观 钟粹宫
开始参观 珍宝馆
开始参观 珍宝馆大殿
结束参观 珍宝馆大殿
结束参观 珍宝馆
开始参观 乾清宫
开始参观 乾清宫大殿
结束参观 乾清宫大殿
结束参观 乾清宫
A游客离开 故宫 收起相机
结束参观 故宫

可以看到,参观路线不变,但 A 游客有自己的景点动作。 同理可以带着 B 游客来参观故宫。

看到这里,是不是对访问者模式有轻度理解了!接下来说 AST。

怎么遍历 AST?

其实 AST 也是树结构,自然也能用深度优先遍历(DFS)来遍历,有个不同的地方在于,有子节点的 key 不一定都是 children,所以需要用 switch 来处理。

举一个 LISP 语言的 AST 例子:

lisp 复制代码
<!-- 等同于 JavaScript 的 (add(2, subtract(4, 2))) -->
(add 2 (subtract 4 2))

对应的 AST 如下:

js 复制代码
const originalAST = {
  type: 'Program',
  body: [
    {
      type: 'CallExpression',
      name: 'add',
      params: [
        {
          type: 'NumberLiteral',
          value: '2',
        },
        {
          type: 'CallExpression',
          name: 'subtract',
          params: [
            {
              type: 'NumberLiteral',
              value: '4',
            },
            {
              type: 'NumberLiteral',
              value: '2',
            },
          ],
        },
      ],
    },
  ],
};

用图表示可能更加明显:

你可以自己尝试先遍历这个 AST,来加深对遍历的理解。

js 复制代码
function traverse(node) {
  console.log('开始访问', node.type, node.name || node.value || '根节点');
  // 之前是统一的children,现在需要用switch来处理
  // if (node.children) {
  //   node.children.forEach((child) => traverse(child));
  // }
  // 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
  switch (node.type) {
    case 'Program':
      node.body.forEach((child) => traverse(child));
      break;
    case 'CallExpression':
      node.params.forEach((child) => traverse(child));
      break;
    default:
      break;
  }
  console.log('结束访问', node.type, node.name || node.value || '根节点');
}

console.log(traverse(originalAST));

运行结果如下:

csharp 复制代码
开始访问 Program 根节点
开始访问 CallExpression add
开始访问 NumberLiteral 2
结束访问 NumberLiteral 2
开始访问 CallExpression subtract
开始访问 NumberLiteral 4
结束访问 NumberLiteral 4
开始访问 NumberLiteral 2
结束访问 NumberLiteral 2
结束访问 CallExpression subtract
结束访问 CallExpression add
结束访问 Program 根节点

带着 visitor 来遍历 AST

首先改造下 traverse 函数,添加 visitor 参数。

js 复制代码
function traverse(node, visitor) {
  console.log('开始访问', node.type, node.name || node.value || '根节点');
  // 获取访问者在这个节点要做的操作
  const doThingsInThisNode = visitor[node.type];
  // 进入节点的时候,看看有没有要干的事
  doThingsInThisNode?.enter?.(node);
  // 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
  switch (node.type) {
    case 'Program':
      node.body.forEach((child) => traverse(child, visitor));
      break;
    case 'CallExpression':
      node.params.forEach((child) => traverse(child, visitor));
      break;
    default:
      break;
  }
  // 离开节点的时候,看看有没有要干的事
  doThingsInThisNode?.exit?.(node);
  console.log('结束访问', node.type, node.name || node.value || '根节点');
}

上面的 visitor 其实是共享的,不用每次都传,可以单独抽离一个递归函数 traverseNode 来处理。

js 复制代码
function traverse(node, visitor) {
  // visitor是共享的,不迭代
  traverseNode(node);

  function traverseNode(node) {
    console.log('开始访问', node.type, node.name || node.value || '根节点');
    // 获取访问者在这个节点要做的操作
    const doThingsInThisNode = visitor[node.type];
    // 进入节点的时候,看看有没有要干的事
    doThingsInThisNode?.enter?.(node);
    // 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
    switch (node.type) {
      case 'Program':
        node.body.forEach((child) => traverseNode(child));
        break;
      case 'CallExpression':
        node.params.forEach((child) => traverseNode(child));
        break;
      default:
        break;
    }
    // 离开节点的时候,看看有没有要干的事
    doThingsInThisNode?.exit?.(node);
    console.log('结束访问', node.type, node.name || node.value || '根节点');
  }
}

有时候需要当前节点的父节点,所以可以传递一个 parent 参数。

js 复制代码
function traverse(node, visitor) {
  // visitor是共享的,不迭代。但是node和parent是迭代的
  traverseNode(node, null);

  function traverseNode(node, parent) {
    console.log('开始访问', node.type, node.name || node.value || '根节点');
    // 获取访问者在这个节点要做的操作
    const doThingsInThisNode = visitor[node.type];
    // 进入节点的时候,看看有没有要干的事
    doThingsInThisNode?.enter?.(node, parent);
    // 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
    switch (node.type) {
      case 'Program':
        node.body.forEach((child) => traverseNode(child, node));
        break;
      case 'CallExpression':
        node.params.forEach((child) => traverseNode(child, node));
        break;
      default:
        break;
    }
    // 离开节点的时候,看看有没有要干的事
    doThingsInThisNode?.exit?.(node, parent);
    console.log('结束访问', node.type, node.name || node.value || '根节点');
  }
}

现在我们带着 visitor 来遍历 AST,visitor 是一个对象,它包含多个方法,每个方法对应一个操作。想实现将每个节点的类型和相关信息都收集到一个数组中。

js 复制代码
const nodeArray = [];
const visitor = {
  Program: {
    enter(node, parent) {
      nodeArray.push({ type: node.type, name: '根节点', parent });
    },
  },
  CallExpression: {
    enter(node, parent) {
      nodeArray.push({ type: node.type, name: node.name, parent });
    },
  },
  NumberLiteral: {
    enter(node, parent) {
      nodeArray.push({ type: node.type, value: node.value, parent });
    },
  },
};

console.log(traverse(originalAST, visitor));

运行之后,nodeArray 的结果如下:

json 复制代码
[
  { type: 'Program', name: '根节点', parent: null },
  {
    type: 'CallExpression',
    name: 'add',
    parent: { type: 'Program', body: [Array] }
  },
  {
    type: 'NumberLiteral',
    value: '2',
    parent: { type: 'CallExpression', name: 'add', params: [Array] }
  },
  {
    type: 'CallExpression',
    name: 'subtract',
    parent: { type: 'CallExpression', name: 'add', params: [Array] }
  },
  {
    type: 'NumberLiteral',
    value: '4',
    parent: { type: 'CallExpression', name: 'subtract', params: [Array] }
  },
  {
    type: 'NumberLiteral',
    value: '2',
    parent: { type: 'CallExpression', name: 'subtract', params: [Array] }
  }
]

终极挑战:将 AST 转换为另一种 AST(就是上面的例子换个 visitor)

其实就是遍历 旧的 AST,然后根据 visitor 的规则转换为新的 AST。

先看下新的 AST 的结构,就是将 LISP 语言的 AST 转换为 JavaScript 的 AST:

json 复制代码
{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": {
          "type": "Identifier",
          "name": "add"
        },
        "arguments": [
          {
            "type": "NumberLiteral",
            "value": "2"
          },
          {
            "type": "CallExpression",
            "callee": {
              "type": "Identifier",
              "name": "subtract"
            },
            "arguments": [
              {
                "type": "NumberLiteral",
                "value": "4"
              },
              {
                "type": "NumberLiteral",
                "value": "2"
              }
            ]
          }
        ]
      }
    }
  ]
}

用图表示可能更加明显:

AST结构对比图:

csharp 复制代码
LISP风格AST:                    JavaScript风格AST:
Program                         Program
└── CallExpression (add)         └── ExpressionStatement
    ├── NumberLiteral (2)            └── CallExpression
    └── CallExpression (subtract)        ├── callee: Identifier (add)
        ├── NumberLiteral (4)            └── arguments: [...]
        └── NumberLiteral (2)

找清楚不同节点转换的规则就可以写visitor了。

js 复制代码
let newAST;
const visitor = {
  Program: {
    enter(node, parent) {
      newAST = { type: 'Program', body: [] };
      // 等会遍历到body里项的时候,push到newAST.body,用node._context做指针
      node._context = newAST.body;
    },
    exit(node) {
      delete node._context;
    },
  },
  CallExpression: {
    enter(node, parent) {
      let newNode = {
        type: 'CallExpression',
        callee: { type: 'Identifier', name: node.name },
        arguments: [],
      };

      // 等会遍历到arguments里项的时候,push到newNode.arguments,用node._context做指针
      node._context = newNode.arguments;

      // 如果当前节点的父节点不是CallExpression,则需要用ExpressionStatement包裹
      if (parent.type !== 'CallExpression') {
        newNode = {
          type: 'ExpressionStatement',
          expression: newNode,
        };
      }
      // 这里的parent._context可以理解为当前节点的父节点的_context
      parent._context.push(newNode);
    },
    exit(node) {
      delete node._context;
    },
  },
  NumberLiteral: {
    enter(node, parent) {
      const newNode = { type: 'NumberLiteral', value: node.value };
      parent._context.push(newNode);
    },
  },
};
traverse(originalAST, visitor);
console.log(newAST);

运行结果如下:

json 复制代码
{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": {
          "type": "Identifier",
          "name": "add"
        },
        "arguments": [
          {
            "type": "NumberLiteral",
            "value": "2"
          },
          {
            "type": "CallExpression",
            "callee": {
              "type": "Identifier",
              "name": "subtract"
            },
            "arguments": [
              {
                "type": "NumberLiteral",
                "value": "4"
              },
              {
                "type": "NumberLiteral",
                "value": "2"
              }
            ]
          }
        ]
      }
    }
  ]
}

这里的 node._context 是重中之重,它是一个指针,指向当前节点的父节点的_context,用来存储当前节点的子节点转换后的节点。因为子节点需要 push 到父节点的_context 中,所以需要用指针来存储。

但是用完之后需要删除,因为不能影响修改原有的 AST,所以需要用 delete node._context 来删除。

完整的代码如下:

js 复制代码
// 将旧的 AST 转换为新的 AST
function transformer(ast) {
  let newAST;
  // 这里的visitor可以更换成其他的visitor,比如将AST转换为另一种语言的AST
  const visitor = {
    Program: {
      enter(node, parent) {
        newAST = { type: 'Program', body: [] };
        // 等会遍历到body里项的时候,push到newAST.body,用node._context做指针
        node._context = newAST.body;
      },
    },
    CallExpression: {
      enter(node, parent) {
        let newNode = {
          type: 'CallExpression',
          callee: { type: 'Identifier', name: node.name },
          arguments: [],
        };

        // 等会遍历到arguments里项的时候,push到newNode.arguments,用node._context做指针
        node._context = newNode.arguments;

        // 如果当前节点的父节点不是CallExpression,则需要用ExpressionStatement包裹
        if (parent.type !== 'CallExpression') {
          newNode = {
            type: 'ExpressionStatement',
            expression: newNode,
          };
        }
        // 这里的parent._context可以理解为当前节点的父节点的_context
        parent._context.push(newNode);
      },
    },
    NumberLiteral: {
      enter(node, parent) {
        const newNode = { type: 'NumberLiteral', value: node.value };
        // 这里的parent._context可以理解为当前节点的父节点的_context
        parent._context.push(newNode);
      },
    },
  };

  traverse(ast, visitor);
  return newAST;
}
// 这个是通用的遍历AST的函数,可以用于遍历任何AST
function traverse(node, visitor) {
  // visitor是共享的,不迭代。但是node和parent是迭代的
  traverseNode(node, null);

  function traverseNode(node, parent) {
    console.log('开始访问', node.type, node.name || node.value || '根节点');
    // 获取访问者在这个节点要做的操作
    const doThingsInThisNode = visitor[node.type];
    // 进入节点的时候,看看有没有要干的事
    doThingsInThisNode?.enter?.(node, parent);
    // 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
    switch (node.type) {
      case 'Program':
        node.body.forEach((child) => traverseNode(child, node));
        break;
      case 'CallExpression':
        node.params.forEach((child) => traverseNode(child, node));
        break;
      default:
        break;
    }
    // 离开节点的时候,看看有没有要干的事
    doThingsInThisNode?.exit?.(node, parent);
    console.log('结束访问', node.type, node.name || node.value || '根节点');
  }
}

// 原始的LISP语言的AST
const originalAST = {
  type: 'Program',
  body: [
    {
      type: 'CallExpression',
      name: 'add',
      params: [
        {
          type: 'NumberLiteral',
          value: '2',
        },
        {
          type: 'CallExpression',
          name: 'subtract',
          params: [
            {
              type: 'NumberLiteral',
              value: '4',
            },
            {
              type: 'NumberLiteral',
              value: '2',
            },
          ],
        },
      ],
    },
  ],
};
console.log(JSON.stringify(transformer(originalAST), null, 2));

从上面可以看出,transformer 函数主要就是 visitor 和 traverse 函数的组合,visitor 是用来转换 AST 的,traverse 是带着 visitor 用来遍历 AST 的。但 visitor 是可以更换的。

前端开发中的 Visitor 模式应用

需要遍历固定的树结构,不同的场景需要对节点做不同的操作,这时候就可以使用 Visitor 模式。

Visitor 模式在前端开发中有广泛的应用,以下是常见的场景:

  • 编译器设计:Babel、TypeScript
  • 代码分析:ESLint、代码检查
  • 性能优化:Bundle 分析、性能监控,
  • 开发工具:调试器、测试工具
  • 代码生成:模板引擎、代码生成器

比如 Vue 模板编译器 中,需要对模板进行转换,这时候就可以使用 Visitor 模式。

javascript 复制代码
const vueVisitor = {
  ElementNode: {
    enter(node) {
      // 生成渲染函数
      generateRenderFunction(node);
    },
  },
};

掌握Visitor模式可以帮助更好地理解和开发前端工具链,提升开发效率。通过分离操作与结构,Visitor模式让代码更加模块化、可扩展和可维护。

相关推荐
CUC-MenG4 小时前
2025牛客国庆集训派对day7 M C 个人题解
数学·算法·线段树·差分·扫描线
木易 士心4 小时前
Nginx 基本使用和高级用法详解
运维·javascript·nginx
蒙特卡洛的随机游走4 小时前
Spark的宽依赖与窄依赖
大数据·前端·spark
共享家95274 小时前
QT-常用控件(多元素控件)
开发语言·前端·qt
幸运小圣4 小时前
Iterator迭代器 【ES6】
开发语言·javascript·es6
葱头的故事4 小时前
将传给后端的数据转换为以formData的类型传递
开发语言·前端·javascript
中微子4 小时前
🚀 2025前端面试必考:手把手教你搞定自定义右键菜单,告别复制失败的尴尬
javascript·面试
_23334 小时前
vue3二次封装element-plus表格,slot透传,动态slot。
前端·vue.js
jump6804 小时前
js中数组详解
前端·面试