寫在開頭
關於Angular臟檢查,之前沒有仔細學習,只是旁聽道說,Angular 會定時的進行周期性數據檢查,將前台和后台數據進行比較,所以非常損耗性能。
這是大錯而特錯的。我甚至在新浪前端面試的時候胡說一通,現在想來真是羞愧難當! 沒有深入了解就信口開河實在難堪大任。
最后被拒也是理所當然。
在剖析之前,非常感謝坐鎮蘇寧的徐飛,現在已經不在蘇寧了,我也是在他翻譯的文章(Build Your own AngularJS)和博客才略懂一二。
 徐飛關於知乎問題國內前端團隊分布和前景是怎樣的?的回答也是特別有意思。
誤區糾正
首先糾正誤區,Angular並不是周期性觸發藏檢查。
 只有當UI事件,ajax請求或者 timeout 延遲事件,才會觸發臟檢查。
 為什么叫臟檢查? 對臟數據的檢查就是臟檢查,比較UI和后台的數據是否一致!
 下面解釋:
$watch 對象。
Angular 每一個綁定到UI的數據,就會有一個 $watch 對象。
 這個對象包含三個參數
watch = {
    name:'',      //當前的watch 對象 觀測的數據名
    getNewValue:function($scope){ //得到新值
        ...
        return newValue;
        },
    listener:function(newValue,oldValue){  // 當數據發生改變時需要執行的操作
        ...
    }
}
 
        getNewValue() 可以得到當前$scope 上的最新值,listener 函數得到新值和舊值並進行一些操作。
而常常我們在使用Angular的時候,listener 一般都為空,只有當我們需要監測更改事件的時候,才會顯示地添加監聽。
每當我們將數據綁定到 UI 上,angular 就會向你的 watchList 上插入一個 $watch。
 比如:
<span>{{user}}</span>
<span>{{password}}</span>
 
        這就會插入兩個$watch 對象。
 之后,開始臟檢查。
 好了,我們先把臟檢查放一放,來看它之前的東西
 ??
 雙向數據綁定 ! 只有先理解了Angular的雙向數據綁定,才能透徹理解臟檢查 。
雙向數據綁定
Angular實現了雙向數據綁定。無非就是界面的操作能實事反應到數據,數據的更改也能在界面呈現。
 界面到數據的更改,是由 UI 事件,ajax請求,或者timeout 等回調操作,而數據到界面的呈現則是由臟檢查來做.
 這也是我開始糾正的誤區
 只有當觸發UI事件,ajax請求或者 timeout 延遲,才會觸發臟檢查。
 看下面的例子
<div ng-controller="CounterCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="counter=counter+1">increase</button>
</div>
 
        function CounterCtrl($scope) {
    $scope.counter = 1;
}
 
        毫無疑問,我每點擊一次button,counter就會+1,因為點擊事件,將couter+1,而后觸發了臟檢查,又將新值2 返回給了界面.
 這就是一個簡單的雙向數據綁定的流程.
 但是就只有這么簡單嗎??
 看下面的代碼
'use strict';
var app = angular.module('app', []);
app.directive('myclick', function() {
    return function(scope, element, attr) {
        element.on('click', function() {
            scope.data++;
            console.log(scope.data)
        })
    }
})
app.controller('appController', function($scope) {
    $scope.data = 0;
});
 
            <div ng-app="app">
        <div ng-controller="appController">
            <span>{{data}}</span>
            <button myclick>click</button>
        </div>
    </div>
 
        點擊后,毫無反應.
 ???
試試在 console.log(scope.data) 后面添加 scope.$digest(); 試試?
很明顯,數據增加了。如果使用$apply () 呢? 當然可以(后面會接受 $apply 和 $digest 的區別)
為什們呢?
 假設沒有AngularJS,要讓我們自己實現這個類似的功能,該怎么做呢?
<body>
    <button ng-click="increase">increase</button>
    <button ng-click="decrease">decrease</button>
    <span ng-bind="data"></span>
    <script src="app.js"></script>
</body>
 
        
window.onload = function() {
    'use strict';
    var scope = {
        increase: function() {
            this.data++;
        },
        decrease: function decrease() {
            this.data--;
        },
        data: 0
    }
    function bind() {
        var list = document.querySelectorAll('[ng-click]');
        for (var i = 0, l = list.length; i < l; i++) {
            list[i].onclick = (function(index) {
                return function() {
                    var func = this.getAttribute('ng-click');
                    scope[func](scope);
                    apply();
                }
            })(i);
        }
    }
    // apply
    function apply() {
        var list = document.querySelectorAll('[ng-bind]');
        for (var i = 0, l = list.length; i < l; i++) {
            var bindData = list[i].getAttribute('ng-bind');
            list[i].innerHTML = scope[bindData];
        }
    }
    bind();
    apply();
}
 
        測試一下:

