JavaScript依赖注入的四种实现方式与inversify.js解析
本文系统梳理了JavaScript中依赖注入的四种主流实现模式。从最初利用Function.prototype.toString解析参数名的基础方案,到AngularJS基于双Injector和Provider机制的高级注入,再到TypeScript结合装饰器与元数据反射的现代化实践,最后深入解析inversify.js如何构建符合SOLID原则的IoC容器。文章通过代码示例对比了各方案的优劣及适用场景,为前端工程化中的依赖管理提供了完整的技术参考。
JavaScript依赖注入概述
作为面向对象编程中实现控制反转(Inversion of Control,下文称IoC)的核心手段之一,依赖注入(Dependency Injection,下文称DI)在OOP领域应用广泛且持久不衰。例如在J2EE生态中,Spring框架便是这一模式的标杆。JavaScript社区同样进行了积极探索,AngularJS便是基于DI架构构建的知名框架。然而,由于JavaScript缺乏反射机制且不支持Annotation语法,长期以来并未出现类似Spring的成熟框架。随着ECMAScript标准快速迭代,各类方言与框架蓬勃发展,可以预见,优秀的JavaScript原生DI框架的出现已是大势所趋。
本文旨在总结JavaScript中常见的依赖注入实现方式,并以inversify.js为例,展示社区在DI框架领域的探索成果。全文共分为四个部分进行阐述。
一. 基于Injector、Cache和函数参数名的依赖注入 二. AngularJS中基于双Injector的依赖注入 三. TypeScript中基于装饰器和反射的依赖注入 四. inversify.js——Javascript技术栈中的IoC容器
基于参数名的基础依赖注入
一. 基于Injector、Cache和函数参数名的依赖注入
尽管JavaScript原生不支持反射语法,但Function.prototype上的toString方法提供了替代方案,使得在运行时探查函数内部结构成为可能。toString方法会返回包含function关键字及完整函数定义的字符串。基于此,我们可以利用正则表达式提取函数所需的参数名,从而推断函数的运行依赖。 例如,Student类上的write方法签名write(notebook, pencil)表明其执行依赖于notebook和pencil对象。因此,我们可以先将这两个对象存入缓存(cache),再通过注入器(injector)向write方法提供这些依赖:
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...
为了保持更好的封装性,缓存对象不一定需要暴露给外部作用域,更多时候它们以闭包变量或私有属性的形式存在:
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即可:
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添加额外属性的方式来明确声明其依赖:
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的依赖注入
熟悉AngularJS的开发者会注意到,在injector注入之前,我们可以通过调用module的config方法来配置随后将被注入的对象。典型场景包括路由配置中的$routeProvider。与上一节直接将现成对象(如new Notebook())存入cache的做法不同,AngularJS中的依赖注入还包含“实例化”或“调用工厂方法”的过程。 这便引出了providerInjector、instanceInjector以及它们各自拥有的providerCache和instanceCache的概念。 在AngularJS中,我们通常获取的是instanceInjector,而providerInjector则以闭包变量的形式存在。每当我们需要依赖注入服务时(例如获取notebook),instanceInjector会首先查询instanceCache中是否存在notebook属性。若存在,则直接注入;若不存在,则将任务转交给providerInjector。providerInjector会将“Provider”字符串拼接到“notebook”后,形成新键名“notebookProvider”,并在providerCache中查询。若未找到,则抛出Unknown Provider异常:
若找到该provider,则将其返回给instanceInjector。instanceInjector获取notebookProvider后,会调用其工厂方法$get,获取返回值notebook对象。该对象会被放入instanceCache以备后续使用,同时也被注入到最初声明依赖的函数中。
需要注意的是,AngularJS的依赖注入方式存在缺陷:利用单个instanceInjector服务全局会带来副作用,导致无法单独跟踪和控制某一条依赖链条。即使在没有交叉依赖的情况下,不同module中的同名provider也会产生覆盖,此处不再展开。
另外,对于习惯Java和C#等语言中高级IoC容器的开发者而言,上述方式可能显得别扭。因为在OOP中,我们通常不将依赖以参数形式传递给方法,而是通过constructor或setters作为属性传递给实例,以实现封装。确实如此,第一、二节中的依赖注入方式未能充分体现面向对象特性,毕竟这些方式在JavaScript中已存在多年,甚至无需ES5语法支持。希望了解JavaScript社区近年关于依赖注入研究成果的读者,可继续阅读后续内容。
TypeScript装饰器与反射注入
三. TypeScript中基于装饰器和反射的依赖注入
博主对JavaScript各类方言的学习热情一般,尤其是ECMAScript提案和草案更新迅速,很多时候借助polyfill和babel的preset即可满足需求。但TypeScript是一个例外(尽管Decorator目前仍是早期提案,但已有polyfill可用)。前文提到,JavaScript社区迟迟未出现优秀IoC容器与其语言特性有关。就依赖注入而言,TypeScript带来了哪些不同?至少有以下三点: * TypeScript增加了编译时类型检查,使JavaScript具备了一定的静态语言特性 * TypeScript支持装饰器(Decorator)语法,与传统注解(Annotation)相似 * TypeScript支持元信息(Metadata)反射,无需再调用Function.prototype.toString方法 下面我们将尝试利用TypeScript的新语法来规范和简化依赖注入。此次我们不再向函数或方法中注入依赖,而是向类的构造函数中注入。 TypeScript支持对类、方法、属性和函数参数进行装饰,此处需使用类的装饰。继续上一节的例子,利用TypeScript对代码进行重构:
class Pencil {
public printName() {
console.log('this is a pencil');
}
}
class Eraser {
public printName() {
console.log('this is an eraser');
}
}
class Notebook {
public printName() {
console.log('this is a notebook');
}
}
class Student {
pencil: Pencil;
eraser: Eraser;
notebook: Notebook;
public constructor(notebook: Notebook, pencil: Pencil, eraser: Eraser) {
this.notebook = notebook;
this.pencil = pencil;
this.eraser = eraser;
}
public write() {
if (!this.notebook || !this.pencil) {
throw new Error('Dependencies not provided!');
}
console.log('writing...');
}
public draw() {
if (!this.notebook || !this.pencil || !this.eraser) {
throw new Error('Dependencies not provided!');
}
console.log('drawing...');
}
}
以下是injector和装饰器Inject的实现。injector的resolve方法在接收到传入的构造函数时,会通过name属性提取该构造函数的名字(例如class Student的name属性为字符串“Student”)。接着以Student为key,在dependenciesMap中取出Student的依赖。至于dependenciesMap中依赖关系何时存入,这是装饰器Inject的逻辑,后文会涉及。取出Student的依赖后,由于这些依赖已是构造函数的引用而非简单字符串(如Notebook、Pencil的构造函数),因此可直接使用new语句获取这些对象。获取到Student类所依赖的对象后,如何将这些依赖作为构造函数的参数传入Student中?最简单的方法是使用ES6的spread操作符。在无法使用ES6的环境下,也可通过伪造一个构造函数来完成上述逻辑。注意,为使instanceof操作符不失效,该伪造构造函数的prototype属性应指向原构造函数的prototype属性。
var dependenciesMap = {};
var injector = {
resolve: function (constructor) {
var dependencies = dependenciesMap[constructor.name];
dependencies = dependencies.map(function (dependency) {
return new dependency();
});
// 如果可以使用ES6的语法,下面的代码可以合并为一行:
// return new constructor(...dependencies);
var mockConstructor: any = function () {
constructor.apply(this, dependencies);
};
mockConstructor.prototype = constructor.prototype;
return new mockConstructor();
}
};
function Inject(...dependencies) {
return function (constructor) {
dependenciesMap[constructor.name] = dependencies;
return constructor;
};
}
injector和装饰器Inject逻辑完成后,即可用来装饰class Student并享受依赖注入带来的便利:
// 装饰器的使用非常简单,只需要在类定义的上方添加一行代码
// Inject是装饰器的名字,后面是function Inject的参数
@Inject(Notebook, Pencil, Eraser)
class Student {
pencil: Pencil;
eraser: Eraser;
notebook: Notebook;
public constructor(notebook: Notebook, pencil: Pencil, eraser: Eraser) {
this.notebook = notebook;
this.pencil = pencil;
this.eraser = eraser;
}
public write() {
if (!this.notebook || !this.pencil) {
throw new Error('Dependencies not provided!');
}
console.log('writing...');
}
public draw() {
if (!this.notebook || !this.pencil || !this.eraser) {
throw new Error('Dependencies not provided!');
}
console.log('drawing...');
}
}
var student = injector.resolve(Student);
console.log(student instanceof Student); // true
student.notebook.printName(); // this is a notebook
student.pencil.printName(); // this is a pencil
student.eraser.printName(); // this is an eraser
student.draw(); // drawing
student.write(); // writing
利用装饰器,我们还可以实现一种较为激进的依赖注入方式,下文称之为RadicalInject。RadicalInject对原代码侵入性较强,未必适合具体业务场景,此处一并介绍。要理解RadicalInject,需深入理解TypeScript装饰器的原理及Array.prototype上的reduce方法。
function RadicalInject(...dependencies){
var wrappedFunc:any = function (target: any) {
dependencies = dependencies.map(function (dependency) {
return new dependency();
});
// 使用mockConstructor的原因和上例相同
function mockConstructor() {
target.apply(this, dependencies);
}
mockConstructor.prototype = target.prototype;
// 为什么需要使用reservedConstructor呢?因为使用RadicalInject对Student方法装饰之后,
// Student指向的构造函数已经不是一开始我们声明的class Student了,而是这里的返回值,
// 即reservedConstructor。Student的指向变了并不是一件不能接受的事,但是如果要
// 保证student instanceof Student如我们所期望的那样工作,这里就应该将
// reservedConstructor的prototype属性指向原Student的prototype
function reservedConstructor() {
return new mockConstructor();
}
reservedConstructor.prototype = target.prototype;
return reservedConstructor;
}
return wrappedFunc;
}
使用RadicalInject,原构造函数实质上已被一个新函数代理,使用上更为简单,甚至无需再实现injector:
@RadicalInject(Notebook, Pencil, Eraser)
class Student {
pencil: Pencil;
eraser: Eraser;
notebook: Notebook;
public constructor() {}
public constructor(notebook: Notebook, pencil: Pencil, eraser: Eraser) {
this.notebook = notebook;
this.pencil = pencil;
this.eraser = eraser;
}
public write() {
if (!this.notebook || !this.pencil) {
throw new Error('Dependencies not provided!');
}
console.log('writing...');
}
public draw() {
if (!this.notebook || !this.pencil || !this.eraser) {
throw new Error('Dependencies not provided!');
}
console.log('drawing...');
}
}
// 不再出现injector,直接调用构造函数
var student = new Student();
console.log(student instanceof Student); // true
student.notebook.printName(); // this is a notebook
student.pencil.printName(); // this is a pencil
student.eraser.printName(); // this is an eraser
student.draw(); // drawing
student.write(); // writing
由于class Student的constructor方法需接收三个参数,直接无参调用new Student()会导致TypeScript编译器报错。此处仅为分享思路,读者可暂时忽略此错误。有兴趣的读者也可尝试类似思路代理工厂方法而非直接代理构造函数,以避免此类错误,此处不再展开。
AngularJS2团队为了获得更好的装饰器和反射语法支持,曾计划另起炉灶,基于AtScript(AtScript中的“A”指Annotation)开发新框架。但最终选择拥抱TypeScript,从而形成了微软与谷歌的合作局面。
需要说明的是,在缺乏相关标准和浏览器厂商支持的情况下,TypeScript在运行时仅是纯粹的JavaScript,下一节的例子将印证这一点。
inversify.js IoC容器实战
四. inversify.js——Javascript技术栈中的IoC容器
从JavaScript出现各种支持高级语言特性的方言即可预见,IoC容器的出现只是时间问题。例如博主今天要介绍的基于TypeScript的inversify.js,便是其中的先行者之一。 inversify.js比博主前文实现的例子更为先进,其最初设计目的便是为了让前端工程师能在JavaScript中写出符合SOLID原则的代码,立意高远。体现在代码中,便是处处有接口,将“Depend upon Abstractions. Do not depend upon concretions.”(依赖于抽象,而非依赖于具体)体现得淋漓尽致。继续使用上面的例子,但由于inversify.js面向接口,上述代码需进一步重构:
interface NotebookInterface {
printName(): void;
}
interface PencilInterface {
printName(): void;
}
interface EraserInterface {
printName(): void;
}
interface StudentInterface {
notebook: NotebookInterface;
pencil: PencilInterface;
eraser: EraserInterface;
write(): void;
draw(): void;
}
class Notebook implements NotebookInterface {
public printName() {
console.log('this is a notebook');
}
}
class Pencil implements PencilInterface {
public printName() {
console.log('this is a pencil');
}
}
class Eraser implements EraserInterface {
public printName() {
console.log('this is an eraser');
}
}
class Student implements StudentInterface {
notebook: NotebookInterface;
pencil: PencilInterface;
eraser: EraserInterface;
constructor(notebook: NotebookInterface, pencil: PencilInterface, eraser: EraserInterface) {
this.notebook = notebook;
this.pencil = pencil;
this.eraser = eraser;
}
write() {
console.log('writing...');
}
draw() {
console.log('drawing...');
}
}
由于使用了inversify框架,我们无需自行实现injector和Inject装饰器,只需从inversify模块中引用相关对象:
import { Inject } from "inversify";
@Inject("NotebookInterface", "PencilInterface", "EraserInterface")
class Student implements StudentInterface {
notebook: NotebookInterface;
pencil: PencilInterface;
eraser: EraserInterface;
constructor(notebook: NotebookInterface, pencil: PencilInterface, eraser: EraserInterface) {
this.notebook = notebook;
this.pencil = pencil;
this.eraser = eraser;
}
write() {
console.log('writing...');
}
draw() {
console.log('drawing...');
}
}
这样就够了吗?还记得前文提到的TypeScript中各种概念仅是语法糖吗?与上一节直接将constructor引用传递给Inject不同,由于inversify.js面向接口,诸如NotebookInterface、PencilInterface之类的接口仅由TypeScript提供的语法糖,在运行时并不存在。因此,我们在装饰器中声明依赖时只能使用字符串形式而非引用形式。不过不用担心,inversify.js为我们提供了bind机制,在接口的字符串形式和具体构造函数之间搭建了桥梁:
import { TypeBinding, Kernel } from "inversify";
var kernel = new Kernel();
kernel.bind(new TypeBinding("NotebookInterface", Notebook));
kernel.bind(new TypeBinding("PencilInterface", Pencil));
kernel.bind(new TypeBinding("EraserInterface", Eraser));
kernel.bind(new TypeBinding("StudentInterface", Student));
注意此步需从inversify模块中引入TypeBinding和Kernel,并且为了保证返回值类型以及整个编译时静态类型检查顺利通过,泛型语法也被使用了起来。 说到这里,理解new TypeBinding(“NotebookInterface”, Notebook)便很自然了:为依赖于“NotebookInterface”字符串的类提供Notebook类的实例,返回值向上溯型到NotebookInterface。 完成这些步骤后,使用方式也较为顺畅:
var student: StudentInterface = kernel.resolve("StudentInterface");
console.log(student instanceof Student); // true
student.notebook.printName(); // this is a notebook
student.pencil.printName(); // this is a pencil
student.eraser.printName(); // this is an eraser
student.draw(); // drawing
student.write(); // writing
最后,顺带提及ECMAScript中相关提案的现状与进展。Google的AtScript团队曾有过Annotation的提案,但AtScript项目终止,该提案自然随之搁置。目前较有望成为es7标准的是一个关于装饰器的提案:https://github.com/wycats/javascript-decorators。感兴趣的读者可前往相关GitHub页面跟踪了解。尽管DI仅是OOP编程众多模式和特性之一,但它折射出JavaScript在OOP道路上艰难前行的历程。总体而言,这也算得上路途坦荡,前途光明。回到依赖注入话题,一边是翘首以盼的JavaScript社区,一边是姗姗来迟的IoC容器,二者最终能产生怎样的化学反应,让我们拭目以待。
怎么优化 React 应用性能?避免不必要的重渲染
2026-11-05 12:43:40
怎么利用 Nuxt.js useHead 优化 SEO 与元数据管理
2026-11-05 10:37:58
JS混淆常见手段解析:字符串加密与控制流平坦化还原思路
2026-11-04 19:07:42
JavaScript能在浏览器中直接获取MAC地址吗
2026-11-03 20:34:17
JavaScript策略模式:用对象映射替代if-else分支
2026-11-03 19:46:11
