前端高手进阶 - 前中兴软创主任工程师 - 拉勾教育

这一讲我们继续来讲一个重要的抽象知识——设计模式,先来看看维基百科对设计模式的定义:

设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题所提出的解决方案。设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。

从这个定义不难看出,设计模式就是一套抽象的理论,属于编程知识中的 “道” 而非“术”,对于理论的学习我们最好的学习方式就是通过与实践结合来加深理解,所以接下来我们在分析设计模式相关概念的同时通过具体实例来加深对其理解。

设计模式原则

设计模式其实是针对面向对象编程范式总结出来的解决方案,所以设计模式的原则都是围绕 “类” 和“接口”这两个概念来提出的,其中下面 6 个原则非常重要,因为这 6 个原则决定了设计模式的规范和标准。

开闭原则

开闭原则指的就是对扩展开放、对修改关闭。编写代码的时候不可避免地会碰到修改的情况,而遵循开闭原则就意味着当代码需要修改时,可以通过编写新的代码来扩展已有的代码,而不是直接修改已有代码本身。

下面的伪代码是一个常见的表单校验功能,校验内容包括用户名密码验证码,每个校验项都通过判断语句 if-else 来控制。

  1. function validate() {
  2. if (!username) {
  3. ...
  4. } else {
  5. ...
  6. }
  7. if (!pswd){
  8. ...
  9. } else {
  10. ...
  11. }
  12. if (!captcha) {
  13. ...
  14. } else {
  15. ...
  16. }
  17. }

这么写看似没有问题,但其实可扩展性并不好,如果此时增加一个校验条件,就要修改 validate() 函数内容。

下面的伪代码遵循开闭原则,将校验规则抽取出来,实现共同的接口 IValidateHandler,同时将函数 validate() 改成 Validation 类,通过 addValidateHandler() 函数添加校验规则,通过 validate() 函数校验表单。这样,当有新的校验规则出现时,只要实现 IValidateHandler 接口并调用 addValidateHandler() 函数即可,不需要修改类 Validation 的代码。

  1. class Validation {
  2. private validateHandlers: ValidateHandler[] = [];
  3. public addValidateHandler(handler: IValidateHandler) {
  4. this.validateHandlers.push(handler)
  5. }
  6. public validate() {
  7. for (let i = 0; i < this.validateHandlers.length; i++) {
  8. this.validateHandlers[i].validate();
  9. }
  10. }
  11. }
  12. interface IValidateHandler {
  13. validate(): boolean;
  14. }
  15. class UsernameValidateHandler implements IValidateHandler {
  16. public validate() {
  17. ...
  18. }
  19. }
  20. class PwdValidateHandler implements IValidateHandler {
  21. public validate() {
  22. ...
  23. }
  24. }
  25. class CaptchaValidateHandler implements IValidateHandler {
  26. public validate() {
  27. ...
  28. }
  29. }

里氏替换原则

里氏替换原则是指在使用父类的地方可以用它的任意子类进行替换。里氏替换原则是对类的继承复用作出的要求,要求子类可以随时替换掉其父类,同时功能不被破坏,父类的方法仍然能被使用。

下面的代码就是一个违反里氏替换原则的例子,子类 Sparrow 重载了父类 Bird 的 getFood() 函数,但返回值发生了修改。那么如果使用 Bird 类实例的地方改成 Sparrow 类实例则会报错。

  1. class Bird {
  2. getFood() {
  3. return '虫子'
  4. }
  5. }
  6. class Sparrow extends Bird {
  7. getFood() {
  8. return ['虫子', '稻谷']
  9. }
  10. }

对于这种需要重载的类,正确的做法应该是让子类和父类共同实现一个抽象类或接口。下面的代码就是实现了一个 IBird 接口来遵循里氏替换原则。

  1. interface IBird {
  2. getFood(): string[]
  3. }
  4. class Bird implements IBird{
  5. getFood() {
  6. return ['虫子']
  7. }
  8. }
  9. class Sparrow implements IBird {
  10. getFood() {
  11. return ['虫子', '稻谷']
  12. }
  13. }

依赖倒置原则

准确说应该是避免依赖倒置,好的依赖关系应该是类依赖于抽象接口,不应依赖于具体实现。这样设计的好处就是当依赖发生变化时,只需要传入对应的具体实例即可。

下面的示例代码中,类 Passenger 的构造函数需要传入一个 Bike 类实例,然后在 start() 函数中调用 Bike 实例的 run() 函数。此时类 Passenger 和类 Bike 的耦合非常紧,如果现在要支持一个 Car 类则需要修改 Passenger 代码。

  1. class Bike {
  2. run() {
  3. console.log('Bike run')
  4. }
  5. }
  6. class Passenger {
  7. construct(Bike: bike) {
  8. this.tool = bike
  9. }
  10. public start() {
  11. this.tool.run()
  12. }
  13. }

