AngularJS双向绑定的实现
2017-12-10 / modified at 2022-04-04 / 3.5k words / 13 mins
️This article has been over 2 years since the last update.

本文将分析第一代AngularJS的主流程,结合笔者最近改造与培训的实践,将快速帮助Java后端开发掌握原理,写出前端界面,早点下班回家。

本文写作之时,AngularJS已经快有10年了,并进入了LTS阶段,但是AngularJS的生态圈使它依然适用于老旧项目渐进改造以及逻辑复杂的企业级项目开发。如果你希望开发对SEO友好,更加“新”的框架的话,可以尝试Vue,React等。

本文需要的知识基础

在阅读本文前,你需要了解

  • AngularJS的基本用法,比如ng-click, filter
  • Angular内部采用了函数式与依赖注入,返回的基本上都是柯里(Curry)化后的函数,也就是很多匿名闭包,你一定要明白断点执行的不一定是从上到下的,因此需要掌握一定的调试技巧。
  • JS全部为引用传递,除了copy或者extend外,直接使用=赋值可能会出现多绑一的问题
  • 浏览器的js解释器是单线程(DOMThread),但是浏览器内部的Socket/Timer是native实现,比如WebKit可以实现WorkerGroup多线程加载
  • 有Android的ListView开发经验的读者懂得更快

为了简化板面与照顾使用JQuery的读者,本文预制如下函数

1
2
3
4
5
6
7
function $(dom){//本文不借助JQuery,但是代码因此会长一些
return angular.element(document.querySelector(dom));
}
function getSrv(name, element) {//获取被依赖注入的服务
element = element || '*[ng-app]';
return $(element).injector().get(name);
}

本文需要的工具

  • 任意一个加载AngularJS的Chrome网页,比如这里Binding to a getter/setter例子

1. 流程综述

Angular本质是通过DSL解释器(详见垠神的怎样写一个解释器)来扩展html语义的框架。如果你看过类似的文章,你将更好的理解AngularJS中的Scope(作用域)。

它的主要流程概要如下

  1. 读取$dom中的attr修饰的DSL(directive{})生成AST,并编译生成基于javaScript的字节码函数
  2. 绑定$dom对应的上下文,执行刚刚生成的link函数,产生新的数据
  3. (通过手动apply或者各种Listener)将刚刚的数据更新到界面

1.1. 启动分析

在引入的angular.js末端将自动通过jqLite配置onLoad监听器进行启动

1
2
3
jqLite(function() {//当`document.complete`事件触发`angularInit`
angularInit(window.document, bootstrap);
});

此部分类似于WebView中的onPageFinished接口,保证函数在进行DOM操作前已经下载完成,以免后续编译DOM出现多线程异常。

被自动触发的angularInit其实是一个[ng-app]的Select扫描,即$('[ng-app]')

扫描后将调用的是如下的apply操作,为了方便最简流程理解,我屏蔽了依赖注入,并改为注入后的变量名称

1
2
3
4
5
6
7
8
9
10
$rootScope.$apply(function() { //3. 执行Digest,调用watcher的回掉
var element = $('[ng-app]')
element.data('$injector', injector);
//1. 解析DSL,生成基于js的“字节码”
//注意$compile(element)返回的是一个Func,而不是一个Obj,这里返回的func是同一个Function
//如果你用publicLinkFn.toString()进行Diff对比可以发现,返回的是一样的
var publicLinkFn = $compile(element);
//2. 执行link函数,更新数据(但是还没有更新界面)
var linkedDom = publicLinkFn($rootScope);
});

1.2. Apply的含义

Apply指生成数据更新界面