可以看到我們沒有直接使用DOM的onclick方法,而是搞了一個ng-click,然后在bind里面把這個ng-click對應的函數拿出來,綁定到onclick的事件處理函數中。為什么要這樣呢?因為數據雖然變更了,但是還沒有往界面上填充,我們需要在此做一些附加操作。
 另外,由於雙向綁定機制,在DOM操作中,雖然更新了數據的值,但是並沒有立即反映到界面上,而是通過 apply() 來反映到界面上,從而完成職責的分離,可以認為是單一職責模式了。
 在真正的Angular中,ng-click 封裝了click,然后調用一次 apply 函數,把數據呈現到界面上
 在Angular 的apply函數中,這里先進行臟檢測,看 oldValue 和 newVlue 是否相等,如果不相等,那么講newValue 反饋到界面上,通過如果通過 $watch 注冊了 listener事件,那么就會調用該事件。
臟檢查的優缺點
經過我們上面的分析,可以總結:
- 簡單理解,一次臟檢查就是調用一次 $apply() 或者 $digest(),將數據中最新的值呈現在界面上。
 - 而每次 UI 事件變更,ajax 還有 timeout 都會觸發 $apply()。
 
然而就有了接下來的討論?
不斷觸發臟檢查是不是一種好的方式?
 有很多人認為,這樣對性能的損耗很大,不如 setter 和 getter 的觀察者模式。 但是我們看下面這個例子
<span>{{checkedItemsNumber}}</span>
 
        function Ctrl($scope){
   var  list = [];
   $scope.checkedItemsNumber = 0;
   for(var i = 0;i<1000;i++){
    list.push(false);
   } 
   $scope.toggleChecked = function(flag){
    for(var i = 0,l= list.length;i++){
        list[i] = flag;
        $scope.checkedItemsNumber++;
    }
   }
}
 
        在臟檢測的機制下,這個過程毫無壓力,會等待到 循環執行結束,然后一次更新 checkedItemsNumber,應用到界面上。 但是在基於setter的機制就慘了,每變化一次checkedItemsNumber就需要更新一次,這樣性能就會極低。
 所以說,兩種不同的監控方式,各有其優缺點,最好的辦法是了解各自使用方式的差異,考慮出它們性能的差異所在,在不同的業務場景中,避開最容易造成性能瓶頸的用法。
好了,現在已經了解了雙向數據綁定了 臟檢查的觸發機制,那么,臟檢查內部又是怎么實現的呢?
臟檢查的內部實現
首先,構造$scope 對象,
function $scope = function(){}
 
        現在,我們回到開頭 $watch。
 我們說,每一個綁定到UI上的數據都有擁有一個對應的$watch 對象,這個對象會被push到watchList中。
 它擁有兩個函數作為屬性
- getNewValue() 也叫監控函數,勇於在值發生變化后得到提示,並返回新值。
 - listener() 監聽函數,用於在數據變更的時候響應行為。
還有一個字符串屬性 - name: 當前watch作用的變量名
 
function $scope(){
   this. $$watchList = [];
}
 
        在Angular框架中,雙美元符前綴$$表示這個變量被當作私有的來考慮,不應當在外部代碼中調用。
現在我們可以定義$watch方法了。它接受兩個函數作參數,把它們存儲在$$watchers數組中。我們需要在每個Scope實例上存儲這些函數,所以要把它放在Scope的原型上:
$scope.prototype.$watch = function(name,getNewValue,listener){
    var watch = {
       name:name,
       getNewValue : getNewValue,
       listener : listener
    };
    this.$$watchList.push(watch);
}
 
        另外一面就是$digest函數。它執行了所有在作用域上注冊過的監聽器。我們來實現一個它的簡化版,遍歷所有監聽器,調用它們的監聽函數:
$scope.prototype.$digest = function(){
    var list = this.$$watchList;
    for(var i = 0,l = list.length;i<l;i++){
        list[i].listener();
    }
}
 
        現在,我們就可以添加監聽器並且運行臟檢查了。
var scope = new Scope();
scope.$watch(function() {
    console.log("hey i have got newValue")
}, function() {
    console.log("i am the listener");
})
scope.$watch(function() {
    console.log("hey i have got newValue 2")
}, function() {
    console.log("i am the listener2");
})
scope.$disget();
 
        
代碼會托管到github,測試文件路徑跟命令中路徑一致
OK,兩個監聽均已經觸發。
 這些本身沒什么大用,我們要的是能檢測由getNewValue返回指定的值是否確實變更了,然后調用監聽函數。
 那么,我們需要在getNewValue() 上每次都得到數據上最新的值,所以需要得到當前的scope對象
getNewValue = function(scope){
    return scope[this.name];
}
 
        是監控函數的一般形式:從作用域獲取一些值,然后返回。
$digest函數的作用是調用這個監控函數,並且比較它返回的值和上一次返回值的差異。如果不相同,監聽器就是臟的,它的監聽函數就應當被調用。
想要這么做,$digest需要記住每個監控函數上次返回的值。既然我們現在已經為每個監聽器創建過一個對象,只要把上一次的值存在這上面就行了。下面是檢測每個監控函數值變更的$digest新實現:
$scope.prototype.$digest = function(){
    var list = this.$$watchList;
    for(var i = 0,l= list.length;i++){
        var watch = list[i];
        var newValue = watch.getNewValue(this);
        // 在第一次渲染界面,進行一個數據呈現.
        var oldValue = watch.last;
        if(newValue!=oldValue){
            watch.listener(newValue,oldValue);
        }
        watch.last = newValue;
    }
}
 
        對於每一個watch,我們使用 getNewValue() 並且把scope實例 傳遞進去,得到數據最新值 。然后和上一次值進行比較,如果不同,那就調用 getListener,同時把新值和舊值一並傳遞進去。 最終,我們把last 屬性設置為新返回的值,也就是最新值。
 這個$digest 再一次調用,last 為undefined,所以一定會進行一次數據呈現。
