在上一節項目初始化中,我們最終得到了一個可以運行的基礎代碼庫,它的基本結構如下:

其中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臟檢查機制的簡易實現,后續章節依然會在此基礎上進行優化和修改。為了防止篇幅太長,今后只給出重要的測試用例及測試結果。文章的完整代碼點擊這里可以進行查看。
