MVVM大比拼之AngularJS源码精析
简介
AngularJS的学习资源已经非常非常多了,AngularJS基础请直接看官网文档。这里推荐几个深度学习的资料:
- AngularJS学习笔记 作者:邹业盛 。这个笔记非常细致,记录了作者对于AngularJS各个方面的思考,其中也不乏源码级的分析。
- 构建自己的AngularJS 。虽然放出第一章后作者就写书去了。但这第一部分已经足以带领读者深入窥探angularJS在核心概念上的实现,特别是dirty check。有愿意继续深入的读者可以去买书。
- Design Decisions in AngularJS。 google io 上AngularJS作者的演讲视频,非常值得一看。
其实随便google一下就会看到非常的多的AngularJS的深度文章,AngularJS的开发团队本身对外也非常活跃。特别是现在AngularJS 2.0也在火热设计和开发中,大家完全可以把握这个机会跟进一下。设计文档在这里。在这些资料面前,我的源码分析只能算是班门弄斧了。不过人总要自己思考,否则和咸鱼没有区别。 以下源码以1.3.0为准。
入口
除了使用 ng-app,angular还有手工的入口:
1
|
angular.bootstrap(document,[ ‘module1‘ , ‘module2‘ ])
|
angularJS build的相关信息和文件结构翻阅一下gruntFile就清楚了。我们直击/src/Angular.js 的1381行 bootstrap 定义:
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
|
function
bootstrap(element, modules, config) {
if
(!isObject(config)) config = {};
var
defaultConfig = {
strictDi: false
};
config = extend(defaultConfig, config);
var
doBootstrap = function () {
element = jqLite(element);
if
(element.injector()) {
var
tag = (element[0] === document) ? ‘document‘
: startingTag(element);
throw
ngMinErr( ‘btstrpd‘ , "App Already Bootstrapped with this Element ‘{0}‘" , tag);
}
modules = modules || [];
modules.unshift([ ‘$provide‘ , function ($provide) {
$provide.value( ‘$rootElement‘ , element);
}]);
modules.unshift( ‘ng‘ );
var
injector = createInjector(modules, config.strictDi);
injector.invoke([ ‘$rootScope‘ , ‘$rootElement‘ , ‘$compile‘ , ‘$injector‘ , ‘$animate‘ ,
function (scope, element, compile, injector, animate) {
scope.$apply( function () {
element.data( ‘$injector‘ , injector);
compile(element)(scope);
});
}]
);
return
injector;
};
var
NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/;
if
(window && !NG_DEFER_BOOTSTRAP.test(window.name)) {
return
doBootstrap();
}
window.name = window.name.replace(NG_DEFER_BOOTSTRAP, ‘‘ );
angular.resumeBootstrap = function (extraModules) {
forEach(extraModules, function (module) {
modules.push(module);
});
doBootstrap();
};
} |
已经熟练使用AngularJS的读者应该马上就注意到,代码中部的createInjector和后面的几行代码就已经暴露了两个核心概念的入口:“依赖注入”和“视图编译”。
依赖注入
先不要急着去看 createInjector 的定义, 先看看后面这一句 injector.invoke()。在angular中有显式注入和隐式注入,这里是显式。往 invoke 中传如的参数是个数组,数组前n-1个参数对应着对最后一个函数的每一个参数,也就是最后一个函数中要传入的依赖。不难猜想,injector应该是个对象,其中保存了所有已经实例化过的service等可以作为依赖的函数或对象,调用invoke时就会按名字去取依赖。现在让我们去验证吧。翻到 /src/auto/injector.js 609:
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
|
function
createInjector(modulesToLoad, strictDi) {
strictDi = (strictDi === true );
var
INSTANTIATING = {},
providerSuffix = ‘Provider‘ ,
path = [],
loadedModules = new
HashMap(),
providerCache = {
$provide: {
provider: supportObject(provider),
factory: supportObject(factory),
service: supportObject(service),
value: supportObject(value),
constant: supportObject(constant),
decorator: decorator
}
},
providerInjector = (providerCache.$injector =
createInternalInjector(providerCache, function () {
throw
$injectorMinErr( ‘unpr‘ , "Unknown provider: {0}" , path.join( ‘ <- ‘ ));
}, strictDi)),
instanceCache = {},
instanceInjector = (instanceCache.$injector =
createInternalInjector(instanceCache, function (servicename) {
var
provider = providerInjector.get(servicename + providerSuffix);
return
instanceInjector.invoke(provider.$get, provider, undefined, servicename);
}, strictDi));
forEach(loadModules(modulesToLoad), function (fn) { instanceInjector.invoke(fn || noop); });
return
instanceInjector;
/*下面省略若干函数定义*/
} |
我们从最后的返回值看到,真实的injector对象又是由 createInternalInjector 创造的。只不过最后对于所有需要加载的模块(也就是参数modulesToLoad),主动使用instanceInjector.invoke执行了一次。明显这个invoke和前面讲到的invoke是同一个函数,但是前面传的参是数组,用来显示传入依赖,这里传的参看起来是函数,那很有可能是隐式注入的调用。 另外值得注意的是这里有个 providerInjector 也是用 createInternalInjector 创造的。它在instancInjector 的 createInternalInjector 中被用到了。
下面让我们看看 createInternalInjector :
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
48
49
50
51
52
53
|
function
createInternalInjector(cache, factory) {
function
getService(serviceName) {
/*省略*/
}
function
invoke(fn, self, locals, serviceName){
if
( typeof
locals === ‘string‘ ) {
serviceName = locals;
locals = null ;
}
var
args = [],
$inject = annotate(fn, strictDi, serviceName),
length, i,
key;
for (i = 0, length = $inject.length; i < length; i++) {
key = $inject[i];
if
( typeof
key !== ‘string‘ ) {
throw
$injectorMinErr( ‘itkn‘ ,
‘Incorrect injection token! Expected service name as string, got {0}‘ , key);
}
args.push(
locals && locals.hasOwnProperty(key)
? locals[key]
: getService(key)
);
}
if
(!fn.$inject) {
// this means that we must be an array.
fn = fn[length];
}
// #5388
return
fn.apply(self, args);
}
function
instantiate(Type, locals, serviceName) {
/*省略*/
}
return
{
invoke: invoke,
instantiate: instantiate,
get: getService,
annotate: annotate,
has: function (name) {
return
providerCache.hasOwnProperty(name + providerSuffix) || cache.hasOwnProperty(name);
}
};
}
|
我们快先看看之前对 invoke 函数的猜测是否正确,我们前面看到了调用它时第一个参数为数组或者函数,如果你记性不错的话,应该也注意到前面还有一句:
1
|
instanceInjector.invoke(provider.$get, provider, undefined, servicename) |
好,我们来看 invoke。注意 $inject = annotate(fn, strictDi, serviceName) 。这里的第一个参数 fn 就是之前提到的可以是数组也可以是函数。大家自己去看 annotate 的定义吧,就是这一句,提取出了所有依赖的名字,对于隐式注入试用 toString 加上 正则匹配来提取的,所以如果 angular 应用代码压缩时进行了变量名混淆的话,隐式注入就失效了。继续看,提取出名字之后,通过 getService 获取到了每一个依赖的实例,最后在用 fn.apply 传入依赖即可。 还记得之前的 providerInjector 吗,它其实是用来提供一些快速注册 service 等可依赖实例的。它提供的一些方法其实都直接暴露到了 angular 对象上,大家如果仔细看过文档其实就很明了了:
总体来说依赖注入在实现上并没有什么特别巧妙的地方,但有价值的是angular从很早就有了完整的模块化体系,依赖是模块化体系中很重要的一部分。而模块化的意义也不只是拆分、解耦而已,从工程实践的角度来说,模块化是实现那些超越单个工程师所能掌握的大工程的基石之一。
视图编译
关于 $compile 的使用和相应地内部机制其实文档已经很详细了。看这里。我们这里看源码的目的有两个:一是看数据改动时触发的 $digest 具体是如何更新视图的;二是看源码是否有些精妙之处可以学习。 打开 /src/ng/compile.js 511行,注意到这里定义的 $compileProvider 是 provider 的写法,不熟悉的请去看下文档。provider在用的时候会实例化,而我们在用的 $compile 函数实际上就是 this.$get 这个数组的最后一个元素(一个函数)的返回值。跳到638行看定义,源码太长,我就不贴了。后面只贴关键的地方。这个函数的返回了一个叫compile的函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function
compile($compileNodes, transcludeFn, maxPriority, ignoreDirective,
previousCompileContext) {
/*省略若干行预处理节点的代码*/
var
compositeLinkFn =
compileNodes($compileNodes, transcludeFn, $compileNodes,
maxPriority, ignoreDirective, previousCompileContext);
safeAddClass($compileNodes, ‘ng-scope‘ );
return
function publicLinkFn(scope, cloneConnectFn, transcludeControllers){
/*省略若干行和cloneConnectFn等有关的代码*/
if
(compositeLinkFn) compositeLinkFn(scope, $linkNode, $linkNode);
return
$linkNode;
};
}
|
没有什么神奇的,返回的这个publicLinkFn就是我们用来link scope的函数。而这个函数实际上又是调用了 compileNodes 生成的 compositeLinkFn。如果你熟悉 directive 的使用,那我们不妨轻松地猜测一下这个 compileNodes 应该就是收集了节点中的各种指令然后调用相应地compile函数,并将link函数组合起来成为一个新函数,也就是这个compositeLinkFn以供调用。而 directive 里的link函数扮演了将scope的变化映射到节点上(使用 scope.$watch),将节点变化映射到scope(通常要用scope.$apply来触发scope.$digest)的角色。 我可以直接说“恭喜你,猜对了”吗?这里没什么复杂的,大家自己看下吧。值得再看看的是scope.$watch 和 scope.$digest。通常我们用 watch 来将视图更新函数注册相应地scope下,用digest来对比当前scope的属性是否有变动,如果有变化就调用注册的这些函数。我前面文章中说的angular性能不如ko等框架并且可能遇到瓶颈就是出于这个机制。我们来翻一下$digest的底:
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
$digest: function () {
/*省略若干变量定义代码*/
beginPhase( ‘$digest‘ );
lastDirtyWatch = null ;
do
{ // "while dirty" loop
dirty = false ;
current = target;
/*省略若干行异步任务代码*/
traverseScopesLoop:
do
{ // "traverse the scopes" loop
if
((watchers = current.$$watchers)) {
// process our watches
length = watchers.length;
while
(length--) {
try
{
watch = watchers[length];
// Most common watches are on primitives, in which case we can short
// circuit it with === operator, only when === fails do we use .equals
if
(watch) {
if
((value = watch.get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: ( typeof
value == ‘number‘
&& typeof
last == ‘number‘
&& isNaN(value) && isNaN(last)))) {
dirty = true ;
lastDirtyWatch = watch;
watch.last = watch.eq ? copy(value) : value;
watch.fn(value, ((last === initWatchVal) ? value : last), current);
/*省略若干行log代码*/
} else
if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
dirty = false ;
break
traverseScopesLoop;
}
}
} catch
(e) {
clearPhase();
$exceptionHandler(e);
}
}
}
// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
if
(!(next = (current.$$childHead ||
(current !== target && current.$$nextSibling)))) {
while (current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while
((current = next));
// `break traverseScopesLoop;` takes us to here
if ((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
/*省略若干 throw error*/
}
} while
(dirty || asyncQueue.length);
clearPhase();
while (postDigestQueue.length) {
try
{
postDigestQueue.shift()();
} catch
(e) {
$exceptionHandler(e);
}
}
}
|
这段代码有两个关键的loop,对应两个关键概念。大loop就是所谓的dirty check。什么是dirty?只要进入了这个循环,就是dirty的,直到值已经稳定下来。我们看到源码中用了lastDirtyWatch来作为标记,要使watch === lastDirtyWatch,至少第二次循环才能实现。这是因为在调用监听函数的时候,监听函数本身可能去修改属性,所以我们必须等到值已经完全不变了(或者超过了最大循环值)才能结束digest。另外看那个insanity warning,digest是进行深度优先遍历检测的。所以在设计复杂的directive时,要非常注意在scope哪个层级调用digest。在写简单应用的时候,dirty check和遍历子元素都没有什么问题,但是相比于基于observer的模式,最主要的缺点是它的所有监听函数都是注册在scope上的,每次digest都要检测所有的watcher是否有变化。
最后总结一下视图,angular在视图层的设计上较为完备,但同时概念也更多更复杂,在首屏渲染时速度不够快。并且内存开销是vue ko等轻框架倍数级的。但它的本身的规范和各个方面考虑的周全性确是非常值得学习,实际上也对后来者产生了极大的指导性意义。
其他
这里再记录一个实践中的问题,就是如何对数据实现getter 和setter?比如说这样一个场景:有个三个输入框,第一个让用户填姓,第二个填名,第三个自动显示“姓+空格+名”。用户也可以直接在第三个框中填,第一框和第二框会自动变化。这个时候如果有类似于ko的computed property就简单了,不然只能用$watch加中间变量去实现,代码会有点难看。有代码洁癖的话相信各位迟早会碰到这个问题,以下提供几个参考资料:
- https://github.com/angular/angular.js/issues/768
- http://*.com/questions/11216651/computed-properties-in-angular-js 请看第二个答案。
- http://*.com/questions/21289577/getter-setter-support-with-ng-model-in-angularjs
总结
总体来说,AngularJS无论在设计还是实践上都具有指导性意义。对新手来说学习曲线较陡,但如果能深入,收获是很大的。AngularJS本身在工程上也有很多其他产出,比如karma,从它中间独立出来发展成了通用测试框架。还是建议各位读者可以跟一跟AngularJS2.0的开发,必能受益。