双向数据绑定是AngularJS最主要的特性之一,这个特性使得我们操作DOM变得更加容易,也使得AngularJS更适应于实时变化的场景,如实时监控。刚开始在使用AngularJs 时这个特性让我简直不能再惊讶,所以去看看实现咯。
小栗子
首先我们先来考虑一个简单的场景,就是我们可以输入数值,来控制一个矩形的长度。在这里,数据与表现的关系是:
1 2 3 4 5
| > 长度数值保存在变量中 > 变量显示于某个 input 中 > 变量的值即是矩形的长度 > input 中的值变化时,变量也要变化 > input 中的值变化时,矩形的长度也要变化
|
所以我们在不使用ng 时,应该先找到input,然后获取值,给input添加一个chang事件,将这个值实时变化更改给矩形框的长度,然后再更改矩形框的DOM显示。所以在正常情况下,必须使用的响应的函数(如change)来进行同步更改,不然并不能进行实时更改相应值。 而在 ng中,可以实现如下:
1 2 3 4 5 6 7 8 9 10 11
| <div ng-controller="TestCtrl"> <div style="width: 10px; height: 10px; background-color: red" ng-style="style"> </div> <input type="text" name="width" ng-model="style.width" /> </div> <script type="text/javascript" charset="utf-8"> var TestCtrl = function($scope){ $scope.style = {width: 100 + 'px'}; } angular.bootstrap(document.documentElement); </script>
|
在这个例子中,我们并未直接调用chang函数,而是使用了ng-model 与ng-style这两个指令,并非是这两个指令有什么神奇的地方,而是AngularJS在实现时给ng-model 绑定了事件来进行数据处理,所以才有了不用我们自己再写change函数。
原理
在AngularJS中扩展了事件循环,生成一个angular context
的运行环境。在scope()函数的原型上实现的$watch方法中有一个$$watcher
队列。每当绑定一些东西到DOM上时就会在$$watcher这个队列里添加一个 $$watcher。那这写$watch是什么时候生成的呢? 当我们的模版加载完毕时,也就是在linking阶段,Angular解释器会寻找每个directive,然后生成每个需要的$watch。
在scope()的原型上还有一个方法是$digest
,这个方法主要作用是一个循环。当浏览器接收到可以被angular context
处理的事件时,$digest循环就会触发。这个循环是由两个更小的循环组合起来的。一个处理evalAsync队列,另一个处理$$watcher队列。这个是处理什么的呢?$digest将会遍历我们的$watch,这个循环会将$$watchers中的每一个值都进行检查,检查当前值和原来值是否相同,这就是 dirty-checking
(脏值检查)。当值检查完后,会继续看这个队列是否有再更新过,如果有至少一个更新过,这个循环会再次触发,知道队列中的所有值都不再变化。记住如果循环超过10次的话,它将会抛出一个异常,防止无限循环。 当$digest循环结束时,DOM相应地变化。
每一个进入angular context的事件都会执行一个$digest循环,也就是说每次我们输入一个字母循环都会检查整个页面的所有$watch。那么事件怎么样就可以进入angular context 中呢?这个是通过$apply 来进行控制的。当事件触发时,调用$apply,则会进入angular contxt ,若是没有调用$apply 则不会进入。
在调用$apply之后 才会调用$digest.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| $apply: function(expr) { try { beginPhase('$apply'); return this.$eval(expr); } catch (e) { $exceptionHandler(e); } finally { clearPhase(); try { $rootScope.$digest(); } catch (e) { $exceptionHandler(e); throw e; } } }
|
这主要就是双向数据绑定主要用到的方法: $watch,$digest,$apply。
下面我们来具体分析一下视图到模型 和模型到视图这两个单方面的实现。
视图到模型
先看下面这个栗子 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <body ng-controller="MainCtrl"> <clickable foo="foo" bar="bar"></clickable> <hr /> {{ hello }} <button ng-click="setHello()">Change hello</button> <script type="text/javascript"> app = angular.module('app', []); app.controller('MainCtrl', function($scope) { $scope.foo = 0; $scope.bar = 0; $scope.hello = "Hello"; $scope.setHello = function() { $scope.hello = "World"; }; }); app.directive('clickable', function() { return { restrict: "E", scope: { foo: '=', bar: '=' }, template: '<ul style="background-color: lightblue"><li>{{foo}}</li><li>{{bar}}</li></ul>', link: function(scope, element, attrs) { element.bind('click', function() { scope.foo++; scope.bar++; }); } } }); </script> </body>
|
在这个小栗子中,点击蓝色列表并不会使得DOM中的显示发生变化,但在点击change Hello这个按钮的时候会使得蓝色列表同时发生变化,这表明在点击蓝色按钮的时候确实会使得模型上的数据发生更改,即在DOM元素上绑定事件,就可以产生视图到模型的绑定。
在这个例子中,{ { } }
指令将foo,bar 放在$$watcher队列中了,所以不需要自己手动添加了,我们也可以使用$watch 来检测自己的想检测的变量。
1 2 3 4 5 6 7 8 9 10 11
| <body ng-controller="MainCtrl"> <input ng-model="name" /> Name updated: {{updated}} times. </body> app.controller('MainCtrl', function($scope) { $scope.name = "Angular"; $scope.updated = -1; $scope.$watch('name', function() { $scope.updated++; }); });
|
但 检测引用类型时,添加$watch 函数的第三个参数,若是为true,则表示的是我们比较的是对象的值而不是引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <body ng-controller="MainCtrl"> <input ng-model="user.name" /> Name updated: {{updated}} times. </body> app.controller('MainCtrl', function($scope) { $scope.user = { name: "Fox" }; $scope.updated = 0; $scope.$watch('user', function(newValue, oldValue) { if (newValue === oldValue) { return; } $scope.updated++; }, true); });
|
那为什么没有在点击蓝色列表时发生DOM上的同步更改呢? 而在点击change Hello这个按钮的时候会使得蓝色列表同时发生变化?这是因为我们还没给蓝色列表添加模型到视图上的绑定。但是ng-click 是angular实现的,会自动调用$digest循环$$watcher队列,更新DOM。那怎么样给蓝色列表添加模型到视图的绑定呢?
模型到视图
将上面的指令实现改成下面这样,就可以实现模型到视图的绑定了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| app.directive('clickable', function() { return { restrict: "E", scope: { foo: '=', bar: '=' }, template: '<ul style="background-color: lightblue"><li>{{foo}}</li><li>{{bar}}</li></ul>', link: function(scope, element, attrs) { element.bind('click', function() { scope.$apply(function() { scope.foo++; scope.bar++; }); }) } } });
|
调用$apply会强制一次$digest循环(除非当前正在执行循环,这种情况下会抛出一个异常,这是我们不需要在那里执行$apply的标志)。
简单实现
其实整个双向数据绑定过程就是 :有一个队列($$watcher)用来存放想绑定的数据,有一个循环($digest)来检查这个队列中的值是否脏了(dirty-checking),有一个启动函数($apply)来决定什么时候来启动这个循环。所以我们自己也可以实现这样的一个双向绑定。只是我们自己的环境比较简单,也用不到angular context ,所以 用不到 $apply;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| <body> <input type="text" name="name" value=""> <script type="text/javascript"> var Scope = function() { this.$$watchers = []; }; Scope.prototype.$watch = function(watchExp, listener) { this.$$watchers.push({ watchExp: watchExp, listener: listener || function() {} }); }; Scope.prototype.$digest = function() { var dirty; do { dirty = false; for (var i = 0; i < this.$$watchers.length; i++) { var newValue = this.$$watchers[i].watchExp(), oldValue = this.$$watchers[i].last; if (oldValue !== newValue) { this.$$watchers[i].listener(newValue, oldValue); dirty = true; this.$$watchers[i].last = newValue; } } } while (dirty); }; var $scope = new Scope(); $scope.name = 'Ryan'; var element = document.querySelectorAll('input'); element[0].onkeyup = function() { $scope.name = element[0].value; $scope.$digest(); }; $scope.$watch(function() { return $scope.name; }, function(newValue, oldValue) { console.log('Input value updated - it is now ' + newValue); element[0].value = $scope.name; }); var updateScopeValue = function updateScopeValue() { $scope.name = 'Bob'; $scope.$digest(); }; </script> </body>
|
参考文献
- 理解$watch ,$apply 和 $digest — 理解数据绑定过程
- AngularJS 数据双向绑定揭秘