某“大神”挖了個隕石坑,我於是乎似懂非懂的接手,玩了一個月angular。現在項目告一段落,暫別了繁重的重復性工作,可以開始回顧、認真的折騰下之前猶抱琵琶的angular。
angular吸引人的特性之一就是雙向綁定,model有變化view自動更新。一說到自動執行,首先浮到腦海的必須是監聽和回調函數。angular也確實是這樣做的,scope.$watch就是此行為的接口。一如所有的類庫或框架,使用起來很簡單,實現卻並不容易。
我不是一個執念於從零開始的人,喜歡站在巨人的肩上,這篇隨筆取材於此:創建你自己的AngularJS。
首先,先搭建一個單元測試環境,如果嫌麻煩當然可以不用,如果不會可以在node環境下輸入以下命令行:
npm install -g grunt //安裝grunt npm install -g bower //安裝bower npm install -g yo //安裝yeoman npm install -g generator-angular //安裝angular生成器 yo angular //生成angular項目文件 npm install //安裝項目依賴包 bower install //安裝前端依賴庫 grunt test //執行單元測試
以上命令來自記憶,或有遺漏,總之如果執行成功表示環境搭建完畢。
接下來開始編寫自己的scope,我們先整理下測試環境,在angular/test/spec下建立兩個文件夾,命名隨意,我是define和unit。
修改angular/test/karma.conf.js中的files字段:
files: [ "test/spec/define/*.js", "test/spec/unit/*.js" ]
然后可以新建Scope類了,在spec/define中新建scope.js:
'use strict'; function Scope() { }
是的,只是一個簡單的構造函數。
然后在spec/unit中新建test.js,編寫單元測試語句:
'use strict'; describe("Scope", function() { it("can be constructed and used as an object", function() { var scope = new Scope(); scope.prop = 1; expect(scope.prop).toBe(1); }) });
如無意外,打印出來的結果是這樣的:
PhantomJS 1.9.8 (Windows 8): Executed 1 of 1 SUCCESS (0 secs / 0.001 secs)
其中PhantomJS是一個不展示用戶界面的瀏覽器,測試的代碼就是在這里面跑的。
模型已經搭建完成,現在我們要實現的是監聽scope內部prop屬性的變動。要實現監聽,首先內部要有監聽器的隊列,其次要有添加監聽器的函數,最后還要有輪詢檢測的函數,分別在scope.js中添加這三個內容:
'use strict'; function Scope() { this.$$watchers = []; } Scope.prototype.$watch = function() { var watcher = {}; this.$$watchers.unshift(watcher); } Scope.prototype.$digest = function() { }
一般類庫框架,對於添加監聽器的函數都支持兩個參數,字符串和回調函數,比如jquery中的on。對於我們來說,字符串應該是要監聽的屬性。修改$watch函數如下:
Scope.prototype.$watch = function(prop, callback) { var watcher = { prop: prop, callback: callback }; this.$$watchers.unshift(watcher); }
現在輪到$digest函數,輪詢函數的任務在於遍歷所有的監聽器,比較當前屬性和上一個屬性是否不同,不同則執行監聽器中的callback。等等,上一個屬性怎么獲取的,看來添加監聽器的時候應該保存一個初始值,於是兩個函數都要修改:
Scope.prototype.$watch = function(prop, callback) { var watcher = { prop: prop, callback: callback, last: this[prop] }; this.$$watchers.unshift(watcher); }; Scope.prototype.$digest = function() { var scope = this; scope.$$watchers.forEach(function(watcher) { if(scope[watcher.prop] !== watcher.last) { watcher.last = scope[watcher.prop]; watcher.callback(); } }); };
現在感覺功能已經實現了,但實際上有沒有用呢,要寫個單元測試驗證一下,修改test.js:
'use strict'; describe("Scope", function() { it("can be constructed and used as an object", function() { var scope = new Scope(), callback = jasmine.createSpy(); //創建一個可檢測是否被調用的回調函數 scope.prop = 1; //初始化值 scope.$watch('prop', callback); //添加監聽函數 scope.prop = 2; //重新賦值 scope.$digest(); //輪詢一遍 expect(callback).toHaveBeenCalled(); //檢測回調函數是否被調用 }); });
執行grunt test,得到的結果如下:
PhantomJS 1.9.8 (Windows 8): Executed 1 of 1 SUCCESS (0.004 secs / 0.003 secs)
可見是正常可用的,但是不是哪里寫錯了導致怎么執行都正常呢,我們注釋掉重新賦值的這行:
//scope.prop = 2;
得到的測試結果如下:
PhantomJS 1.9.8 (Windows 8): Executed 1 of 1 (1 FAILED) ERROR (0.01 secs / 0.002 secs)
嚴格測試下,添加兩個監聽器:
'use strict'; describe("Scope", function() { it("can be constructed and used as an object", function() { var scope = new Scope(), callback1 = jasmine.createSpy(), callback2 = jasmine.createSpy(); scope.prop1 = 1; scope.prop2 = 1; scope.$watch('prop1', callback1); scope.$watch('prop2', callback2); scope.prop1 = 2; scope.prop2 = 2; scope.$digest(); expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); }); });
結果也是沒有問題的:
PhantomJS 1.9.8 (Windows 8): Executed 1 of 1 SUCCESS (0.004 secs / 0.002 secs)
基本功能是沒問題了,但還需要優化。
首先,代碼不夠“優雅”。監聽器中的prop屬性唯一的用處就是在$digest中供取值,而且為此$digest需要將this重新付給scope變量導致$digest中代碼偏亂。把scope.js的代碼整理一下:
'use strict'; function Scope() { this.$$watchers = []; } Scope.prototype.$watch = function(prop, callback) { var scope = this, watcher = { get: function() { return scope[prop]; }, callback: callback, last: scope[prop] }; scope.$$watchers.unshift(watcher); }; Scope.prototype.$digest = function() { this.$$watchers.forEach(function(watcher) { if(watcher.get() !== watcher.last) { watcher.last = watcher.get(); watcher.callback(); } }); };
這樣是不是整潔多了?
還有,回調函數現在是不傳入參數的,而按照習慣來說,應該傳入新值和舊值吧。所以要對$digest和test.js作出修改:
Scope.prototype.$digest = function() { this.$$watchers.forEach(function(watcher) { if(watcher.get() !== watcher.last) { watcher.callback(watcher.get(), watcher.last); watcher.last = watcher.get(); } }); };
'use strict'; describe("Scope", function() { it("can be constructed and used as an object", function() { var scope = new Scope(), newValue, oldValue, callback = function(newV, oldV) { newValue = newV; oldValue = oldV; }; scope.prop = 1; scope.$watch('prop', callback); scope.prop = 2; scope.$digest(); expect(newValue).toBe(2); expect(oldValue).toBe(1); }); });
測試的結果是ok的。
最后,當我們在回調函數中對設置了監聽器的屬性進行賦值時,會出現問題,比如修改test.js:
'use strict'; describe("Scope", function() { it("can be constructed and used as an object", function() { var scope = new Scope(), callback1 = function(newV, oldV) { scope.prop2 = 2; scope.counter++; }, callback2 = function(newV, oldV) { scope.prop1 = 2; scope.counter++; }; scope.prop1 = 1; scope.prop2 = 1; scope.counter = 0; scope.$watch('prop1', callback1); scope.$watch('prop2', callback2); scope.prop1 = 2; //失敗 //scope.prop2 = 2; //成功 scope.$digest(); expect(scope.counter).toBe(2); }); });
注意注釋的部分,為什么會產生這種偏差呢?因為在$watch中,我們是使用unshift插入監聽器的,當在callback1中設置prop2的時候,prop2的監聽器已經被輪詢過了,所以不再會調用。
那怎么解決這一問題呢?這里就要使用到dirty-checking了。在$digest中不停的對監聽器進行輪詢,但最少輪詢一次,也就是do...while...。當有回調函數被調用時,則置整個輪詢的dirty為true,需要進行下一次輪詢;當一次輪詢中沒用任何回調函數被調用,則終止輪詢。修改$digest如下:
Scope.prototype.$digest = function() { var dirty; do { dirty = false; this.$$watchers.forEach(function(watcher) { if(watcher.get() !== watcher.last) { dirty = true; watcher.callback(watcher.get(), watcher.last); watcher.last = watcher.get(); } }); } while(dirty) };
但這樣做有一個致命的問題就是,當回調函數這樣的時候:
callback1 = function(newV, oldV) { scope.prop2 += 1; scope.counter++; }, callback2 = function(newV, oldV) { scope.prop1 += 1; scope.counter++; };
PhantomJS表示,你根本停不下來:
WARN [PhantomJS 1.9.8 (Windows 8)]: Disconnected (1 times), because no message in 10000 ms. Warning: Task "karma:unit" failed. Use --force to continue.
這里就要設置一個while循環的上限值TTL(Time To Live):
Scope.prototype.$digest = function() { var dirty, ttl = 10; do { dirty = false; ttl--; this.$$watchers.forEach(function(watcher) { if(watcher.get() !== watcher.last) { dirty = true; watcher.callback(watcher.get(), watcher.last); watcher.last = watcher.get(); } }); } while(dirty && ttl > 0) };
好了,到這里就結束了。現在去看angular中scope的source code,一定能看到很多眼熟的東西。