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


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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM