雙向綁定的簡單實現——基於「臟檢測」

本文基於「臟檢測」機制實現一個簡單的雙向綁定。若您對如何使用ES5的getter/setter實現動態數據綁定較為感興趣,可移步至雙向綁定的簡單實現——基於ES5對象的getter/setter機制。

臟檢測基本原理

眾所周知,Angular的雙向綁定是採用「臟檢測」的方式來更新DOM——Angular對常用的dom事件、xhr事件進行了封裝,觸發時會調用$digest cycle。在$digest流程中,Angular將遍歷每個數據變數的watcher,比較它的新舊值。當新舊值不同時,觸發listener函數,執行相關的操作。

實現簡單的雙向綁定

本次我們實現的雙向綁定主要基於兩個指令:ng-bind、ng-click。DOM結構如下:

<div> <form> <input type="text" ng-bind="count" /> <button type="button" ng-click="increment" >increment</button> </form> <div ng-bind="count"> </div></div>

首先我們需要先封裝一個Scope類,類中包含一個$$watchers對象數組,該數組用於保存各數據變數的監聽器:

function Scope(){ this.$$watchers=[]; //監聽器}

$$watchers的成員對象結構如下:

{ name:name, //數據變數名 last:"", //數據變數舊值 newVal:exp, //返回數據變數新值的函數 listener:listener || function(){} //監聽回調函數,變數「臟」時觸發}

為什麼newVal設成函數?原因是如果賦成數據變數值,那麼它的新值將一直等於創建監聽器時綁定的值,而實際上數據的值是在不斷變化的。使用函數便能在每次調用時返回它的最新值。

然後,我們需要添加一個成員函數$watch,該函數用於創建監聽器並綁定至當前作用域:

Scope.prototype.$watch=function(name,exp,listener){ this.$$watchers.push({ name:name, //數據變數名 last:"", //數據變數舊值 newVal:exp, //返回數據變數新值的函數 listener:listener || function(){} //監聽回調函數,變數「臟」時觸發 })}

有了$watch函數,我們可以利用如下代碼來將一個Scope中的數據變數添加至作用域的監視器數組中:

var $scope=new Scope();$scope.name="Lowes";for(var key in $scope){ //非函數數據才進行綁定 if(key!="$$watchers" && typeof $scope[key]!="function") { $scope.$watch(key, (function (index) { return function(){ return $scope[index]; } })(key)) }}

下面到了關鍵的$digest實現。基本原理是對監視器的新舊值進行對比,當新舊值不同時,調用listener函數進行相應操作,並將舊值更新為新值。它將不斷重複這一過程,直到所有數據變數的新舊值相等

Scope.prototype.$digest=function(){ var dirty=true; while(dirty){ dirty=false; for(var i=0;i<this.$$watchers.length;i++){ var newVal=this.$$watchers[i].newVal(); var oldVal=this.$$watchers[i].last; if(newVal!==oldVal){ dirty=true; this.$$watchers[i].listener(oldVal,newVal); this.$$watchers[i].last=newVal; } } }};

至此,一個簡單的臟檢測機制便寫好了。

然而為了實現真正的雙向綁定,我們需要加入對ng指令的解析和臟檢測觸發時將新的變數值更新到DOM上。

首先對帶ng-click屬性的DOM進行解析:

var bindList=document.querySelectorAll("[ng-click]");for(var i=0;i<bindList.length;i++){ bindList[i].onclick=(function(index){ return function() { $scope[bindList[index].getAttribute("ng-click")](); $scope.$digest(); //調用函數時觸發$digest } })(i)}

然後對帶ng-bind屬性的互動式DOM(input、textarea等)進行解析:

var inputList=document.querySelectorAll("input[ng-bind]"); for(var i=0;i<inputList.length;i++){ inputList[i].addEventListener("input",(function(index){ return function(){ $scope[inputList[index].getAttribute("ng-bind")]=inputList[index].value; $scope.$digest(); //調用函數時觸發$digest } })(i));}

其中$scope為已創建好的作用域。

最後,為了將新的數據變數反映到DOM上,我們需要在$digest流程中加入對DOM的更新操作。更改之後的代碼如下(僅實現包含ng-bind的DOM更新):

Scope.prototype.$digest=function(){var bindList = document.querySelectorAll("[ng-bind]"); //獲取所有含ng-bind的DOM節點 var dirty=true; while(dirty){ dirty=false; for(var i=0;i<this.$$watchers.length;i++){ var newVal=this.$$watchers[i].newVal(); var oldVal=this.$$watchers[i].last; if(newVal!==oldVal){ dirty=true; this.$$watchers[i].listener(oldVal,newVal); this.$$watchers[i].last=newVal; for (var j = 0; j < bindList.length; j++) { //獲取DOM上的數據變數的名稱 var modelName=bindList[j].getAttribute("ng-bind"); //數據變數名相同的DOM才更新 if(modelName==this.$$watchers[i].name) { if (bindList[j].tagName == "INPUT") { //更新input的輸入值 bindList[j].value = this[modelName]; } else { //更新非互動式DOM的值 bindList[j].innerHTML = this[modelName]; } } } } } }};

最後我們使用如下代碼創建一個作用域,並綁定兩個數據:

var $scope=new Scope(); $scope.count=0; $scope.increment=function(){ this.count++;};

看一下效果:(知乎文章不支持gif.....就不演示了)

上面的實現方式沒有考慮對象和數組,主要是為了展示臟檢測的基本原理和簡單的實現方式。在真正的生產環境中,我們需要對「臟檢測」做許多的優化,包括對遍歷更新的優化以及對值比較的優化等。

完整代碼

<div> <form> <input type="text" ng-bind="count" /> <button type="button" ng-click="increment" >increment</button> </form> <div ng-bind="count"> </div></div>

<script> function Scope(){ this.$$watchers=[]; //監聽器 } Scope.prototype.$watch=function(name,exp,listener){ this.$$watchers.push({ name:name, //數據變數名 last:"", //數據變數舊值 newVal:exp, //返回數據變數新值的函數 listener:listener || function(){} //監聽回調函數,變數「臟」時觸發 }) } Scope.prototype.$digest=function(){ var bindList = document.querySelectorAll("[ng-bind]"); //獲取所有含ng-bind的DOM節點 var dirty=true; while(dirty){ dirty=false; for(var i=0;i<this.$$watchers.length;i++){ var newVal=this.$$watchers[i].newVal(); var oldVal=this.$$watchers[i].last; if(newVal!==oldVal && !isNaN(newVal) && !isNaN(oldVal)){ dirty=true; this.$$watchers[i].listener(oldVal,newVal); this.$$watchers[i].last=newVal; for (var j = 0; j < bindList.length; j++) { //獲取DOM上的數據變數的名稱 var modelName=bindList[j].getAttribute("ng-bind"); //數據變數名相同的DOM才更新 if(modelName==this.$$watchers[i].name) { if (bindList[j].tagName == "INPUT") { //更新input的輸入值 bindList[j].value = this[modelName]; } else { //更新非互動式DOM的值 bindList[j].innerHTML = this[modelName]; } } } } } } }; window.onload=function(){ var $scope=new Scope(); $scope.count=0; $scope.increment=function(){ this.count++; }; //解析ng指令 var bindList=document.querySelectorAll("[ng-click]"); for(var i=0;i<bindList.length;i++){ bindList[i].onclick=(function(index){ return function() { $scope[bindList[index].getAttribute("ng-click")](); $scope.$digest(); //調用函數時觸發$digest } })(i) } var inputList=document.querySelectorAll("input[ng-bind]"); for(var i=0;i<inputList.length;i++){ inputList[i].addEventListener("input",(function(index){ return function(){ $scope[inputList[index].getAttribute("ng-bind")]=inputList[index].value; $scope.$digest(); //調用函數時觸發$digest } })(i)); } //綁定數據 for(var key in $scope){ if(key!="$$watchers" && typeof $scope[key]!="function") { //非函數數據才進行綁定 $scope.$watch(key, (function (index) { return function(){ return $scope[index]; } })(key)) } } $scope.$digest(); };</script>

推薦閱讀:

深入淺出React高階組件

TAG:前端开发 | 前端框架 | Web开发框架 |