深入学习AngularJS中数据的双向绑定机制
angular js (angular.js) 是一组用来开发web页面的框架、模板以及数据绑定和丰富ui组件。它支持整个开发进程,提供web应用的架构,无需进行手工dom操作。 angularjs很小,只有60k,兼容主流浏览器,与 jquery 配合良好。双向数据绑定可能是angularjs最酷最实用的特性,将mvc的原理展现地淋漓尽致.
angularjs的工作原理是:html模板将会被浏览器解析到dom中, dom结构成为angularjs编译器的输入。angularjs将会遍历dom模板, 来生成相应的ng指令,所有的指令都负责针对view(即html中的ng-model)来设置数据绑定。因此, ng框架是在dom加载完成之后, 才开始起作用的.
在html中:
<body ng-app="ngapp"> <div ng-controller="ngctl"> <label ng-model="mylabel"></label> <input type="text" ng-model="myinput" /> <button ng-model="mybutton" ng-click="btnclicked"></button> </div> </body>
在js中:
// angular app var app = angular.module("ngapp", [], function(){ console.log("ng-app : ngapp"); }); // angular controller app.controller("ngctl", [ '$scope', function($scope){ console.log("ng-controller : ngctl"); $scope.mylabel = "text for label"; $scope.myinput = "text for input"; $scope.btnclicked = function() { console.log("label is " + $scope.mylabel); } }]);
如上,我们在html中先定义一个angular的app,指定一个angular的controller,则该controller会对应于一个作用域(可以用$scope前缀来指定作用域中的属性和方法等). 则在该ngctl的作用域内的html标签, 其值或者操作都可以通过$scope的方式跟js中的属性和方法进行绑定.
这样, 就实现了ng的双向数据绑定: 即html中呈现的view与angularjs中的数据是一致的. 修改其一, 则对应的另一端也会相应地发生变化.
这样的方式,使用起来真的非常方便. 我们仅关心html标签的样式, 及其对应在js中angular controller作用域下绑定的属性和方法. 仅此而已, 将众多复杂的dom操作全都省略掉了.
这样的思想,其实跟jquery的dom查询和操作是完全不一样的, 因此也有很多人建议用angularjs的时候,不要混合使用jquery. 当然, 二者各有优劣, 使用哪个就要看自己的选择了.
ng中的app相当于一个模块module, 在每个app中可以定义多个controller, 每个controller都会有各自的作用域空间,不会相互干扰.
绑定数据是怎样生效的
初学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这样把批量操作延时到一次更新,否则性能会更低。
所以说,两种不同的监控方式,各有其优缺点,最好的办法是了解各自使用方式的差异,考虑出它们性能的差异所在,在不同的业务场景中,避开最容易造成性能瓶颈的用法。