关于Angular脏检查,以前没有仔细学习,只是旁听道说,Angular 会定时的进行周期性数据检查,将前台和后台数据进行比较,因此很是损耗性能。javascript
这是大错而特错的。我甚至在新浪前端面试的时候胡说一通,如今想来真是羞愧难当! 没有深刻了解就信口开河实在难堪大任。html
最后被拒也是理所固然。前端
在剖析以前,很是感谢坐镇苏宁的徐飞,如今已经不在苏宁了,我也是在他翻译的文章(Build Your own AngularJS)和博客才略懂一二。
徐飞关于知乎问题国内前端团队分布和前景是怎样的?的回答也是特别有意思。java
首先纠正误区,Angular并非周期性触发藏检查。
只有当UI事件,ajax请求或者 timeout 延迟事件,才会触发脏检查。
为何叫脏检查? 对脏数据的检查就是脏检查,比较UI和后台的数据是否一致!
下面解释:git
Angular 每个绑定到UI的数据,就会有一个 $watch 对象。
这个对象包含三个参数github
watch = { name:'', //当前的watch 对象 观测的数据名 getNewValue:function($scope){ //获得新值 ... return newValue; }, listener:function(newValue,oldValue){ // 当数据发生改变时须要执行的操做 ... } }
getNewValue() 能够获得当前$scope 上的最新值,listener 函数获得新值和旧值并进行一些操做。面试
而经常咱们在使用Angular的时候,listener 通常都为空,只有当咱们须要监测更改事件的时候,才会显示地添加监听。ajax
每当咱们将数据绑定到 UI 上,angular 就会向你的 watchList 上插入一个 $watch。
好比:数组
<span>{{user}}</span> <span>{{password}}</span>
这就会插入两个$watch 对象。
以后,开始脏检查。
好了,咱们先把脏检查放一放,来看它以前的东西
??
双向数据绑定 ! 只有先理解了Angular的双向数据绑定,才能透彻理解脏检查 。app
Angular实现了双向数据绑定。无非就是界面的操做能实事反应到数据,数据的更改也能在界面呈现。
界面到数据的更改,是由 UI 事件,ajax请求,或者timeout 等回调操做,而数据到界面的呈现则是由脏检查来作.
这也是我开始纠正的误区
只有当触发UI事件,ajax请求或者 timeout 延迟,才会触发脏检查。
看下面的例子
<div ng-controller="CounterCtrl"> <span ng-bind="counter"></span> <button ng-click="counter=counter+1">increase</button> </div>
function CounterCtrl($scope) { $scope.counter = 1; }
毫无疑问,我每点击一次button,counter就会+1,由于点击事件,将couter+1,然后触发了脏检查,又将新值2 返回给了界面.
这就是一个简单的双向数据绑定的流程.
可是就只有这么简单吗??
看下面的代码
'use strict'; var app = angular.module('app', []); app.directive('myclick', function() { return function(scope, element, attr) { element.on('click', function() { scope.data++; console.log(scope.data) }) } }) app.controller('appController', function($scope) { $scope.data = 0; });
<div ng-app="app"> <div ng-controller="appController"> <span>{{data}}</span> <button myclick>click</button> </div> </div>
点击后,毫无反应.
???
试试在 console.log(scope.data) 后面添加 scope.$digest(); 试试?
很明显,数据增长了。若是使用$apply () 呢? 固然能够(后面会接受 $apply 和 $digest 的区别)
为什们呢?
假设没有AngularJS,要让咱们本身实现这个相似的功能,该怎么作呢?
<body> <button ng-click="increase">increase</button> <button ng-click="decrease">decrease</button> <span ng-bind="data"></span> <script src="app.js"></script> </body>
window.onload = function() { 'use strict'; var scope = { increase: function() { this.data++; }, decrease: function decrease() { this.data--; }, data: 0 } function bind() { var list = document.querySelectorAll('[ng-click]'); for (var i = 0, l = list.length; i < l; i++) { list[i].onclick = (function(index) { return function() { var func = this.getAttribute('ng-click'); scope[func](scope); apply(); } })(i); } } // apply function apply() { var list = document.querySelectorAll('[ng-bind]'); for (var i = 0, l = list.length; i < l; i++) { var bindData = list[i].getAttribute('ng-bind'); list[i].innerHTML = scope[bindData]; } } bind(); apply(); }
测试一下:
能够看到咱们没有直接使用DOM的onclick方法,而是搞了一个ng-click,而后在bind里面把这个ng-click对应的函数拿出来,绑定到onclick的事件处理函数中。为何要这样呢?由于数据虽然变动了,可是尚未往界面上填充,咱们须要在此作一些附加操做。
另外,因为双向绑定机制,在DOM操做中,虽然更新了数据的值,可是并无当即反映到界面上,而是经过 apply() 来反映到界面上,从而完成职责的分离,能够认为是单一职责模式了。
在真正的Angular中,ng-click 封装了click,而后调用一次 apply 函数,把数据呈现到界面上
在Angular 的apply函数中,这里先进行脏检测,看 oldValue 和 newVlue 是否相等,若是不相等,那么讲newValue 反馈到界面上,经过若是经过 $watch 注册了 listener事件,那么就会调用该事件。
通过咱们上面的分析,能够总结:
然而就有了接下来的讨论?
不断触发脏检查是否是一种好的方式?
有不少人认为,这样对性能的损耗很大,不如 setter 和 getter 的观察者模式。 可是咱们看下面这个例子
<span>{{checkedItemsNumber}}</span>
function Ctrl($scope){ var list = []; $scope.checkedItemsNumber = 0; for(var i = 0;i<1000;i++){ list.push(false); } $scope.toggleChecked = function(flag){ for(var i = 0,l= list.length;i++){ list[i] = flag; $scope.checkedItemsNumber++; } } }
在脏检测的机制下,这个过程毫无压力,会等待到 循环执行结束,而后一次更新 checkedItemsNumber,应用到界面上。 可是在基于setter的机制就惨了,每变化一次checkedItemsNumber就须要更新一次,这样性能就会极低。
因此说,两种不一样的监控方式,各有其优缺点,最好的办法是了解各自使用方式的差别,考虑出它们性能的差别所在,在不一样的业务场景中,避开最容易形成性能瓶颈的用法。
好了,如今已经了解了双向数据绑定了 脏检查的触发机制,那么,脏检查内部又是怎么实现的呢?
首先,构造$scope 对象,
function $scope = function(){}
如今,咱们回到开头 $watch。
咱们说,每个绑定到UI上的数据都有拥有一个对应的$watch 对象,这个对象会被push到watchList中。
它拥有两个函数做为属性
function $scope(){ this. $$watchList = []; }
在Angular框架中,双美圆符前缀$$表示这个变量被看成私有的来考虑,不该当在外部代码中调用。
如今咱们能够定义$watch方法了。它接受两个函数做参数,把它们存储在$$watchers数组中。咱们须要在每一个Scope实例上存储这些函数,因此要把它放在Scope的原型上:
$scope.prototype.$watch = function(name,getNewValue,listener){ var watch = { name:name, getNewValue : getNewValue, listener : listener }; this.$$watchList.push(watch); }
另一面就是$digest函数。它执行了全部在做用域上注册过的监听器。咱们来实现一个它的简化版,遍历全部监听器,调用它们的监听函数:
$scope.prototype.$digest = function(){ var list = this.$$watchList; for(var i = 0,l = list.length;i<l;i++){ list[i].listener(); } }
如今,咱们就能够添加监听器而且运行脏检查了。
var scope = new Scope(); scope.$watch(function() { console.log("hey i have got newValue") }, function() { console.log("i am the listener"); }) scope.$watch(function() { console.log("hey i have got newValue 2") }, function() { console.log("i am the listener2"); }) scope.$disget();
代码会托管到github,测试文件路径跟命令中路径一致
OK,两个监听均已经触发。
这些自己没什么大用,咱们要的是能检测由getNewValue返回指定的值是否确实变动了,而后调用监听函数。
那么,咱们须要在getNewValue() 上每次都获得数据上最新的值,因此须要获得当前的scope对象
getNewValue = function(scope){ return scope[this.name]; }
是监控函数的通常形式:从做用域获取一些值,而后返回。
$digest函数的做用是调用这个监控函数,而且比较它返回的值和上一次返回值的差别。若是不相同,监听器就是脏的,它的监听函数就应当被调用。
想要这么作,$digest须要记住每一个监控函数上次返回的值。既然咱们如今已经为每一个监听器建立过一个对象,只要把上一次的值存在这上面就好了。下面是检测每一个监控函数值变动的$digest新实现:
$scope.prototype.$digest = function(){ var list = this.$$watchList; for(var i = 0,l= list.length;i++){ var watch = list[i]; var newValue = watch.getNewValue(this); // 在第一次渲染界面,进行一个数据呈现. var oldValue = watch.last; if(newValue!=oldValue){ watch.listener(newValue,oldValue); } watch.last = newValue; } }
对于每个watch,咱们使用 getNewValue() 而且把scope实例 传递进去,获得数据最新值 。而后和上一次值进行比较,若是不一样,那就调用 getListener,同时把新值和旧值一并传递进去。 最终,咱们把last 属性设置为新返回的值,也就是最新值。
这个$digest 再一次调用,last 为undefined,因此必定会进行一次数据呈现。
好了,咱们看看这个监控函数如何运行的
var scope = new $scope(); scope.hello = 10; scope.$watch('hello', function(scope) { // 注意,要理解这里的this ,这个函数实际是 var newValue = watch.getNewValue(this); 这样调用,那么 this 就指的是当前监听器watch,因此能够获得name return scope[this.name] }, function(newValue, oldValue) { console.log('newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$digest(); scope.hello = 10; scope.$digest(); scope.hello = 20; scope.$digest();
运行结果
咱们已经实现了Angular做用域的本质:添加监听器,在digest里运行它们。
也已经能够看到几个关于Angular做用域的重要性能特性:
在看咱们上面的程序:
$scope.prototype.$digest = function(){ var list = this.$$watchList; for(var i = 0,l= list.length;i++){ var watch = list[i]; var newValue = watch.getNewValue(this); // 在第一次渲染界面,进行一个数据呈现. var oldValue = watch.last; if(newValue!=oldValue){ watch.listener(newValue,oldValue); } watch.last = newValue; } }
咱们这样作,就要求每一个监听器watch 都必须注册 listener,然而事实是:在Angular 应用中,只有少数的监听器须要注册listener。
更改 $scope.prototype.$wacth,在这里放置一个空的函数。
$scope.prototype.$watch = function(name,getNewValue,listener){ var watch = { name:name, getNewValue : getNewValue, listener : listener || function(){} }; this.$$watchList.push(watch); }
貌似这样已经初步理解了脏检查原理,可是一个重要的问题咱们忽视了。
前后注册了两个监听器,第二个监听器的listener 改变了 第一个监听器对应数据的值,那么这么作会检测的到吗?
看下面的例子
var scope = new $scope(); scope.first = 10; scope.second = 1; scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first = 8; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$digest(); console.log(scope.first); console.log(scope.second);
能够看到,值为 8,1,已经发生改变,可是界面上的值却没有改变。
如今来修复这个问题。
咱们须要改变一下digest,让它持续遍历全部监听器,直到监控的值中止变动。
首先,咱们把如今的$digest函数更名为$$digestOnce,它把全部的监听器运行一次,返回一个布尔值,表示是否还有变动了。
$scope.prototype.$$digestOnce = function() { var dirty; var list = this.$$watchList; for(var i = 0,l = list.length;i<l;i++ ){ var watch = list[i]; var newValue = watch.getNewValue(this.name); var oldValue = watch.last; if(newValue !==oldValue){ watch.listener(newValue,oldValue); // 由于listener操做,已经检查过的数据可能变脏 dirty = true; } watch.last = newValue; return dirty; } };
而后,咱们从新定义$digest,它做为一个“外层循环”来运行,当有变动发生的时候,调用$$digestOnce:
$scope.prototype.$digest = function() { var dirty = true; while(dirty) { dirty = this.$$digestOnce(); } };
$digest如今至少运行每一个监听器一次了。若是第一次运行完,有监控值发生变动了,标记为dirty,全部监听器再运行第二次。这会一直运行,直到全部监控的值都再也不变化,整个局面稳定下来了。
在Angular做用域里并非真的有个函数叫作$$digestOnce,相反,digest循环都是包含在$digest里的。咱们的目标更可能是清晰度而不是性能,因此把内层循环封装成了一个函数。
测试一下
var scope = new $scope(); scope.first = 10; scope.second = 1; scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first = 8; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$digest(); console.log(scope.first); console.log(scope.second);
能够看到,如今界面上的数据已经所有为最新
咱们如今能够对Angular的监听器有另一个重要认识:它们可能在单次digest里面被执行屡次。这也就是为何人们常常说,监听器应当是幂等的:一个监听器应当没有边界效应,或者边界效应只应当发生有限次。好比说,假设一个监控函数触发了一个Ajax请求,没法肯定你的应用程序发了多少个请求。
若是两个监听器循环改变呢?像如今这样:
var scope = new $scope(); scope.first = 10; scope.second = 1; scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.second ++; }) scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first ++; })
那么,脏检查就不会停下来,一直循环下去。如何解决呢?
咱们要作的事情是,把digest的运行控制在一个可接受的迭代数量内。若是这么屡次以后,做用域还在变动,就勇敢放手,宣布它永远不会稳定。在这个点上,咱们会抛出一个异常,由于无论做用域的状态变成怎样,它都不太多是用户想要的结果。
迭代的最大值称为TTL(short for Time To Live)。这个值默认是10,可能有点小(咱们刚运行了这个digest 100,000次!),可是记住这是一个性能敏感的地方,由于digest常常被执行,并且每一个digest运行了全部的监听器。用户也不太可能建立10个以上链状的监听器。
咱们继续,给外层digest循环添加一个循环计数器。若是达到了TTL,就抛出异常:
$scope.prototype.$digest = function() { var dirty = true; var checkTimes = 0; while(dirty) { dirty = this.$$digestOnce(); checkTimes++; if(checkTimes>10 &&dirty){ throw new Error("检测超过10次"); console.log("123"); } }; };
测试一下
var scope = new $scope(); scope.first = 1; scope.second = 10; scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.second++; console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first++; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); }) scope.$digest();
好了,关于 Angular 脏检查和 双向数据绑定原理就介绍到这里,虽然离真正的Angular 还差不少,可是也能基本解释原理了。
关于 这篇中全部的代码,已经放到github上 https://github.com/apawn/HFLib/tree/master/HFLib/angular .
推荐一本原著 《Build Your Own AngularJS》,书中详细介绍了如何构建一个AngularJS。估计翻译版本会在年后出版,若是能够读完这本书,那么 JS的能力将会上升一个等级。
关于司徒正美的 《Javascript框架设计》 也是前端深刻研究的必读之书。
后面在阅读的时候,我会把本身的阅读经验分享出来。
只是把这些搞明白以后,如今再也没有去面试新浪应届生的机会了 。
虽然不知道明年会在哪,但必定会进入一个优秀的前端团队并努力展现更好的面貌的。
若是您有意,欢迎联系我,Email:mymeat@126.com
在这篇中,我有提到 VueJS 中 基于 setter 和 getter 的实现,我讲会深刻学习并在下一篇介绍。