Cruyun's Blog


Talk is cheap, show you my code


MVVM双向数据绑定(一):AngularJS脏数据检查

1. 先行知识:MVVM

MVVM 由以下三个内容组成:

  • View:界面
  • Model:数据模型
  • ViewModel:作为桥梁负责沟通 View 和 Model

在 JQuery 时期,如果需要刷新 UI 时,需要先取到对应的 DOM 再更新 UI,这样数据和业务的逻辑就和页面有强耦合。

在 MVVM 中,UI 是通过数据驱动的,数据一旦改变就会相应的刷新对应的 UI,UI 如果改变,也会改变对应的数据。这种方式就可以在业务处理中只关心数据的流转,而无需直接和页面打交道。ViewModel 只关心数据和业务的处理,不关心 View 如何处理数据,在这种情况下,View 和 Model 都可以独立出来,任何一方改变了也不一定需要改变另一方,并且可以将一些可复用的逻辑放在一个 ViewModel 中,让多个 View 复用这个 ViewModel。

在 MVVM 中,最核心的也就是数据双向绑定,例如 Angluar 的脏数据检查,Vue 中的数据劫持。

本文主要学习总结了Angluar 的脏数据检查。

2. 脏检查机制

双向数据绑定是 AngularJS 的核心机制之一。当 view 中有任何数据变化时,会更新到 model ,当 model 中数据有变化时,view 也会同步更新,显然,这需要一个监控。

原理就是,Angular 在 scope 模型上设置了一个监听队列,用来监听数据变化更新 view 。每次绑定一个东西到 view 上时 AngularJS 就会往 $watch 队列里插入一条 $watch,用来检测它监视的 model 里是否有变化的东西。当浏览器接收到可以被 angular context 处理的事件时,$digest 循环就会触发,遍历所有的 $watch,最后更新 dom。

2.1 $scope

Scopes are used for many different purposes:

  1. Sharing data between controllers and views
2.  Sharing data between different parts of the application
3. Broadcasting and listening for events
4. Watching for changes in data

可以看到,scope 才是双向数据绑定的核心部分,其中主要包含watch和digest的代码:

1
2
3
4
function Scope() {
this.$$watchers = [];
this.$$lastDirtyWatch = null;
}

Scope类中包含一个$$watchers对象数组,该数组用于保存各数据变量的监听器。(在Angular框架中,双美元符前缀$$表示这个变量被当作私有的来考虑,不应当在外部代码中调用。)

$watch 方法用于创建监听器并绑定至当前作用域,接受两个函数做参数,把他们存储在 watchers 数组中。

1
2
3
4
5
6
7
8
9
10
scope.prototype.$watch = function(name, getNewValue, listener){
var watch = {
name: name,
getNewValue: getNewValue,
listener : listener || function(){},
last: '',
};
this.$$watchList.push(watch);
}

getNewValue 函数每次放回最新值。

2.1.2 $scope

下面是$digest 函数,它执行了所有在作用域上注册的监听器,对监视器的新旧值进行对比,当新旧值不同时,调用listener函数进行相应操作,并将旧值更新为新值。它将不断重复这一过程,直到所有数据变量的新旧值相等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Scope.prototype.$digest = function() {
var dirty = true;
while(dirty){
dirty = false;
for(var i = 0; i < this.$$watchers.length; i++){
var newVal = this.$$watchers[i].getNewValue();
var oldVal = this.$$watchers[i].last;
if(newVal! == oldVal){
dirty = true;
this.$$watchers[i].listener(oldVal,newVal);
this.$$watchers[i].last = newVal;
}
}
}
};

对于每一个watch,我们使用 getNewValue() 并且把scope实例传递进去,得到数据最新值 。然后和上一次值进行比较,如果不同,那就调用 listener,同时把新值和旧值一并传递进去。 最终,我们把last 属性设置为新返回的值,也就是最新值。
这个$digest 再一次调用,last 为undefined,所以一定会进行一次数据呈现。

$digest 循环的上限是 10 次(超过 10次后抛出一个异常,防止无限循环)。

$digest 循环不会只运行一次。在当前的一次循环结束后,它会再执行一次循环用来检查是否有 models 发生了变化。

这就是脏检查(Dirty Checking),它用来处理在 listener 函数被执行时可能引起的 model 变化。因此 $digest 循环会持续运行直到 model 不再发生变化,或者 $digest 循环的次数达到了 10 次(超过 10 次后抛出一个异常,防止无限循环)。

