前端也要懂的解耦思想:从面向对象到面向接口

2,529 阅读6分钟

前端似乎很少谈设计思想,大部分的编程思想和设计模式都是来自于后端的经验和总结。但是随着前端工程化越来越重,促使我们这些前端切图仔也不得不开始关注编程思想和设计模式。从面向过程到面向对象,所有编程理念和思想的提出,本质上是为了调和程序员稳定的个体智力水平和程序不断上升的复杂度之间的矛盾。所以层出不穷的先进编程思想永远都是围绕同一个主题,即“高内聚低耦合”,使得程序容易被人类开发和维护。

前端领域,我们是天然接受面向对象编程理念的。但面向对象其实也衍生出了一些更细的设计原则,这些原则指导我们写出符合“高内聚低耦合”主题的代码。但可惜前端很少系统性的讲究这些设计原则(虽然大家可能多多少少都在不自觉的运用这些原则),作为一个前端小白,我也是最近才开始接触这些概念。在学习这些编程思想的过程中感悟颇多,让我的前端搬砖工作更加有迹可循,所以我想谈一谈最近正在学习的依赖倒置原则,帮助在开发中减少依赖关系,降低耦合度。

面向对象设计七大原则:

1. 单一职责原则(Single Responsibility Principle)

每一个类应该专注于做一件事情。

2. 里氏替换原则(Liskov Substitution Principle)

超类存在的地方,子类是可以替换的。

3. 依赖倒置原则(Dependence Inversion Principle)

实现尽量依赖抽象,不依赖具体实现。

4. 接口隔离原则(Interface Segregation Principle)

应当为客户端提供尽可能小的单独的接口,而不是提供大的总的接口。

5. 迪米特法则(Law Of Demeter)

又叫最少知识原则,一个软件实体应当尽可能少的与其他实体发生相互作用。

6. 开闭原则(Open Close Principle)

面向扩展开放,面向修改关闭。

7. 组合/聚合复用原则(Composite/Aggregate Reuse Principle CARP)

尽量使用合成/聚合达到复用,尽量少用继承,一个类中有另一个类的对象。

假设我们现在有一个非常简单的需求:设计一个会使用武器进行攻击的战士。

class Gun{
  fire(victim){
    console.log('gun is firing ' + victim)
  }
}
class Soldier{
  constructor(gun){
    this.gun= gun
  }
}

const gun= new Gun()
const soldierG= new Soldier(gun)

soldierG.gun.fire('dog')  // gun is firing dog

以上代码实现了我们的需求,但是问题在于,上层模块Soldier高度依赖下层模块Gun的实现。如果这个时候需要让战士操作另一种武器砍刀,而砍刀需要用cut方法操作,则不得不修改Soldier模块。这就是依赖导致的耦合问题,导致系统不稳定,增加维护难度。那么我们其实也很容易想到将所有武器都暴露出统一的操作方法给高层模块Soldier调用:

class Gun{
  fire(victim){
    console.log('gun is firing ' + victim)
  }
  start(victim){
    this.fire(victim)
  }
}

class Knife{
  cut(victim){
    console.log('sword is cutting ' + victim)
  }
  start(victim){
    this.cut(victim)
  }
}
class Soldier{
  constructor(InputWeapon){
    this.weapon= new InputWeapon()
  }
  attack(victim){
    this.weapon.start(victim)
  }
}

const soldierG= new Soldier(Gun)
const soldierS= new Soldier(Knife)

soldierG.attack('dog')  // gun is firing dog
soldierS.attack('pig')  // knife is cutting pig

这样看起来似乎好多了!我们有了一个潜规则,即让所有底层模块都定义一个start方法,以供高层模块调用。Soldier模块再也不需要关心武器的具体实现细节,所有拥有start方法的武器都可以被战士正确使用。

看似已经完美,但我们仍旧面临一些棘手的问题:如何保证每一件武器都拥有start方法?如何保证每次调用start方法都被传入了正确的参数?

在JavaScript的世界里只能考验开发人员的自觉性和记忆力了,一旦项目变得庞大,后期的开发和维护将变得困难。但是幸好我们有了TypeScript!利用TypeScript我们可以将所有的潜规则和约定抽象成明面上的接口,底层模块的实现细节依赖高层模块提前定义好的需求接口(依赖反转了!)。高层的模块再也不需要依赖低层次模块的具体实现,只需要依赖其需求抽象接口即可。

//定义接口:
interface TWeaponClass{  //定义武器类型
  new(): TWeapon
}

interface TWeapon{  //定义需要实现的方法
  start(victim: string) : void,  //start方法只接受一个string类型参数,无返回内容
}
class Gun implements TWeapon{  //约定武器必须实现start方法
  private fire(victim: string){
    console.log('gun is firing ' + victim)
  }
  public start(victim: string){  
    this.fire(victim)
  }
}

class Knife implements TWeapon{
  private cut(victim: string){
    console.log('knife is cutting ' + victim)
  }
  public start(victim: string){
    this.cut(victim)
  }
}
class Soldier{
  private weapon: TWeapon
  constructor(InputWeapon: TWeaponClass){
    this.weapon= new InputWeapon()
  }
  public attack(victim: string){  //attck方法只接受一个string类型参数
    this.weapon.start(victim)
  }
}

const soldierG= new Soldier(Gun)
const soldierS= new Soldier(Knife)

soldierG.attack('dog')  // gun is firing dog
soldierS.attack('pig')  // knife is cutting pig

好了,如果我们按照这样的思路,面向接口编写程序,其实我们已经遵循了依赖倒置原则:

依赖倒置原则(DIP)

依赖倒置是指一种特定的的解耦形式,使得高层次的模块不依赖低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象.该原则规定:

1. 高层次的模块不应该依赖低层次模块,二者都应该依赖其抽象接口。

上层是使用者,下层是被使用者,这就导致的结果是上层依赖下层了,下层变动了,自然就会影响到上层了,导致系统不稳定,甚至是牵一发而动全身。那怎么减少依赖呢?就是上层和下层都去依赖另一个抽象,这个抽象比较稳定,整个就来说就比较稳定了。

2. 抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口。

面向对象编程时面向抽象或者面向接口编程,抽象一般比较稳定,实现抽象的具体肯定是要依赖抽象的,抽象不应该去依赖别的具体,应该依赖抽象。


但即使我们遵循了依赖倒置原则,降低了耦合关系,但耦合仍旧存在。比如对于Weapon模块,依赖它的Solider模块需要自己完成对它的实例化。如果未来Weapon模块的构造方法发生了变化(如需要传参),则我们又不得不修改高层模块Soldier的代码。更糟糕的情况是,作为底层模块很可能被多个高层模块依赖,这时候更是需要全局搜索找到这些高层模块一个一个修改。这无疑增加了维护成本,降低了程序的稳定性。

解耦之道漫漫,下一次我们将利用一种全新的设计思想IoC(控制反转),来进一步解耦模块间的依赖关系。

前端小白,文中难免有谬误,请各位大佬轻喷!