[AngularJS⾯⾯观]17.依赖注⼊---注解的定义与实现
本篇⽂章继续介绍angular⽤以实现依赖注⼊的关键元素之⼀ - 注解(Annotation)。
在前⼏篇⽂章中,我们已经分析和讨论了有关angular依赖注⼊的⼏个⽅⾯:
1.
2.
3.
既然我们定义的服务和数据都已经被angular注⼊器托管在其内部的缓存中了,接下来应该如何使⽤它呢?写过angular应⽤的同学们应该都写过下⾯这类代码:
var testApp = dule('test', []);
// ......
});
我们直接在testContoller的函数中定义了两个服务,$scope以及$rootScope,然后就能够在应⽤的业务逻辑中使⽤这两项服务了,不需要⾃⼰将它们创建出来,也不需要做什么特别的准备⼯作,⼀切都显的⽔到渠成。但是!程序开发并不是变魔法,看似不可思议的事情背后总是有⽀撑它发⽣的逻辑。我们都知道这个逻辑就是依赖注⼊,⽽依赖注⼊的关键在于注⼊器,所以问题就演变成了注⼊器是如何找到对应的被托管的对象的呢?
答案是通过注解(Annotation)。所谓注解,它的本质就是给源代码添加⼀些元数据。有Java开发经验的同学想必都见
过@Override,@Deprecated以及@SuppressWarnings这类常⽤注解吧。它们的意义分别是表明某个⽅法覆盖了/实现了⽗类型(可以是⽗类,也可以是接⼝)上的同名⽅法;表明某个⽅法已经废弃了,不推荐再使⽤;抑制编译器产⽣警告信息。因为这类信息⼗分必要,但是⼜不好直接以传统意义上的源代码的形式体现出来,所以才设计出了注解这种数据类型。
那么切换到angular的上下⽂中,⼜是如何来实现注解的呢?这个注解需要解决什么问题呢?这就是本篇⽂章需要分析和讨论的主题。
我们已经知道定义的各种服务的函数实际上并不由我们⾃⼰来调⽤,⽽是交给angular框架提供的注⼊器进⾏调⽤,在调⽤的过程中⾸先我们需要知道注⼊器在哪个阶段会需要使⽤到注解提供的信息。其
实在上⼀篇⽂章中在介绍注⼊器实例化托管对象的过程中可能发⽣循环依赖异常时就已经有⼀些线索了,这些线索就隐藏在异常的调⽤栈之中,我们来看看:
angular.js:13920 Error: [$injector:cdep] Circular dependency found: rvice1 <- rvice2 <- rvice1
/1.5.8/$injector/cdep?p0=rvice1%20%3C-%20rvice2%20%3C-%20rvice1
at angular.js:68
at getService (angular.js:4656)
at injectionArgs (angular.js:4688)
at Object.instantiate (angular.js:4730)
at Object.<anonymous> (angular.js:4573)
at Object.invoke (angular.js:4718)
forcedReturnValue [as $get] (angular.js:4557)
at Object.invoke (angular.js:4718)
at angular.js:4517
at getService (angular.js:4664)
可以看到在⼏个调⽤点: Object.instantiate -> injectionArgs -> getService。
毫⽆疑问,从字⾯意思上就能够理解这⾥发⽣了什么。⾸先注⼊器会尝试实例化⼀个被托管的对象,在实例化的过程中由于该对象也存在依赖关系,需要⾸先解析这些依赖关系,得到依赖关系之后,才能够调⽤getService完成真正的实例化操作。⽽解析依赖关系实际上就是我们所关注的注解的⽣成过程。那么在清楚了注解的应⽤场景后,让我们看看injectionArgs这个函数的逻辑是怎样的:
function injectionArgs(fn, locals, rviceName) {
var args = [],
// 获取注解信息
$inject = createInjector.$$annotate(fn, strictDi, rviceName);
for (var i = 0, length = $inject.length; i < length; i++) {
var key = $inject[i];
// 确保$inject中的每个key都是字符串类型,否则抛出异常
if (typeof key !== 'string') {
throw $injectorMinErr('itkn',
'Incorrect injection token! Expected rvice name as string, got {0}', key);
}
// 通过注解的key来得到的真正依赖的对象
args.push(locals && locals.hasOwnProperty(key) ? locals[key] :
getService(key, rviceName));
}
return args;
}
这段代码完成了⼏件事情:
烹调
1. 通过createInjector.$$annotate来得到所调⽤函数的注解信息$inject。这是我们需要关注的重点。
2. 遍历注解信息$inject,确保每个元素都是字符串类型(即被依赖的托管对象的名字),如果存在别的类型将直接抛出异常。
3. 通过$inject中的被依赖托管对象的名字来得到真正的被托管对象。最后将这些对象返回。
因此如何构建$inject就是问题的关键所在。构建$inject的过程被封装到了createInjector.$$annotate表⽰的函数中,⽽这个函数的实现在injector.js中,代码如下所⽰:
// 在injector.js的最后⼀⾏定义了如下代码:createInjector.$$annotate = annotate
function annotate(fn, strictDi, name) {
var $inject,
argDecl,
last;
if (typeof fn === 'function') {
if (!($inject = fn.$inject)) {
// 没有提供$inject并且⾮严格模式时,使⽤源码解析的⽅式构建$inject
$inject = [];
if (fn.length) {
if (strictDi) {
if (!isString(name) || !name) {
哪些国家过春节
name = fn.name || anonFn(fn);
}
throw $injectorMinErr('strictdi',
'{0} is not using explicit annotation and cannot be invoked in strict mode', name);
}
孕妇发烧怎么退烧
argDecl = extractArgs(fn);
forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) {
怎样创建公众号$inject.push(name);
});
});
}
fn.$inject = $inject;
}
} el if (isArray(fn)) {
// 当使⽤Array-Style的声明⽅式时,去掉最后⼀个元素即为$inject
last = fn.length - 1;
asrtArgFn(fn[last], 'fn');
$inject = fn.slice(0, last);
} el {
// 抛出异常
asrtArgFn(fn, 'fn', true);
}
// 得到注解信息供后续使⽤
return $inject;
}
看看上述代码的整体逻辑,可以发现$inject的构建⼤概有⼏种⽅式:
1. 直接给fn提供$inject属性。
2. fn类型是函数且没有fn上没有$inject这个属性并且strictDi不为true时,通过⼀段操作来得到$inject`。
3. 当fn为数组类型时,截取它的前n-1个元素作为 $inject。
这三种⽅式即为angular中注解的⼏种声明和⼯作⽅式。下⾯我们逐⼀进⾏介绍:
1. 直接提供$inject属性
⽰例代码如下所⽰:
var testApp = dule('test', []);
testCtrlFunc.$inject = ['aConstant', 'bConstant'];
function testCtrlFunc(a, b) {
// a 代表的就是 aConstant
// b 代表的就是 bConstant
}
这种⽅式简单粗暴,但是由于它还需要给函数附加⼀个属性,导致实际中很少⽤到。正是因为这⼀点,才有了第⼆种基于数组的注解声明⽅式的诞⽣。它将原来的函数替换成⼀个数组,数组的前n-1个元素表⽰的就是$inject的信息,最后⼀个元素为函数本⾝。
因此使⽤这种声明⽅式的代码是这个样⼦的:
var testApp = dule('test', []);
百叶居ller('testCtrl', ['aConstant', 'bConstant', testCtrlFunc]);
function testCtrlFunc(a, b) {
// a 代表的就是 aConstant
// b 代表的就是 bConstant
}
其实也是换汤不换药,换了个马甲就出来骗⼈了。这样的写法好处缩短了⼀点点代码量,代码也更加紧凑了。但是这还是满⾜不了懒⼈程序员们的需求,还是太⿇烦了。
于是第三种⽅式横空出世。在初学angular的时候,我们会写这样的代码:
var testApp = dule('test', []);
// 假设我们已经定义过了aConstant以及bConstant
// ......
});
这⾥我们既没有为控制器的函数声明$inject,也⽊有使⽤基于数组的注解声明⽅式。但是我们还是能⽤上aConstant以及bConstant,这不是”⿊魔法”是什么?我们来看看这个”⿊魔法”背后是什么逻辑在⽀撑着它,重点就在annotate函数中的下⾯这⼀段:
argDecl = extractArgs(fn);
forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) {
$inject.push(name);
});
});
// extractArgs函数的定义
function extractArgs(fn) {
var fnText = String.call(fn).replace(STRIP_COMMENTS, ''),
args = fnText.match(ARROW_ARG) || fnText.match(FN_ARGS);
return args;
}
// 使⽤到的各种正则表达式
var ARROW_ARG = /^([^\(]+?)=>/;
var FN_ARGS = /^[^\(]*\(\s*([^\)]*)\)/m;
var FN_ARG_SPLIT = /,/;
var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
这个功能初看上去严重依赖于正则表达式。的确,它的总体思路是去解析函数的源代码,从其中提取出参数列表,然后通过参数列表来构建出需要的$inject注解信息。
来看看具体的实现过程是怎么样的:
1. ⾸先,需要得到函数的源代码并将参数列表解析出来。
// 通过String.call(fn)得到函数的源代码,然后去除掉其中的注释
我说你做
var fnText = String.call(fn).replace(STRIP_COMMENTS, ''),
// 通过ARROW_ARG或者FN_ARGS解析得到参数列表并返回
args = fnText.match(ARROW_ARG) || fnText.match(FN_ARGS);
return args;
那么在获取函数的源代码时,为什么调⽤的是String.call(fn),⽽不是直接调⽤fn.toString呢?这⾥涉及到了⼀些JavaScript原型继承的概念。我们需要考虑⼀种情况,如果fn上重新定义了toString这个属性那不就没法得到源代码了嘛?所以,使⽤String能够保证调⽤的是函数类型的原型对象上的那个最原始的toString⽅法。
得到源代码后,通过将匹配到的注释代码替换成为”来完成注释的删除⼯作。有了不含有注释的源代码,就可以进⼀步通过匹配箭头函数参数列表的正则表达式以及常规函数参数列表的正则表达式来完成参数列表的解析了。
关于箭头函数,它实际上是ES6中的定义的规范之⼀,可以参考关于箭头函数的说明。它从形式上很接近Lambda表达式,这也是近些年来很多编程语⾔都会添加的特性之⼀,也是为了迎合⽇渐流⾏的函数式编程。⽽常规函数不⽤多说,就是我们最常见的那种定义函数的⽅式。箭头函数和常规函数的⽰例如下所⽰:
// 箭头函数
(aConstant, bConstant) => {
// ......
}
// 常规函数
function(aConstant, bConstant) {
// ......
}
2. 进⼀步解析参数列表,得到每个参数对应被托管对象的名字。
// argDecl就是匹配了参数列表的结果
argDecl = extractArgs(fn);
forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) {
$inject.push(name);
买年货作文});
});
注意上⾯调⽤了argDecl[1].split(FN_ARG_SPLIT)来得到由所有参数组成的⼀个数组。为什么这⾥取的是argDecl的第⼆个元素呢?是因为ARROW_ARG也好,FN_ARGS也好,都定义了分组。第⼀组才是真正匹配上的参数列表的那⼀部分。所以需要基于argDecl[1]来调⽤split⽅法。关于正则表达式的⽤法,可以参考很多⽂档,⽐如,这⾥就不赘述了。得到了参数数组后,还需要⼀些处理才能够得到
真正的被托管对象名字。这个处理主要是通过FN_ARG这⼀正则表达式完成的,在这个表达式中定义了两个组,第⼀个组匹配可能出现的下划线,第⼆个组匹配的才是被托管对象的名字信息。所以我们可以看到上述代码中使⽤了replace⽅法不那么常见,但是功能⾮常强⼤的⼀个重载,具体⽂档可以参考中”指定⼀个函数作为参数”这⼀部分。传⼊到replace⽅法中的第⼆参数的函数签名是这样的:function(all, underscore, name),all代表的是匹配的整个字符串,underscore和name分别代表第⼀组和第⼆组。
为什么需要处理下划线呢?这是基于angular的⼀条约定:如果参数被⼀个下划线字符包围,那么⾸先需要去掉包围它的下划线,剩下的部分才作为被托管对象的名字。注意,单侧的下划线不会被去掉。举个例⼦:
var testApp = dule('test', []);
// 假设我们已经定义过了aConstant,bConstant以及cConstant
// ......
});