Que's Blog

WebFrontEnd Development

0%

MVVM的简单实现-脏检测

写在前面

敲下这几个时候心里其实有些犹豫。但是很多事有了开始就行了。遇到的困难主要是第一步难,走过去了就会很平坦。

决定研究一下MVVM,前一篇文章主要讲了MVVM数据绑定的两种实现方式:观察者模式和脏检查模式。最后决定用脏检查来试试。原因上有若干:

  1. 观察者模式这个在触发上可以理解而且比较直观,但是脏检查模式还是对触发机制有些模糊不清
  2. 脏检测据说实现起来相对容易
  3. 脏检测这个过程里面的细节个人非常感兴趣
  4. Angular资料比较多 书籍也很多可以参考供参考,mvvm框架这种复杂东西并不适合闭门造车了

良好的开端,成功的一半。——贺瑞斯 《书简集》

写在前面

本文将基于《Build You Own AngularJS》展开。这里自己试试按照《Build You Own AngularJS》步骤来构建。

从Scope开始

书中的原话是:
Scopes are used for many different purposes:

  • Sharing data between controllers and views
  • Sharing data between different parts of the application
  • Broadcasting and listening for events
  • Watching for changes in data

这里就不翻译了,都是常见的单词。

当然,虽然这样,但是我认为Scope作为数据双向绑定,才是最大的兴趣点。

我们遵循宏观再微观的顺序,先整整这部分覆盖的内容点。

  1. digest cycle和脏检测本身, 包括:$watch,$digest,$apply.
  2. Scope继承 – 这个机制使得不同等级Scope之间的数据和事件继承成为可能.
  3. 高效率的面向集合数据的脏检测
  4. 事件体系:$on, $emit, and $broadcast.

环境设定篇

开始之前先扯一扯环境安装

1
2
3
4
5
6
7
8
//创建目录并npm init,一路回车
mkdir youownangularjs && cd youownangularjs && npm init
//安装grunt插件
npm i -g grunt-cli //如果有跳过
npm i grunt grunt-contrib-jshit grunt-contrib-testem sinon --save-dev
npm install -g phantomjs
//测试需要用到的库 lodash和jquery
npm i jquery lodash --save

然后是Gruntfile.js,这个是grunt任务的配置文件:

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
module.exports = function(grunt) {
grunt.initConfig({
jshint: {
all: ['src/**/*.js', 'test/**/*.js'],
options: {
globals: {
_: false,
$: false,
jasmine: false,
describe: false,
it: false,
expect: false,
beforeEach: false,
afterEach: false,
sinon: false

},
browser: true, devel: true

}
},
testem: {
unit: {
options: {
framework: 'jasmine2',
launch_in_dev: ['PhantomJS'],
before_tests: 'grunt jshint',
serve_files: [
'node_modules/lodash/lodash.js',//原书是index.js貌似版本更新后变化了
'node_modules/jquery/dist/jquery.js',
'node_modules/sinon/pkg/sinon.js',
'src/**/*.js',
'test/**/*.js'
],
watch_files: [
'src/**/*.js',
'test/**/*.js'
]
}
}
}

});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-testem');
grunt.registerTask('default', ['testem:run:unit']);
};

项目结构:

1
2
3
4
5
6
7
8
9
├── Gruntfile.js
├── index.js
├── package.json
├── src
│   ├── hello.js
│   └── scope.js
└── test
├── hello_spec.js
└── scope.spec.js

