本文基于“脏检测”机制实现一个简单的双向绑定。若您对如何使用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 = $scope[modelName];
}
else {
//更新非交互式DOM的值
bindList[j].innerHTML = $scope[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 = $scope[modelName];
}
else {
//更新非交互式DOM的值
bindList[j].innerHTML = $scope[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>