深入理解AngularJs-scope的脏检查(一)


Posted in Javascript onJune 19, 2017

进入正文前的说明:本文中的示例代码并非AngularJs源码,而是来自书籍<<Build Your Own AngularJs>>, 这本书的作者仅依赖jquery和lodash一步一步构建出AngularJs的各核心模块,对全面理解AngularJs有非常巨大的帮助。若有正在使用AngulaJs攻城拔寨并且希望完全掌握手中武器的小伙伴,相信能对你理解AngularJs带来莫大帮助,感谢作者。

在这篇文章中,希望能让您理清楚以下几项与scope相关的功能:

1.dirty-checking(脏检测)核心机制,主要包括:$watch 和 $digest;

2.几种不同的触发$digest循环的方式:$eval, $apply, $evalAsync, $applyAsync;

3.scope的继承机制以及isolated scope;

4.依赖于scope的事件循环:$on, $broadcast, $emit.

现在开始我们的第一部分:scope和dirty-checking

dirty-checking(脏检测)原理简述:scope通过$watch方法向this.$$watchers数组中添加watcher对象(包含watchFn, listenerFn, valueEq, last 四个属性)。每当$digest循环被触发时,它会遍历$$watchers数组,执行watcher中的watchFn,获取当前scope上某属性的值(一个watcher对应scope上一个被监听属性),然后去同watcher中的last(上一次的值)做比较,若两值不相等,就执行listenerFn。

function Scope() {
  this.$$watchers = []; // 监听器数组
  this.$$lastDirtyWatch = null; // 每次digest循环的最后一个脏的watcher, 用于优化digest循环
  this.$$asyncQueue = []; // scope上的异步队列
  this.$$applyAsyncQueue = []; // scope上的异步apply队列
  this.$$applyAsyncId = null; //异步apply信息
  this.$$postDigestQueue = []; // postDigest执行队列
  this.$$phase = null; // 储存scope上正在做什么,值有:digest/apply/null
  this.$root = this; // rootScope

  this.$$listeners = {}; // 存储包含自定义事件键值对的对象

  this.$$children = []; // 存储当前scope的儿子Scope,以便$digest循环递归
}

实际上scope就是一个普通的javascript对象,一个类构造函数,可以通过new进行实例化。根据脏检测的原理,接下来,我们一起看看scope的$watch方法的实现。

/* $watch方法:向watchers数组中添加watcher对象,以便对应调用 */
Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {
  var self = this;

  watchFn = $parse(watchFn);

  // watchDelegate: 针对watch expression是常量和 one-time-binding的情况,进行优化。在第一次初始化之后删除watch
  if(watchFn.$$watchDelegate) {
    return watchFn.$$watchDelegate(self, listenerFn, valueEq, watchFn);
  }
  var watcher = {
    watchFn: watchFn,
    listenerFn: listenerFn || function() {},
    valueEq: !!valueEq,
    last: initWatchVal
  };

  this.$$watchers.unshift(watcher);
  this.$root.$$lastDirtyWatch = null;

  return function() {
    var index = self.$$watchers.indexOf(watcher);
    if(index >= 0) {
      self.$$watchers.splice(index, 1);
      self.$root.$$lastDirtyWatch = null;
    }
  };
};

$watch方法的参数:

watchFn-监视表达式,在使用$watch时,通常是传入一个expression, 经过$parse服务处理后返回一个监视函数,提供动态访问scope上属性值的功能,可以看作 function() { return scope.someValue; }。

listenerFn-监听函数,当$digest循环dirty时(即scope上$$watchers数组中有watcher监测到属性值变化时),执行的回调函数。

valueEq-是否全等监视,布尔值,valueEq默认为false,此时$watch对监视对象进行“引用监视”,如果被监视的表达式是原始数据类型,$watch能够发现改变。如果被监视的表达式是引用类型,由于引用类型的赋值只是将被赋值变量指向当前引用,故$watch认为没有改变。若需要对引用类型进行监视,则需要将valueEq设置为true,这是$watch会对被监视对象进行“全等监视”,在每次比较前会用angular.copy()对被监视对象进行深拷贝,然后用angular.equal()进行比对。虽然“全等监视”能够监视到所有改变,但如果被监视对象很大,性能肯定会大打折扣。所以应该根据实际情况来使用valueEq。

从代码中能够看出,$watch的功能其实非常简单,就是构造watcher对象,并将watcher对象插入到scope.$$watchers数组中,然后返回一个销毁当前watcher的函数。

接下来进入到脏检测最核心的部分:$digest循环

《Build your own AngularJs》的作者将$digest分成了两个函数:$digestOnce 和 $digest。这虽然不用与框架源码,但能够使代码更易理解。两个函数实际上分别对应了$digest的内层循环和外层循环。代码如下:

内层循环

Scope.prototype.$$digestOnce = function() {
      var dirty;
      var continueLoop = true;
      var self = this;

      this.$$everyScope(function(scope) {
        var newValue, oldValue;

        _.forEachRight(scope.$$watchers, function(watcher) {
          try {
            if(watcher) {
              newValue = watcher.watchFn(scope);
              oldValue = watcher.last;

              if(!scope.$$areEqual(newValue, oldValue, watcher.valueEq)) {
                scope.$root.$$lastDirtyWatch = watcher;

                watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
                
                watcher.listenerFn(newValue,
                  (oldValue === initWatchVal? newValue : oldValue), scope);
                dirty = true;
              } else if(scope.$root.$$lastDirtyWatch === watcher) {
                continueLoop = false;
                return false;
              }
            }
          } catch(e) {
            console.error(e);
          }
        });
        return continueLoop;
      });

      return dirty;
    };

代码中,$$everyScope是递归childScope执行回调函数的工具方法,后面会贴出。

$digestOnce的核心逻辑就在$$everyScope方法的循环体内,即遍历scope.$$watchers, 比对新旧值,根据比对结果确定是否执行listenerFn,并向listenerFn中传入newValue, oldValue, scope供开发者获取。

示例代码第18行,watcher.last的赋值证实了上文提到的$watch的第三个参数valueEq的作用。

示例代码第23行,由于$digest循环会一直运行直到没有dirty watcher时,故单次$digest循环通过缓存最后一个dirty的watcher,在下一次$digest循环时如果遇到$$lastDirtyWatcher就停止当前循环。这样做减少了遍历watcher的数量,优化了性能。

 外层循环

在我们的示例中,外层循环即由 $digest来控制。$digest函数主要由do while循环体内调用$digestOnce进行脏检测 以及 对其他一些异步操作的处理组成。代码如下:

// digest循环的外循环,保持循环直到没有脏值为止
    Scope.prototype.$digest = function() {
      var ttl = TTL;
      var dirty;
      this.$root.$$lastDirtyWatch = null;

      this.$beginPhase('$digest');

      if(this.$root.$$applyAsyncId) {
        clearTimeout(this.$root.$$applyAsyncId);
        this.$$flushApplyAsync();
      }

      do {
        while (this.$$asyncQueue.length) {
          try {
            var asyncTask = this.$$asyncQueue.shift();
            asyncTask.scope.$eval(asyncTask.expression);
          } catch(e) {
            console.error(e);
          }
        }

        dirty = this.$$digestOnce();

        if((dirty || this.$$asyncQueue.length) && !(ttl--)) {
          this.$clearPhase();
          throw TTL + ' digest iterations reached';
        }
      } while (dirty || this.$$asyncQueue.length);
      this.$clearPhase();

      while(this.$$postDigestQueue.length) {
        try {
          this.$$postDigestQueue.shift()();
        } catch(e) {
          console.error(e);
        }
      }
    };

在这一节中我们的主要关注点是脏检测,异步任务相关的$$applyAsync,$$flushApplyAsync,$$asyncQueue,$$postDigestQueue之后再做分析。

示例代码第24行,调用$$digestOnce,并把返回值赋值给dirty。在do while循环中,只要dirty为true,那么循环就会一直执行下去,直到dirty的值为 false。这就是脏检测机制的外层循环的实现,是不是觉得其实很简单呢,嘿嘿。

设想一下,某些值可能会在listenerFn中持续被改变并且,无法稳定下来,那势必会出现死循环。为了解决这个问题,AngularJs使用 TTL(time to live)来对循环次数进行控制,超过最大次数,就会throw错误 并 告诉开发者循环可能永远不会稳定。

现在我们把注意力移到代码第26行的 if 代码块上,不难看出,这里是对最大$digest循环次数进行了限制,每执行一次do while循环的循环体,TTL就会自减1。当TTL值为0,再进行循环就会报错。当然咯,这个TTL的值也是能够进行配置的。

现在,相信小伙伴们对$digest循环已经比较清楚了吧~简单来说,dirty-checking就是依赖缓存在scope上的$$watchers和$digest循环来对值进行监听的。有了$digest,当然还需要有手段去触发它咯。

