五分钟掌握 JavaScript 中的 IoC

2,642 阅读7分钟

IoC,控制反转(Inversion of Control)。它是依赖倒置原则(Dependence Inversion Principle)的一种实现方式,也就是面向接口编程。IoC的实现借助于第三方容器,可以解耦具有依赖关系的对象,降低开发维护成本。

接下来我们一起通过一个完整的示例来进一步了解这些概念。

一个亟待扩展的业务模块

首先,我们来看一个示例:

class Order{
    constructor(){}
    getInfo(){
        console.log('这是订单信息')
    }
}

let order = new Order('新订单');
order.getInfo()

以上代码为某系统的订单管理模块,目前的功能是输出订单信息。

为订单模块添加评价功能

随着业务的发展,需要对订单添加评价功能:允许用户对订单进行评价以提高服务质量。

非常简单的需求对不对?对原有代码稍作修改,增加评价模块即可:

class Rate{
    star(stars){
        console.log('您对订单的评价为%s星',stars);
    }
}
class Order{
    constructor(){
        this.rate = new Rate();
    }
    // 省去模块其余部分 ...
}

let order = new Order('新订单');
order.getInfo();
order.rate.star(5);

一个小小的改动而已,很轻松就实现了:新增一个评价模块,将其作为依赖引入订单模块即可。很快 QA 测试也通过了,现在来杯咖啡庆祝一下吧 ☕️

为模块添加分享功能

刚刚端起杯子,发现 IM 上产品同学的头像亮了起来:

PM:如果订单以及评论能够分享至朋友圈等场景那么将会大幅提升 xxxxx

RD:好的 我调研一下

刚刚添加了评分模块,分享模块也没什么大不了的:

class Rate(){ /** 评价模块的实现 */}

class Share(){
    shareTo(platform){
        switch (platform) {
            case 'wxfriend':
                console.log('分享至微信好友');
                break;
            case 'wxposts':
                console.log('分享至微信朋友圈');
                break;
            case 'weibo':
                console.log('分享至微博');
                break;
            default:
                console.error('分享失败,请检查platform');
                break;
        }
    }
}

class Order{
    constructor(){
        this.rate = new Rate();
        this.share = new Share();
    }
    // 省去模块其余部分 ...
}

const order = new Order();
order.share.shareTo('wxposts');

这次同样新增一个分享模块,然后在订单模块中引入它。重新编写运行单测后,接下来QA需要对Share模块进行测试,并且对Order模块进行回归测试。

好像有点不对劲儿?可以预见的是,订单这个模块在我们产品生命周期中还处于初期,以后对他的扩展/升级或者维护将是一件很频繁的事情。如果每次我们都去修改主模块和依赖模块的话,虽然能够满足需求,但是对开发及测试不足够友好:需要双份的单测(如果你有的话),冒烟,回归...而且生产环境的业务逻辑和依赖关系远远要比示例中复杂,这种不完全符合开闭原则的方式很容易产生额外的bug。

使用IoC的思想改造模块

顾名思义,IoC的主要行为是将模块的控制权倒置。上述示例中我们将Order称为高层模块,将RateShare称为低层模块;高层模块中依赖低层模块。而IoC则将这种依赖关系倒置:高层模块定义接口,低层模块实现接口;这样当我们修改或新增低层模块时就不会破坏开闭原则。其实现方式通常是依赖注入:也就是将所依赖的低层模块注入到高层模块中。

在高层模块中定义静态属性来维护依赖关系:

class Order {
    // 用于维护依赖关系的Map
    static modules = new Map();
    constructor(){
        for (let module of Order.modules.values()) {
            // 调用模块init方法
            module.init(this);
        }
    }
    // 向依赖关系Map中注入模块
    static inject(module) {
        Order.modules.set(module.constructor.name, module);
    }
    /** 其余部分略 */
}

class Rate{
    init(order) {
        order.rate = this;
    }
    star(stars){
        console.log('您对订单的评价为%s星',stars);
    }
}

