聊一聊框架中不变的设计原则

684 阅读13分钟

家常话短(唠嗑)

即使是在瞬息万变的前端领域,也存在一些具备“一次学习,终生受用”特性的知识,比如前端性能优化的核心思路,再比如我们这次要展开来讲的JavaScript设计模式。

烹饪有菜谱,游戏有攻略,每个领域都存在一些能够让我们又好又快地达成目标的“套路”。在程序世界,编程的“套路”就是设计模式。

现在前端越来越难招到合适的人了,在很多情况下,受试者的编码能力、设计思维和计算机基础都不那么经得起推敲。

再加上最近裁员寒潮到来,我们一线开发人员被退上风口浪尖上了。。。

但是目前前端领域的缺口很大,前端领域距离人才饱和还有很长很长一段时路要走。

设计模式的根本目的是为了使我们的代码具备更强的应对变化的能力。面对变幻莫测的需求,代码尚且有23式,更何况我们写代码的人呢?

有缺口却招不到人,这是个非常麻烦的事情。这一点,除了需求方和受试者双方的问题,和前端这个行业的特性也有着密不可分的关系。

所以我们需要冷静下来思考这么一个问题:我很拼,别人也很拼,所有人都在拼的时候,我特别的地方、或者准确地说——我的核心竞争力在哪里

能够决定一个前端工程师的本质的,不是那些瞬息万变的技术点,而是那些不变的东西

什么是不变的东西?

我给大家举个几年的一个段子,某厂有个团队发了一则招聘广告,说重金求 Node 玩家,愿意给很高的 P 级,薪资什么的不用说自然非常丰厚。完了没过几天,这则招聘信息的截图竟然出现在了一个培训机构的招生广告里,附的文案是“ BAT 砸 xx 万招 Node 工程师,而你却还在学 PHP?!”。这事儿作为一个段子流传了很久,因为它足够滑稽——大家想想,Node的语法难不难?不难,准确地说,足够友好了!我相信每个团队都不缺会用 Node 或者说用任何一门语言去写 Hello World 的同学 —— 缺的是能够驾驭这门语言、能凭借自己深刻的架构思想和工程思想去支配这门语言、利用它去创造牛逼产出的人。

所谓“不变的东西”,说的就是这种驾驭技术的能力

具体来说,它分为以下三个层次:

  • 能用健壮的代码去解决具体的问题;

  • 能用抽象的思维去应对复杂的系统;

  • 能用工程化的思想去规划更大规模的业务。

这三种能力在你的成长过程中是层层递进的关系,而后两种能力可以说是对架构师的要求。事实上,在我入行以来接触过的工程师里,能做到第一点,并且把它做到扎实、做到娴熟的人,已经堪称同辈楷模

改变自我认识,前端工程师,首先是软件工程师

基础理论知识是一个人的基线,理论越强基线越高。再为自己定一个目标和向上攀附的阶梯,那么达到目标就是时间问题,而很多野路子工程师搞了半辈子也未达到优秀工程师的基线,很多他们绞尽脑汁得出的高深学问,不过是正规工程师看起来很自然的东西。 -——来自某位大咖说的话

过去,人们对软件工程的理解比较狭隘,认为前端就是页面,和软件是两回事儿。随着前端应用复杂度的日新月异,如今的前端应用也妥妥地成为了软件思想的一种载体,而前端工程师,也被要求在掌握多重专业技能之余,具备最基本的软件理论知识。

技术人之间的口水战,每次但凡想上升一点高度,便要拿”架构“这样高大上的话题出来晃晃眼。但事实上,很多人缺乏的并不是这种高瞻远瞩的激情,而是我们前面提到的“不变能力”中最基本的那一点——用健壮的代码去解决具体的问题的能力。这个能力在软件工程领域所对标的经典知识体系,恰恰就是设计模式。

所以说,想做靠谱开发,先掌握设计模式

回归正题

接下来我们聊一个重要的抽象知识 -- 设计模式,看下维基百科对设计模式的定义

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

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

设计模式原则

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

开闭原则

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

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

function validate() {

    // 校验用户名

    if (!username) {...} else {...}

    // 校验密码

    if (!pswd){... } else {...}

    // 校验验证码

    if (!captcha) {...} else {...}

}

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

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

class Validation {

    private validateHandlers: ValidateHandler[] = [];

    public addValidateHandler(handler: IValidateHandler) {

        this.validateHandlers.push(handler)

    }

    public validate() {

        for (let i = 0; i < this.validateHandlers.length; i++) {

            this.validateHandlers[i].validate();

        }

    }

}

interface IValidateHandler {

    validate(): boolean;

}

