访问者设计模式 (The Visitor design pattern)
定义和特点
访问者模式是一种行为型设计模式,用于将算法与对象结构分离。 该模式允许你定义新的操作(访问者)而无需修改现有对象结构(被访问者)。 通过这种方式,你可以在不改变对象结构的情况下添加新的操作。
参与者
在访问者模式中,有两个主要角色:被访问者和访问者。
- 被访问者:一个具有一组元素和方法method的对象结构。
- 访问者:一个能够对这些元素执行不同操作visit的对象。
- 被访问者提供了接受accept访问者的方法,以便访问者能够在需要时访问元素。
使用流程
- 首定义访问者接口,其中包含一组访问方法,每个方法对应一种操作。
- 在被访问者接口中添加一个接受访问者的方法accept,以便访问者可以访问被访问者的元素。
- 被访问者的具体实现类需要实现这个接收访问者的方法,并**将自身作为参数(this)**传递给访问者的具体访问方法。
- 创建一个具体的访问者对象v,并将其传递给被访问者的接受方法,即accept(v)
- 被访问者将根据传递的访问者对象调用相应的访问方法,从而执行特定的操作。 通过这种【改变新建不同结构的访问者,而不修改被访问者】的方式,你可以在不改变被访问者的结构的情况下,为其添加新的操作。
通俗的理解
- 访问者设计模式有两个部分完成,在实现不同目标的过程中保持其中一方(被访问者)的结构不变,只修改另外一方的结构(访问者)。
- 也就是牺牲一个保全另外一个。
- 也可以将被访问者看成是一个工具库,而访问者则是组合使用工具库中的工具去完成一件任务,当任务的内容发生改变的时候,需要组合不同的工具以及使用这些工具的顺序;但是工具库中的工具不会随着任务内容的变化增加或者减少。
作用
- 访问者模式允许你将算法(指的就是访问者)与对象结构(指的就是被访问者)分离,并通过定义访问者接口和被访问者接口来实现多态性。
- 这种模式适用于需要对一个对象结构中的元素进行不同操作的场景,同时又希望保持对象结构的稳定性。
举例
// 被访问者接口:点心店
interface Bakery {
accept(visitor: CustomerVisitor): void; // 接受用户订单
make(): string; // 制作普通点心
makePlus(): string; // 制作精品点心
}
// 具体的被访问者:圆点心店
class CirclePastry implements Bakery {
// 被访问者的接受方法
accept(visitor: CustomerVisitor): void {
// 将自身作为参数(this)传递给访问者的具体访问方法
visitor?.visitCirclePastry(this); // 接受用户订单之后按照订单要求制作蛋糕
}
make(): string {
return "制作圆点心";
}
makePlus(): string {
return "制作精品圆点心";
}
}
// 具体的被访问者:方点心店
class SquarePastry implements Bakery {
accept(visitor: CustomerVisitor): void {
visitor?.visitSquarePastry(this); // 接受用户订单之后按照订单要求制作蛋糕
}
make(): string {
return "制作方点心";
}
makePlus(): string {
return "制作精品方点心";
}
}
// 访问者接口:顾客订单模板
interface CustomerVisitor {
visitCirclePastry(pastry: CirclePastry): void; // 规定要在圆形点心店制作什么
visitSquarePastry(pastry: SquarePastry): void; // 规定要在方形点心店制作什么
}
// 具体的访问者:点心爱好者1下的订单
class PastryLover implements CustomerVisitor {
visitCirclePastry(pastry: CirclePastry): void { // 点心爱好者1下的订单要在圆形点心店制作一个普通蛋糕
console.log(`点心爱好者选择了${pastry.make()}`);
}
visitSquarePastry(pastry: SquarePastry): void {
console.log(`点心爱好者选择了${pastry.make()}`); // 点心爱好者1下的订单要在方形点心店制作一个普通蛋糕
}
}
// 使用示例
const circlePastry: Bakery = new CirclePastry();
const squarePastry: Bakery = new SquarePastry();
const pastryLover: CustomerVisitor = new PastryLover();
circlePastry.accept(pastryLover); // 输出:点心爱好者选择了制作圆点心
squarePastry.accept(pastryLover); // 输出:点心爱好者选择了制作方点心
// 在保证被访问者结构的不变的前提下通过修改访问者的结构达到完成不同操作的目的
// 访问者接口:高级顾客
interface CustomerVisitorPlus {
visitCirclePastry(pastry: CirclePastry): void;
visitSquarePastry(pastry: SquarePastry): void;
}
// 具体的访问者:点心爱好者2下的订单
class PastryLoverWithMoney implements CustomerVisitorPlus {
visitCirclePastry(pastry: CirclePastry): void {
console.log(`点心爱好者选择了${pastry.makePlus()}`); // 点心爱好者2下的订单要在圆形点心店制作一个精品蛋糕
}
visitSquarePastry(pastry: SquarePastry): void {
console.log(`点心爱好者选择了${pastry.make()}`); // 点心爱好者2下的订单要在方形点心店制作一个普通蛋糕
}
}
const pastryLoverWithMoney: PastryLoverWithMoney = new PastryLoverWithMoney();
circlePastry.accept(pastryLoverWithMoney); // 输出:点心爱好者选择了制作高级圆点心
squarePastry.accept(pastryLoverWithMoney); // 输出:点心爱好者选择了制作普通方点心
// 可以看出来circlePastry和squarePastry都被复用了
过程小结
被访问者需要提供一个accept接口来接受访问者对象,accept方法接受访问者对象之后立刻调用访问者对象上面的visit类方法,并将自身(this)作为参数传入visit类方法中去:visit**(this);访问者的visit类方法被调用之后,按照预先设定的规则执行入参上的方法。
再举一例
- 假设小明是一条小狗
- 它可以吃早饭,吃午饭,吃晚饭
- 还可以跳舞,捡球
- 无论是吃饭或者跳舞捡球都需要主人的命令
- 那么,如果你有这么一条小狗,你和它之间的日常就可以抽象成访问者设计模式
- 也就是说小明是被访问的对象,你或者你下达的命令是访问者
class Dog {
name: string;
state = {
alreadyEatBreakfast: false,
alreadyEatLunch: false,
alreadyEatDinner: false,
}
constructor(name: string){
this.name = name;
}
eatBreakfast(){
if(this.state.alreadyEatBreakfast) throw new Error('一天不能吃两顿早饭');
this.state.alreadyEatBreakfast = true;
}
eatLunch(){
if(this.state.alreadyEatLunch) throw new Error('一天不能吃两顿午饭');
this.state.alreadyEatLunch = true;
}
eatDinner(){
if(this.state.alreadyEatDinner) throw new Error('一天不能吃两顿晚饭');
this.state.alreadyEatDinner = true;
}
dance(){
console.log(`${this.name}正在给你跳舞!`);
}
catchBall(){
console.log(`${this.name}正在给你捡球!`);
}
accept(visitor: Command) {
visitor.visit(this);
}
}
class Command {
constructor(public action: string){}
visit(acceptor: Dog){
acceptor[this.action]();
}
}
const xiaoMing = new Dog('xiaoming');
const c1 = new Command('eatBreakfast');
const c2 = new Command('dance');
const c3 = new Command('eatBreakfast');
xiaoMing.accept(c1);
xiaoMing.accept(c2); // xiaoming正在给你跳舞!
xiaoMing.accept(c3); // Error: 一天不能吃两顿早饭
Babel插件中的使用
在 Babel 插件中修改 AST(抽象语法树)时,通常会使用访问者模式。
- 定义访问者:定义一个访问者对象,该对象包含用于处理不同类型的 AST 节点的方法。每个方法对应一种 AST 节点类型,该方法将被调用以访问和处理相应类型的节点。
- 遍历和修改 AST:通过使用 Babel 提供的遍历器(
@babel/traverse),可以遍历整个 AST。在遍历过程中,对于每个访问到的节点,将根据节点的类型调用相应的访问者方法。 - 修改 AST:在访问者方法中,您可以对 AST 进行修改。这可以涉及更改节点属性、替换节点、添加新节点等操作。通过修改 AST,插件可以实现源代码的转换和重写。
应用场景
- DOM 操作:在浏览器中,DOM(文档对象模型)表示网页的结构和内容。使用访问者模式,您可以定义一个访问者对象,该对象可以遍历 DOM 树的节点,并执行相应的操作。例如,可以创建一个访问者来查找特定类型的节点、修改节点属性或样式,或执行其他与 DOM 相关的操作。
- 数据结构操作:JavaScript 中有许多内置的数据结构,如数组、集合、映射等。通过使用访问者模式,您可以定义一个访问者对象,来对这些数据结构进行遍历和操作。例如,可以创建一个访问者来计算数组中的总和、过滤符合特定条件的元素,或者将映射转换为另一种形式。
- 编译器和解析器:在编译器和解析器中,访问者模式经常被用来处理抽象语法树(AST)。通过定义访问者对象,可以遍历 AST 并执行各种语义分析、优化或代码生成操作。这样可以将复杂的编译器逻辑分离到不同的访问者方法中,使其更易于维护和扩展。
- 事件处理:在浏览器中,事件处理是非常常见的任务。访问者模式可以用于处理不同类型的事件,并执行相应的操作。例如,可以创建一个访问者来处理鼠标事件、键盘事件或其他用户交互事件。
- 数据校验和验证:当需要对数据进行复杂的校验和验证时,访问者模式可以提供一种结构化的方法。您可以定义一个访问者对象,该对象遍历数据结构并执行各种校验逻辑。这样可以将校验逻辑从数据结构中分离出来,使其更加可维护和可扩展。
快速记忆
- 访问者设计模式可以将算法和对象结构相分离,可以形象的将算法看成是访问者,将对象接口看成工具库或者被访问者;
- 使用访问者设计模式可以在维持工具库稳定性的前提下,只通过改变使用工具库中的工具及其使用顺序,就能完成不同的任务;
- 有一个前提,那就是工具库中的工具要足够强大,以至于能够覆盖所有可能出现的任务,一旦工具库中的工具不能满足新的任务需求,就只能往工具库中添加新的工具了;
- 如果完成的任务所需要的方法和数据(又称状态)可以由同一个对象满足,那么此时就应该考虑访问者设计模式了。