前言

模板方法模式的核心在于:定义一个操作中的算法骨架(模板方法),并将某些步骤延迟到子类中实现。通常我们会抽象一个父类,父类封装了子类的算法框架,包括实现一些公共方法和所有方法的执行顺序,子类继承父类,可以自己重写父类的方法。

在模板方法模式中,子类实现中相同部分被上移到父类中,而将不同的部分留给子类来实现,最后由父类的模板方法来控制整个算法的执行顺序和流程,子类只关心具体的算法实现。

这种方式十分依赖抽象类,但是JavaScript中很难去模仿一个抽象,但是我们也可以用自己的方式去实现。

咖啡和茶

这个是一个非常经典的例子,有助于我们理解什么是模板方法模式。

泡一杯咖啡

泡咖啡的步骤通常如下:

  1. 把水煮沸
  2. 用沸水冲泡咖啡
  3. 把咖啡倒进杯子
  4. 加糖和牛奶

代码实现如下:

var Coffee = function() {};

Coffee.prototype.boilWater = function() {
    console.log('把水煮沸');
};

Coffee.prototype.brewCoffeeGriends = function() {
    console.log('用沸水冲泡咖啡');
};

Coffee.prototype.pourInCup = function() {
    console.log('把咖啡倒进杯子');
};
Coffee.prototype.addSugarAndMilk = function() {
    console.log('加糖和牛奶');
};

Coffee.prototype.init = function() {
    this.boilWater();
    this.brewCoffeeGriends();
    this.pourInCup();
    this.addSugarAndMilk();
};

var coffee = new Coffee();

coffee.init();

泡茶

泡茶的步骤和泡咖啡差不多:

  1. 把水煮沸
  2. 用沸水浸泡茶叶
  3. 把茶水倒进杯子
  4. 加柠檬
var Tea = function() {};

Tea.prototype.boilWater = function() {
    console.log('把水煮沸');
};

Tea.prototype.steepTeaBag = function() {
    console.log('用沸水浸泡茶叶');
};

Tea.prototype.pourInCup = function() {
    console.log('把茶水倒进杯子');
};

Tea.prototype.addLemon = function() {
    console.log('加柠檬');
};

Tea.prototype.init = function() {
    this.boilWater();
    this.steepTeaBag();
    this.pourInCup();
    this.addLemon();
};

var tea = new Tea();

tea.init();

找出共同点

可以发现泡咖啡和泡茶有很多共同的地方:

  1. 把水煮沸
  2. 用沸水冲泡
  3. 把它倒进杯子
  4. 加调料

这种将公共的部分抽离出来的结果就是抽象,我们将泡咖啡喝泡茶抽象出一个公共的处理方案:Beverage(饮料)

var Beverage = function() {};

Beverage.prototype.boilWater = function() {
    console.log('把水煮沸');
};

Beverage.prototype.brew = function() {}; // 空方法,应该由子类重写

Beverage.prototype.pourInCup = function() {}; // 空方法,应该由子类重写

Beverage.prototype.addCondiments = function() {}; // 空方法,应该由子类重写

Beverage.prototype.init = function() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
};

实现咖啡和茶

利用Beverage类,我们可以扩展出咖啡和茶。

咖啡

var Coffee = function() {};

Coffee.prototype = new Beverage();

Coffee.prototype.brew = function() {
    console.log('用沸水冲泡咖啡');
};

Coffee.prototype.pourInCup = function() {
    console.log('把咖啡倒进杯子');
};

Coffee.prototype.addCondiments = function() {
    console.log('加糖和牛奶');
};

var Coffee = new Coffee();

Coffee.init();

var Tea = function() {};

Tea.prototype = new Beverage();

Tea.prototype.brew = function() {
    console.log('用沸水浸泡茶叶');
};

Tea.prototype.pourInCup = function() {
    console.log('把茶倒进杯子');
};

Tea.prototype.addCondiments = function() {
    console.log('加柠檬');
};

var tea = new Tea();

tea.init();

当我们调用init方法时,就会顺着原型链找到父类的init方法,父类的init方法规定好了泡饮料的顺序,所以我们能成功的泡咖啡和泡茶。

那么谁才是模板方法呢? 答案就是Beverage.prototype.init

Beverage.prototype.init 被称为模板方法的原因是,该方法中封装了子类的算法框架,它作 为一个算法的模板,指导子类以何种顺序去执行哪些方法。在 Beverage.prototype.init 方法中, 算法内的每一个步骤都清楚地展示在我们眼前。

抽象类

模板方法模式是一种严重依赖抽象类的设计模式,但是JavaScript本身并没有抽象类这种代码层的支持,我们也很难去模拟抽象类的实现。

在java中,抽象类的一大作用就是作为向上类型,从而实现面向对象的多态化,另一个作用就是一种契约约束,继承了抽象类的子类,它必须实现定义的接口,如果不实现将无法通过编译器的检查。

