我们将从实现AngularJS的一个核心模块 – Scopes – 开始。Scopes被用在许多不同的方面。
在上面列出的这几项中,最后一项毫无疑问是最有意思的一项。AngularJS scopes实现了一个叫做 dirty-checking的机制,当scope中的一块数据发生改变时,你能够得到通知。你可以照它的样子去实现它,但是它同时也是神秘的 data-binding(数据绑定) 的秘密所在,也是AngularJS的一个主要卖点。
Scopes和Digest
AngularJS的scopes就是一般的JavaScript对象,在它上面你可以绑定你喜欢的属性和其他 对象,然而,它们同时也被添加了一些功能用于观察数据结构上的变化。这些观察的功能都由dirty-checking来实现并且都在一个digest循环中被执行。这就是我们在本章中需要实现的功能。
Scope 对象
我们通过在一个Scope构造函数上面使用new操作符来创建scopes。返回的结果是一个普通的JavaScript对象。我们现在就对这个基本的行为进行测试。
创建一个test/scope_spec.js文件,并将下面的测试代码添加到其中:
test/scope_spec.js ------- /* jshint globalstrict: true */ /* global Scope: false */ '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); }); });
在文件的顶部我们启用了ES5的严格模式,同时让JSHint知道我们可以在这个文件中引用一个叫做Scope的全局对象。
这个测试用来创建一个Scope,并在它上面赋一个任意值,然后检查它是否真正被赋值。
在这里你可能会注意到我们居然使用Scope作为一个全局函数。这绝对不是一个好的JavaScript编程方式!在本书的后面,一旦我们实现了依赖注入,我们将会改正这个错误。
如果你已经在一个终端中使用了grunt watch,在你添加完这个测试文件之后你会发现它出现了错误,原因在于我们现在还没有实现Scope。而这正是我们想要的,测试驱动开发的第一个重要步骤就是首先要看到错误。
在本书中我都会假设测试套件会自动执行,同时在测试应该执行时我并不会明确的指出。
我们可以轻松的让这个测试通过:创建src/scope.js文件然后在其中添加以下内容:
src/scope.js ------ /* jshint globalstrict: true */ 'use strict'; function Scope() { }
在这个测试中,我们将一个属性(aProperty)赋值给了这个scope。这正是Scope上的属性运行的方式。它们就是正常的JavaScript属性,并没有什么特别之处。这里你完全不需要去调用一个特别的setter,也不需要对你赋值的类型进行什么限制。真正的魔法在于两个特别的函数:$watch和$digest。我们现在就来看看这两个函数。
监视对象属性:$watch和$digest
$watch和$digest是同一个硬币的两面。它们二者同时形成了$digest循环的核心:对数据的变化做出反应。
你可以使用$watch函数为scope添加一个监视器。当这个scope中有变化发生时,监视器便会提醒你。你可以通过给$watch提供两个函数来创建一个监视器:
作为一个AngularJS用户,你实际上经常指明一个监视表达式而不是一个监视函数。一个监视表达式是一个字符串,例如”user.firstName”,就像你在一个数据绑定,一个指令属性,或者在一段JavaScript代码中指明的那样。它会在AngularJS内部被解析然后编译成一个监视函数。我们将在本书的第二部分中实现这一点。在那之前我们都将使用底层的方法来直接提供一个监视函数。
硬币的另外一面是$digest函数。它迭代了所有绑定到scope中的监视器,然后进行监视并运行相应的监听函数。
为了实现这一块功能,我们首先来定义一个测试文件并断言你可以使用$watch来注册一个监视器,并且当有人调用了$digest的时候监视器的监听函数会被调用。
为了让事情简单一些,我们将在scope_spec.js文件中添加一个嵌套的describe块。并创建一个beforeEach函数来初始化这个scope,以便我们可以在进行每个测试时重复它:
test/scope_spec.js ------ describe("Scope",function() { var scope = new Scope(); scope.aProperty = 1; expect(scope.aProperty).toBe(1); }); describe("digest",function() { var scope; beforeEach(function() { scope = new Scope(); }); it("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(); }); }); });
在上面的这个测试中我们调用了$watch来在这个scope上注册一个监视器。我们现在对于监视函数本身并没有什么兴趣,因此我们随便提供了一个函数来返回一个常数值。作为监听函数,我们提供了一个Jasmine Spy。接着我们调用了$digest并检查这个监听器是否真正被调用。
spy是一个Jasmine的术语,它用来模拟一个函数。它让我们可以方便的回答诸如“这个函数有没有被调用?”以及“这个函数使用了那个参数”这样的问题。
要让这个测试通过的话,我们还有一些事情需要去做。首先,这个Scope需要有一些地方去存储所有被注册的监视器。我们现在就在Scope构造函数中添加一个数组存储它们:
src/scope.js ----- function Scope(){ this.$$watchers = []; }
现在我们可以来定义$watch函数了。它接收两个函数作为参数,并且将它们储存在$$watchers数组中。我们想要每一个Scope对象都拥有这个函数,因此我们将它添加到Scope的原型中:
src/scope.js ----- Scope.prototype.$watch = function(watchFn,listenerFn) { var watcher = { watchFn: watchFn,listenerFn: listenerFn }; this.$$watchers.unshift(watcher); };
最后我们应该有一个$digest函数。现在,我们来定义一个$digest函数的简化版本,它仅仅只是会迭代所有的注册监视器并调用它们的监听函数:
src/scope.js ----- Scope.prototype.$digest = function() { var length = this.$$watchers.length; var watcher; while (length--) { watcher = this.$$watchers[length]; watcher.listenerFn(); } };
注意到我们在开始时正向添加监视器数组然后逆序迭代它。这样的做法将会让我们在实现移除监视器时轻松一点。
此时,测试会通过但是这个版本的$digest并没有什么实际作用。我们真正想要的是检查监视函数指定的值是否发生了变化,发生变化时才会调用监听函数。这叫做dirty-checking。
检查Dirty值
正如前面所描述的,一个监视器的监视函数应该返回一块我们感兴趣并且发生变化的数据。通常来说,这块数据应该是存在于scope中的某个东西。为了让监视函数更方便的访问scope,我们想要将当前的scope作为监视函数的一个参数来调用它。一个关于firstName属性的监视函数应该如下所示:
---- function(scope){ return scope.firstName; } -----
这就是监视函数通常的样子:从scope中提取一些值然后将它返回。
现在我们来添加一个测试来检查这个scope确实被提供作为监视函数的一个参数:
test/scope_spec.js ---- it("calls the watch function with the scope as the argument",function(){ var watchFn = jasmine.createSpy(); var listenerEn = function(){}; scope.$watch(watchFn,listenerFn); scope.$digest(); expect(watchFn).toHaveBeenCalledWith(scope); });
这一次我们为监视函数创建了一个Spy并使用它来检查watch的调用情况。使测试通过的最简单的方法是修改$digest,如下所示:
src/scope.js ---- Scope.prototype.$digest = function(){ var length = this.$$watcher.length; var watcher; while(length--){ watcher = this.$$watchers[length]; watcher.watchFn(this); watcher.listenerFn(); } };
当然,这也不是完整版的$digest函数。$digest函数的职责是调用监视函数并将它的返回值与上一次的返回值进行对比。如果两次的返回值不同,那么监视器就是dirty的,并且他的监听器函数应该被调用。我们现在来添加一个测试用例:
test/scope_spec.js --- 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); });
我们首先在scope上绑定了两个值:一个字符串和一个数字。我们接着可以绑定一个监视器来监视这个字符串同时在字符串发生变化时增加这个数字。我们期望的是在第一次$digest时计数器会增加一次,然后如果值发生变化的话每次后续的$digest都会使计数器增加一次。
注意我们同时也指定了监听函数:和监视函数一样,它也接受这个scope作为一个参数。它同时也接受这个监视器的新值和旧值。这让应用开发者能够更加轻松的检查究竟发生了什么变化。
为了正常运行,$digest必须记住每个监视函数的上一个值是什么。既然我们对每个监视器已经有了一个对象,我们可以很方便的在这里储存这个值。下面的代码是一个新版本的$digest的定义,它会检查每个监视函数的值的变化:
src/scope.js --- Scope.prototype.$digest = function(){ var length = this.$$watcher.length; var watcher,newValue,oldvalue; while(length--){ watcher = this.$$watchers[length]; newValue = watcher.watchFn(this); oldValue = watcher.last; if(newValue !== oldValue){ watch.last = newValue; watch.listenFn(newValue,this); } } };
对于每个监视器,我们将监视函数的返回值同我们在last属性中存储的值进行对比。如果二者有区别,我们就会调用监听器函数,将新值和旧值都传递给它,同时也将scope本身传递给它。最后,我们将监视器的last属性设置为新的返回值,以便我们在下一次也能进行比较。
我们现在已经实现了Angular scopes的核心部分:绑定监视器函数以及将它们在一个digest中运行。
我们同时也了解到了Angular scopes中几个重要的表现行为:
- 为一个scope添加一个不是它自己的数据对性能很有影响。如果没有监视器在监视一个属性,那么这个属性在不在scope上没有关系。Angular并不会迭代scope上的属性,它只会迭代监视器。
- 每次$digest运行时,所有监视器都被调用一次。正因为如此,关注监视器的数目很重要,关注每个监视函数和表达式的性能也很重要。
初始化监视值
将一个监视函数的返回值同存储在last中的属性进行比较在大多数情况下是有效的,但是当监视器函数第一次执行时会是什么情形呢?既然此时我们还没有设置last属性,它的值的undefined。在此时监视器的合法值为undefined也不能使程序正常运行:
test/scope_spec.js --- it("calls listener when watch value is first undefined",function() { scope.counter = 0; scope.$watch( function(scope) { return scope.someValue; },scope) { scope.counter++; } ); scope.$digest(); expect(scope.counter).toBe(1); });
我们也应该在这里调用监听器函数。我们需要做的事情是将last属性初始化一个独有的值,这个值要和监视函数可能返回的值都不同。
有一个函数很适合处理这里的情形,因为JavaScript中的函数是所谓的引用值 - 它们除了自己谁都不相等。我们在scope.js中引入一个函数:
src/scope.js ---- function initWatchVal(){}
src/scope.js ---- Scope.prototype.$watch = function(watchFn,listenerFn) { var watcher = { watchFn: watchFn,listenerFn: listenerFn,last: initWatchVal }; this.$$watchers.unshift(watcher); };
使用这种方法新的监视器将总是可以调用监听器函数,无论监视函数会返回什么。
获得Digest的通知
如果你在一个Angular scope被digest的时候想要获得通知,你可以利用在每次digest时所有的监视函数都要被执行这一点:你只需要注册一个没有监听函数的监视函数即可。我们将这一点添加到测试中。
tesr/scope_src.js 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(); });
这个监听函数并不需要返回任何东西,但是它可以,在这个例子中它也返回了一些东西。当这个scope在digest时,我们目前实现的代码会抛出一个错误。这是因为我们试图去掉调用一个并不存在的监听函数。为了给这个情形添加支持,我们需要在$watch中检查监听器是否被省略,如果是的话,在其中放入一个空函数:
src/scope.js ------ Scope.prototype.$watch = function(watchFn,listenerFn){ var watcher = { watchFn: watchFn,listenerFn: listenerFn || function() {},last: initWatchVal }; this.$$watchers.unshift(watcher); };
如果你使用了这个模式,一定要记住Angular会查看watchFn的返回值,即使不存在listenerFn。如果你返回一个值,这个值会在dirty-checking的检查范围之内。为了确认你对这种模式的用法不会引起额外的麻烦,不要返回任何东西就可以。在上面的例子中这个监视器将会恒为undefined。
在dirty的时候保持digesting
我们现在已经实现了核心的部分,但是我们离真正的Angular还远得很。例如,我们现在的代码还不支持一种典型的场景:监听函数本身会改变scope中的属性。如果这种情况发生了,我们需要用另一个监视器来查看属性有没有变化,它在同一个digest循环中可能并不会注意到属性的变化:
tesr/scope_spec.js ---- it("triggers chained watchers in the same digest",function() { scope.name = 'Jane'; scope.$watch( function(scope) { return scope.nameUpper; },scope) { if (newValue) { scope.initial = newValue.substring(0,1) + '.'; } } ); scope.$watch( function(scope) { return scope.name; },scope) { if (newValue) { scope.nameUpper = newValue.toUpperCase(); } } ); scope.$digest(); expect(scope.initial).toBe('J.'); scope.name = 'Bob'; scope.$digest(); expect(scope.initial).toBe('B.'); });
我们在这个scope中有两个监视器:一个用来监视nameUpper属性,并且根据它来为initial赋值,另一个监视name属性并为根据它来为nameUpper赋值。我们期望的是当scope中的name发生变化时,nameUpper和initial属性都会在digest中相应的发生变化。但是,这种情况目前还没有实现。
我们很细心的排列监视器以便依赖的监视器可以首先被注册。如果顺序反过来,测试将会马上通过,因为监视器将会以正确的顺序发生。然而,监视器之间的依赖关系并不依赖于它们的注册顺序。我们马上将会看到这一点。
我们现在要做的事情就是修改digest以便它能够持续的迭代所有监视函数,直到被监视的值停止变化。多做几次digest是我们能够获得运用于监视器并依赖于其他监视器的变化。
首先,我们将目前的$digest重命名为$$digestOnce,并且调整它以便它能够在所有监视器上运行一遍,然后返回一个布尔值来说明有没有任何变化:
src/scope.js ---- Scope.prototype.$$digestOnce = function(){ var length = this.$$watchers.length; var watcher,dirty; while(length--){ watcher = this.$$watchers[length]; newValue = watcher.watchFn(this); oldValue= watcher.last; if(newValue !== oldValue){ watcher.last == newValue; watcher.listenerFn(newValue,this); dirty = true; } } return dirty; };
接着,我们重定义$digest以便它能够运行“外循环”,在变化发生时调用$$digestOnce:
src/scope.js ----- Scope.prototype.$digest = function(){ var dirty; do { dirty = this.$$digestOnce(); } while (dirty); };
$digest现在会在所有的监视器上至少运行一次。如果,在第一次循环后,被监视的值改变了,那么这次循环被标记为dirty,所有的监视器将会运行第二次。循环将会一直进行到没有任何监视值发生变化并且状态稳定为止。
Angular scope中实际上并没有一个叫做$$digestOnce的函数。相反,所有的digest循环都嵌套在$digest中。我们在这里的目的是说明方法,因此我们故意将内部循环抽取出来成为一个单独的函数。
我们现在可以来编写另一个Angular监视函数中重要的观察者了:在每次digest循环中它必须要运行多次。这就是人们经常说监视器应该满足幂等性的原因:一个监视函数应该没有任何的副作用,或者在运行任意次数时都会发生副作用。举例来说,如果一个监视函数触发了一个Ajax请求,我们就不能保证你的应用汇总有多少个请求了。
原文链接:https://www.f2er.com/angularjs/149053.html