黎清龙

1 年前
  • 3446

    浏览
  • 2

    评论
  • 1

    收藏

前端需要知道的 依赖注入(Dependency Injection, DI)

本文作者:IMWeb 黎清龙 原文出处:IMWeb社区 未经同意,禁止转载

前端需要知道的 依赖注入(Dependency Injection, DI)

1. 前言

XX库实现了依赖注入,哇塞,好牛X呀~~~

切,依赖注入的实现那么简单,不就一个map + 函数参数解析而已吗?

可是,你真的了解 依赖注入(Dependency Injection, DI) 吗?

本文将详细解释什么是依赖注入,并解释属于前端的依赖注入

注意

  1. 本文专门为前端同学解释什么是依赖注入,文中例子也是js,非前端同学可以选择绕道
  2. 已经知道依赖注入的同学也可以绕道

2. 什么是 依赖注入

2.1. 它是模式

首先,依赖注入是一个设计模式,因为它解决的是一类问题

2.2. 理解它的作用域

要知道依赖注入是解决什么问题,最好先了解一个原则:

依赖倒转原则(Dependence Inversion Priciple, DIP)提倡:

  1. 高层模块不应该依赖低层模块。两个都应该依赖抽象
  2. 抽象不应该依赖细节,细节应该依赖抽象
  3. 针对接口编程,不要针对实现编程

在编程时,我们对系统进行模块化,它们之间有依赖,比如模块A依赖模块B

那么依据DIP,模块A应该依赖模块B的接口,而不应该依赖模块B的实现

这样做的好处就不详叙了

下图描述了这个关系图:

这里需要注意一点,虽然模块A只依赖接口编程,但在运行的时候,它还是需要有一个具体的模块来负责模块A需要的功能的,所以模块A在【运行时】是需要一个【真的】模块B,而不是它的接口

所以上图中,Module和Interface之间的线是包含,而不是关联

也就是说,模块A在【运行时】需要有一个接口的实现模块作为它的属性

那么这个实现模块怎么来?它是怎么初始化,然后怎么传给模块A的?

解决这个问题的就是依赖注入,这就是它的作用域

上面的结构图再扩展一下就是非常著名的设计模式——桥接

2.3. 前端的依赖注入

对于前端来说,很少有抽象,更别说有接口了

但是,依赖注入却是一直都存在,只是许多同学没有认出来而已

下面来看看前端最常见的一个依赖注入:

// moduleA.js
define('moduleA', ['moduleB'], function(moduleB) {
    return {
        init: function() {
            this.I_need = ModuleB.someFun();
        }
    };
});

这是个很普通的代码,太正常了,我们每天都会写这些代码,即使define包裹可能是构建帮我们写的

还记得前面说的依赖注入的作用域,它只做两件事:

  1. 初始化被依赖的模块
  2. 注入到依赖模块中

这个时候应该知道了,define就是做这些事的:

  1. 它负责初始化moduleB
  2. 它通过函数参数的形式注入到moduleA里面去

3. 依赖注入的作用

为什么需要依赖注入?它的作用和意义是什么?

关于这个,我们还是要从依赖注入做了什么事来探索:

1. 初始化被依赖的模块

如果不通过依赖注入模式来初始化被依赖的模块,那么就要依赖模块自己去初始化了

那么问题来了:依赖模块就耦合了被依赖模块的初始化信息了

2. 注入到依赖模块中

被依赖模块已经被其他管理器初始化了,那么依赖模块要怎么获取这个模块呢?

有两种方式:

  1. 自己去问
  2. 别人主动给你

没用依赖注入模式的话是1,用了之后就是2

想想,你需要某个东西的时候,你去找别人要,你需要提供别人什么信息?

最简单的就是那个东西叫什么,是的,正式一点,你需要一个名称

没错,方式1的问题是:依赖模块耦合了被依赖模块的【名称】还有那个【别人】

而方式2解决了这个问题,让依赖模块只依赖需要的模块的接口

可以看到,注入的两个方式的主动权是相反的

因此,依赖注入(Dependency Injection, DI) 有时候也被称为 控制反转(Inversion of Control, IoC)

它们不是一个东西,有兴趣的同学可以深入学习

3.1. 代码解释

文字比较抽象,那么我们用代码来说明依赖注入的作用以及好处

// config.js
require.config = {
    path: {
        jquery: 'common/jquery'
    }
};

// moduleA.js
define('moduleA', ['jquery'], function($) {
    return {
        init: function() {
            this.$dom = $('#id');
        }
    };
});

用过模块加载器的都知道,一般我们可以配置怎样去获取模块的定义,也就是模块的实现代码

一般是通过配置文件的形式

上面的代码很简单,moduleA依赖了jquery库,在模块加载器中,我们配置了jquery模块在哪里初始化

可以看到,jquery模块的代码是在本地的

现在,不管什么原因,我们想要使用一个线上代码库版本的jquery,怎么办?简单:

// config.js
require.config = {
    path: {
        jquery: 'http://path/to/online/jquery'
    }
};

可以看到,我们只需要修改模块加载器的配置就可以了

这个配置就是被依赖模块(jquery)的初始化信息

这个就是依赖注入的第一个好处:依赖模块与被依赖模块的初始化信息解耦

这个例子也是很常见的代码:

// moduleA.js
var $ = require('jquery');

module.exports = {
    init: function() {
        this.$dom = $('#id');
    }
};

