AngularJS中的依赖注入实际应用场景?

依赖注入听起来挺玄乎,是不是就是指通过给函数添加形参,使得caller传入的参数和callee接受的参数逻辑分离,使得函数通过依赖管理系统仅仅需要声明…
关注者
263
被浏览
19,618

4 个回答

谢邀。

确实是你说的这样。

所谓依赖注入,通俗地举例,有个人养了一只宠物,他可以喂宠物吃东西,宠物会自己吃:

function PetKeeper(pet) {
  this.pet = pet;
}

PetKeeper.prototype.feed = function(food) {
  this.pet.eat(food);
};

function Pet(type) {
  this.type = type;
}

Pet.prototype.eat = function(food) {
  alert("I am a " + this.type + ", I'm eating " + food);
};

var tom = new Pet("cat");
var jerry = new Pet("mouse");

var keeper = new PetKeeper(tom);
keeper.feed("fish");

keeper.pet = jerry;
keeper.feed("rice");

这个例子里,pet是外部注入的,在feed函数定义里,并不知道pet到底是什么(在带接口的语言里,至少还是知道是个什么,在动态语言里就是两眼一抹黑了……),只有当它被调用的时候,才知道pet是什么。

这个过程的好处是什么呢?如果我们在PetKeeper内部去创建tom或jerry,就表示PetKeeper要对Pet产生依赖。一个对别人有依赖的东西,它想要单独测试,就需要在依赖项齐备的情况下进行。如果我们在运行时注入,就可以减少这种依赖,比如在单元测试的时候使用模拟类就行。

比如你有一个a,依赖于b,实际业务中,b的实现很复杂:

function A(b) {
  this.b = b;
}

A.prototype.a1 = function() {
  alert(100 + this.b.b1());
};

function B() {}

B.prototype.b1 = function() {
  //这里可能很复杂而且不好模拟,比如依赖于生产环境的一些调用
}

那么,我如何用单元测试来验证A自身的逻辑是正确的呢?如果有强依赖,这里就不好办了,必须实例化真正的B,但是B的调用要依赖于生产环境。换个方式考虑,我们用一个接口与B相同的类来做模拟,只要改变它的返回值,实现各种边界条件,把它的实例注入到A的构造函数中,就可以让A自身的逻辑得到测试了。

function MockB() {}

MockB.prototype.b1 = function() {
  return 99;
};

在AngularJS里,依赖注入的目的是为了减少组件间的耦合,它的实现是这个过程:

function Art(Bar, Car) {}

我怎么知道这个Art在实例化的时候要传入Bar和Car的实例呢?形参名称是没法取到的,所以只有狠一点,用toString()来取到刚才这一行字符串,然后用正则表达式取到Bar和Car这两个字符串,然后到模块映射中取到对应的模块,实例化之后传入。

但是这样也有问题,如果这个js被压缩了,很可能命名都变了,压缩成了这样:

function a1(b1, b2) {}

这时候再这样就不知道原先是什么类型了。在这里,有类型声明的语言就不会有问题,比如:

function art(bar:Bar, car:Car) : Art {}

就算你把art, bar, car都改名了,也还是能知道类型,但js里不行。所以,怎么办呢?

aaa.controller("Art", [function(Bar, Car) {}, "Bar", "Car"]);

注意在AngularJS里面,他很可能建议你这么写,但也可以这么写:

Art.$inject = ["Bar", "Car"];

这么一来,我只要拿到Art,就能取到依赖项的名称了,就可以实例化再注入,也不怕压缩了。

@徐飞

回答了 IoC 的基本概念,并举例说明了 IoC 有助于提高可测性的好处,同时也详细解答了angular 的 IoC 解决方案,我这里补充些实际的场景案例来说明下 di 对模块化复用的巨大优势,以及脱离 angualr 的 IoC 解决方案。

实际场景

我们的业务系统是基于 MVC 的架构,假设我们现在有一个项目A,要开发一个列表的页面,于是骨架代码会这样:

// A/ListModel.js
define(
    function (require) {
        function ListModel() {
            this.store = {};
        }

        ListModel.prototype.load = function () {
            this.set('items', [1, 2, 3, 4, 5]);
        };

        ListModel.prototype.set = function (key, value) {
            this.store[key] = value;
        };

        ListModel.prototype.get = function (key) {
            return this.store[key];
        };

        return ListModel;
    }
);

// A/ListView.js
define(
    function (require) {

        function ListView() {}

        ListView.prototype.render = function () {
            document.body.innerHTML = this.model.get('items');
        };

        return ListView;
    }
);


