xufei/blog

Angular沉思录(一)数据绑定

xufei opened this issue · 23 comments

Angular沉思录

接触AngularJS已经两年多了,时常问自己一些问题,如果是我实现它,会在哪些方面选择跟它相同的道路,哪些方面不同。为此,记录了一些思考,给自己回顾,也供他人参考。

初步大致有以下几个方面:

  • 数据双向绑定
  • 视图模型的继承关系
  • 模块和依赖注入的设计
  • 待定

数据的双向绑定

Angular实现了双向绑定机制。所谓的双向绑定,无非是从界面的操作能实时反映到数据,数据的变更能实时展现到界面。

一个最简单的示例就是这样:

<div ng-controller="CounterCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="counter=counter+1">increase</button>
</div>
function CounterCtrl($scope) {
    $scope.counter = 1;
}

这个例子很简单,毫无特别之处,每当点击一次按钮,界面上的数字就增加一。

绑定数据是怎样生效的

初学AngularJS的人可能会踩到这样的坑,假设有一个指令:

var app = angular.module("test", []);

app.directive("myclick", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.counter++;
        });
    };
});

app.controller("CounterCtrl", function($scope) {
    $scope.counter = 0;
});
<body ng-app="test">
    <div ng-controller="CounterCtrl">
        <button myclick>increase</button>
        <span ng-bind="counter"></span>
    </div>
</body>

这个时候,点击按钮,界面上的数字并不会增加。很多人会感到迷惑,因为他查看调试器,发现数据确实已经增加了,Angular不是双向绑定吗,为什么数据变化了,界面没有跟着刷新?

试试在scope.counter++;这句之后加一句scope.digest();再看看是不是好了?

为什么要这么做呢,什么情况下要这么做呢?我们发现第一个例子中并没有digest,而且,如果你写了digest,它还会抛出异常,说正在做其他的digest,这是怎么回事?

我们先想想,假如没有AngularJS,我们想要自己实现这么个功能,应该怎样?

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>two-way binding</title>
    </head>
    <body onload="init()">
        <button ng-click="inc">
            increase 1
        </button>
        <button ng-click="inc2">
            increase 2
        </button>
        <span style="color:red" ng-bind="counter"></span>
        <span style="color:blue" ng-bind="counter"></span>
        <span style="color:green" ng-bind="counter"></span>

        <script type="text/javascript">
            /* 数据模型区开始 */
            var counter = 0;

            function inc() {
                counter++;
            }

            function inc2() {
                counter+=2;
            }
            /* 数据模型区结束 */

            /* 绑定关系区开始 */
            function init() {
                bind();
            }

            function bind() {
                var list = document.querySelectorAll("[ng-click]");
                for (var i=0; i<list.length; i++) {
                    list[i].onclick = (function(index) {
                        return function() {
                            window[list[index].getAttribute("ng-click")]();
                            apply();
                        };
                    })(i);
                }
            }

            function apply() {
                var list = document.querySelectorAll("[ng-bind='counter']");
                for (var i=0; i<list.length; i++) {
                    list[i].innerHTML = counter;
                }
            }
            /* 绑定关系区结束 */
        </script>
    </body>
</html>

可以看到,在这么一个简单的例子中,我们做了一些双向绑定的事情。从两个按钮的点击到数据的变更,这个很好理解,但我们没有直接使用DOM的onclick方法,而是搞了一个ng-click,然后在bind里面把这个ng-click对应的函数拿出来,绑定到onclick的事件处理函数中。为什么要这样呢?因为数据虽然变更了,但是还没有往界面上填充,我们需要在此做一些附加操作。

从另外一个方面看,当数据变更的时候,需要把这个变更应用到界面上,也就是那三个span里。但由于Angular使用的是脏检测,意味着当改变数据之后,你自己要做一些事情来触发脏检测,然后再应用到这个数据对应的DOM元素上。问题就在于,怎样触发脏检测?什么时候触发?

我们知道,一些基于setter的框架,它可以在给数据设值的时候,对DOM元素上的绑定变量作重新赋值。脏检测的机制没有这个阶段,它没有任何途径在数据变更之后立即得到通知,所以只能在每个事件入口中手动调用apply(),把数据的变更应用到界面上。在真正的Angular实现中,这里先进行脏检测,确定数据有变化了,然后才对界面设值。

所以,我们在ng-click里面封装真正的click,最重要的作用是为了在之后追加一次apply(),把数据的变更应用到界面上去。

那么,为什么在ng-click里面调用$digest的话,会报错呢?因为Angular的设计,同一时间只允许一个$digest运行,而ng-click这种内置指令已经触发了$digest,当前的还没有走完,所以就出错了。

$digest和$apply

在Angular中,有$apply和$digest两个函数,我们刚才是通过$digest来让这个数据应用到界面上。但这个时候,也可以不用$digest,而是使用$apply,效果是一样的,那么,它们的差异是什么呢?