class UsernameValidateHandler implements IValidateHandler {

    public validate() {...}

}

class PwdValidateHandler implements IValidateHandler {

    public validate() {...}

}

class CaptchaValidateHandler implements IValidateHandler {

    public validate() {...}

}

里氏替换原则

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

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

class Bird {
  getFood() { return '虫子' }
}

class Sparrow extends Bird {
  getFood() { return ['虫子', '稻谷'] }
}

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

interface IBird {
  getFood(): string[]
}

class Bird implements IBird{
  getFood() { return ['虫子'] }
}

class Sparrow implements IBird {
  getFood() { return ['虫子', '稻谷'] }
}

 extends和implements区别:

1、在类的声明中,通过关键字extends来创建一个类的子类。

一个类通过关键字implements声明自己使用一个或者多个接口

extends 是继承某个类, 继承之后可以使用父类的方法, 也可以重写父类的方法;

implements 是实现多个接口, 接口的方法一般为空的, 必须重写才能使用

2、extends是继承父类,子类除了不能继承父类的私有成员(方法和属性)和构造函数,其他的都可以继承。

TypeScript 一次只能继承一个类,不支持继承多个类,但 TypeScript 支持多重继承(A 继承 B,B 继承 C)。

但implements可以实现多个接口,用逗号分开就行了 比如 :

class A extends B implements C,D,E

接口实现的注意点:

a.实现一个接口就是要实现该接口的所有的方法(抽象类除外)。

b.接口中的方法都是抽象的。

c.多个无关的类可以实现同一个接口,一个类可以实现多个无关的接口。

依赖倒置原则

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

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

class Bike {
  run() { 
      console.log('Bike run') 
  }
}

class Passenger {
  construct(Bike: bike) {
    this.tool = bike
  }

  public start() {
    this.tool.run()
  }
}

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

interface ITransportation {
  run(): void
}

class Bike implements ITransportation {
  run() {
    console.log('Bike run')
  }
}

class Car implements ITransportation {
  run() {
    console.log('Car run')
  }
}

class Passenger {
  construct(ITransportation : transportation) {
    this.tool = transportation
  }

  public start() {
    this.tool.run()
  }
}

接口隔离原则

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

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

interface IAnimal {
  eat(): void
  
  drink(): void

  swim(): void
}

class Dog implements IAnimal {
  eat() { ... }
  
  drink() {...}

  swim() {...}

}

class Bird implements IAnimal {

  eat() {...}
    
  drink() {...}

  swim() {...}

}

设计符合最小单元的接口,降低耦合性

interface IAnimal {

  eat(): void
  
  drink(): void

}

interface IBird extends IAnimal {
  swim(): void
}

  
class Dog implements IAnimal {

  eat() {...}
  
  drink() {...}
  }

}

class Bird implements IBird {

  eat() {...}
    
  drink() {...}

  swim() {...}

}

迪米特原则

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

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

class Store {

  set(key, value) {

    window.localStorage.setItem(key, value)

  }

}

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

class Store {

  construct(s) {

    this._store = s

  }

  set(key, value) {

    this._store.setItem(key, value)

  }

}

new Store(window.localstorage)

单一职责原则

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

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

class Util {

  static toTime(date) {...}

  static formatString(str) {...}

  static encode(str) {...}

}

使用单一原则,一个类只负责一项职责

class MyDate {
	toTime(){...}
}
class MyFormat(){
	formatString(){...}
}

class MyTranscode(){
	encode(){...}
}

    
const data = new MyDate()
const format = new MyFormat()
const code = new MyTranscode()
 
{
    toTime:data.toTime,
    formatString: format.formatString,
    encode: code.encode
}

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

设计模式的分类

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

创建型

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

具体包括下面几种模式:

  • 抽象工厂模,提供一个超级工厂类来创建其他工厂类,然后通过工厂类创建类实例;

  • 生成器模式,将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象;

  • 工厂方法模式,定义一个用于创建生成类的工厂类,由调用者提供的参数决定生成什么类实例;

  • 原型模式,将一个对象作为原型,通过对其进行克隆创建新的实例;

  • 单例模式,生成一个全局唯一的实例,同时提供访问这个实例的函数。

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

// src/core/global-api/use.js

export function initUse (Vue: GlobalAPI) {

  Vue.use = function (plugin: Function | Object) {

    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))

    if (installedPlugins.indexOf(plugin) > -1) {

      return this

    }
    ......
  }

}

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

// src/core/vdom/vnode.js