接下来,我们将进入第二部分:触发$digest循环 和 异步任务处理 

$eval

说到触发$digest循环,大部分同学都会想到$apply。要说$apply就需要先说说$eval。

$eval使我们能够在scope的context中执行一段表达式,并允许传入locals object对当前scope context进行修改。

tip:$parse服务能够接受一个表达式或者函数作为参数,经过处理返回一个函数供开发者调用。这个函数有两个参数context object(通常就是scope),locals object(本地对象,常用来覆盖context中的属性)。

Scope.prototype.$eval = function(expr, locals) {
   return $parse(expr)(this, locals);
 };

$apply

$apply 方法接收一个expression或者function作为参数,$apply通过$eval函数执行传入的expression 或 function。最终从$rootScope上触发$digest循环。

$apply 被认为是 使AngularJs与第三方库混合使用最标准的方式。初学者朋友刚开始都会遇到用第三方库修改了scope上的属性或者被watch的属性,但并没有触发$digest循环,导致双向绑定失效的问题。此时,$apply就是解决这种情况的良药!

Scope.prototype.$apply = function(expr) {
  try {
    this.$beginPhase('$apply');
    return this.$eval(expr);
  } finally {
    this.$clearPhase();
    this.$root.$digest();
  }
};

$apply本质上,就是用$eval执行了一段表达式,再调用rootScope的$digest方法。

有时候,当我们能够确定我们不需要从rootScope开始进行$digest循环时,我可以调用scope.digest() 来代替 $apply,这样能够带来性能的提升。

 $evalAsync

$evalAsync 用于延迟执行一段表达式。通常我们更习惯使用$timeout服务来进行代码的延迟执行,但$timeout会将执行控制权交给浏览器,如果浏览器同时还需要执行诸如 ui渲染/事件控制/ajax 等任务时,我们代码延迟执行的时机就会变得非常不可控。

我们来看看$evalAsync是如何让代码延迟执行的时机变得严格,可控的。

Scope.prototype.$evalAsync = function(expr) {
  var self = this;
  if(!self.$$phase && !self.$$asyncQueue.length) {
    setTimeout(function() {
      if(self.$$asyncQueue.length) {
        self.$root.$digest();
      }
    }, 0);
  }

  this.$$asyncQueue.push({
    scope: this,
    expression: expr
  });
};

$evalAsync方法的主要功能是从代码第11行开始,向$$asyncQueeu中添加对象。$$asyncQueue队列的执行是在$digest的do while循环中进行的。

while (this.$$asyncQueue.length) {
  try {
    var asyncTask = this.$$asyncQueue.shift();
    asyncTask.scope.$eval(asyncTask.expression);
  } catch(e) {
    console.error(e);
  }
}

$evalAsync的代码会在正在运行的$digest循环中被执行,如果当前没有正在运行的$digest循环,会自己延迟触发一个$digest循环来执行延迟代码。

 $applyAsync

$applyAsync用于合并短时间内多次$digest循环,优化应用性能。

在日常开发工作中,常常会遇到要短时间内接收若干http响应,同时触发多次$digest循环的情况。使用$applyAsync可合并若干次$digest,优化性能。

/* 这个方法用于 知道需要在短时间内多次使用$apply的情况,
  能够对短时间内多次$digest循环进行合并,
  是针对$digest循环的优化策略
  */
Scope.prototype.$applyAsync = function(expr) {
  var self = this;
  self.$$applyAsyncQueue.push(function() {
    self.$eval(expr);
  });

  if(self.$root.$$applyAsyncId === null) {
    self.$root.$$applyAsyncId = setTimeout(function() {
      self.$apply(_.bind(self.$$flushApplyAsync, self));
    }, 0);
  }
};

$$postDigest

$$postDigest方法提供了在下一次digest循环后执行代码的方式,这个方法的前缀是"$$",是一个AngularJs内部方法,应用开发极少用到。

此方法不自主触发$digest循环,而是在别处产生$digest循环之后执行。

/* $$postDigest 用于在下一次digest循环后执行函数队列 
   不同于applyAsync 和 evalAsync, 它不触发digest循环
   */
 Scope.prototype.$$postDigest = function(fn) {
   this.$$postDigestQueue.push(fn);
 };

到这里,我们对脏检测的原理,即它的工作机制就了解的差不多了。希望这些知识能够帮助你更好的应用AngularJs来开发,能够更轻松地定位错误。

