作为面向对象编程中实现控制反转(Inversion of Control,下文称IoC)最常见的技术手段之一,依赖注入(Dependency Injection,下文称DI)可谓在OOP编程中大行其道经久不衰。比如在J2EE中,就有大名鼎鼎的执牛耳者Spring。Javascript社区中自然也不乏一些积极的尝试,广为人知的AngularJS很大程度上就是基于DI实现的。遗憾的是,作为一款缺少反射机制、不支持Annotation语法的动态语言,Javascript长期以来都没有属于自己的Spring框架。当然,伴随着ECMAScript草案进入快速迭代期的春风,Javascript社区中的各种方言、框架可谓群雄并起,方兴未艾。可以预见到,优秀的JavascriptDI框架的出现只是早晚的事。
本文总结了Javascript中常见的依赖注入方式,并以inversify.js为例,介绍了方言社区对于Javascript中DI框架的尝试和初步成果。文章分为四节:
一. 基于Injector、Cache和函数参数名的依赖注入
二. AngularJS中基于双Injector的依赖注入
三. TypeScript中基于装饰器和反射的依赖注入
四. inversify.js——Javascript技术栈中的IoC容器
一. 基于Injector、Cache和函数参数名的依赖注入
尽管Javascript中不原生支持反射(Reflection)语法,但是Function.prototype上的toString方法却为我们另辟蹊径,使得在运行时窥探某个函数的内部构造成为可能:toString方法会以字符串的形式返回包含function关键字在内的整个函数定义。从这个完整的函数定义出发,我们可以利用正则表达式提取出该函数所需要的参数,从而在某种程度上得知该函数的运行依赖。
比如Student类上write方法的函数签名write(notebook, pencil)就说明它的执行依赖于notebook和pencil对象。因此,我们可以首先把notebook和pencil对象存放到某个cache中,再通过injector(注入器、注射器)向write方法提供它所需要的依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
var cache = {}; // 通过解析Function.prototype.toString()取得参数名 function getParamNames(func) { // 正则表达式出自http://krasimirtsonev.com/blog/article/Dependency-injection-in-JavaScript var paramNames = func.toString().match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)[1]; paramNames = paramNames.replace(/ /g, ''); paramNames = paramNames.split(','); return paramNames; } var injector = { // 将func作用域中的this关键字绑定到bind对象上,bind对象可以为空 resolve: function (func, bind) { // 取得参数名 var paramNames = getParamNames(func); var params = []; for (var i = 0; i < paramNames.length; i++) { // 通过参数名在cache中取出相应的依赖 params.push(cache[paramNames[i]]); } // 注入依赖并执行函数 func.apply(bind, params); } }; function Notebook() {} Notebook.prototype.printName = function () { console.log('this is a notebook'); }; function Pencil() {} Pencil.prototype.printName = function () { console.log('this is a pencil'); }; function Student() {} Student.prototype.write = function (notebook, pencil) { if (!notebook || !pencil) { throw new Error('Dependencies not provided!'); } console.log('writing...'); }; // 提供notebook依赖 cache['notebook'] = new Notebook(); // 提供pencil依赖 cache['pencil'] = new Pencil(); var student = new Student(); injector.resolve(student.write, student); // writing... |
有时候为了保证良好的封装性,也不一定要把cache对象暴露给外界作用域,更多的时候是以闭包变量或者私有属性的形式存在的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function Injector() { this._cache = {}; } Injector.prototype.put = function (name, obj) { this._cache[name] = obj; }; Injector.prototype.getParamNames = function (func) { // 正则表达式出自http://krasimirtsonev.com/blog/article/Dependency-injection-in-JavaScript var paramNames = func.toString().match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)[1]; paramNames = paramNames.replace(/ /g, ''); paramNames = paramNames.split(','); return paramNames; }; Injector.prototype.resolve = function (func, bind) { var self = this; var paramNames = self.getParamNames(func); var params = paramNames.map(function (name) { return self._cache[name]; }); func.apply(bind, params); }; var injector = new Injector(); var student = new Student(); injector.put('notebook', new Notebook()); injector.put('pencil', new Pencil()) injector.resolve(student.write, student); // writing... |
比如现在要执行Student类上的另一个方法function draw(notebook, pencil, eraser),因为injector的cache中已经有了notebook和pencil对象,我们只需要将额外的eraser也存放到cache中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function Eraser() {} Eraser.prototype.printName = function () { console.log('this is an eraser'); }; // 为Student增加draw方法 Student.prototype.draw = function (notebook, pencil, eraser) { if (!notebook || !pencil || !eraser) { throw new Error('Dependencies not provided!'); } console.log('drawing...'); }; injector.put('eraser', new Eraser()); injector.resolve(student.draw, student); |
通过依赖注入,函数的执行和其所依赖对象的创建逻辑就被解耦开来了。
当然,随着grunt/gulp/fis等前端工程化工具的普及,越来越多的项目在上线之前都经过了代码混淆(uglify),因而通过参数名去判断依赖并不总是可靠,有时候也会通过为function添加额外属性的方式来明确地说明其依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Student.prototype.write.depends = ['notebook', 'pencil']; Student.prototype.draw.depends = ['notebook', 'pencil', 'eraser']; Injector.prototype.resolve = function (func, bind) { var self = this; // 首先检查func上是否有depends属性,如果没有,再用正则表达式解析 func.depends = func.depends || self.getParamNames(func); var params = func.depends.map(function (name) { return self._cache[name]; }); func.apply(bind, params); }; var student = new Student(); injector.resolve(student.write, student); // writing... injector.resolve(student.draw, student); // draw... |
二. AngularJS中基于双Injector的依赖注入
熟悉AngularJS的同学很快就能联想到,在injector注入之前,我们在定义module时还可以调用config方法来配置随后会被注入的对象。典型的例子就是在使用路由时对$routeProvider的配置。也就是说,不同于上一小节中直接将现成对象(比如new Notebook())存入cache的做法,AngularJS中的依赖注入应该还有一个”实例化”或者”调用工厂方法”的过程。
这就是providerInjector、instanceInjector以及他们各自所拥有的providerCache和instanceCache的由来。
在AngularJS中,我们能够通过依赖注入获取到的injector通常是instanceInjector,而providerInjector则是以闭包中变量的形式存在的。每当我们需要AngularJS提供依赖注入服务时,比如想要获取notebook,instanceInjector会首先查询instanceCache上是存在notebook属性,如果存在,则直接注入;如果不存在,则将这个任务转交给providerInjector;providerInjector会将”Provider”字符串拼接到”notebook”字符串的后面,组成一个新的键名”notebookProvider”,再到providerCache中查询是否有notebookProvider这个属性,如有没有,则抛出异常Unknown Provider异常:
如果有,则将这个provider返回给instanceInjector;instanceInjector拿到notebookProvider后,会调用notebookProvider上的工厂方法$get,获取返回值notebook对象,将该对象放到instanceCache中以备将来使用,同时也注入到一开始声明这个依赖的函数中。过程描述起来比较复杂,可以通过下面的图示来说明:
需要注意的是,AngularJS中的依赖注入方式也是有缺陷的:利用一个instanceInjector单例服务全局的副作用就是无法单独跟踪和控制某一条依赖链条,即使在没有交叉依赖的情况下,不同module中的同名provider也会产生覆盖,这里就不详细展开了。
另外,对于习惯于Java和C#等语言中高级IoC容器的同学来说,看到这里可能觉得有些别扭,毕竟在OOP中,我们通常不会将依赖以参数的形式传递给方法,而是作为属性通过constructor或者setters传递给实例,以实现封装。的确如此,一、二节中的依赖注入方式没有体现出足够的面向对象特性,毕竟这种方式在Javascript已经存在多年了,甚至都不需要ES5的语法支持。希望了解Javascript社区中最近一两年关于依赖注入的研究和成果的同学,可以继