$watch&$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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
'use strict';
describe("Scope", function() {
it("可以用来做构造器,也可以作为一个对象使用|can be constructed and used as an object", function() { var scope = new Scope();
scope.aProperty = 1;
expect(scope.aProperty).toBe(1);
});
describe("digest", function() {
var scope;
beforeEach(function() { scope = new Scope();
});
it('第一次$digest时候调用监视器的监听函数|calls the listener function of a watch on first $digest', function() {
var watchFn = function() { return 'wat'; };
var listenerFn = jasmine.createSpy();
scope.$watch(watchFn, listenerFn);

scope.$digest();

expect(listenerFn).toHaveBeenCalled();
});
it("调用监视器的监听函数时候将scope作为参数传入|calls the watch function with the scope as the argument", function() {
var watchFn = jasmine.createSpy();
var listenerFn = function() { };
scope.$watch(watchFn, listenerFn);
scope.$digest();
expect(watchFn).toHaveBeenCalledWith(scope);
});
it("当监听的值改变时候调用监视器的监听函数|calls the listener function when the watched value changes", function() { scope.someValue = 'a';
scope.counter = 0;
scope.$watch(
function(scope) { return scope.someValue; }, function(newValue, oldValue, scope) { scope.counter++; }
);
expect(scope.counter).toBe(0);
scope.$digest();
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(1);
scope.someValue = 'b';
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(2);
});

it("当监视值开始是undefined时候调用监视函数|calls listener when watch value is first undefined", function() {
scope.counter = 0;
scope.$watch(
function(scope) { return scope.someValue; }, function(newValue, oldValue, scope) { scope.counter++; }
);
scope.$digest();
expect(scope.counter).toBe(1);
});

it("may have watchers that omit the listener function", function() {
var watchFn = jasmine.createSpy().and.returnValue('something');
scope.$watch(watchFn);
scope.$digest();
expect(watchFn).toHaveBeenCalled();
});
it("相同digest里面监视器循环调用|triggers chained watchers in the same digest", function() {
//先监视nameUpper,然后再监视name(监听函数里面修改nameUpper),最后改变name以期循环调用
scope.name = 'Jane';
scope.$watch(
function(scope) { return scope.nameUpper; }, function(newValue, oldValue, scope) {
if (newValue) {
scope.initial = newValue.substring(0, 1) + '.';
} }
);
scope.$watch(
function(scope) { return scope.name; }, function(newValue, oldValue, scope) {
if (newValue) {
scope.nameUpper = newValue.toUpperCase();
} }
);
scope.$digest();
expect(scope.initial).toBe('J.');
scope.name = 'vob';
scope.$digest();
expect(scope.initial).toBe('V.');
});
it("终止一个迭代10次的监视器|gives up on the watches after 10 iterations", function() {
scope.counterA = 0;
scope.counterB = 0;
scope.$watch(
function(scope) { return scope.counterA; }, function(newValue, oldValue, scope) {
scope.counterB++;
}
);
scope.$watch(
function(scope) { return scope.counterB; }, function(newValue, oldValue, scope) {
scope.counterA++;
}
);
expect((function() { scope.$digest(); })).toThrow();
});
it("结束digest,当最后的监视器是干净的|ends the digest when the last watch is clean", function() {
scope.array = _.range(100);
var watchExecutions = 0;
_.times(100, function(i) { scope.$watch(
function(scope) {
watchExecutions++;
return scope.array[i];
},
function(newValue, oldValue, scope) { }
); });
scope.$digest();
expect(watchExecutions).toBe(200);
scope.array[0] = 420;
scope.$digest();
expect(watchExecutions).toBe(301);
});

it("does not end digest so that new watches are not run", function() {
scope.aValue = 'abc';
scope.counter = 0;
scope.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope) {
scope.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope) {
scope.counter++;
}
);
} );
scope.$digest();
expect(scope.counter).toBe(1);
});
it("基于值的对比,如果可以的话|compares based on value if enabled", function() {
scope.aValue = [1, 2, 3];
scope.counter = 0;
scope.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope) {
scope.counter++;
},
true
);
scope.$digest();
expect(scope.counter).toBe(1);
scope.aValue.push(4);
scope.$digest();
expect(scope.counter).toBe(2);
});
it('正确的处理NaN|correctly handles NaNs', function() {
scope.number = 0/0; // NaN
scope.counter = 0;

scope.$watch(
function(scope) { return scope.number; },
function(newValue, oldValue, scope) {
scope.counter++;
}
);

scope.$digest();
expect(scope.counter).toBe(1);

scope.$digest();
expect(scope.counter).toBe(1);
});
});

});

列下主要的标题:

  1. Scope 对象
  2. 监视对象属性:$watch和$digest
  3. 检查Dirty值
  4. 初始化监视值
  5. 获得Digest的通知
  6. 在dirty的时候保持digesting
  7. 终止不稳定的Digest
  8. 短路digest进程,如果最后一个污点被清除
  9. 基于值的脏检测

主要的逻辑如下图:

这是Scope部分主要包含watch和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
/* jshint globalstrict: true */
'use strict';
function Scope() {
this.$$watchers = [];
this.$$lastDirtyWatch = null;
}
function initWatchVal() { }


Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {
var watcher = {
watchFn: watchFn,
listenerFn: listenerFn || function() { },
valueEq: !!valueEq,
last: initWatchVal
};
this.$$watchers.push(watcher);
this.$$lastDirtyWatch = null;
};

Scope.prototype.$$digestOnce = function() {
var self = this;
var newValue, oldValue, dirty;
_.forEach(this.$$watchers, function(watcher) {
newValue = watcher.watchFn(self);
oldValue = watcher.last;
if (!self.$$areEqual(newValue, oldValue, watcher.valueEq)) {
self.$$lastDirtyWatch = watcher;
watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
watcher.listenerFn(newValue,(oldValue === initWatchVal ? newValue : oldValue), self);
dirty = true;
} else if (self.$$lastDirtyWatch === watcher) {
return false;
} });
return dirty;
};
Scope.prototype.$digest = function() {
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do {
dirty = this.$$digestOnce();
if (dirty && !(ttl--)) {
throw "10 digest iterations reached";
}
} while (dirty);
};


Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {
if (valueEq) {
return _.isEqual(newValue, oldValue);
} else {
return newValue === oldValue ||
(typeof newValue === 'number' && typeof oldValue === 'number' &&
isNaN(newValue) && isNaN(oldValue));
}
};

代码不多,但是可以通过上面的测试用例。

这里我们来根据测试和标题整理相关知识点——不得不说,这些都是基础的东西,但是组合在一起就变得很考验开发者的功底。废话不说,开始干活。

step1-watchFn传参

1
2
3
4
5
6
7
8
9
it('第一次$digest时候调用监视器的监听函数|calls the listener function of a watch on first $digest', function() {
var watchFn = function() { return 'wat'; };
var listenerFn = jasmine.createSpy();
scope.$watch(watchFn, listenerFn);

scope.$digest();

expect(listenerFn).toHaveBeenCalled();
});

先看这个测试,这个测试意图是,当scope.$watch(watchFn, listenerFn);开始监控变化后,一旦运行了$digest,就会触发listenerFn这个回调。
这个测试是基于怎样的目的呢?这是确保$digest的功能设置初衷:确保遍历watchers数组,然后遍历它执行里面的监视函数。

step2-$digest初次运行

1
2
3
4
5
6
7
it("调用监视器的监听函数时候将scope作为参数传入|calls the watch function with the scope as the argument", function() {
var watchFn = jasmine.createSpy();
var listenerFn = function() { };
scope.$watch(watchFn, listenerFn);
scope.$digest();
expect(watchFn).toHaveBeenCalledWith(scope);
});

这个测试其实两个测试了。expect(watchFn).toHaveBeenCalledWith(scope);,一个是显式的,scope被作为参数传入watchFn,二是隐式的,watchFn被scope.$digest()触发了。这里,在代码中的 newValue = watcher.watchFn(self);使得代码得以通过。

step3-检查Dirty值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  it("当监听的值改变时候调用监视器的监听函数|calls the listener function when the watched value changes", function() { 

scope.someValue = 'a';
scope.counter = 0;
scope.$watch(
function(scope) {
return scope.someValue;
},
function(newValue, oldValue, scope) {
scope.counter++;
}
);
expect(scope.counter).toBe(0);
scope.$digest();
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(1);
scope.someValue = 'b';
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(2);

});

监听值改变以后进行触发。这是脏检测的一个测试,如果被监视的值改变了,那么进行检测,然后调用对应的监听函数。
这里测试意图中有几点需要注意一下:

  1. 第一次$digest时候没有改变值就触发了监听函数
  2. 第二次再次直接$digest没有触发
  3. 第三次改变scope.someValue没有执行$digest没有触发监听函数
  4. 第四次在someValue改变前提下执行$digest,监听函数执行了

这里新旧值进行脏检测主要是通过这块代码:

1
2
3
4
5
6
var newValue, oldValue; _.forEach(this.$$watchers, function(watcher) {
newValue = watcher.watchFn(self); oldValue = watcher.last;
if (newValue !== oldValue) {
watcher.last = newValue;
watcher.listenerFn(newValue, oldValue, self);
}

step4-初始化监视值

1
2
3
4
5
6
7
8
it("当监视值开始是undefined时候调用监视函数|calls listener when watch value is first undefined", function() {
scope.counter = 0;
scope.$watch(
function(scope) { return scope.someValue; }, function(newValue, oldValue, scope) { scope.counter++; }
);
scope.$digest();
expect(scope.counter).toBe(1);
});

当执行$watch时候scope.someValue是undefined,也就是说,这段测试代码期待即使要监视的属性是undefined也要可以顺利通过。这是一个fixbug性质的测试。
目标是为了测试初始化监视值。
这里有一些地方值得关注:

  1. last即使使得undefined合法也无法使得程序正常运行(undefined===undefined = true,当已有这个监视器会重复)
  2. 而我们需要一个不会重复的值.javascript中函数是引用值,除了自身不会和谁相等。可以利用到这里。此时它同监视器函数可能返回的任何值都不会相同。

step5-获得Digest的通知

1
2
3
4
5
6
it("may have watchers that omit the listener function", function() {
var watchFn = jasmine.createSpy().and.returnValue('something');
scope.$watch(watchFn);
scope.$digest();
expect(watchFn).toHaveBeenCalled();
});

这里焦点是:

  1. 这里的$watch仅仅传了一个参数,监听函数listenerFn是空的,这样当遍历过程中执行对应的监听函数必然报错,因为这个listenerFn不存在。所以代码里面有一句 listenerFn: listenerFn || function() { },这是容错处理。
  2. toHaveBeenCalled是确认是否调用,这里测试用来确保digest时候获取通知。

step6-在dirty的时候保持digesting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it("相同digest里面监视器链式调用|triggers chained watchers in the same digest", function() {
//先监视nameUpper,然后再监视name(监听函数里面修改nameUpper),最后改变name以期循环调用
scope.name = 'Jane';
scope.$watch(
function(scope) { return scope.nameUpper; }, function(newValue, oldValue, scope) {
if (newValue) {
scope.initial = newValue.substring(0, 1) + '.';
} }
);
scope.$watch(
function(scope) { return scope.name; }, function(newValue, oldValue, scope) {
if (newValue) {
scope.nameUpper = newValue.toUpperCase();
} }
);
scope.$digest();
expect(scope.initial).toBe('J.');
scope.name = 'vob';
scope.$digest();
expect(scope.initial).toBe('V.');
});

这里测试主要逻辑是先监视nameUpper,再监视name,name的监听函数里面修改nameUpper以便改了name之后能把变化蔓延到nameUpper,这样可以测试是否可以做到链式调用。

step7-终止不稳定的Digest

链式调用存在一个问题是两个监视器对应的监听函数里面互相修改彼此的值,这样会无限循环处理脏数据,造成死循环,所以要防止这种情况,对应的代码是:

1
2
3
4
5
6
7
8
9
10
11
Scope.prototype.$digest = function() {
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do {
dirty = this.$$digestOnce();
if (dirty && !(ttl--)) {
throw "10 digest iterations reached";
}
} while (dirty);
};

这里设置了ttl值,值为10。当ttl==10,此时会抛出错误。这样当脏检测机制遇到数据全部干净或者10次迭代循环后会结束digest过程。

step8-短路digest进程

脏检测最大的问题是优化问题。按照原理来说,脏检测每次执行检测都需要遍历所有的监视属性,那么随着监视属性的数量不断增多,那么会导致脏检测过程变慢这个缺陷无限放大,如果达到一个理论的量,那么会存在内存溢出和应用卡顿到没法用的问题和风险,所以这里有必要进行下一步的优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it("结束digest,当最后的监视器是干净的|ends the digest when the last watch is clean", function() {
scope.array = _.range(100);
var watchExecutions = 0;
_.times(100, function(i) {
scope.$watch(
function(scope) {
watchExecutions++;
return scope.array[i];
},
function(newValue, oldValue, scope) { }
);
});
scope.$digest();
expect(watchExecutions).toBe(200);
scope.array[0] = 420;
scope.$digest();
expect(watchExecutions).toBe(301);
});

这段测试的意图应该比较明显,_.range(100)创建一个包含100个从0递增到99的数字的数组,_.times则相当于Array的each方法,迭代器里面传入了index索引。
这个测试里面监视了从0到99。预期第一次执行$digest(),watchExecutions会等于200,也就是说watchFn被执行了200次。第二次在修改了其中一个值以后,再运行$digest(),会多运行101次watchFn。

这里有些地方需要关注,那就是200和101这两个数。
我们这里来算算这个数:

  1. 首先$.watch操作将数据压入到watcher数组,完毕之后数组里面有100个元素
  2. $digest运行时候会进行脏检测获取newValue,这里会调用1次watchFn,加起来有100次
  3. do…while这断,因为原来没有数据,此时数据是脏的,导致运行了2次$$digestOnce,这样200就得到了
  4. 改变值以后再次执行$digest(),仍然会如同上一次,先do一次,然后判断是否脏数据,然后再运行一次do逻辑。

优化的主要是第4步,这里整理了一下watch和digest逻辑。首先,digest是基于do…while执行,也就是说当dirty==true时候这个digest不会停止。默认想法下,这个digest是一轮一轮的来对脏数据进行处理,但是经过优化之后,可以跳过一些环节,因为脏数据在数组顶部一些位置,这样处理完了可以直接跳过后面的。

主要的标记在这里:

1
2
3
4
5
6
self.$$lastDirtyWatch = watcher;
watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
watcher.listenerFn(newValue,
(oldValue === initWatchVal ? newValue : oldValue),
self);
dirty = true;

具体来说,这个流程是这样的 ,当第一次进行watch时候,值不存在,所以digest过程do一次之后,触发dirty==true,又跑了一次do逻辑——跑了一次全部,因为$$lastDirtyWatch指向最后一个,当再次改变一个值以后,跑一次do逻辑,将$$lastDirtyWatch作为游标指向最后一个污点属性(此时$$lastDirtyWatch指向第一个),然后进入触发dirty==true环节,此时因为$$lastDirtyWatch==watcher(第一个监视属性)为真,所以短路跳出了。这样就不需要再跑一次全部。

step9-基于值的脏检测->比较全部基于值

脏检测对比过程中需要确定值类型,当对比的值是一个引用类型的话,即使改变了值,但是由于是一个引用,那么最终还是会相等,因为指向没有任何改变。所以在脏检测过程中需要对引用类型进行一次深拷贝再比较,也就是说,即使是引用值,也必须转换为基于值的比较。

测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it("基于值的对比,如果可以的话|compares based on value if enabled", function() {
scope.aValue = [1, 2, 3];
scope.counter = 0;
scope.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope) {
scope.counter++;
},
true
);
scope.$digest();
expect(scope.counter).toBe(1);
scope.aValue.push(4);
scope.$digest();
expect(scope.counter).toBe(2);
});