// A/ListController.js
define(
    function (require) {
        var Model = require('./ListModel');
        var View = require('./ListView');


        function ListController() {
            this.model = new Model();
            this.view = new View();
            this.view.model = this.model;
        }

        ListController.prototype.enter = function () {
            this.model.load();
            this.view.render();
        };

        return ListController;
    }
);

// A/main.js
define(
    function (require) {
        var List = require('ListController');
        var list = new List();
        list.enter();
    }
);

运行结果就是在页面中展示列表数据。

过了一段时间,另一个新项目B来了,B项目也要开发一个列表页,但交互展示和A项目的列表页是一致的,不同之处在于数据源要来自 B 项目的后端。 我们来看看 A项目的代码,发现ListController.js, ListView.js好像都不用变,仅仅需要覆写 ListModel.js的load方法,使得其加载的数据来自 B 项目就解决了数据源变化的需求。

好的,面向对象的多态解决方案来了,我们继承 A 项目的 ListModel就好了:

// B/ListModel.js
define(
    function (require) {

        var AListModel = require('A/ListModel');

        function ListModel() {
            AListModel.apply(this, arguments);
        }

        // 数据源设置为了 B 的数据 
        ListModel.prototype.load = function () {
            this.set('items', [5, 4, 3, 2, 1]);
        };
        
        return ListModel;
    }
);

我们在 B/ListModel.js中重写了 load 方法,接下来就是怎么去重用A/ListController, A/ListView, 继续看下 A 的代码,看到 A/ListController中的:

var Model = require('./ListModel');
var View = require('./ListView');

我去,这直接依赖了当前包的ListModel和 ListView 喂~~,ListView 还好咱不用变,ListModel 咋办? 咋替换成B/ListModel啊?

上述问题的本质在于,A 项目的代码针对了具体实现编程,而非接口。 A/ListController直接依赖了具体实现(A/ListModel, A/ListView),这使得其复用性大大降低(好像你只能去复制粘贴代码,改其中几行来复用了- -!) 。

于是我们重构下 A项目的代码,将依赖外置,由外部传入依赖实例,也就是具体实现,不同的项目有不同的实现,但都遵守同一个接口(js 中则是隐式接口):

// A/ListController.js
define(
    function (require) {

        function ListController(model, view) {
            this.model = model;
            this.view = view;
            this.view.model = this.model;
        }

        ListController.prototype.enter = function () {
            this.model.load();
            this.view.render();
        };

        return ListController;
    }
);

// A/main.js
define(
    function (require) {
        var List = require('ListController');
        var Model = require('ListModel');
        var View = require('ListView');

        var model = new Model();
        var view = new View();
        var list = new List(model, view);
        
        list.enter();
    }
);

上面的代码中将A/ListController对具体 model 和 view 的依赖都外置了,由外部(这里是 A/main.js)创建好传入构造函数,A/ListController对model 和 view 如何构造,是怎么实现的都不需要关心,只要知道 model 实现了 load 接口,view 实现了 render 接口就行了。 好了,到这一步基本解决了对具体依赖的解耦,接下来我们看看 B 项目的代码怎么写:

// B/ListModel.js
define(
    function (require) {

        var AListModel = require('A/ListModel');

        function ListModel() {
            AListModel.apply(this, arguments);
        }

        // 继承 A/ListModel
        ListModel.prototype = new AListModel();
        
        // 数据源设置为了 B 的数据 
        ListModel.prototype.load = function () {
            this.set('items', [5, 4, 3, 2, 1]);
        };
        
        return ListModel;
    }
);

// B/main.js
define(
    function (require) {
        // 重用 A项目的Controller 和 View
        var List = require('A/ListController');
        var View = require('A/ListView');

        // 引入自己的定制 Model
        var Model = require('ListModel');

        var model = new Model();
        var view = new View();
        // 由构造函数将 model 和 view 两个依赖注入给控制器
        var list = new List(model, view);

        list.enter();
    }
);

我们看到,通过依赖注入,B 项目的列表开发工作量仅仅是简单的重写 A.ListMdel#load,以及在入口文件处创建好依赖即可。控制和视图的开发工作量都节约下来了,这无疑是巨大的收益。

简单小结

真正的项目复杂性远不止上面代码中的这么简单,还包括了数据源对象,模板,对 dom 封装的控件等其他模块。

我们系统原来的 mvc 架构,开发一个业务模块将 m,v,c等各个依赖紧紧的耦合在了一起,随着业务的发展,项目也越来越多,我们发现这些项目具有很多共同点,仅仅是局部不同,于是我们通过控制反转将业务模块中各个容易变化的部件抽象解耦,不同的项目去实现自己的定制需求,而通用代码不要重复开发,大概的架构演变如下图(Action 对应代码中的 Controller):


