一步步構建本身的AngularJS(2)——scope之$watch及$digest

上一節項目初始化中,咱們最終獲得了一個能夠運行的基礎代碼庫,它的基本結構以下:html

 其中node_modules文件夾存放項目中的第三方依賴模塊,src存放咱們的項目代碼源文件,test存放測試用例文件,.jshintrc是jshint插件的配置文件,karma.conf.js是karma的配置文件,package.json是npm的配置文件,結構其實很簡單。從本節開始,會在這個代碼庫的基礎上進行咱們本身Angular的實現。node

首先,在寫代碼以前,在命令行中輸入npm test命令,讓咱們的測試用例代碼實時在後臺進行最新代碼的測試,以便咱們隨時知道咱們的代碼是否符合規範,這一行爲做爲一個後臺任務貫穿於咱們框架實現整個過程,對於測試結果再也不一一列舉,若是出現錯誤須要自行修改代碼讓其符合測試用例的預期。git

scope在Angular中實際上就是一個普通的對象,在該對象中存在各類屬性和方法,同時咱們也能夠本身在該對象上設置屬性。scope的做用主要有如下幾種:github

1)在controllers和views之間共享數據;npm

2) 在應用的各個不一樣部分之間共享數據;json

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

相關文章
相關標籤/搜索