在上一节项目初始化中,我们最终得到了一个可以运行的基础代码库,它的基本结构如下:
其中node_modules文件夹存放项目中的第三方依赖模块,src存放我们的项目代码源文件,test存放测试用例文件,.jshintrc是jshint插件的配置文件,karma.conf.js是karma的配置文件,package.json是npm的配置文件,结构其实很简单。从本节开始,会在这个代码库的基础上进行我们自己Angular的实现。
首先,在写代码之前,在命令行中输入npm test命令,让我们的测试用例代码实时在后台进行最新代码的测试,以便我们随时知道我们的代码是否符合规范,这一行为作为一个后台任务贯穿于我们框架实现整个过程,对于测试结果不再一一列举,如果出现错误需要自行修改代码让其符合测试用例的预期。
scope在Angular中实际上就是一个普通的对象,在该对象中存在各种属性和方法,同时我们也可以自己在该对象上设置属性。scope的作用主要有以下几种:
1)在controllers和views之间共享数据;
2) 在应用的各个不同部分之间共享数据;
3)广播和监听事件;
4)监听数据的变化;
在本文中,我们首先来从头实现一个scope及它的digest循环和脏检查机制,主要通过$watch和$digest两个方法来实现.
首先,在src目录下创建一个scope.js,用来存放scope实现的相关代码,同时在test目录下创建一个scope_spec.js,用来存放与scope相关的测试用例。
我们第一步需要实现的是通过构造函数new出来一个scope实例,在该实例下我们能够设置相关属性,本着TDD(测试驱动开发)的思想,我们首先编写相关测试用例,然后再进行实现,在test/scope_spec.js中编写以下代码:
1 'use strict'; 2 var Scope = require('../src/scope'); 3 describe("Scope", function() { 4 it("can be constructed and used as an object", function() { 5 var scope = new Scope(); 6 scope.aProperty = 1; 7 expect(scope.aProperty).toBe(1); 8 }); 9 });
在该测试用例中我们引入对于scope的实现,采用new运算符得到一个scope实例,在该实例上能够添加任何属性,并在设置属性之后测试被设置的值是否正确。
在src/scope.js中的实现如下:
1 'use strict'; 2 function Scope() { 3 } 4 module.exports = Scope;
目前的实现很简单,仅仅是一个构造函数,不需要解释。
接着,我们需要在每个scope实例中实现一个$watch方法,它的作用是监测某个值,当其发生变化的时候调用某个函数进行某项操作,该方法需要两个参数,第一个参数是一个function,用来返回需要被监测的值(Angular本身的实现中,第一个参数不一定为function,可为任意值,此处为了简化,暂且让第一个参数为function,其他类型参数的监测,后续会给出实现)。第二个参数为另一个function,当被监测的值发生变化的时候,需要调用该函数。在scope中,我们使用$watch函数设置对于某些值得监测,称之为一个watcher,一个scope实例中存在若干watcher,digest循环的作用就是启动一轮循环,检查该scope下面的所有watcher,如果发生变化,调用该watcher的函数(即第二个参数)。对于digest,我们使用scope下面的$digest方法来实现。
按照上述思想,我们修改test/scope_spec.js文件的内容如下:
1 describe("Scope", function() { 2 it("can be constructed and used as an object", function() { 3 var scope = new Scope(); 4 scope.aProperty = 1; 5 expect(scope.aProperty).toBe(1); 6 }); 7 describe("digest", function() { 8 var scope; 9 beforeEach(function() { 10 scope = new Scope(); 11 }); 12 it("calls the listener function of a watch on first $digest", function() { 13 var watchFn = function() { return 'wat'; }; 14 var listenerFn = jasmine.createSpy(); 15 scope.$watch(watchFn, listenerFn); 16 scope.$digest(); 17 expect(listenerFn).toHaveBeenCalled(); 18 }); 19 }); 20 });
黄色背景部分是发生变化的部分,它定义了一个关于digest的测试用例,在该用例中,每个测试用来开始的时候,首先new一个scope实例,接着调用该scope下面的$watch方法在其下面设置一个watcher(此处被检测的值返回的是一个字符串,只是为了占位,并不代表被监测的真实值),然后调用$digest方法,调用完毕后,需要确定该watcher的第二个函数参数是否被调用过,如果被调用过就符合我们的预期。
这个时候可以查看后台的karma报告的错误信息,该测试用刘肯定是无法通过的,因为我们还没有在scope.js中实现这两个方法。接着在src/scope.js中实现这两个方法,代码如下:
1 'use strict'; 2 var _ = require('lodash'); 3 function Scope() { 4 this.$$watchers = []; 5 } 6 Scope.prototype.$watch = function(watchFn, listenerFn) { 7 var watcher = { 8 watchFn: watchFn, 9 listenerFn: listenerFn 10 }; 11 this.$$watchers.push(watcher); 12 }; 13 Scope.prototype.$digest = function() { 14 _.forEach(this.$$watchers, function(watcher) { 15 watcher.listenerFn(); 16 }); 17 };
在上面代码的第四行,在构造函数中添加了一个$$watchers属性,用来存放该scope下面的所有watcher,由于它是一个私有属性,这里使用$$前缀来表示,只能够在内部实现代码中调用。6-12行是$watch方法的实现,它的作用是在该scope下面创建一个watcher,由于它是个实例方法,所以我们定义在prototype上。它拥有两个参数,第一个参数函数返回被监测的值,第二个参数当被检测的值发生变化后被调用。创建watcher的是指就是将这个watcher对象加入到$$watchers数组中去。13-16行是$digest方法的实现,它的作用是当调用该方法的时候,遍历该scope下面的所有watcher,并执行其监测函数。
这个时候可以保存后查看karma报告的测试信息,显示诸如以下信息:
表示我们之前的测试用例通过,今后所有的功能开发都基于这种先写测试用例,后写实现,然后查看测试结果的模式,此后其他的测试结果不再给出。
一般情况下,我们需要监测的变化的值都是该scope下面的某个属性值,这就需要我们的$watch函数的第一个参数返回值能够获取到scope实例。基于此,我们将scope实例作为参数传入$watch的第一个参数函数中,编写测试用例如下test/scope_spec.js:
1 it("calls the watch function with the scope as the argument", function() { 2 var watchFn = jasmine.createSpy(); 3 var listenerFn = function() { }; 4 scope.$watch(watchFn, listenerFn); 5 scope.$digest(); 6 expect(watchFn).toHaveBeenCalledWith(scope); 7 });
在该用例中,我们希望调用$watch之后,确保它拥有scope作为其参数,src/scope.js实现如下:
1 Scope.prototype.$digest = function() { 2 var self = this; 3 _.forEach(this.$$watchers, function(watcher) { 4 watcher.watchFn(self); 5 watcher.listenerFn(); 6 }); 7 };
首先第2行存储this对象,即scope实例对象,然后第4行将其作为参数传递给watchFn并执行。
$digest的方法需要实现的是循环scope下所有的watcher,在某个watcher下面,首先通过watchFn函数得到被监测的值,将其与上次存储的值进行比较,如果发生变化,则执行listenerFn。测试用例test/scope_sepc.js如下:
1 it("calls the listener function when the watched value changes", function() { 2 scope.someValue = 'a'; 3 scope.counter = 0; 4 scope.$watch( 5 function(scope) { return scope.someValue; }, 6 function(newValue, oldValue, scope) { scope.counter++; } 7 ); 8 expect(scope.counter).toBe(0); 9 scope.$digest(); 10 expect(scope.counter).toBe(1); 11 scope.$digest(); 12 expect(scope.counter).toBe(1); 13 scope.someValue = 'b'; 14 expect(scope.counter).toBe(1); 15 scope.$digest(); 16 expect(scope.counter).toBe(2); 17 });
在scope下面设置一个someValue对象,并使用$watch方法监测该对象,如果发生变化即newValue不等于oldValue,则执行counter++;只有每次someValue的值发生了变化之后,counter的值才能够增加。
src/scope.js实现如下:
1 Scope.prototype.$digest = function() { 2 var self = this; 3 var newValue, oldValue; 4 _.forEach(this.$$watchers, function(watcher) { 5 newValue = watcher.watchFn(self); 6 oldValue = watcher.last; 7 if (newValue !== oldValue) { 8 watcher.last = newValue; 9 watcher.listenerFn(newValue, oldValue, self); 10 } 11 }); 12 };
重新修改$digest方法,通过watchFn来得到newValue,通过存储在watcher本身的属性last来记录上次的值,通过===来比较,如果不相等,则将watcher.last赋值为newValue,然后再执行listenerFn函数,这个函数的参数newValue表示被检测的值得最新值,oldValue表示上次的值,self代表scope本身。
接着,我们知道当第一次初始化一个watcher的时候,它没有last属性,只有经过一次比较$digest调用之后,last的值才不为空,所以需要初始化watcher的last属性。
src/scope.js如下:
1 function initWatchVal() { } 2 Scope.prototype.$watch = function(watchFn, listenerFn) { 3 var watcher = { 4 watchFn: watchFn, 5 listenerFn: listenerFn, 6 last: initWatchVal 7 }; 8 this.$$watchers.push(watcher); 9 };
我们重新定义了$watch方法,为每个watcher初始化了一个last值,为了保证它是一个唯一的值,除了与它自身相等,与其他任何值都不能相等,我们采用一个function来初始化它。
在我们第一次调用$digest方法进行比较newValue和oldValue的时候,这个时候oldValue是initWatchVal即初始值,所以需要额外判断,如果是初始值,则在listenerFn中将其初始化为newValue,实现如下src/scope.js:
1 Scope.prototype.$digest = function() { 2 var self = this; 3 var newValue, oldValue; 4 _.forEach(this.$$watchers, function(watcher) { 5 newValue = watcher.watchFn(self); 6 oldValue = watcher.last; 7 if (newValue !== oldValue) { 8 watcher.last = newValue; 9 watcher.listenerFn(newValue, 10 (oldValue === initWatchVal ? newValue : oldValue), 11 self); 12 } 13 }); 14 };
第9-11行实现了对于oldValue参数的初始化,让它等于oldValue(不是第一次比较),或者等于newValue(第一次比较)。
在某些情况下,调用$watch函数的时候有可能只传递了第一个参数,并没有listnerFn,考虑到这种现象,修改scope.js如下:
1 Scope.prototype.$watch = function(watchFn, listenerFn) { 2 var watcher = { 3 watchFn: watchFn, 4 listenerFn: listenerFn || function() { }, 5 last: initWatchVal 6 }; 7 this.$$watchers.push(watcher); 8 };
我们给listenerFn一个默认的值—空的function,当调用者省略第二个参数也能够正常运行。
考虑到一种极端的情况是,当我们在$digest函数中执行某个listenerFn的时候,有可能这个listenerFn本身会修改scope下面的某个属性值,而这个属性值又被某个watcher所监测,这样会导致对于这个watcher的监测不会得到通知,也不会触发其listenerFn。所以我们需要定义$digest的行为是让其一直遍历所有的watcher,直到被监听的所有watcher的值都停止变化为止。这个时候我们需要定义一个$digestOnce函数,它只遍历一次该scope下的所有watcher,并最终返回一个值表示是否还存在还在发生变化的watcher的值。src/scope.js实现如下:
1 Scope.prototype.$$digestOnce = function() { 2 var self = this; 3 var newValue, oldValue, dirty; 4 _.forEach(this.$$watchers, function(watcher) { 5 newValue = watcher.watchFn(self); 6 oldValue = watcher.last; 7 if (newValue !== oldValue) { 8 watcher.last = newValue; 9 watcher.listenerFn(newValue, 10 (oldValue === initWatchVal ? newValue : oldValue), 11 self); 12 dirty = true; 13 } 14 }); 15 return dirty; 16 };
上述代码通过返回的dirty值来确定是否还存在变化。接着我们修改$digest方法来调用该函数如下:scope.js
1 Scope.prototype.$digest = function() { 2 var dirty; 3 do { 4 dirty = this.$$digestOnce(); 5 } while (dirty); 6 };
一直调用$digestOnce函数,直到返回的dirty值为false。在这种情况下,每次$digest只要有一个watcher的值发生变化,则该次遍历就被标记为dirty,就要进行新一轮的循环,直到该轮循环中所有watcher的值都没有发生变化,这个时候才被认为是稳定了。
在某些极端情况下,例如两个watcher互相监测对方的值,这会导致两者返回值都不稳定,这种循环依赖的情况会导致整个$digest过程无法停止下来,而一直遍历所有watcher,这种情况需要避免。当前的做法是定义一个变量记录循环的次数,如果超过这个次数,则throw一个error,告诉调用者$digest次数达到上限了,实现如下src/scope.js
1 Scope.prototype.$digest = function() { 2 var ttl = 10; 3 var dirty; 4 do { 5 dirty = this.$$digestOnce(); 6 if (dirty && !(ttl--)) { 7 throw "10 digest iterations reached"; 8 } 9 } while (dirty); 10 };
我们采取10次为上限,当次数超过十次的时候,直接抛出错误。
考虑一种情况,当一个scope下面拥有100个watcher的时候,当遍历所有的watcher的时候,恰好只有第一个是dirty的,其他都是clean的。但是就是这一个watcher会导致我们整个一次$digest循环成为dirty,从而进入到下次循环。在下次循环过程中,所有watcher都没有发生变化即为clean,但是就是这样一个小小的watcher,会导致我们需要遍历200次不同的watcher!针对这种情况,我们可以在一次遍历中标记最后一个为dirty的watcher,当下次循环遇到的watcher恰好是上次标记的watcher并变成clean的时候,我们就可以停止遍历,而不是继续进行该次遍历直到最后。按照这种思想实现如下:scope.js
1 'use strict'; 2 var _ = require('lodash'); 3 var Scope = require('../src/scope'); 4 function Scope() { 5 this.$$watchers = []; 6 this.$$lastDirtyWatch = null; 7 } 8 Scope.prototype.$digest = function() { 9 var ttl = 10; 10 var dirty; 11 this.$$lastDirtyWatch = null; 12 do { 13 dirty = this.$$digestOnce(); 14 if (dirty && !(ttl--)) { 15 throw "10 digest iterations reached"; 16 } 17 } while (dirty); 18 }; 19 Scope.prototype.$$digestOnce = function() { 20 var self = this; 21 var newValue, oldValue, dirty; 22 _.forEach(this.$$watchers, function(watcher) { 23 newValue = watcher.watchFn(self); 24 oldValue = watcher.last; 25 if (newValue !== oldValue) { 26 self.$$lastDirtyWatch = watcher; 27 watcher.last = newValue; 28 watcher.listenerFn(newValue, 29 (oldValue === initWatchVal ? newValue : oldValue), 30 self); 31 dirty = true; 32 } else if (self.$$lastDirtyWatch === watcher) { 33 return false; 34 } 35 }); 36 return dirty; 37 };
第6行在构造函数中定义了一个$$lastDirtyWatch变量来存储每一轮循环中最后一个被标记为dirty的watcher,接着在32-34行当循环到一个watcher为clean的时候,判断它时候是我们标记的上一轮循环中最后一个
dirty的watcher,如果是,就不用再循环了,直接跳出循环(在lodash的forEach方法中返回false直接跳出)。
同时在每次在scope下面新加入一个watcher的时候,需要将该scope的$$lastDirtyWatch属性重置,否则被新加入的watcher并不会被考虑,实现如下scope.js:
1 Scope.prototype.$watch = function(watchFn, listenerFn) { 2 var watcher = { 3 watchFn: watchFn, 4 listenerFn: listenerFn || function() { }, 5 last: initWatchVal 6 }; 7 this.$$watchers.push(watcher); 8 this.$$lastDirtyWatch = null; 9 };
在每次调用$watch方法的时候都需要重置$$lastDirtyWatch属性。
在我们的$digest实现中,比较采用的是===这种方式,在JS中对于原始类型这种方式完全没有问题,但是对于像数组对象等引用类型,这种方式就存在问题了。例如一个数组一开始是var arr=[1,2],后来变成了arr=[1,2,3],实际上本身发生了变化,但是使用===运算符比较还是相等的。这就是说我们之前的比较是一种基于引用的比较,而对于引用类型元素,需要基于值进行比较。所以我们需要设置一个属性,表示对于该watcher的比较是基于引用的还是基于值的(由于基于值得比较性能消耗较大,所以默认是基于引用的比较)。实现如下:scope.js
1 Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) { 2 var watcher = { 3 watchFn: watchFn, 4 listenerFn: listenerFn || function() { }, 5 valueEq: !!valueEq, 6 last: initWatchVal 7 }; 8 this.$$watchers.push(watcher); 9 this.$$lastDirtyWatch = null; 10 };
上述代码中,当我们加入一个watcher的时候,采用valueEq参数指定该watcher是基于引用的还是基于值的比较,使用!!运算符将其转换为一个布尔类型。
接着我们需要定义一个方法,在引用比较的情况下进行基于引用的比较,否则基于值得比较,实现如下:
1 Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) { 2 if (valueEq) { 3 return _.isEqual(newValue, oldValue); 4 } else { 5 return newValue === oldValue; 6 } 7 };
在第3行我们利用lodash的isEqual方法来进行基于值的比较。
接着我们在$digestOnce方法中调用$$areEqual方法,如下:
1 Scope.prototype.$$digestOnce = function() { 2 var self = this; 3 var newValue, oldValue, dirty; 4 _.forEach(this.$$watchers, function(watcher) { 5 newValue = watcher.watchFn(self); 6 oldValue = watcher.last; 7 if (!self.$$areEqual(newValue, oldValue, watcher.valueEq)) { 8 self.$$lastDirtyWatch = watcher; 9 watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue); 10 watcher.listenerFn(newValue, 11 (oldValue === initWatchVal ? newValue : oldValue), 12 self); 13 dirty = true; 14 } else if (self.$$lastDirtyWatch === watcher) { 15 return false; 16 } 17 }); 18 return dirty; 19 };
在第7行,利用$$areEqual方法判断该watcher是否还是dirty的,如果是就需要深拷贝该watcher下面的newValue作为其last属性。
到目前为止,我们已经可以通过$watch函数监听scope下面的任意属性值(无论是原始类型还是引用类型),并启动$digest循环进行dirty-checking.最后还有一中极端的情况,就是当我们监测是指为NaN的时候,它本身与自己是不相等的,这会导致其永远是dirty的,需要考虑到这种极端情况,实现如下:
1 Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) { 2 if (valueEq) { 3 return _.isEqual(newValue, oldValue); 4 } else { 5 return newValue === oldValue || 6 (typeof newValue === 'number' && typeof oldValue === 'number' && 7 isNaN(newValue) && isNaN(oldValue)); 8 } 9 };
在上述代码中,如果被检测的值为NaN,则进行特殊处理,如果oldValue和newValue都是NaN并且都是number,则认为两者是相等的。
以上就是我们自己实现的AngularJS中Scope下面的$watch及$digest脏检查机制的简易实现,后续章节依然会在此基础上进行优化和修改。为了防止篇幅太长,今后只给出重要的测试用例及测试结果。文章的完整代码点击这里可以进行查看。