基本思路都躲不过:封装变化的,固化不变的。难点在于区分哪些是变化的,哪些又是不变的。

IoC的解决方案

上面的代码中,我们将模块的依赖在 main.js 中手动创建好,然后调用模块的构造函数传入,这个过程就是依赖反转,依赖的创建转移给了外部 main.js,模块仅仅做获取依赖的工作。这一过程我们发现也是冗余重复的,当需要创建的依赖多了后,main.js的代码也要随之冗余膨胀,于是有了 IoC 容器来做这一过程:项目声明依赖配置,IoC 容器根据配置做好相关的依赖创建工作即可。


在 angular 中,依赖声明是在构造函数中或者$inject中做的,在构造函数中angualr根据命名参数去查找依赖声明,并做好依赖的创建工作,原理自然是利用 Function#toString方法了,所以说怕压缩,不过这点也不是什么大不了的问题,一个方案是上面答案提到的写死$inject或者在controller 工厂参数中写好依赖,另一个是通过构件工具去做:比如:

npmjs.com/package/ng-an

,我推荐后者。

但 angular 的依赖注入存在以下问题:

1. 和 angular 紧密整合,移植成本较大。

2. 依赖注入方式单一,仅有构造函数注入。要是一个模块依赖很多的话,构造函数中的依赖声明得写脱。

既然我们可以通过构造函数传入依赖,那完全也可以提供另一个函数给 IoC 容器,让 IoC 容器调用这个函数传入依赖,这个注入方式称之为接口注入;如果函数命名风格为setter(setXXX),又可以称之为 setter注入;再加上 js 语言的动态性,可以动态的给对象赋值新属性,于是我们又可以这样注入:instance.dependency = xxxx, 这个我们暂时称之为属性注入。

3. 未能和模块加载器结合。 在浏览器环境中,很多场景都是异步的过程,我们需要的依赖模块并不是一开始就加载好的,或许我们在创建的时候才会去加载依赖模块,再进行依赖创建,而 angualr 的 IoC 容器没法做到这点。

以下是软文


针对 angular 的这些问题,我们自己开发了一个 IoC 容器:

ecomfe/uioc · GitHub

,主要特点:

1. 独立的库,不和任何框架整合,随便用。

2. 配置上支持 AMD/CMD 规范的异步 Loader (nodejs自不必说,同步 loader更简单了)。

3. 丰富的 IoC 注入方式:setter 注入,构造函数注入,属性注入。

4. 简化配置的方案:根据setter自动注入,配置导入。

5. 依赖作用域的管理:单例,多例,静态。

6. 支持construtor和factory两种依赖构造方式。

拿上面的B 项目用我们的 IoC容器改造后如下:

// B/config.js
define(
    {
        // key 为提供给 ioc 的组件id,值为相关配置
        list: {
            // 组件所在的模块id,这里复用了 A的 ListController
            module: 'A/ListController',
            // 构造函数注入,$ref声明依赖,两个依赖id分别为 model 和 view
            args: [
                {$ref: 'model'}, {$ref: 'view'}
            ]
        },

        // 这里使用了B定制的 ListModel
        model: {module: 'B/ListModel'},

        view: {module: 'A/ListView'}
    }
);


// B/ListModel.js
define(
    function (require) {

        var AListModel = require('A/ListModel');

        function ListModel() {
            AListModel.apply(this, arguments);
        }

        // 继承 A/ListModel
        ListModel.prototype = new AListModel();

        // 数据源设置为了 B 的数据 
        ListModel.prototype.load = function () {
            this.set('items', [5, 4, 3, 2, 1]);
        };

        return ListModel;
    }
);

// B/main.js
define(
    function (require) {
        var IoC = require('uioc');
        var config = require('config');

        // 实例化ioc容器,传入配置注册各个组件
        var ioc = IoC(config);

        // 获取 list 组件后,调用对应的 enter 方法
        ioc.getComponent('list', function (list) {
            list.enter();
        });
    }
);

嗯,改造后,以后要是有个 C 项目,在view 层上有定制需求,那 C 就自己继承或者重写一个 ListView,在配置中将 view 定制为 C/ListView即可完成新一轮的开发。变的只是配置和定制部分。

好了- -,uioc 这货的配置 api 和 spring ioc 是不是很像?嗯,配置语法确实是参考了 spring,在此基础上,整合了前端的 loader, 顺便利用了js 的动态性做了一些扩充。