这里scope.counter==1而不是等于2,是由于$$lastDirtyWatch指向了第一个。其他地方没有什么值得注意的地方。

step10-NaNs兼容

为什么要有这个检测呢?一句代码的事情: (NaN===NaN) === false
虽然奇怪但是确实如此,如果不对此做特殊处理,那么NaN在脏检测函数中将始终是脏数据。不过,lodash的isEqual方法已经对此做了兼容。所以我们不需要改什么代码了。
这里是测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it('正确的处理NaN|correctly handles NaNs', function() {
scope.number = 0/0; // NaN
scope.counter = 0;

scope.$watch(
function(scope) {
return scope.number;
},
function(newValue, oldValue, scope) {
scope.counter++;
}
);

scope.$digest();
expect(scope.counter).toBe(1);

scope.$digest();
expect(scope.counter).toBe(1);
});

这没什么奇怪的,但是如果没有isEqual大法加持,正常情况下$digest每执行一次,都会执行一次linstenFn的。。。也就是说scope.counter会被+1.

$eval、$apply和$evalAsync

$eval

$eval的作用是在scope中执行给出的表达式。
$eval很容易实现,代码主要是这样,测试代码就不管了,因为简单的实在不想贴测试:

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

$apply

$apply作用是将外部js代码引入到scope的digest环节来。这个方法可能是非常非常广为人知的一个方法。尤其是用jquery处理数据更新数据,ajax获取数据更新view什么的。
但是$.apply真的没有比$.eval复杂到哪儿去。他实际上调用了$.eval,然后手动触发了digest,代码:

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

try/catch用的比较多,但是try/finally估计很少见了.finally在try和catch代码执行完毕后执行,不管这两个环节结果如何。

$evalAsync

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