聪明的同学已经看到问题在哪里了,没错,这个模块依赖了被依赖模块的名字

这里会有两个问题:

  1. 模块重名问题,还记得那些年我们给模块起名字的日子吗?
  2. 改变模块依赖方式

像jquery这种库,有许多都是最先加载,并且全局使用的:

// moduleA.js
module.exports = {
    init: function() {
        this.$dom = $('#id');
    }
};

对于这种情况,我们的组件代码就得改动了

不同的模块依赖方式给通用组件的实现造成了很大的困扰

为了不改动组件代码,通常我们这样做:

// jquery.js
module.exports = window.$;

当然,这是题外话了

从上面的例子应该可以知道,依赖注入帮助我们解决了依赖模块对被依赖模块的初始化解耦

4. 依赖注入模式的实现细节

4.1. 组件容器(模块管理器)

一般依赖注入模式都实现在某个容器中,在前端我们可以管它为模块管理器

组件容器负责管理所有的组件,管理他们的初始化,以及依赖,并提供接口获取组件

通常容器会把组件的初始化信息聚集在某个配置文件中,比如xml文件或者json文件等

这样做的好处是可以很轻易的修改组件的初始化信息,并且可以实现组件的热启动

对于前端来说,模块管理器,比如requireJs,就是负责模块的初始化工作的

但是模块加载器的重心不是依赖注入

因此这里提供一个依赖注入容器的简单例子:

// injector
// APP Instance -- Global & Singleton
var injector = {
    set: function(name, factory) {
        // name: the dependency name
        // factory: can be a factory function
        //          or just a value
    },
    get: function(name) {}
};

// a.js
injector.set('env', 'dev');

// b.js
injector.set('b', function() {
    return {
        sayYes: function() {
            console.log('Yes!');
        },
        sayNo: function() {
            console.log('No!');
        }
    };
});

// c.js
injector.set('c', function(env, b) {
    if (env === 'dev') {
        b.sayYes();
    } else {
        b.sayNo();
    }
});

实现起来并没有难点,injector其实就只是个map

用factory函数的好处是可以延迟模块的初始化

另外一个难点是要读取函数的形参名,但是我们也可以这样改来避开这个难点:

// injector
var injector = {
    set: function(name, array) {
        // name: the dependency name
    },
    get: function(name) {}
};

// c.js
injector.set('c', ['env', 'b', function(env, b) {
    if (env === 'dev') {
        b.sayYes();
    } else {
        b.sayNo();
    }
}]);

4.2. 初始化

可以看到模块管理器实际上只是一个容器

现在我们需要一个初始化模块,下面提供一个小栗子:

// initializer.js
function initializer() {
    // to load the module in initializer.config
}

initializer.config = {
    initList: ['./a.js', './b.js', 'http://path/to/other/module.js'],
    map: {
        'jquery': 'http://path/to/online/jquery.js'
    }
};

initializer();

可以看到,如果文件内容本身就有注册模块的代码的话,initializer只需要加载js文件即可,比如上面的a.js和b.js文件

当然也可以加载线上资源

如果文件内容没有注册模块的代码的话,就需要initializer自己帮忙注册了

比如栗子中的jquery

如果系统是服务器端的nodejs代码的话,就可以实现模块的热插拔了

4.3. 注入方式

被依赖模块怎样赋值给依赖模块,主要有三种方式

4.3.1. 构造函数注入

前面define和angular的依赖注入都是使用构造函数的注入方式,如下:

// define
define('moduleA', ['moduleB'], function(moduleB) {
    return {
        init: function() {
            this.I_need = ModuleB.someFun();
        }
    };
});

// anguler
someModule.controller('MyController', ['$scope', 'greeter', function($scope, greeter) {
  // ...
}]);

4.3.2. setter注入

直接上例子:

// moduleA.js
var moduleA = {
    do: function() {
        this.helper.doSomething();
    },
    setHelper: function(helper) {
        this.helper = helper;
    }
};

// initializer.js
function initializer() {
    // ...
    moduleA.setHelper(new Helper());
}

4.3.3. 接口注入

接口注入主要是把注入过程抽象成接口的形式,让注入方式可以被轻易扩展

在前端并不怎么使用接口,因此这种注入方式就不详述

5. 对比——服务定位模式 (Service Locator, SL)

读者可能对服务定位模式不太了解,但是看了下面的代码就知道了

var fs = require('fs');
var path = require('path');
var moduleB = require('./moduleB');
var moduleC = require('path/to/moduleC');

没错,require就是一个服务定位模式

所谓的服务定位模式就是把所有服务(模块)资源的管理都放到一个定位者那里

所有需要服务的模块都找它要就行了,就是这么简单

服务定位模式也能解决依赖注入的作用域问题

服务定位者负责初始化服务,它也提供服务资源

只是依赖注入是被动,服务定位模式需要模块自己主动去请求,详见【3. 依赖注入的作用】

对于前端来说,

服务定位模式肯定更常见,它的优点就是简单,缺点是所有模块都需要依赖定位者

依赖注入模式的优点是控制反转,更利于组件化,缺点是不是前端的基础能力(谁让require是基础。。。)

6. 结语

依赖注入模式并不神秘,也不是什么高大上

Java时代的Spring就已经把依赖注入推向顶峰

本文只想向前端同学传达:依赖注入的思想非常值得学习

2条评论

    您需要 注册 一个IMWeb账号或者 才能进行评论。