下一章,我会继续为大家介绍文章开头提到的另外两处scope相关的特性。篇幅较长,感谢您的耐心阅读~也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
避免 showModalDialog 弹出新窗体的原因分析
May 31 Javascript
javascript中有趣的反柯里化深入分析
Dec 05 Javascript
jquery实现动态菜单的实例代码
Nov 28 Javascript
JS的location.href跳出框架打开新页面的方法
Sep 04 Javascript
仿淘宝TAB切换搜索框搜索切换的相关内容
Sep 21 Javascript
javascript实现鼠标点击页面 移动DIV
Dec 02 Javascript
Jquery Easyui表单组件Form使用详解(30)
Dec 19 Javascript
Angular中的$watch、$watchGroup、$watchCollection
Jun 25 Javascript
原生JS实现的双色球功能示例
Feb 02 Javascript
浅谈Vue响应式(数组变异方法)
May 07 Javascript
JavaScript如何处理移动端拍摄图片旋转问题
Nov 16 Javascript
Vue环境搭建+VSCode+Win10的详细教程
Aug 19 Javascript
jQuery 实现双击编辑表格功能
Jun 19 #jQuery
Web制作验证码功能实例代码
Jun 19 #Javascript
angularjs+bootstrap实现自定义分页的实例代码
Jun 19 #Javascript
详解vue服务端渲染(SSR)初探
Jun 19 #Javascript
jQuery实现简单的手风琴效果
Apr 17 #jQuery
原生JS+Canvas实现五子棋游戏实例
Jun 19 #Javascript
Node.js环境下Koa2添加travis ci持续集成工具的方法
Jun 19 #Javascript
You might like
file_get_contents(&quot;php://input&quot;, &quot;r&quot;)实例介绍
2013/07/01 PHP
PHP7生产环境队列Beanstalkd用法详解
2020/05/19 PHP
微博@符号的用户名提示效果。(想@到谁?)
2010/11/05 Javascript
javascript 学习笔记(onchange等)
2010/11/14 Javascript
兼容IE和Firefox火狐的上下、左右循环无间断滚动JS代码
2013/04/19 Javascript
jQuery 如何先创建、再修改、后添加DOM元素
2014/05/20 Javascript
PHP使用方法重载实现动态创建属性的get和set方法
2014/11/17 Javascript
百度地图API之本地搜索与范围搜索
2015/07/30 Javascript
JavaScript引用类型和基本类型详解
2016/01/06 Javascript
详解iframe与frame的区别
2016/01/13 Javascript
jquery插件Jplayer使用方法简析
2016/04/22 Javascript
JavaScript易错知识点整理
2016/12/05 Javascript
JS html时钟制作代码分享
2017/03/03 Javascript
详解webpack多页面配置记录
2018/01/22 Javascript
NodeJS父进程与子进程资源共享原理与实现方法
2018/03/16 NodeJs
使用Vue开发自己的Chrome扩展程序过程详解
2019/06/21 Javascript
Vue+ElementUI项目使用webpack输出MPA的方法
2019/08/27 Javascript
Vue实现 点击显示再点击隐藏效果(点击页面空白区域也隐藏效果)
2020/01/16 Javascript
用vue 实现手机触屏滑动功能
2020/05/28 Javascript
Python中使用OpenCV库来进行简单的气象学遥感影像计算
2016/02/19 Python
numpy中loadtxt 的用法详解
2018/08/03 Python
python装饰器的特性原理详解
2019/12/25 Python
举例详解CSS3中的Transition
2015/07/15 HTML / CSS
HTML5 新事件 小结
2009/07/16 HTML / CSS
马来西亚在线药房:RoyalePharma
2019/12/01 全球购物
美国宠物护理专家:Revival Animal Health
2020/01/05 全球购物
C++如何引用一个已经定义过的全局变量
2014/08/25 面试题
你经历的项目中的SCM配置项主要有哪些?什么是配置项?
2013/11/04 面试题
院药学专业个人求职信
2013/09/21 职场文书
小学红领巾中秋节广播稿
2014/01/13 职场文书
煤矿安全演讲稿
2014/05/09 职场文书
大学生毕业求职信
2014/06/12 职场文书
传承焦裕禄精神思想汇报2014
2014/09/10 职场文书
党的群众路线教育实践活动个人整改方案
2014/09/21 职场文书
领导党的群众路线教育实践活动个人对照检查材料
2014/09/23 职场文书
安全责任协议书范本
2016/03/23 职场文书