这里看测试代码,可以更加清晰了解设计意图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it("executes $evalAsync'ed function later in the same cycle", function() { scope.aValue = [1, 2, 3];
scope.asyncEvaluated = false;
scope.asyncEvaluatedImmediately = false;
scope.$watch(
function(scope) { return scope.aValue; },
function(newValue, oldValue, scope) {
scope.$evalAsync(function(scope) {
scope.asyncEvaluated = true;
});
scope.asyncEvaluatedImmediately = scope.asyncEvaluated;
});
scope.$digest();
expect(scope.asyncEvaluated).toBe(true);
expect(scope.asyncEvaluatedImmediately).toBe(false);
});

很显然,这里$evalAsync内部的函数在 scope.asyncEvaluatedImmediately = scope.asyncEvaluated;之前完成了。
实现这点不算难,找个地方保存这个函数,然后在digest过程中的do里面先执行一次就可以了。
修改后代码这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Scope() {
this.$$watchers = [];
this.$$lastDirtyWatch = null;
this.$$asyncQueue = [];//add
}
Scope.prototype.$digest = function () {
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do {
<!-- 添加start -->
while (this.$$asyncQueue.length) {
var asyncTask = this.$$asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression);
}
<!-- 添加end -->
dirty = this.$$digestOnce();
if (dirty && !(ttl--)) {
throw "10 digest iterations reached";
}
} while (dirty);
};

继续看下一个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it("executes $evalAsync'ed functions even when not dirty", function () {
scope.aValue = [1, 2, 3];
scope.asyncEvaluatedTimes = 0;
scope.$watch(function (scope) {
if (scope.asyncEvaluatedTimes < 2) {
scope.$evalAsync(function (scope) {
scope.asyncEvaluatedTimes++;
});
}
return scope.aValue;
},
function (newValue, oldValue, scope) {
});
scope.$digest();
expect(scope.asyncEvaluatedTimes).toBe(2);
});

报错了。。。因为第二次运行时候数据不是脏的,因此没有进入do逻辑。。。改改:

1
2
3
4
5
6
Scope.prototype.$digest = function() { 
...
do {
...
} while (dirty || this.$$asyncQueue.length); };

这样一来测试是通过了,但是问题是如果如果watcher里面一直$evalAsync导致不停执行do逻辑造成死循环怎么办?所以继续改,先上测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
it("eventually halts $evalAsyncs added by watches", function () {
scope.aValue = [1, 2, 3];
scope.$watch(function (scope) {
scope.$evalAsync(function (scope) {
});
return scope.aValue;
},
function (newValue, oldValue, scope) {
});
expect(function () {
scope.$digest();
}).toThrow();
});

代码改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Scope.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累减,最后抛出错误终止。

收个尾

实际上写完$eval、$apply和$evalAsync这块时候突然发觉有些跑题了。文章初衷还是学习一下脏检测是如何运行的。所以就暂且打住了。不过考虑到往后还会继续深入下去,暂时就不做删除了。

这里再次回顾整理一下脏检测:
回顾一下文章标题,总结下脏检测实现思路。

  1. Scope 对象
  2. 监视对象属性:$watch和$digest
  3. 检查Dirty值
  4. 初始化监视值
  5. 获得Digest的通知
  6. 在dirty的时候保持digesting
  7. 终止不稳定的Digest
  8. 短路digest进程,如果最后一个污点被清除
  9. 基于值的脏检测

个人总结:
基本上脏检测呢,说得有些术语化,其实整个过程真的和高深扯不上太多关系。主要的思路是这样的:

  1. 实现Scope函数,在原型上挂载$watch和$digest和方法
  2. digest过程返回Dirty值用来判断是否继续执行digest过程
  3. $watch负责添加要监视的属性,而digest负责消灭污点
  4. 至于更多的初始值啊,基于值的比较啊,NaN啊,死循环啊,digest优化之类,都不过是细节补充
  5. $apply实现真的说明了很多

最后一句话总结就是digest是手动触发——真的没有太多深奥的东西。
当然,这里手动触发有不少值得注意的场景:

  1. Scope内部数组的splice,slice,push,pop等堆栈操作
  2. View2Data环节过程中各种事件触发
  3. $apply等等

到这里这一篇就此暂结,本文主要整理了脏检测过程中细节的实现和测试。最后发现除了深入了实现机制,确实也没有什么特别耀眼的东西。但是整个过程,还是深入了解了脏检测实现过程中需要考虑的细节,收获还是不少。

Thanks For 《Build You Own AngularJS》