如果遵循依赖倒置原则,可以声明一个接口 ITransportation,让 Passenger 类的构造函数改为 ITransportation 类型,从而做到 Passenger 类和 Bike 类解耦,这样当 Passenger 需要支持 Car 类的时候,只需要新增 Car 类即可。

  1. interface ITransportation {
  2. run(): void
  3. }
  4. class Bike implements ITransportation {
  5. run() {
  6. console.log('Bike run')
  7. }
  8. }
  9. class Car implements ITransportation {
  10. run() {
  11. console.log('Car run')
  12. }
  13. }
  14. class Passenger {
  15. construct(ITransportation : transportation) {
  16. this.tool = transportation
  17. }
  18. public start() {
  19. this.tool.run()
  20. }
  21. }

接口隔离原则

不应该依赖它不需要的接口,也就是说一个类对另一个类的依赖应该建立在最小的接口上。目的就是为了降低代码之间的耦合性,方便后续代码修改。

下面就是一个违反接口隔离原则的反例,类 Dog 和类 Bird 都继承了接口 IAnimal,但是 Bird 类并没有 swim 函数,只能实现一个空函数 swim()。

  1. interface IAnimal {
  2. eat(): void
  3. swim(): void
  4. }
  5. class Dog implements IAnimal {
  6. eat() {
  7. ...
  8. }
  9. swim() {
  10. ...
  11. }
  12. }
  13. class Bird implements IAnimal {
  14. eat() {
  15. ...
  16. }
  17. swim() {
  18. }
  19. }

迪米特原则

一个类对于其他类知道得越少越好,就是说一个对象应当对其他对象尽可能少的了解。这一条原则要求任何一个对象或者方法只能调用该对象本身和内部创建的对象实例,如果要调用外部的对象,只能通过参数的形式传递进来。这一点和纯函数的思想相似。

下面的类 Store 就违反了迪米特原则,类内部使用了全局变量。

  1. class Store {
  2. set(key, value) {
  3. window.localStorage.setItem(key, value)
  4. }
  5. }

一种改造方式就是在初始化的时候将 window.localstorage 作为参数传递给 Store 实例。

  1. class Store {
  2. construct(s) {
  3. this._store = s
  4. }
  5. set(key, value) {
  6. this._store.setItem(key, value)
  7. }
  8. }
  9. new Store(window.localstorage)

单一职责原则

应该有且仅有一个原因引起类的变更。这个原则很好理解,一个类代码量越多,功能就越复杂,维护成本也就越高。遵循单一职责原则可以有效地控制类的复杂度。

像下面这种情形经常在项目中看到,一个公共类聚集了很多不相关的函数,这就违反了单一职责原则。

  1. class Util {
  2. static toTime(date) {
  3. ...
  4. }
  5. static formatString(str) {
  6. ...
  7. }
  8. static encode(str) {
  9. ...
  10. }
  11. }

了解了设计模式原则之后,下面再来看看具体的设计模式。

设计模式的分类

经典的设计模式有 3 大类,共 23 种,包括创建型结构型行为型

创建型

创建型模式的主要关注点是 “如何创建和使用对象”,这些模式的核心特点就是将对象的创建与使用进行分离,从而降低系统的耦合度。使用者不需要关注对象的创建细节,对象的创建由相关的类来完成。

具体包括下面几种模式:

  • 抽象工厂模,提供一个超级工厂类来创建其他工厂类,然后通过工厂类创建类实例;
  • 生成器模式,将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象;
  • 工厂方法模式,定义一个用于创建生成类的工厂类,由调用者提供的参数决定生成什么类实例;
  • 原型模式,将一个对象作为原型,通过对其进行克隆创建新的实例;
  • 单例模式,生成一个全局唯一的实例,同时提供访问这个实例的函数。

下面的代码示例是 Vue.js 源码中使用单例模式的例子。其中,构造了一个唯一的数组 _installedPlugins 来保存插件,并同时提供了 Vue.use() 函数来新增插件。

  1. export function initUse (Vue: GlobalAPI) {
  2. Vue.use = function (plugin: Function | Object) {
  3. const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  4. if (installedPlugins.indexOf(plugin) > -1) {
  5. return this
  6. }
  7. ......
  8. }
  9. }

下面的代码中,cloneVNode() 函数通过已有 vnode 实例来克隆新的实例,用到了原型模式。

  1. export function cloneVNode (vnode: VNode): VNode {
  2. const cloned = new VNode(
  3. vnode.tag,
  4. vnode.data,
  5. vnode.children && vnode.children.slice(),
  6. vnode.text,
  7. vnode.elm,
  8. vnode.context,
  9. vnode.componentOptions,
  10. vnode.asyncFactory
  11. )
  12. cloned.ns = vnode.ns
  13. cloned.isStatic = vnode.isStatic
  14. cloned.key = vnode.key
  15. cloned.isComment = vnode.isComment
  16. cloned.fnContext = vnode.fnContext
  17. cloned.fnOptions = vnode.fnOptions
  18. cloned.fnScopeId = vnode.fnScopeId
  19. cloned.asyncMeta = vnode.asyncMeta
  20. cloned.isCloned = true
  21. return cloned
  22. }

结构型

结构型模式描述如何将类或对象组合在一起形成更大的结构。它分为类结构型模式和对象结构型模式,类结构型模式采用继承机制来组织接口和类,对象结构型模式釆用组合或聚合来生成新的对象。

