深入理解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 相关文章推荐
DIV菜单层实现代码
Nov 19 Javascript
Jqgrid设置全选(选择)及获取选择行的值示例代码
Dec 28 Javascript
jquery跨域请求示例分享(jquery发送ajax请求)
Mar 25 Javascript
jquery实现简单实用的打分程序实例
Jul 23 Javascript
jquery获取复选框的值的简单实例
May 26 Javascript
jQuery EasyUI提交表单验证
Jul 19 Javascript
原生js 封装get ,post, delete 请求的实例
Aug 11 Javascript
JQuery常见节点操作实例分析
May 15 jQuery
基于iview的router常用控制方式
May 30 Javascript
ES6 async、await的基本使用方法示例
Jun 06 Javascript
在vs code 中如何创建一个自己的 Vue 模板代码
Nov 10 Javascript
关于angular 8.1使用过程中的一些记录
Nov 25 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插件 HTMLPurifier HTML解析器
2013/07/01 PHP
怎样搭建PHP开发环境
2015/07/28 PHP
PHP抽奖算法程序代码分享
2015/10/08 PHP
windows7配置Nginx+php+mysql的详细教程
2016/09/04 PHP
php基于session锁防止阻塞请求的方法分析
2017/08/07 PHP
PHP使用SOAP调用API操作示例
2018/12/25 PHP
在vs2010中调试javascript代码方法
2011/02/11 Javascript
JS实现的省份级联实例代码
2013/06/24 Javascript
jQuery实现html元素拖拽
2015/07/21 Javascript
jQuery旋转插件jqueryrotate用法详解
2016/10/13 Javascript
微信小程序(三):网络请求
2017/01/13 Javascript
vue 2.0组件与v-model详解
2017/03/27 Javascript
AngularJS1.X学习笔记2-数据绑定详解
2017/04/01 Javascript
详解如何使用Node.js编写命令工具——以vue-cli为例
2017/06/29 Javascript
使用Node.js实现RESTful API的示例
2017/08/01 Javascript
vue2.0移动端滑动事件vue-touch的实例代码
2018/11/27 Javascript
详解JavaScript中的链式调用
2020/11/27 Javascript
[02:52]2017DOTA2国际邀请赛中国区预选赛晋级之路
2017/07/03 DOTA
python判断字符串是否纯数字的方法
2014/11/19 Python
python如何实现远程控制电脑(结合微信)
2015/12/21 Python
解决python3中自定义wsgi函数,make_server函数报错的问题
2017/11/21 Python
微信跳一跳python代码实现
2018/01/05 Python
python装饰器简介---这一篇也许就够了(推荐)
2019/04/01 Python
Python时间序列处理之ARIMA模型的使用讲解
2019/04/02 Python
元组列表字典(莫烦python基础)
2019/04/03 Python
python程序如何进行保存
2020/07/03 Python
有关pycharm登录github时有的时候会报错connection reset的问题
2020/09/15 Python
全球知名的婚恋交友网站:Match.com
2017/01/05 全球购物
日本整理专家Marie Kondo的官方在线商店:KonMari
2020/06/29 全球购物
房地产促销活动方案
2014/03/01 职场文书
2014县政府领导班子对照检查材料思想汇报
2014/09/25 职场文书
今日说法观后感
2015/06/08 职场文书
法定代表人资格证明书
2015/06/18 职场文书
2016年校长新年寄语
2015/08/17 职场文书
大队委员竞选演讲稿
2015/11/20 职场文书
什么是执行力?9个故事告诉您:成功绝非偶然!
2019/07/05 职场文书