深入理解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 相关文章推荐
ExtJS 2.0实用简明教程 之Border区域布局
Apr 29 Javascript
javascript实现动态模态绑定grid过程代码
Sep 22 Javascript
javascript 自定义回调函数示例代码
Sep 26 Javascript
jQuery实现模拟marquee标签效果
Jul 14 Javascript
基于JavaScript代码实现自动生成表格
Jun 15 Javascript
JS判断来路是否是百度等搜索索引进行弹窗或自动跳转的实现代码
Oct 09 Javascript
原生js仿淘宝网商品放大镜效果
Feb 28 Javascript
详谈表单格式化插件jquery.serializeJSON
Jun 23 jQuery
详解Vue-cli webpack移动端自动化构建rem问题
Apr 07 Javascript
vue-cli3+typescript初体验小结
Feb 28 Javascript
vuex实现及简略解析(小结)
Mar 01 Javascript
vue实现几秒后跳转新页面代码
Sep 09 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
php微信公众号开发之校园图书馆
2018/10/20 PHP
javascript 表单的友好用户体现
2009/01/07 Javascript
javascript获得服务器端控件的ID的实现代码
2011/12/28 Javascript
Google Dart编程语法和基本类型学习教程
2013/11/27 Javascript
基于iframe实现类似于ajax的页面无刷新
2014/05/31 Javascript
JS实现简单的顶部定时关闭层效果
2014/06/15 Javascript
修改js confirm alert 提示框文字的简单实例
2016/06/10 Javascript
AngularJS基础 ng-csp 指令详解
2016/08/01 Javascript
详解Angular 自定义结构指令
2017/06/21 Javascript
详解a++和++a的区别
2017/08/30 Javascript
基于javascript中的typeof和类型判断(详解)
2017/10/27 Javascript
vue组件中使用iframe元素的示例代码
2017/12/13 Javascript
Vue-cli@3.0 插件系统简析
2018/09/05 Javascript
vue 使用axios 数据请求第三方插件的使用教程详解
2019/07/05 Javascript
Vue项目中使用WebUploader实现文件上传的方法
2019/07/21 Javascript
JS中自定义事件的使用与触发操作实例分析
2019/11/01 Javascript
linux服务器快速卸载安装node环境(简单上手)
2021/02/22 Javascript
[02:55]DOTA2英雄基础教程 发条技师
2013/12/04 DOTA
[02:09]DOTA2辉夜杯 EHOME夺冠举杯现场
2015/12/28 DOTA
python利用hook技术破解https的实例代码
2013/03/25 Python
python中pandas.DataFrame的简单操作方法(创建、索引、增添与删除)
2017/03/12 Python
Python日期时间对象转换为字符串的实例
2018/06/22 Python
postman模拟访问具有Session的post请求方法
2019/07/15 Python
Python Dict找出value大于某值或key大于某值的所有项方式
2020/06/05 Python
pandas to_excel 添加颜色操作
2020/07/14 Python
浅谈基于HTML5的在线视频播放方案
2016/02/18 HTML / CSS
Merrell美国官网:美国登山运动鞋品牌
2018/02/07 全球购物
struct与class的区别
2014/02/03 面试题
解释下面关于J2EE的名词
2013/11/15 面试题
司机的工作范围及职责
2013/11/13 职场文书
大学生的网络创业计划书
2013/12/26 职场文书
查环查孕证明
2014/01/10 职场文书
第二批党的群众路线教育实践活动个人整改方案
2014/10/31 职场文书
歼十出击观后感
2015/06/11 职场文书
环保主题班会教案
2015/08/13 职场文书
教师学习中国梦心得体会
2016/01/05 职场文书