具体包括下面几种模式:

  • 适配器模式,将一个类的接口转换成另一个类的接口,使得原本由于接口不兼容而不能一起工作的类能一起工作;
  • 桥接模式,将抽象与实现分离,使它们可以独立变化,它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度;
  • 组合模式,将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性;
  • 装饰器模式,动态地给对象增加一些职责,即增加其额外的功能;
  • 外观模式,为多个复杂的子系统提供一个统一的对外接口,使这些子系统更加容易被访问;
  • 享元模式,运用共享技术来有效地支持大量细粒度对象的复用;
  • 代理模式,为某对象提供一种代理以控制对该对象的访问,即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。

Vue.js 在判断浏览器支持 Proxy 的情况下会使用代理模式,下面是具体源码:

  1. initProxy = function initProxy (vm) {
  2. if (hasProxy) {
  3. const options = vm.$options
  4. const handlers = options.render && options.render._withStripped
  5. ? getHandler
  6. : hasHandler
  7. vm._renderProxy = new Proxy(vm, handlers)
  8. } else {
  9. vm._renderProxy = vm
  10. }
  11. }

Vue 的 Dep 类则应用了代理模式,调用 notify() 函数来通知 subs 数组中的 Watcher 实例。

  1. export default class Dep {
  2. static target: ?Watcher;
  3. id: number;
  4. subs: Array<Watcher>;
  5. constructor () {
  6. this.id = uid++
  7. this.subs = []
  8. }
  9. addSub (sub: Watcher) {
  10. this.subs.push(sub)
  11. }
  12. removeSub (sub: Watcher) {
  13. remove(this.subs, sub)
  14. }
  15. depend () {
  16. if (Dep.target) {
  17. Dep.target.addDep(this)
  18. }
  19. }
  20. notify () {
  21. const subs = this.subs.slice()
  22. if (process.env.NODE_ENV !== 'production' && !config.async) {
  23. subs.sort((a, b) => a.id - b.id)
  24. }
  25. for (let i = 0, l = subs.length; i < l; i++) {
  26. subs[i].update()
  27. }
  28. }
  29. }

行为型

行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,它涉及算法与对象间职责的分配。

行为型模式分为类行为模式对象行为模式,类的行为模式采用继承机制在子类和父类之间分配行为,对象行为模式采用多态等方式来分配子类和父类的职责。

具体包括下面几种模式:

  • 责任链模式,把请求从链中的一个对象传到下一个对象,直到请求被响应为止,通过这种方式去除对象之间的耦合;
  • 命令模式,将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开;
  • 策略模式,定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的用户;
  • 解释器模式,提供如何定义语言的文法,以及对语言句子的解释方法,即解释器;
  • 迭代器模式,提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示;
  • 中介者模式,定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解;
  • 备忘录模式,在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它;
  • 观察者模式,多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为;
  • 状态模式,类的行为基于状态对象而改变;
  • 访问者模式,在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问;
  • 模板方法模式,定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。

下面是 Vue.js 中使用状态对象 renderContext 的部分源码:

  1. export function initRender (vm: Component) {
  2. vm._vnode = null
  3. vm._staticTrees = null
  4. const options = vm.$options
  5. const parentVnode = vm.$vnode = options._parentVnode
  6. const renderContext = parentVnode && parentVnode.context
  7. vm.$slots = resolveSlots(options._renderChildren, renderContext)
  8. vm.$scopedSlots = emptyObject
  9. ......
  10. }

Vue.js 中通过 Object.defineProperty 劫持再发送消息则属于观察者模式。

  1. Object.defineProperty(obj, key, {
  2. enumerable: true,
  3. configurable: true,
  4. get: function reactiveGetter () {
  5. ......
  6. },
  7. set: function reactiveSetter (newVal) {
  8. const value = getter ? getter.call(obj) : val
  9. if (newVal === value || (newVal !== newVal && value !== value)) {
  10. return
  11. }
  12. if (process.env.NODE_ENV !== 'production' && customSetter) {
  13. customSetter()
  14. }
  15. if (getter && !setter) return
  16. if (setter) {
  17. setter.call(obj, newVal)
  18. } else {
  19. val = newVal
  20. }
  21. childOb = !shallow && observe(newVal)
  22. dep.notify()
  23. }
  24. })

总结

虽然 JavaScript 并不是一门面向对象的语言,但设计模式的原则和思想对我们编写代码仍有很重要的指导意义。

本课时介绍了设计模式的 6 个重要原则,包括开闭原则、里氏替换原则、依赖倒置原则、接口隔离原则、迪米特原则、单一职责原则,重点讨论了接口和类的使用方式;然后介绍了 3 类设计模式以及对应的例子,创建型模式重点关注如何创建类实例,结构型模式重点关注类之间如何组合,行为型模式关注多个类之间的函数调用关系。

要全部记住 23 种设计模式有些困难,重点在于理解其背后的思想与目的,从而做到心中有数,在此之上配合编码实践,才能最终完全掌握。

最后布置一道思考题:你还在框架代码中找到过哪些设计模式的应用?