JavaScript设计模式之模版方法模式

本文正在参加「金石计划」

概念

在《JavaScript设计模式与开发实践》 中对模版方法模式的介绍:模版方法模式是一种只需使用继承就可以实现的非常简单的模式。
模版方法模式由两部分组成:抽象父类具体的实现子类。通常,在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

举例说明

这里拿书中介绍的一个经典例子:咖啡与茶,来讲解模版方法模式。当我们泡一杯茶或泡一杯咖啡通常需要这些步骤:

泡咖啡步骤 泡茶步骤
1. 水煮沸 1. 水煮沸
2. 沸水冲泡咖啡 2. 沸水浸泡茶叶
3. 把咖啡倒进杯子 3. 把茶水倒进杯子
4. 加糖和牛奶 4. 加柠檬

根据上面步骤,首先,我们需要找到不同点:

    1. 原料不同,一个是茶,一个是咖啡,但我们把它抽象为饮料
    1. 泡的方式不同:一个是冲泡,一个是浸泡,但我们可以把它抽象为
    1. 加入调料不同:一个是糖和牛奶,一个是柠檬,但我们可以把它抽象为调料

经过抽象后,我们就可以把这两个事情整理成以下四步:

    1. 水煮沸
    1. 沸水冲泡饮料
    1. 饮料倒进杯子
    1. 加调料

接下来我们用代码实现一下:

首先,我们创建一个抽象父类表示冲泡饮料的全过程:

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();
}

其中,一些不同的逻辑:brewpourInCupaddCondiments应该是一个空方法,由子类重写。

然后,我们来创建Coffee子类和Tea子类:

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 Coffee();
tea.init();

当我们调用子类的init方法时,该请求会顺着原型链,被委托给父类Beverage原型上的init方法。

在上面的例子中,谁才是所谓的模版方法呢?答案就是:Beverage.prototype.init。原因是,该方法中封装了子类的算法框架,它作为一个算法的模版,指导子类以何种顺序去执行哪些方法。

抽象类

JavaScript中并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于JavaScript是一门类型模糊语言,所以隐藏对象的类型在JavaScript中并不重要。
另一方面,当我们在Javascript中使用原型继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没办法保证子类会重写父类中的抽象方法

如何解决

假如我们忘记了实现某一个方法呢?比如:忘记编写Coffee.prototype.brew,此时,当我们执行时,会找到父类中的brew方法Beverage.prototype.brew,而父类的brew方法是一个空方法,显然是不符合我们需要的。这种情况下,我们在编写代码的时候完全得不到警告,完全寄托于程序员的记忆力和自觉性是很危险。那么,应该如何去解决这个问题呢?

    1. 鸭子类型模拟接口检查:
    var checkBrew = function(beverage) {
      return beverage.brew === Beverage.prototype.brew
    }
    
    // coffee为刚才创建的coffe实例 new Coffee()
    console.log('checkBrew', checkBrew(coffee)) // 子类有实现:false;子类没有实现:true
    

    Coffee子类没有实现brew方法时,checkBrew会返回true,表示当前的方法是继承父类的,否则为false

    1. Beverage.prototype.brew方法抛出一个异常,至少我们在运行时会得到一个报错:
    Beverage.prototype.brew = function() {
        throw new Error('子类必须重写brew方法')
    }
    

相比这两种方式来说:
第一种方式可以在创建对象的时候用鸭子类型来检查,但需要在业务代码中添加这种与业务无关的逻辑;
第二种方式实现更加简单,付出的额外代价更少,但是我们得到错误的时间更靠后,只有在运行过程中才知道哪里发生了错误

真的需要继承么

在刚才的例子中,我们使用继承的方式实现了咖啡与茶的例子。模版方法模式是基于继承的设计模式,而在JavaScript中,实际并没有提供真正的类式继承继承是通过对象与对象之间的委托(基于原型链的方式)实现的,而在JavaScript中,真的需要用这种方式来实现么?
这就需要介绍一个新的设计原则:好莱坞原则

好莱坞原则

好莱坞原则也被称为控制反转 (IoC) 原则。 好莱坞原则指出“不要打电话给我们,我们会打电话给你。” 这意味着不是开发人员调用函数,而是函数在准备好执行时调用开发人员。 在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 coffee = new Coffee();
coffee.init();