抽象类除了定义抽象方法,还可以有具体的实现,我们可以将公共的部分放在抽象类中,再通过继承实现代码的复用。

下面是java的实现:

public abstract class Beverage { // 饮料抽象类
    final void init() { // 模板方法
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }
    
    void boilWater() { // 具体方法 boilWater 
        System.out.println("把水煮沸");
    }
    
    abstract void brew(); // 抽象方法 brew 
    
    abstract void addCondiments(); // 抽象方法 addCondiments 
    
    abstract void pourInCup(); // 抽象方法 pourInCup 
}

public class Coffee extends Beverage { // Coffee 类
    @Override
    void brew() { // 子类中重写 brew 方法
        System.out.println("用沸水冲泡咖啡");
    }
    
    @Override
    void pourInCup() { // 子类中重写 pourInCup 方法
        System.out.println("把咖啡倒进杯子");
    }
    
    @Override
    void addCondiments() { // 子类中重写 addCondiments 方法
        System.out.println("加糖和牛奶");
    }
}

public class Tea extends Beverage { // Tea 类
    @Override
    void brew() { // 子类中重写 brew 方法
        System.out.println("用沸水浸泡茶叶");
    }
    
    @Override
    void pourInCup() { // 子类中重写 pourInCup 方法
        System.out.println("把茶倒进杯子");
    }
    
    @Override
    void addCondiments() { // 子类中重写 addCondiments 方法
        System.out.println("加柠檬");
    }
}

public class Test {
    private static void prepareRecipe(Beverage beverage) {
        beverage.init();
    }
    
    public static void main(String args[]) {
        Beverage coffee = new Coffee(); // 创建 coffee 对象
        prepareRecipe(coffee); // 开始泡咖啡
        // 把水煮沸
        // 用沸水冲泡咖啡
        // 把咖啡倒进杯子
        // 加糖和牛奶
        Beverage tea = new Tea(); // 创建 tea 对象
        prepareRecipe(tea); // 开始泡茶
        // 把水煮沸
        // 用沸水浸泡茶叶
        // 把茶倒进杯子
        // 加柠檬
    }
}

JavaScript中的应对方式

JavaScript底层没有从语法层面上提供对抽象类的支持,当我们使用原型继承的时候,是不会任何形式的检查,我们也没法保证子类一定会重新父类的“抽象方法”。

那么我们如何保证在运行init模板方法的时候不会出现问题呢?

  • 第一种做法就是使用鸭子类型来模拟类型检查,我们在运行前先判断子类是否重写了父类的方法,但是这样会带来不必要的复杂性,而且这个判断会掺杂在业务逻辑中,影响单一原则。

判断是否重写了父类方法,可以如下:

// 封装一个函数来检查必要的方法是否被覆盖
function checkIfMethodImplemented(instance, methodName) {
    if (typeof instance[methodName] !== 'function' || instance[methodName] === Beverage.prototype[methodName]) {
        throw new Error(`Method ${methodName} must be implemented in${instance.constructor.name}`);
    }
}

是否是一个函数以及是否是抽象类的方法。

  • 第二种就是抛出异常,父类在实现的时候是抛出一个异常,如果子类不进行重写,就无法正常运行。
Beverage.prototype.brew = function() {
    throw new Error('子类必须重写 brew 方法');
};

Beverage.prototype.pourInCup = function() {
    throw new Error('子类必须重写 pourInCup 方法');
};

Beverage.prototype.addCondiments = function() {
    throw new Error('子类必须重写 addCondiments 方法');
};

这种方式实现简单,付出的额外代价也很少,缺点就是我们只能在运行后才能知道问题所在。

在代码编程中,我们会有3种时机来获取错误信息:

  1. 编写代码的时候,通过编译器静态检查得到错误;
  2. 创建对象的时候,通过鸭子类型进行接口检查;
  3. 在程序运行过程中才知道哪里发生了错误;

模板方法模式的使用场景

在 Web 开发中也能找到很多模板方法模式的适用场景,比如我们在构建一系列的 UI 组件, 这些组件的构建过程一般如下所示:

  1. 初始化一个 div 容器;
  2. 通过 ajax 请求拉取相应的数据;
  3. 把数据渲染到 div 容器里面,完成组件的构造;
  4. 通知用户组件渲染完毕。

我们看到,任何组件的构建都遵循上面的 4 步,其中第(1)步和第(4)步是相同的。第(2)步不 同的地方只是请求 ajax 的远程地址,第(3)步不同的地方是渲染数据的方式。

于是我们可以把这 4 个步骤都抽象到父类的模板方法里面,父类中还可以顺便提供第(1)步和 第(4)步的具体实现。当子类继承这个父类之后,会重写模板方法里面的第(2)步和第(3)步。

