认识Babel的Visitor——访问者模式

2,790 阅读6分钟

前言

我喜欢从一些设计模式实例(或者使用场景)来学习设计模式,通过具体应用场景加深自己对设计模式的理解。

上篇文章一直都在用,原来这就是设计模式让大家对一些常见设计模式有个初步认识,本文会继续探讨设计模式中的访问者模式。这个模式虽然不常用,但我们可以去了解其中的思想,以后遇到特定场景就能用上了。

认识访问者模式

意图

表示一个作用于某对象结构中的各元素的操作,它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

最简单的访问者

通过定义一个访问者,代替直接访问对象,来减少两个对象之间的耦合

var data = []

var handler = function() {}

handler.prototype.get = function() {}

var vistor = function(handler, data) {
  handler.get(data)
}

编译器

编译器会将源程序转换为AST(抽象语法树),然后在AST上进行类型检查、代码优化、流程分析等操作,这些操作大多要求对于不同的节点进行不同的处理。例如对于赋值语句节点的处理就不同于对表达式节点的处理,因此有用于赋值语句的类,有用于变量访问的类,有用于算术表达式的类等。

image.png

这样的问题是,将所有这些操作分散到各节点类中会导致整体代码难以理解、难以维护和修改。将类型检查(TypeCheck)、流程分析(GenerateCode)和代码优化打印(PrettyPrint)代码放在一起,容易产生混乱。而且当我们新增一个操作时,会重新编译所有的这些类。

使用访问者模式

访问者模式帮助我们将每个类中的相关操作包装在一个独立的对象(Visitor)中,并在遍历AST时将此对象传递给当前访问的元素。当一个元素“接收”该访问者时,该元素向访问者发送一个包含自身类信息的请求。该请求同时也将该元素本身作为一个参数,然后访问者将为该元素执行该操作。

例如,不使用访问者的编译器调用TypeCheck操作进行类型检查,每个节点将对调用它的成员的TypeCheck以实现自身的TypeCheck。如果该编译器使用访问者进行类型检查,那它将创建一个TypeCheckVisitor对象,并以这个对象为一个参数在抽象语法树上调用Accept操作。每个节点在实现Accept时将会回调访问者:一个赋值节点调用访问者的VisitAssignment操作,而一个变量引用将调用VisitVariableRef。以前类AssignmentNode的TypeCheck操作现在称为TypeCheckingVisitor的VisitAssignment操作。

image.png

image.png

为使访问者不仅仅制作类型检查,我们需要所有抽象语法树的访问者有一个抽象的父类NodeVisitor。NodeVisitor为每个节点类定义一个操作,并且将不再需要在节点类中增加与特定应用相关的代码。访问者模式将每一个编译步骤的操作封装在一个与该步骤相关的Visitor中。

什么情况下考虑使用访问者模式?

  • 一个对象结构包含很多类对象,他们有不同接口,而你想对这些对象实施一些依赖于其具体类的操作
  • 需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而你想避免让这些操作“污染”这些对象的类。Visitor使得你可以将相关的操作集中起来定义在一个类中。
  • 定义对象结构的类很少改变,但经常需要在此结构上定义新的操作。

访问者模式的特点:

  • 在类的内部结构不变的情况下,不同的访问者访问这个对象都会呈现出不同的处理方式
  • 加入新的操作,相对容易,而无需改变结构本身
  • 访问者所进行的操作,其代码是集中在一起的
  • 当采用访问者模式的时候,就会打破组合类的封装

来认识下Babel的Visitor

babel的工作流程主要经过三个阶段:parse,traverse,generate,其中在traverse阶段用到了访问者模式。这个阶段babel主要做了两件事:

  • 对AST树进行深度优先遍历
  • 对节点进行添加、更新和移除等操作

从上面编译器的例子,我们可以看出访问者模式其实是在把对象结构和操作逻辑分开,使得两者可以独立拓展。对应到babel traverse阶段的实现,就是将遍历AST和操作节点两部分逻辑分离,在traverse AST的时候,调用注册的visitor来对其进行处理。这样使得AST的结构和遍历算法固定,visitor可以通过插件独立扩展。

AST树结构

假设以下代码:

function print(str){
    console.log(str)
}

解析成AST树的关键结构如下(AST Explorer):

image.png

其中type字段显示的就是节点类型,更多节点类型的定义可以查看https://github.com/babel/babylon/blob/master/ast/spec.md

babel在遍历AST树的时候,处理一个节点时,是以访问者的形式获取节点信息,并进行相关操作,这种方式是通过一个visitor对象来完成的,在visitor对象中定义了对于各种节点的访问函数,这样就可以针对不同的节点做出不同的处理。我们编写的Babel插件其实也是通过定义一个实例化visitor对象处理一系列的AST节点来完成我们对代码的修改操作。

为了更好地理解babel的visitor的作用,下面我们来编写一个babel插件。

打印位置信息插件

当我们采用console.log来调试我们的代码的时候,一堆console容易产生混乱,为了解决这个问题,我们实现一个打印位置信息插件。该插件的功能是打印信息的时候,顺带打印出当前console代码所在的位置(即行数和列数),插件相关代码如下:

export default function ({types}) {
  return {
    visitor: {
      // 我们的访问者代码将放在这里   
      CallExpression(path, state) {
        const calleeName = generate(path.node.callee).code;
        if (targetCalleeName.includes(calleeName)) {
          const { line, column } = path.node.loc.start;
          path.node.arguments.unshift(
            types.stringLiteral(`loc:[${line},${column}]`)
          );
        }
      }
    },
  };
}

转换前:

const str = "babel";
console.log(str);

const a = 1,
  b = 2;
console.log(a + b);

转换后:

const str = "babel";
console.log("loc:[2,0]", str);
const a = 1,
      b = 2;
console.log("loc:[6,0]", a + b);

参考