在这段代码中,我们把brew、pourInCup、addCondiments等方法传入Beverage函数,Beverage函数返回构造器 F
F 类中包含了模板方法F.prototype.init。跟继承得到的效果一样,该模板方法里依然封装了饮料子类的算法框架。

源码中的模版方法模式

Axios

Axios源码中的request方法就用到了模版方法模式request方法处理了发送请求、请求/响应拦截器的逻辑,如下所示的源码所示:

// lib/core/Axios.js

Axios.prototype.request = function request(config) {
  /*eslint no-param-reassign:0*/
  // Allow for axios('example/url'[, config]) a la fetch API
  if (typeof config === 'string') {
    config = arguments[1] || {};
    config.url = arguments[0];
  } else {
    config = config || {};
  }

  config = mergeConfig(this.defaults, config);

  // Set config.method
  if (config.method) {
    config.method = config.method.toLowerCase();
  } else if (this.defaults.method) {
    config.method = this.defaults.method.toLowerCase();
  } else {
    config.method = 'get'
  }

  // Hook up interceptors middleware
  var chain = [dispatchRequest, undefined];
  var promise = Promise.resolve(config);

  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });

  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
};

通过request函数实现,我们可以了解到,当我们发起一个请求时

  • 首先对我们传入的config参数进行了处理
  • 然后创建一个chain用来将我们设置的请求拦截器、请求、响应拦截器按照一定的顺序执行,按照源码中的逻辑,我们生成的chain为:

JavaScript设计模式之模版方法模式

  • 最终我们返回的promise为:

JavaScript设计模式之模版方法模式

为什么说request方法就用到了模版方法模式呢?
因为request函数封装一个请求的执行顺序,类比前面的泡一杯茶和咖啡一样,request函数相当于是抽象父类,将规定每次请求时子类的执行顺序,而Axios中的拦截器则相当于具体的实现子类。我们可以自行设置请求拦截器和响应拦截器,这样就可以保持拦截器灵活性的同时,确保每个请求中的执行顺序。

Vue生命周期

有了上面的例子,相信大家能够很容易的理解vue生命周期中所用到的模版方法模式。我们简单的看下Vue源码中的生命周期部分:

// src/core/instance/init.js

Vue.prototype._init = function() {
    // ....
    
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
    
    // ...
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}

// src/plaforms/web/runtime
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

// src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
    // ...

    callHook(vm, 'beforeMount')

    // ...
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }

    new Watcher(vm, updateComponent, noop, {
        before () {
          if (vm._isMounted) {
            callHook(vm, 'beforeUpdate')
          }
        }
    }, true /* isRenderWatcher */)
    
    hydrating = false

   if (vm.$vnode == null) {
     vm._isMounted = true
     callHook(vm, 'mounted')
   }
   
   return vm
}

我选取了一部分源码,对源码的细节就不多介绍了,从代码中可以看到,在beforeCreate是在组件initState之前,初始化完成后会执行到created,而在组件render之前会先调用beforeMount,在执行完vm._update()把 VNode patch 到真实DOM后,执行mounted钩子。这样,通过在vue源码中就定义好了抽象父类以及子类执行的顺序,至于每个生命周期中具体要做什么,那就由开发者自行实现了。这个思路和前面的例子都类似,同时使用到了模版方法模式

模版方法模式的应用

通过前面的介绍,相信大家已经对模版方法模式有所了解。那么这种设计模式适合用在哪些场景呢?《JavaScript设计模式与开发实践》中所介绍:

  • 生命周期
    这个类比到前面Vue生命周期的实现,就不过多说明了

  • 构建UI组件
    当需要构建一系列UI组件,这些组件都有一些统一的步骤:
    (1)初始化div容器 -> (2)ajax拉取数据 -> (3)数据渲染到div容器中 -> (4)通知用户组件更新完毕
    我们可以把这个步骤抽象到父类的模版方法中,当子类继承了父类后,会重写第 (2) (3) 步

对于UI组件,不难想到,著名的UI组件库antd中应该会有用到的地方。希望小伙伴们思考一下哪些组件适合使用模版方法模式呢?大家可以一起在评论区讨论一下^_^.

总结

最后,引用书中的说法:
模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放-封闭原则的。
但在JavaScript中,我们很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。

感谢阅读 🙏

原文链接:https://juejin.cn/post/7220782242255290427 作者:liangyue

(0)
上一篇 2023年4月12日 上午10:38
下一篇 2023年4月12日 上午10:48

相关推荐

发表评论

登录后才能评论