钩子方法

通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适 用于大多数子类的,但如果有一些特别“个性”的子类呢?

比如我们在饮料类 Beverage 中封装了 饮料的冲泡顺序:

  1. 把水煮沸
  2. 用沸水冲泡饮料
  3. 把饮料倒进杯子
  4. 加调料

假如有的顾客它不需要加调料,但是Beverage父类已经规定好了冲泡饮料的4个步骤,总不能因为部分用户不加调料,我就改动父类把第四步去掉吧,如果其他人又要,那我又要改动父类给加上,这显然不合适。

为此我们可以利用钩子方法(hook)来解决这个问题,我们父类提供一个钩子方法,这个方法可以有一个默认的实现,就是允许添加调料,子类可以重写该方法,从而控制是否添加调料。

var Beverage = function() {};

Beverage.prototype.boilWater = function() {
    console.log('把水煮沸');
};

Beverage.prototype.brew = function() {
    throw new Error('子类必须重写 brew 方法');
};

Beverage.prototype.pourInCup = function() {
    throw new Error('子类必须重写 pourInCup 方法');
};

Beverage.prototype.addCondiments = function() {
    throw new Error('子类必须重写 addCondiments 方法');
};

Beverage.prototype.customerWantsCondiments = function() {
    return true; // 默认需要调料
};

Beverage.prototype.init = function() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    if (this.customerWantsCondiments()) { // 如果挂钩返回 true,则需要调料
        this.addCondiments();
    }
};

var CoffeeWithHook = function() {};

CoffeeWithHook.prototype = new Beverage();

CoffeeWithHook.prototype.brew = function() {
    console.log('用沸水冲泡咖啡');
};

CoffeeWithHook.prototype.pourInCup = function() {
    console.log('把咖啡倒进杯子');
};

CoffeeWithHook.prototype.addCondiments = function() {
    console.log('加糖和牛奶');
};

CoffeeWithHook.prototype.customerWantsCondiments = function() {
    return window.confirm('请问需要调料吗?');
};

var coffeeWithHook = new CoffeeWithHook();

coffeeWithHook.init();

好莱坞原则

学习完模板方法模式之后,我们要引入一个新的设计原则 —— 著名的“好莱坞原则”

好莱坞无疑是演员的天堂,但好莱坞也有很多找不到工作的新人演员,许多新人演员在好莱 坞把简历递给演艺公司之后就只有回家等待电话。有时候该演员等得不耐烦了,给演艺公司打电 话询问情况,演艺公司往往这样回答:“不要来找我,我会给你打电话。”

在设计中,这样的规则就称为好莱坞原则。在这一原则的指导下,我们允许底层组件将自己 挂钩到高层组件中,而高层组件会决定什么时候、以何种方式去使用这些底层组件,高层组件对 待底层组件的方式,跟演艺公司对待新人演员一样,都是“别调用我们,我们会调用你”

模板方法模式是好莱坞原则的一个典型使用场景,它与好莱坞原则的联系非常明显,当我们 用模板方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类, 哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。

其中发布订阅模式和回调函数,都是好莱坞原则的应用,子类不需要主动去询问,而是等到父类或其它调用。

真的需要继承吗?

模板方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序, 子类继承父类之后,父类通知子类执行这些方法,好莱坞原则很好地诠释了这种设计技巧,即高层组件调用底层组件。

我们也可以遵循好莱坞原则来实现JavaScript的“模板方法模式”:

var Beverage = function(param) {

    var boilWater = function() {
        console.log('把水煮沸');
    };

    var brew = param.brew || function() {
        throw new Error('必须传递 brew 方法');
    };

    var pourInCup = param.pourInCup || function() {
        throw new Error('必须传递 pourInCup 方法');
    };

    var addCondiments = param.addCondiments || function() {
        throw new Error('必须传递 addCondiments 方法');
    };

    var F = function() {};

    F.prototype.init = function() {
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    };

    return F;
};

var Coffee = Beverage({
    brew: function() {
        console.log('用沸水冲泡咖啡');
    },
    pourInCup: function() {
        console.log('把咖啡倒进杯子');
    },
    addCondiments: function() {
        console.log('加糖和牛奶');
    }
});

var Tea = Beverage({
    brew: function() {
        console.log('用沸水浸泡茶叶');
    },
    pourInCup: function() {
        console.log('把茶倒进杯子');
    },
    addCondiments: function() {
        console.log('加柠檬');
    }
});

var coffee = new Coffee();
coffee.init();

var tea = new Tea();
tea.init();

通过这种方式,不使用继承我们也能实现模板方法的效果,F类的init方法依赖封装了饮料子类的算法框架。

分类: JavaScript设计模式与开发实践 标签: JavaScript模式模板方法模式

评论

暂无评论数据

暂无评论数据

目录