Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Angular沉思录(一)数据绑定 #10

Open
xufei opened this issue Nov 26, 2014 · 23 comments
Open

Angular沉思录(一)数据绑定 #10

xufei opened this issue Nov 26, 2014 · 23 comments

Comments

@xufei
Copy link
Owner

xufei commented Nov 26, 2014

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
Copy link

hax commented Nov 26, 2014

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

@hax
Copy link

hax commented Nov 26, 2014

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

@xufei
Copy link
Owner Author

xufei commented Nov 26, 2014

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

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

@hax
Copy link

hax commented Nov 26, 2014

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

@xufei
Copy link
Owner Author

xufei commented Nov 27, 2014

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

我觉得理想过程是:

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

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

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

@hax
Copy link

hax commented Nov 27, 2014

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

@xufei
Copy link
Owner Author

xufei commented Nov 27, 2014

@hax 嗯,是这样的

@liekkas
Copy link

liekkas commented Jan 20, 2015

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

@SiZapPaaiGwat
Copy link

对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');
})

@ssehacker
Copy link

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

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

@paddingme
Copy link

<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
Copy link
Owner Author

xufei commented Jul 6, 2015

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

@paddingme
Copy link

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

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

@guox191
Copy link

guox191 commented Nov 14, 2015

点赞

@mcwz
Copy link

mcwz commented Nov 20, 2015

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

@lijsh
Copy link

lijsh commented Feb 15, 2016

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

@Picknight
Copy link

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

@wuyanan
Copy link

wuyanan commented Apr 27, 2016

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

@MRLuowen
Copy link

MRLuowen commented Jul 19, 2016

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

@bigggge
Copy link

bigggge commented Jan 6, 2017

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

@justu
Copy link

justu commented Apr 7, 2017

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

@wangmeijian
Copy link

学习

@wustdong
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests