前端也要懂的解耦思想:从面向接口到IoC容器

2,075 阅读5分钟

上一文《前端也要懂的解耦思想:从面向对象到面向接口》我们已经通过依赖倒置原则(DIP),将模块间的依赖关系从具体实现细节转化成了抽象需求接口,即所谓的面向接口编程,从而实现了很大程度上的解耦。个人目前所遇到的前端项目,通过DIP原则+TS已经可以足够好的处理耦合关系。但随着工程化的日益加重,我们不得不保持忧患意识,思考进一步解耦的可能。

上文末我们提出了目前代码(参考如下)仍旧面临的一些问题隐患:比如我们将低层模块的实例化过程放在高层模块中,如果未来低层模块的构造方法发生了变化(如需要传参),则我们又不得不修改高层模块的代码。作为低层模块大概率要被多个高层模块依赖,这进一步加重了维护负担。那么接下来的解耦方向是非常明确的,就是处理模块实例化过程所导致的耦合关系。

//定义接口:
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

既然是模块实例化过程导致的问题,那么我们或许会比较容易想到将各个模块统一管理。而所有模块的实例化过程则发生在这个管理中心。哪个模块需要依赖相关低层模块,就向这个管理中心去请求调用。这样一来,某个模块的构造方法有修改,则我们只需要在管理中心修改注册信息。这样的修改对于高层模块来说是透明或者无感的,因为管理中心已经帮他们处理好了一切,高层模块依旧原路请求调用即可。 如果你想到了这一层,其实你已经理解了后端领域大名鼎鼎的IoC设计思想!所谓的模块统一管理中心,就是IoC思想中的一个核心概念,IoC容器。

什么是IoC?

Inversion of Control,即控制反转。它不是一种技术,只是一种思想,一个重要的面向对象编程的法则。IoC思想能指导我们设计出松耦合、更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试。有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象。所以对象与对象之间是松散耦合,这样也方便测试,利于复用。

为什么叫做控制反转?

控制:指的是实例创建(实例化、管理)的权利。传统开发⽅式,我们直接在模块内部通过new进行创建实例,是程序主动去创建依赖对象。而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制实例对象的创建。

反转:控制权交给外部环境了(框架、IoC容器)。我们不⽤⾃⼰去new 实例了,⽽是由IoC容器去帮助我们实例化对象并且管理它,我们需要使⽤哪个对象,去问IoC容器要即可。我们丧失了⼀个权利(创建、管理对象的权利),得到了⼀个福利(不⽤考虑对象的创建、管理等⼀系列事情)。

IoC容器里做了哪些事?

  • 类的实例化
  • 查找模块的依赖关系

好了,现在我们就用以上思想来设计一个IoC容器,这个容器要暴露出模块的注册方法和获取模块实例的方法。

interface TModule<T>{
  new(...args: any[]): T
}
class Container{
  private modules= new Map()
  public register<T>(identifier: string, Module: TModule<T>, singleton: boolean = true, args: any[] = []){
    this.modules.set(identifier, {
      singleton,
      args,
      module: Module,
      instance: null as T,
    })
  }
  public get(identifier: string){
    const moduleInfo= this.modules.get(identifier)

    if(!moduleInfo.singleton || !moduleInfo.instance){
      moduleInfo.instance= new moduleInfo.module(...moduleInfo.args)
    }
    return moduleInfo.instance
  }
}

export const container= new Container()

可以看到IoC容器内容的代码相当简单,模块注册方法将模块信息以键值对的形式存放于容器内部一个Map中。get方法利用键获取模块实例(必要时先对模块实例化)。下面我们将模块代码重新设计一下,使其依赖模块的实例化权限全部交给IoC容器,实现控制反转。

class Gun implements TWeapon{
  private fire(victim: string){
    console.log('gun is firing ' + victim)
  }
  public start(victim: string){  
    this.fire(victim)
  }
}
container.register<Gun>('Gun', Gun)

class Knife implements TWeapon{
  private cut(victim: string){
    console.log('knife is cutting ' + victim)
  }
  public start(victim: string){
    this.cut(victim)
  }
}
container.register<Knife>('Knife', Knife)

class Soldier{
  private weapon: TWeapon
  constructor(weaponIdentifier: string){
    this.weapon= container.get(weaponIdentifier)
  }
  public attack(victim: string){
    this.weapon.start(victim)
  }
}
container.register<Soldier>('SoldierG', Soldier, false, ['Gun'])
container.register<Soldier>('SoldierK', Soldier, false, ['Knife'])

const soldierG= container.get('SoldierG')
const soldierK= container.get('SoldierK')

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

现在我们将模块间的依赖关系做了彻底的解耦!虽然模块定义之后需要有一个注册的动作,但是带来的好处却是显而易见的。有任何的模块需要修改(如修改构造方法),则我们只需要在调用register方法时,修改输入参数即可(即模块注册信息),而依赖此模块的其他高层模块则不需要做任何的改动。这大大降低了项目维护负担,提升了整体稳定性。

但IoC的实现却也有一个缺点,就是代码略显繁琐不够优雅,而且在模块内部不得不直接引入IoC容器container,对逻辑造成了一定的侵入。

那是否有办法做优化呢?有的。下一文我们将介绍IoC的经典实现方案DI(依赖注入),使得IoC思想在解耦的同时,也在实现层面变得优雅简单。

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