好了,我們看看這個監控函數如何運行的
var scope = new $scope();
scope.hello = 10;
scope.$watch('hello', function(scope) {
    // 注意,要理解這里的this ,這個函數實際是  var newValue = watch.getNewValue(this); 這樣調用,那么 this 就指的是當前監聽器watch,所以可以得到name
        return scope[this.name]
    },
    function(newValue, oldValue) {
        console.log('newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$digest();
scope.hello = 10;
scope.$digest();
scope.hello = 20;
scope.$digest();
 
 
        運行結果
 
我們已經實現了Angular作用域的本質:添加監聽器,在digest里運行它們。
也已經可以看到幾個關於Angular作用域的重要性能特性:
- 在作用域上添加數據本身並不會有性能折扣。如果沒有監聽器在監控某個屬性,它在不在作用域上都無所謂。Angular並不會遍歷作用域的屬性,它遍歷的是監聽器。一旦將數據綁定到UI上,就會添加一個監聽器。
 - $digest里會調用每個getNewValue(),因此,最好關注監聽器的數量,還有每個獨立的監控函數或者表達式的性能。
 
有時候並不需要注冊那么多的Listener
在看我們上面的程序:
$scope.prototype.$digest = function(){
    var list = this.$$watchList;
    for(var i = 0,l= list.length;i++){
        var watch = list[i];
        var newValue = watch.getNewValue(this);
        // 在第一次渲染界面,進行一個數據呈現.
        var oldValue = watch.last;
        if(newValue!=oldValue){
            watch.listener(newValue,oldValue);
        }
        watch.last = newValue;
    }
}
 
        我們這樣做,就要求每個監聽器watch 都必須注冊 listener,然而事實是:在Angular 應用中,只有少數的監聽器需要注冊listener。
 更改 $scope.prototype.$wacth,在這里放置一個空的函數。
$scope.prototype.$watch = function(name,getNewValue,listener){
    var watch = {
       name:name,
       getNewValue : getNewValue,
       listener : listener || function(){}
    };
    this.$$watchList.push(watch);
}
 
        貌似這樣已經初步理解了臟檢查原理,但是一個重要的問題我們忽視了。
 先后注冊了兩個監聽器,第二個監聽器的listener 改變了 第一個監聽器對應數據的值,那么這么做會檢測的到嗎?
 看下面的例子
var scope = new $scope();
scope.first = 10;
scope.second = 1;
scope.$watch('first', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        console.log('first:      newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$watch('second', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        scope.first = 8;
        console.log('second:     newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$digest();
console.log(scope.first);
console.log(scope.second);
 
        
可以看到,值為 8,1,已經發生改變,但是界面上的值卻沒有改變。
現在來修復這個問題。
當數據臟的時候持續Digest
我們需要改變一下digest,讓它持續遍歷所有監聽器,直到監控的值停止變更。
首先,我們把現在的$digest函數改名為$$digestOnce,它把所有的監聽器運行一次,返回一個布爾值,表示是否還有變更了。
$scope.prototype.$$digestOnce = function() {
  var dirty;
  var list = this.$$watchList;
  for(var i = 0,l = list.length;i<l;i++ ){
    var watch = list[i];
    var newValue = watch.getNewValue(this.name);
    var oldValue = watch.last;
    if(newValue !==oldValue){
        watch.listener(newValue,oldValue);
        // 因為listener操作,已經檢查過的數據可能變臟
        dirty = true;
    }
     watch.last = newValue;
     return dirty;
    }
};
 
        然后,我們重新定義$digest,它作為一個“外層循環”來運行,當有變更發生的時候,調用$$digestOnce:
$scope.prototype.$digest = function() {
  var dirty = true;
  while(dirty) {
    dirty = this.$$digestOnce();
  } 
};
 
        $digest現在至少運行每個監聽器一次了。如果第一次運行完,有監控值發生變更了,標記為dirty,所有監聽器再運行第二次。這會一直運行,直到所有監控的值都不再變化,整個局面穩定下來了。
在Angular作用域里並不是真的有個函數叫做$$digestOnce,相反,digest循環都是包含在$digest里的。我們的目標更多是清晰度而不是性能,所以把內層循環封裝成了一個函數。
測試一下
var scope = new $scope();
scope.first = 10;
scope.second = 1;
scope.$watch('first', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        console.log('first:      newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$watch('second', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        scope.first = 8;
        console.log('second:     newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$digest();
console.log(scope.first);
console.log(scope.second);
 
        
可以看到,現在界面上的數據已經全部為最新
 我們現在可以對Angular的監聽器有另外一個重要認識:它們可能在單次digest里面被執行多次。這也就是為什么人們經常說,監聽器應當是冪等的:一個監聽器應當沒有邊界效應,或者邊界效應只應當發生有限次。比如說,假設一個監控函數觸發了一個Ajax請求,無法確定你的應用程序發了多少個請求。
如果兩個監聽器循環改變呢?像現在這樣:
var scope = new $scope();
scope.first = 10;
scope.second = 1;
scope.$watch('first', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
       scope.second ++;
    })
scope.$watch('second', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        scope.first ++;
    })
 
        那么,臟檢查就不會停下來,一直循環下去。如何解決呢?
更穩定的 $digest
我們要做的事情是,把digest的運行控制在一個可接受的迭代數量內。如果這么多次之后,作用域還在變更,就勇敢放手,宣布它永遠不會穩定。在這個點上,我們會拋出一個異常,因為不管作用域的狀態變成怎樣,它都不太可能是用戶想要的結果。
迭代的最大值稱為TTL(short for Time To Live)。這個值默認是10,可能有點小(我們剛運行了這個digest 100,000次!),但是記住這是一個性能敏感的地方,因為digest經常被執行,而且每個digest運行了所有的監聽器。用戶也不太可能創建10個以上鏈狀的監聽器。
我們繼續,給外層digest循環添加一個循環計數器。如果達到了TTL,就拋出異常:
$scope.prototype.$digest = function() {
  var dirty = true;
  var checkTimes = 0;
  while(dirty) {
    dirty = this.$$digestOnce();
    checkTimes++;
    if(checkTimes>10 &&dirty){
        throw new Error("檢測超過10次");
        console.log("123");
    }
  };
};
 
        測試一下
var scope = new $scope();
scope.first = 1;
scope.second = 10;
scope.$watch('first', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        scope.second++;
        console.log('first:      newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$watch('second', function(scope) {
        return scope[this.name]
    },
    function(newValue, oldValue) {
        scope.first++;
        console.log('second:     newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue);
    })
scope.$digest();
 
        
好了,關於 Angular 臟檢查和 雙向數據綁定原理就介紹到這里,雖然離真正的Angular 還差很多,但是也能基本解釋原理了。
關於 這篇中所有的代碼,已經放到github上 https://github.com/apawn/HFLib/tree/master/HFLib/angular .
 推薦一本原著 《Build Your Own AngularJS》,書中詳細介紹了如何構建一個AngularJS。估計翻譯版本會在年后出版,如果可以讀完這本書,那么 JS的能力將會上升一個等級。
 關於司徒正美的 《Javascript框架設計》 也是前端深入研究的必讀之書。
 后面在閱讀的時候,我會把自己的閱讀經驗分享出來。
只是把這些搞明白之后,現在再也沒有去面試新浪應屆生的機會了 。
 雖然不知道明年會在哪,但一定會進入一個優秀的前端團隊並努力展示更好的面貌的。
 如果您有意,歡迎聯系我,Email:mymeat@126.com
在這篇中,我有提到 VueJS 中 基於 setter 和 getter 的實現,我講會深入學習並在下一篇介紹。