当 $digest 循环结束时,DOM 相应地变化。

$eval、$apply和$evalAsync

$eval

$eval的作用是在scope中执行给出的表达式。

1
2
3
Scope.prototype.$eval = function(expr, locals) {
return expr(this, locals);
};

$apply

$apply作用是将外部js代码引入到scope的digest环节来。这个方法可能是非常广为人知的一个方法。尤其是用jquery处理数据更新数据,ajax获取数据更新view什么的。

它实际上调用了$.eval,然后手动触发了digest,代码:

1
2
3
4
5
6
7
8
9
Scope.prototype.$apply = function(expr) {
try {
return this.$eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {
this.$digest();
}
};

finally 在 try 和 catch 代码执行完毕后执行,不管这两个环节结果如何。

实际上,AngularJS 并不直接调用 $digest(),而是调用 $scope.$apply(),后者会调用 $digest()。因此,一轮 $digest 循环在 $rootScope开始,随后会访问到所有的 children scope 中的 watchers。

现在,假设你将 ng-click 指令关联到了一个 button 上,并传入了一个function 到 ng-click 上。当该button被点击时,AngularJS 会将此 function包装到一个 wrapping function 中,然后传入到 $scope.$apply()。因此,你的function会正常被执行,修改models(如果需要的话),此时一轮$digest循环也会被触发,用来确保view也会被更新。

Note: $scope.$apply()会自动地调用$rootScope.$digest()。$apply()方法有两种形式。第一种会接受一个function作为参数,执行该function并且触发一轮$digest循环。第二种会不接受任何参数,只是触发一轮$digest循环。

$evalAsync

$evalAsync作用是代码延迟执行。setTimeout(function(){},0)是代码延迟执行其中一个办法。但是setTimeout的问题是一旦你使用了它,那么就等于完全放弃了对代码执行时机的控制——浏览器可能去渲染UI,可能去响应事件,直到很久以后才会执行指定的代码片段。$evalAsync更优于setTimeout,就是因为它在这个时机上控制得更好。

利用以上三个方法,可以将控制$digest 循环的代码优化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cope.prototype.$digest = function () {
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do {
while (this.$$asyncQueue.length) {
var asyncTask = this.$$asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression);
}
dirty = this.$$digestOnce();
if ((dirty || this.$$asyncQueue.length) && !(ttl--)) { //修改的代码
throw "10 digest iterations reached";
}
} while (dirty || this.$$asyncQueue.length);
};

这样,当 (dirty || this.$$asyncQueue.length)反复为true时候,就会tll累减,最后抛出错误终止。

什么时候手动调用$apply 方法

取决于是否在 Angular 上下文环境(angular context)。

AngularJS对此有着非常明确的要求,就是它只负责对发生于AngularJS上下文环境中的变更会做出自动地响应(即,在$apply()方法中发生的对于models的更改)。AngularJS的 built-in 指令就是这样做的,所以任何的model 变更都会被反映到 view 中。但是,如果你在AngularJS上下文之外的任何地方修改了 model,那么你就需要通过手动调用$apply()来通知AngularJS。这就像告诉AngularJS,你修改了一些models,希望AngularJS帮你触发watchers来做出正确的响应。

典型的需要调用 $apply() 方法的场景是:

  1. 使用了 JavaScript 中的 setTimeout() 来更新一个 scope model
  2. 用指令设置一个 DOM 事件 listener 并且在该 listener 中修改了一些 models

如何优化脏检查与运行效率

脏检查效率是不高,但在非大量的检查下是可以接受的。所以在绑定大量表达式时请注意所绑定的表达式效率。建议注意一下几点:

  1. 表达式(以及表达式所调用的函数)中少写太过复杂的逻辑
  2. 不要连接太长的 filter(往往 filter 里都会遍历并且生成新数组)
  3. 不要访问 DOM 元素。

小结:

  • 脏检查是一种模型到视图的数据映射机制,由 $apply 或 $digest 触发。
  • 脏检查的范围是整个页面,不受区域或组件划分影响
  • 使用尽量简单的绑定表达式提升脏检查执行速度
  • 尽量减少页面上绑定表达式的个数(单次绑定和ng-if)

另外,使用单次绑定减少绑定表达式数量、善用 ng-if 减少绑定表达式的数量、给 ng-repeat 手工添加 track by等也值得注意,此文不再细述。


以上是我学习脏检查的粗略总结,错漏在所难免。谢谢你的阅读ღ( ´・ᴗ・` )

参考: