Javascript设计模式(上)——— 设计原则

在软件工程中,设计模式是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。

——维基百科

设计模式的存在是为了提高项目代码的可复用率、可维护性及可读性。其中,面向对象编程的六大设计原则广泛被业界所认可,下面介绍一下在 javascript 中比较适用的三个设计原则。

单一职责原则(Single Responsibility Principle)

释义:就一个类或者方法而言,应该仅有一个引起它变化的原因。

体现:一个对象(或方法)只做一件事情。

SRP中的职责被定义为:“变化的原因”。针对一个方法而言,如果我们有两个动机去改写一个方法,那么这个方法就有两个职责。当一个方法承担的职责越多,那么在需求变更的过程中,方法需要被改写的可能性就越大。

我们都知道,修改代码是一件危险的事情,特别是职责之间互相耦合时,一个职责发生变化可能会影响到其他职责的实现,造成意想不到的破坏,这是低内聚和脆弱的设计。

SRP 是所有原则中最简单却也是最难正确运用的原则之一。

事实上,并不是所有的职责都应该一一分离。

如果需求变化时,有两个职责总是同时变化,那就不需要分离他们。比如生成自定义错误时错误码和错误信息是互相对应的,这时候没有必要分开用不同的方法来生成。

设计原则一定要遵守么?

对于设计原则,一方面我们受其指导,但另一方面,我们并不需要死板、强硬的遵守原则。在实际开发中,因为种种原因违法 SRP 是很常见的。

比如 jQuery 的 attr 方法,既可以用来取值,也可以用来赋值,是明显违反 SRP 的做法,符合 SRP 的做法应该是分别实现 getAttrsetAttr 方法。将两个职责耦合实现对于 jQuery 的维护者来说会带来一些困难,但对于 jQuery 的用户来说,却简化了用户的使用。

所以,在设计原则只是在思想上给予指导,实际上我们需要权衡各种因素来决定原则的遵守程度,而不是非黑即白。

SRP 的优缺点

优点:降低了单个类(或对象、方法)的复杂度,按照职责把对象分解成了更小的粒度,这有助于代码的复用及单元测试。单一职责的变更不会影响到其他职责。

缺点:增加编写代码的复杂度,增大了职责间相互联系的难度。

最小知识原则(Lowest Knowledge Principle)

释义:一个软件实体应当尽可能少地与其他实体发生相互作用。

体现:减少对象(方法、系统、类、模块等)之间的联系

我们引用一个简单的例子(引用自《面向对象设计原理与模式》)来说明这一原则:

某军队中的将军需要挖掘一些战壕。其中一种完成任务的模式是:
将军通知上校,让上校找来上尉,再通知上尉找来士兵,最后命令士兵挖掘一些沙坑。

对应的伪代码:

gerneral.getColonel().getMajor().getSoldier().digTrenches()

一个任务需要通过这么长的调用链来完成,很明显是不合理的,因为链中间的任意一个改动都会影响到这条链的结果。当然,挖一个战壕需要将军做这么多繁琐的步骤是很荒谬的,一个将军如果这么发号施令,那很显然不是一个合格的将军。

最合理的做法是,将军吩咐上校布置一些战壕,至于后续的事情,将军并不需要关心。

减少对象之间的联系

LKP 要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个实体间不需要直接通信,那么这两个对象就不要发生直接的相互联系。如果需要进行通信,可以提供第三方来转发。

一个模块或者对象在编写时,应该做到适当的封装,将内部数据或者实现细节隐藏,只暴露必要的接口API供外界访问,这样可以让对象之间的联系维持在较小的范围之内。

更广义的,变量的作用域也应该注意 LKP,将变量的作用域尽可能的限制到最小,即只在需要它的地方可用,那么对其他不相关模块造成的影响就越小,变量被改写和发生冲突的机会也越小。

开放-封闭原则(Open Closed Principle)

释义:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

体现:当需要改动程序功能或者添加新功能时,可以使用增加代码的方式,而不是改动源代码。

还是一个简单、耳熟能详的例子:

        一家生产肥皂的大企业,斥巨资从欧洲引入一条生产线,可以自动完成从原材料加工到包装成箱的整个流程,但美中不足的是生产出来的肥皂有一定的空盒机率。于是老板又找来了一只专业团队,花费上百万改造生产线,最终解决了空盒肥皂的问题。
        另一家企业也引入了这条生产线,同样的空盒问题,但他们的解决方法却简单的多:在生产线旁边增加一台大风扇,空盒在经过时会被吹走。

这个例子告诉我们,相比于修改源程序,如果增加几行代码就能解决问题,那这显然更加简单优雅,修改某个bug最后引入其他问题的案例多如牛毛,而增加代码并不会影响原系统的稳定。

减少条件分支

过多的条件分支代码是造成程序违反 OCP 的一个常见原因。没当需要增加一个新的 if 语句,就需要被迫改动原函数,把 if 换成 switch-case 是换汤不换药的做法。实际上,当看到一大片的 if 或者 switch-case 时,我们应该第一时间考虑,能否利用对象的多态性来重构它们。

利用对象的多态性来让程序遵守 OCP 是一个很常用的技巧。还是一个简单的例子:

实现一个方法,让一个动物发出叫声,当前需求有鸭子和鸡,鸭子发出 “嘎嘎嘎”,鸡发出”咯咯咯”

最直接的想法(往往都不太优雅)当然是使用 if 或者 switch-case 实现:

class Duck {}
class Chicken {}

function makeSound (animal) {
  if (animal instanceof Duck) {
    console.log('嘎嘎嘎')
  } else if (animal instanceof Chicken) {
    console.log('咯咯咯')
  }
}

这时候新的需求来了,我们需要添加新的需求:狗,发出”汪汪汪”,那么函数需要被改成

class Duck {}
class Chicken {}
class Dog {}

function makeSound (animal) {
  if (animal instanceof Duck) {
    console.log('嘎嘎嘎')
  } else if (animal instanceof Chicken) {
    console.log('咯咯咯')
  } else if (animal instanceof Dog) {
    console.log('汪汪汪')
  }
}

这明显不符合 OCP,我们看看如何利用多态的思想。首先,抽取程序中不变的部分:动物都会叫,然后将可变的部分封装起来:不同的动物发出的叫声不同,而根据前文的 LKP makeSound 函数并不需要关心动物的叫声细节,这样程序就具有了可扩展性。

class Duck {
  sound () {
    console.log('嘎嘎嘎')
  }
}
class Chicken {
  sound () {
    console.log('咯咯咯')
  }
}
function makeSound (animal) {
  animal.sound()
}

当需要增加狗狗的叫声时,我们只需要增加一个新的对象,而不需要去改动原有的 makeSound 函数。

封装变化

OCP 看起来是一个比较抽象的原则,没有实际的流程或者模板教我们如何去遵守它。但其实还是存在一定的规律可循,最明显的一个就是

找出程序中变化的或者可能发生变化的地方,将其封装起来

通过封装变化,我们可以将系统中稳定不变的部分和容易变化的部分隔离开。在系统的生命周期中,我们只需要替换容易变化的部分,而稳定的部分几乎不需要去做改动。

除了多态之外,还有其他方式可以帮助我们编写遵守 OCP 的代码

使用回调函数

我们可以把一部分易于变化的逻辑封装在回调函数里,然后把回调函数作为参数传入稳定封闭的函数,程序可以根据回调函数产生不同的结果。

比如,在一个系统中我们在获取用户信息后做一些处理,其中,请求用户信息的过程是不变的,而获取用户信息之后做的处理是变化的,此时我们就可以通过回调函数来实现:

async function getUser (callback) {
  const result = await $.ajax('/api/user')
  callback(result)
}
function consoleUserName () {
  getUser(function (user) {
    console.log(user.name)
  })
}
function consoleUserGender () {
  getUser(function (user) {
    console.log(user.gender)
  })
}

关于OCP

几乎所有的设计模式都是遵守 OCP 的,我们见到的好的设计通常都是经得起 OCP 考验的。不管是具体的设计模式,还是抽象的设计原则,比如前文提到的 SRPLKP ,都是为了让程序遵守 OCP 出现的。甚至我们可以这么说:

OCP 是编写一个好程序的目标,其他设计原则都是为了达到这个目标所做的要求。

需要知道的是,让程序完全遵守 OCP 是不容易做到的,其他原则也类似,我们不需要过于死板的遵守原则,而是应该灵活的运用。针对 OCP ,我们可以做到的有:

  1. 挑选出最容易发生变化的地方,通过构造抽象来封闭这些变化
  2. 在不可避免发声修改时,尽量修改相对容易修改的地方

接受第一次愚弄

下面摘自 Bob 大叔的 《敏捷软件开发原则、模式与实践》。

有句谚语说:”愚弄我一次,应该羞愧的是你。再次被愚弄,应该羞愧的是我。“ 这也是一种有效的对待软件设计的态度。为了防止软件背着不必要的复杂性,我们会允许自己被愚弄一次。

让程序一开始就很满足 OCP 是一件不容易的事情。一方面,我们需要预测程序在哪些地方会发生变化,另一方面,留给程序员的需求排期是有时间限制的,因此,我们可以说服自己去接受不合理代码带来的第一次愚弄。在最初编写代码时,先假设变化永远不会发生,这有利于我们快速完成需求(当然,这并不意味着你可以写出人神共愤的代码,那些显而易见的变化还是要封装起来)。当变化发生并且对我们接下来的工作造成影响时,可以再回过头来封装这些变化,确保不在同一个地方被愚弄两次。

总结

这篇文章主要是总结设计模式六大原则中的 单一职责原则最少知识原则开放封闭原则,这三个原则是在 javascript 编程中比较适用的,另外三个原则 里氏替换原则接口隔离原则依赖倒置原则 在 javascript 编程中不太适用,就不详细讲了,下一篇我们讲讲 Javascript 中常见的设计模式。

参考文献:

  • 《Javascript设计模式与开发实践》—— 曾探
  • 维基百科
0%