1
2
3
4
5
6
7
8
9
function $apply(expr) {
try {//执行上文写的匿名函数,即1,2
return $eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {//3. 更新数据到界面,类似于Android中ListView的*notifyDataSetChanged*接口
$root.$digest();
}
}

上面函数中的1,2,3依次对应概要中的1,2,3。如果你想自己分析源码,按照上面思路即可。当然本文也将带你接着分析。

2. 实现细节

2.1. 编译DSL(compile)

compileNodes是一个树的遍历操作,编译DOM内部涉及到如下操作

1
JQueryDOM -> Attrs -> Lexer -> Parser -> linkFn

这部分主要是用于读取Directive与DSL表达式,并生成模式匹配后的函数。

通过阅读源码,可以发现在collectDirectives中将提取所有的attr,含括号表达式与自定义tag,具体断点位置位于collectDirectives

2.2. 执行DSL(eval)

$Parser$eval

作为入门Angular文章,本文不分析内部的Token与AST。先去了解大致流程,再分析编译细节是更好的方法。

1
var parserFn = getSrv('$parse');//在console中获取parser

接下来我们就可以在console中直接调用了

1
2
3
4
parserFn("1+1*2-(4*5)")
//->ƒ(s,l,a,i){return ifDefined(plus(1,(1)*(2)),0)-ifDefined((4)*(5),0);}
parserFn("{age:18,name:'haruhi'}")
//->ƒ(s,l,a,i){return {'age':18,'name':'haruhi'};}

举个更难的例子,比如

1
parserFn("{age:18,name:'haruhi'}|json")

其实返回的是过滤器调用,也就是说管道操作符|语法通过Parser解释为了一个filter

当Parser操作完成后,就可以执行$eval

1
2
3
4
5
6
7
//获取当前Scope
var scope = getSrv('$rootScope')
//Scope本身除了作为MV*的中间层,同时也是解释器的上下文
scope.a = {age:18,name:'haruhi'}
// -> {age: 18, name: "haruhi"}
scope.$eval('a.name')
// -> "haruhi"

总的来说,就是实现了一个DSL解释器,这里的scope.$eval操作在后期Direactive中使用非常广泛

我在之前的DSL文章中写过: 相同信息量下,DSL的长度越短,潜规则信息就越多,因此也就有一个陡峭的学习曲线,调试成本越高。在上面的例子中也可以发现,通过分析生成后的函数可以掌握filter等执行细节,这就是从只会用开源组件语义定制自己组件的瓶颈区。

对这部分比较纠结的读者,可以在addInterceptor中打上断点,跑完一个简单的断点后,AngularJS的基本流程也就大致清楚了。

2.2.1. Scope分析

Scope在解释器中被称为作用域或者环境,在AngularJS中特指lexical scoping,也就是离函数越近定义的变量优先级越高,此部分通过js的prototype链实现。

它在最底层通过$.data(element,"$scope")这样的Map结构实现DIV与作用域映射的,关于JQLite.data的具体实现,类似于下文的一个全局Map,具体网上有很多分析文章

1
cache[$(div)['jQuery112408303826831449197']]

作用域也是一个对于前端比较难的技术点,很多时候碰到“无法点击”就不知道如何调试了。当然如果你自己不想手动折腾的话,可以尝试安装Chrome扩展,有很多工具F12可以自动计算出DOM绑定的Scope。

2.3. 更新界面(Digest)

以上文在线Plnkr例子为例

方法一

1
2
var scope = $('[ng-controller="ExampleController"]').scope();
scope.$apply('user.name("1926~∞ Excited")')

方法二

1
2
3
scope.$eval('user.name("1926~∞")')
//=>"1926~∞"
scope.$digest()//执行后,界面自动完成刷新

这两种其实是一样的,只不过

  • $apply在内部通过封装try/finally$digest放到了最后。
  • $apply更新的作用域是$rootScope,是深度遍历

在AngularJS中,$digest是重要的更新UI函数,我们对它进行分析(本部分不分析url变化场景)

Digest有点类似Android中Handler队列放入Runnable,但是它并不是你想象的有一个PIPE队列在后台轮询,只有手动调用或者监听(比如按键/鼠标)才会触发Digest

细节如下

  • asyncQueue进行forEach反射调用fn,它对应的是evalAsync,这里主要是实现了Promise
  • $$watchers进行遍历调用watch.fn更新(最常见的是expressionInputWatch),如果last与最新值不相等,将更新为新的值,此处为线型复杂度。

Promise的实现

在AngularJS中,我们有$q来实现链式异步编程,但是如果你打断点分析时发现,比如在$http

  • 数据并不是马上返回,而是将信息发送到了asyncQueue队列中
  • 等到网络响应数据后,通过resolve操作变更状态为Completed
  • 通过各种渠道触发Digest后,通过setTimeout实现调用成功的Callback函数链

在AngularJS的Promise有些不足,$Q是不支持Java中CountDownLatch那样await等待的,同时比较依赖Digest触发事件,如果你在Callback中执行耗时任务,那么可能性能就比较差了

3. 实例分析

3.1. 括号Watcher例子

比如我在业务中写了如下代码

1
<span>{{name}}</span>

它在js内部执行如下

1
2
3
4
5
6
7
8
9
10
11
DSL
|collectDirectives
Direactive
|compileNodes
textInterpolateCompileFn
|scope.publicLinkFn()
textInterpolateLinkFn
|watch()
interpolateFn
|digest
更新nodeValue值

这里的流程均为异步,比较难以分析,主要原因是为了分拆生命周期做了过多的柯里化,导致断点到处飘(不过我见过更烂的RxJava)。

3.1.1. Compile

在执行compile阶段,主要有两个事件: 先collectDirectives执行收集,接着遍历Node执行compileNodes

  • 收集阶段(collectDirectives)

首先在addTextInterpolateDirective中打上断点,在解析括号并生成interpolateFn后,可以发现在编译阶段返回了一个Direactive

1
2
3
4
5
6
7
8
9
10
11
12
{
priority: 0,
compile: function textInterpolateCompileFn(templateNode) {
...
return function textInterpolateLinkFn(scope, node) {
...
scope.$watch(interpolateFn, function interpolateFnWatchAction(value) {
node[0].nodeValue = value;
});
};
}
}
  • 编译Node阶段(compileNodes)

执行Direactive的compile,并返回了textInterpolateLinkFn函数,注意这里是函数还没有执行

3.1.2. Link

在执行link阶段,textInterpolateLinkFn将被调用,并注册一个watcher,注意这里的watcher又是个套路,它这里使用了Delegate模式,实际上文的$watch代码可以简写为

1
2
3
4
5
6
7
8
9
10
11
12
13
scope.$watch(function expressionInputWatch(scope) {
// 执行parser生成的函数,等价于eval('name')
var newInputValue = inputExpressions(scope);
// 类似于Java的equals
if (!expressionInputDirtyCheck(newInputValue, oldInputValueOf, inputExpressions.isPure)) {
lastResult = parsedExpression(scope, undefined, undefined, [newInputValue]);
oldInputValueOf = newInputValue && getValueOf(newInputValue);
}
return lastResult;
}, function interpolateFnWatchAction(value) {
//此处可以打上断点,如果你不懂Digest
node[0].nodeValue = value;
});

3.1.3. Digest

在digest阶段,wacher的expressionInputWatchd对比将被调用,并执行eval('name'),然后调用 watcher.fn,执行interpolateFnWatchAction最终更新nodeValue

通过上面的流程,从括号到更新界面的流程就完成了,不清楚的话,搜索函数名,打上断点。

3.2. ngBind例子

比如我在业务中写了一段代码

1
<span ng-bind="name" />

在遍历DOM编译阶段(compileNodes),调用ngBindDirective中的ngBindCompile,返回了一个ngBindLink的函数

在执行link阶段,调用ngBindLink函数,新增了如下的watcher到$$watcher队列中,相对于上面的括号,套路少了很多

1
2
3
4
5
6
7
8
9
10
11
12
scope.$watch('name', function ngBindWatchAction(value) {
element.textContent = stringify(value);
});
// 上文实际上是添加了如下的watcher
watcher = {
//为element.textContent = stringify(value);位于ngBind中,用于更新DOM
fn: listener,
last: initWatchVal, //空函数func(){}
get: get, //编译'name'后的函数
exp: prettyPrintExpression || watchExp, //'name'
eq: !!objectEquality //空
}

点开get生成的函数机智的打上断点,然后接着跑代码,反查发现在Digest中通过watcher.fn调用来更新DOM

4. 历史遗留项目改造

在使用AngularJS前,笔者被忽悠进来才发现面对的是一个大坑项目,发现项目中遇到如下问题

  • 页面全部采用$Dom进行操作,代码很难看,效率低
  • 缺乏公共组件
  • 手动js拼装XML

这些都可以用AngularJS来搞定,具体改造如下

4.1. Angular重新编译

某些遗留界面(比如Modal/Table)采用Ajax动态加载模版引擎(比如JSP)生成的HTML,这时由于加载后的并不在Angular的MQ中,需要重新编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
function recompile(element, response) {
element.html(response);
// shared scope with parent
var inj = angular.element('[ng-app="myApp"]').injector();
inj.invoke(['$compile', function($compile) {
//new code for angularJS integrate
var s = element.scope();
s.$apply(function() {
var linkFn = $compile(response);
linkFn(s);
});
}])
}

注意一定要先调用element.html,否则旧的onclick事件将被多次绑定。当然这里依然有遗留问题,它之前绑定的scope等data将会永远泄漏,event将会被重复listen,不过好在项目中基本上非单页应用,这点内存损失相对于开发效率提升就可以忽略了。

4.2. JSON序列化

由于Angular为了性能(比如track by)给对象自动加人了$Hash等乱七八糟的东西,这样在进行SpringMVC后台请求时可能报错400。此时你需要使用angular.toJson来代替native的json实现

4.3. Injection

依赖注入需要占用较多的篇幅,本文暂不研究,最重要的是通过func.toStringnative代码反射获取函数参数,然后依据正则表达式从map中调用$get取到的。

4.4. 封装DOM组件

在以往开发中,一般通过$.fn.XX.init来启动JQuery的插件,而且可能源码也被修改过,因此与其在网上找一个开源包不如自己用Angular再封装一道

  • 对于长度/宽带等监听 -> 使用watch+callback代替直接判断
  • 对于UI组件(比如zTree, Validator) -> 在 compile中拼装模版,返回的link函数中拼装初始化函数

5. 总结

AngularJS上手难度有点高,属于开始爬坡快,但是马上遇到瓶颈的类型。对于后端开发人员,虽然AngularJS相对于React,Vue等最新js技术是老大哥了,最后再总结一下优点吧

  • 通过DSL扩展语法实现客户端模版引擎,对使用JSP的人群迁移习惯友好,后期甚至可以定制解释器的语法
  • 生态完善,如同JQuery一样,所有报错都可以搜索到,所有组件都可以Copy与Paste
  • 经过培训与维护Best Practice,可以复用改造已有的JQueryUI/DatePicker等基于DOM的组件
  • AngularJS基本上是最难的前端框架了(对后端反而简单),掌握后再学剩下的Vue与React也更有信心。

参考