双向绑定的简单实现 - 基于 “脏检测”

1,826 阅读4分钟
本文基于“脏检测”机制实现一个简单的双向绑定。若您对如何使用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>