const rate = new Rate();
// 注入依赖
Order.inject(rate);
const order = new Order();
order.rate.star(4); 

以上示例中通过在Order类中维护自己的依赖模块,同时模块中实现init方法供Order在构造函数初始化时调用。此时Order即可称之为容器,他将依赖关系收于囊中。

再次理解IoC

完成了订单模块的改造,我们回过头来再看看IoC:

依赖注入就是把高层模块的所依赖的低层次以参数的方式注入其中,这种方式可以修改低层次依赖而不影响高层次依赖。

但是注入的方式要注意一下,因为我们不可能在高层次模块中预先知道所有被依赖的低层次模块,也不应该在高层次模块中依赖低层次模块的具体实现。

因此注入需要分成两部分:高层次模块中通过加载器机制解耦对低层次模块的依赖,转而依赖于低层次模块的抽象;低层次模块的实现依照约定的抽象实现,并通过注入器将依赖注入高层次模块。

这样高层次模块就脱离了业务逻辑转而成为了低层次模块的容器,而低层次模块则面向接口编程:满足对高层次模块初始化的接口的约定即可。这就是控制反转:通过注入依赖将控制权交给被依赖的低层级模块。

更简洁高效的IoC实现

上述示例中IoC的实现仍略显繁琐:模块需要显式的声明init方法,容器需要显示的注入依赖并且初始化。这些业务无关的内容我们可以通过封装进入基类、子类进行继承的方式来优化,也可以通过修饰器方法来进行简化。

修饰器(Decorators)为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。 Javascript里的修饰器目前处在 建议征集的第二阶段,但在TypeScript里已做为一项实验性特性予以支持。

接下来我们就着重介绍一下通过修饰器如何实现IoC。

通过类修饰器注入

以下示例代码均为TypeScript

首先我们实现低层模块,这些业务模块只处理自己的业务逻辑,无需关注其它:


class Aftermarket {
    repair() {
        console.log('已收到您的售后请求');
    }
}

class Rate {
    star(stars: string) {
        console.log(`评分为${stars}星`);
    }
}

class Share {
    shareTo(platform: string) {
        switch (platform) {
            case 'wxfriend':
                console.log('分享至微信好友');
                break;
            case 'wxposts':
                console.log('分享至微信朋友圈');
                break;
            case 'weibo':
                console.log('分享至微博');
                break;
            default:
                console.error('分享失败,请检查platform');
                break;
        }
    }
}

接下来我们实现一个类修饰器,用于实例化所依赖的低层模块,并将其注入到容器内:

function Inject(modules: any) {
    return function(target: any) {
        modules.forEach((module:any) => {
            target.prototype[module.name] = new module();
        });
    };
}

最后在容器类上使用这个修饰器:

@Inject([Aftermarket,Share,Rate])
class Order {
    constructor() {}
    /** 其它实现略 */
}

const order:any = new Order();
order.Share.shareTo('facebook');

使用属性修饰器实现

Ursajs中使用属性修饰器来实现注入依赖。

Ursajs提供了@Resource修饰器和@Inject修饰器。

其中@Resource为类修饰器,它所修饰类的实例将注入到UrsajsIoC容器中:

@Resource()
class Share{}

@Inject为属性修饰器,在类中使用它可以将@Resource所修饰类的实例注入到指定变量中:

class Order{
    @Inject('share')
    share:Share;
    /** 其它实现略 */
}

在此之外,作为一个简洁优雅的框架,Ursajs还内置了寻址优化,可以更高效的获取资源。

没有银弹

虽然IoC很强大,但它仍然只是一种设计思想,是对某些场景下解决方案的提炼。它无法也不可能解决全部高耦合所带来的问题。而做为开发者,我们有必要识别哪些场景适合什么方案。

小结

  • 复杂系统中高耦合度会导致开发维护成本变高
  • IoC借助容器实现解耦,降低系统复杂度
  • 装饰器实现IoC更加简洁高效
  • 没有银弹

参考

🕯️ R.I.P.