最直接的差异是,$apply可以带参数,它可以接受一个函数,然后在应用数据之后,调用这个函数。所以,一般在集成非Angular框架的代码时,可以把代码写在这个里面调用。

var app = angular.module("test", []);

app.directive("myclick", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.counter++;
            scope.$apply(function() {
                scope.counter++;
            });
        });
    };
});

app.controller("CounterCtrl", function($scope) {
    $scope.counter = 0;
});

除此之外,还有别的区别吗?

在简单的数据模型中,这两者没有本质差别,但是当有层次结构的时候,就不一样了。考虑到有两层作用域,我们可以在父作用域上调用这两个函数,也可以在子作用域上调用,这个时候就能看到差别了。

对于$digest来说,在父作用域和子作用域上调用是有差别的,但是,对于$apply来说,这两者一样。我们来构造一个特殊的示例:

var app = angular.module("test", []);

app.directive("increasea", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.a++;
            scope.$digest();
        });
    };
});

app.directive("increaseb", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.b++;
            scope.$digest();    //这个换成$apply即可
        });
    };
});

app.controller("OuterCtrl", ["$scope", function($scope) {
    $scope.a = 1;

    $scope.$watch("a", function(newVal) {
        console.log("a:" + newVal);
    });

    $scope.$on("test", function(evt) {
        $scope.a++;
    });
}]);

app.controller("InnerCtrl", ["$scope", function($scope) {
    $scope.b = 2;

    $scope.$watch("b", function(newVal) {
        console.log("b:" + newVal);
        $scope.$emit("test", newVal);
    });
}]);
<div ng-app="test">
    <div ng-controller="OuterCtrl">
        <div ng-controller="InnerCtrl">
            <button increaseb>increase b</button>
            <span ng-bind="b"></span>
        </div>
        <button increasea>increase a</button>
        <span ng-bind="a"></span>
    </div>
</div> 

这时候,我们就能看出差别了,在increase b按钮上点击,这时候,a跟b的值其实都已经变化了,但是界面上的a没有更新,直到点击一次increase a,这时候刚才对a的累加才会一次更新上来。怎么解决这个问题呢?只需在increaseb这个指令的实现中,把$digest换成$apply即可。

当调用$digest的时候,只触发当前作用域和它的子作用域上的监控,但是当调用$apply的时候,会触发作用域树上的所有监控。

因此,从性能上讲,如果能确定自己作的这个数据变更所造成的影响范围,应当尽量调用$digest,只有当无法精确知道数据变更造成的影响范围时,才去用$apply,很暴力地遍历整个作用域树,调用其中所有的监控。

从另外一个角度,我们也可以看到,为什么调用外部框架的时候,是推荐放在$apply中,因为只有这个地方才是对所有数据变更都应用的地方,如果用$digest,有可能临时丢失数据变更。

脏检测的利弊

很多人对Angular的脏检测机制感到不屑,推崇基于setter,getter的观测机制,在我看来,这只是同一个事情的不同实现方式,并没有谁完全胜过谁,两者是各有优劣的。

大家都知道,在循环中批量添加DOM元素的时候,会推荐使用DocumentFragment,为什么呢,因为如果每次都对DOM产生变更,它都要修改DOM树的结构,性能影响大,如果我们能先在文档碎片中把DOM结构创建好,然后整体添加到主文档中,这个DOM树的变更就会一次完成,性能会提高很多。

同理,在Angular框架里,考虑到这样的场景:

function TestCtrl($scope) {
    $scope.numOfCheckedItems = 0;

    var list = [];

    for (var i=0; i<10000; i++) {
        list.push({
            index: i,
            checked: false
        });
    }

    $scope.list = list;

    $scope.toggleChecked = function(flag) {
        for (var i=0; i<list.length; i++) {
            list[i].checked = flag;
            $scope.numOfCheckedItems++;
        }
    };
}

如果界面上某个文本绑定这个numOfCheckedItems,会怎样?在脏检测的机制下,这个过程毫无压力,一次做完所有数据变更,然后整体应用到界面上。这时候,基于setter的机制就惨了,除非它也是像Angular这样把批量操作延时到一次更新,否则性能会更低。

所以说,两种不同的监控方式,各有其优缺点,最好的办法是了解各自使用方式的差异,考虑出它们性能的差异所在,在不同的业务场景中,避开最容易造成性能瓶颈的用法。

hax commented

这个是之前发在 div.io 上的吧。我还没帐号,所以只能看不能回复。还是贴在 github 上好。

hax commented

民工兄我估计现在某些考虑可能变化了。比如脏检测,总得来讲,A1的方式是有问题的。
大量更新这个事情我觉得不是主要矛盾。比如假设所有更新都是promise——那么至少也不会把浏览器给卡死,那么就算连续触发了上千次更新似乎也不是大问题。