export function cloneVNode (vnode: VNode): VNode {

  const cloned = new VNode(

    vnode.tag,

    vnode.data,

    // #7975

    // clone children array to avoid mutating original in case of cloning

    // a child.

    vnode.children && vnode.children.slice(),

    vnode.text,

    vnode.elm,

    vnode.context,

    vnode.componentOptions,

    vnode.asyncFactory

  )

  cloned.ns = vnode.ns

  cloned.isStatic = vnode.isStatic

  cloned.key = vnode.key

  cloned.isComment = vnode.isComment

  cloned.fnContext = vnode.fnContext

  cloned.fnOptions = vnode.fnOptions

  cloned.fnScopeId = vnode.fnScopeId

  cloned.asyncMeta = vnode.asyncMeta

  cloned.isCloned = true

  return cloned

}

结构型

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

具体包括下面几种模式:

  • 适配器模式,将一个类的接口转换成另一个类的接口,使得原本由于接口不兼容而不能一起工作的类能一起工作;

  • 桥接模式,将抽象与实现分离,使它们可以独立变化,它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度;

  • 组合模式,将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性;

  • 装饰器模式,动态地给对象增加一些职责,即增加其额外的功能;

  • 外观模式,为多个复杂的子系统提供一个统一的对外接口,使这些子系统更加容易被访问;

  • 享元模式,运用共享技术来有效地支持大量细粒度对象的复用;

  • 代理模式,为某对象提供一种代理以控制对该对象的访问,即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。

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

// src/core/instance/proxy.js

initProxy = function initProxy (vm) {

  if (hasProxy) {

    // determine which proxy handler to use

    const options = vm.$options

    const handlers = options.render && options.render._withStripped

      ? getHandler

      : hasHandler

    vm._renderProxy = new Proxy(vm, handlers)

  } else {

    vm._renderProxy = vm

  }

}

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

// src/core/observer/dep.js

export default class Dep {

  static target: ?Watcher;

  id: number;

  subs: Array<Watcher>;



  constructor () {

    this.id = uid++

    this.subs = []

  }



  addSub (sub: Watcher) {

    this.subs.push(sub)

  }



  removeSub (sub: Watcher) {

    remove(this.subs, sub)

  }



  depend () {

    if (Dep.target) {

      Dep.target.addDep(this)

    }

  }



  notify () {

    // stabilize the subscriber list first

    const subs = this.subs.slice()

    if (process.env.NODE_ENV !== 'production' && !config.async) {

      // subs aren't sorted in scheduler if not running async

      // we need to sort them now to make sure they fire in correct

      // order

      subs.sort((a, b) => a.id - b.id)

    }

    for (let i = 0, l = subs.length; i < l; i++) {

      subs[i].update()

    }

  }

}

行为型

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

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

具体包括下面几种模式:

  • 责任链模式,把请求从链中的一个对象传到下一个对象,直到请求被响应为止,通过这种方式去除对象之间的耦合;

  • 命令模式,将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开;

  • 策略模式,定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的用户;

  • 解释器模式,提供如何定义语言的文法,以及对语言句子的解释方法,即解释器;

  • 迭代器模式,提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示;

  • 中介者模式,定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解;

  • 备忘录模式,在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它;

  • 观察者模式,多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为;

  • 状态模式,类的行为基于状态对象而改变;

  • 访问者模式,在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问;

  • 模板方法模式,定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。

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

// src/core/instance/render.js

export function initRender (vm: Component) {

  vm._vnode = null // the root of the child tree

  vm._staticTrees = null // v-once cached trees

  const options = vm.$options

  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree

  const renderContext = parentVnode && parentVnode.context

  vm.$slots = resolveSlots(options._renderChildren, renderContext)

  vm.$scopedSlots = emptyObject

  // bind the createElement fn to this instance

  // so that we get proper render context inside it.

  // args order: tag, data, children, normalizationType, alwaysNormalize

  // internal version is used by render functions compiled from templates

  ......

}

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

// src/core/observer/index.js

Object.defineProperty(obj, key, {

  enumerable: true,

  configurable: true,

  get: function reactiveGetter () {

    ......

  },

  set: function reactiveSetter (newVal) {

    const value = getter ? getter.call(obj) : val

    /* eslint-disable no-self-compare */

    if (newVal === value || (newVal !== newVal && value !== value)) {

      return

    }

    /* eslint-enable no-self-compare */

    if (process.env.NODE_ENV !== 'production' && customSetter) {

      customSetter()

    }

    // #7981: for accessor properties without setter

    if (getter && !setter) return

    if (setter) {

      setter.call(obj, newVal)

    } else {

      val = newVal

    }

    childOb = !shallow && observe(newVal)

    dep.notify()

  }

})

总结

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

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

讨论环节

你在框架代码中找到过哪些设计模式的应用?欢迎在留言区讨论!