@hax A1的方式确实有问题,用observe是一定比现在强的。

大量更新我个人觉得还是应当搞一种批量的方式,因为即使都是promise,每个里面可是带着界面更新的,这个消耗资源太吓人了。比如说,observe之后,先把待更新内容缓存,然后像之前A1这样,通过某些DOM事件或者网络或者定时器或者手动触发这个更新,在这里面一次跑完所有更新再应用到界面。

hax commented

@xufei 有没有比较实际的例子,我总觉得巨量界面更新像是edge case。

@hax 例子很好举啊,比如一个带checkbox的列表,顶部有一个全选,假设这列表有200条数据不过分吧,然后点一下全选应当是什么过程呢?

我觉得理想过程是:

点击事件开始,更新所有数据,把结果数据一次应用到界面。

我这个例子其实不算好,如果有个单条变更能导致界面reflow的,就更说明问题了。

早上在班车上想到一个好玩的测试场景,一会来写个看看。

hax commented

如果是普通的 getter/setter 方式我产生了 200 次 set,然后触发 200 次对 input checked 的写……如果是react那样产生200次virtual dom我倒是有点担心,但是如果是直接track到元素的那种方式好像也没啥大不了的。另外,我觉得就算用getter/setter也有一个简单的方式,就是我不是直接触发更新,而是设一个dirty标志就好了,在下一次异步时批处理就好了。

@hax 嗯,是这样的

这个$digest有点像flex中的invalidate机制,等需要重绘的时候一并提交更改,提高性能。

对Angular不太懂,之前一直使用RactiveJS,它是基于setter和getter。

关于批量更新的性能问题,其实RactiveJS这个库是有一些考虑的。

对于一般情况下的双向绑定的数组变化,比如shift,unshift,push,pop等,RactiveJS内部会做比较分析,知道哪些部分是需要更新UI,而哪些是不需要的。

比如:

// at the moment, list = [ 'a', 'b', 'c', 'd' ]

// 1. Reset the list:
ractive.set( 'list', [ 'z', 'a', 'b', 'c', 'd' ] )

// 2. Use `unshift`:
list.unshift( 'z' );

像第二种情况RactiveJS只会更新z对应的UI。原文

对于另外一些情况,比如你的例子中的numOfCheckedItems。这个时候如果对于性能有要求,RactiveJS会推荐你放弃使用内部setter,而是直接操作这个部分的数据,这样不会触发UI更新,然后再手动调用update更新视图。

比如你的例子对应到RactiveJS可能就是:

// Angular
$scope.toggleChecked = function(flag) {
    for (var i=0; i<list.length; i++) {
        list[i].checked = flag;
        $scope.numOfCheckedItems++;
    }
};

// Ractive
ractive.on('toggleChecked ', function(e, flag){
    for (var i=0; i<list.length; i++) {
        list[i].checked = flag;
        this.data.numOfCheckedItems++;
    }
    this.update('numOfCheckedItems');
})

赞!!!顺便附$apply的实现:

function $apply(expr) {
try {
return $eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {
$root.$digest();
}
}

<div ng-controller="CounterCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="counter++">increase</button>
</div>

ng-click="counter++" 会报语法错误,改为 ng-click=" counter=counter+1" 才会正确显示。

xufei commented

@paddingme 对,感谢提醒,已修改

试试在scope.counter++;这句之后加一句scope.digest();再看看是不是好了?

应该是 这句之后加一句scope.$digest();

点赞

mcwz commented

太感谢了,我写一个即时聊天的程序,后台推过来的数据已经更新了数据,但是前台展示聊天区域并不变化,只有我在聊天输入区域敲入一个字符后才变化。
读了这篇文章终于知道原因了,在改变了变量后增加了$scope.$digest();终于正常了。
再次感谢!

lijsh commented

配合此文$watch How the $apply Runs a $digest观看效果更佳。

$digest和$apply 的第二个例子中,页面加载后 a,b 的值都是2,是因为 $watch 在页面初始化时给 a 和 b 赋值而执行了一次吗?

谢谢,终于对于angular如何实现双向数据绑定有了一个初步认识。

Vue.js针对你说描述的情况就是做的批量异步处理,在stter函数起作用的时候,只是把这个修改添加到一个页面更新队列中,等待下一个click时间戳,才开始更新dom页面,效率不会差多少。但是你要知道像你所说的这种极端情况在页面开发中会有多少。而大量的脏检查(当前作用域下所有绑定的数据)真的让手机发烫的厉害。不过还是谢谢你的文章,让我学习了很多!!

请问angular2和1实现双向绑定有区别吗?

justu commented

学习了,终于知道$digest和$apply之间的区别了!

学习

什么时候出个angular 2.0+ 的源码解析呀,大佬?