• 中文
  • ENGLISH
从Angular源码看scope(二)
2016/04/01

前言

之前我们探讨过《Angular的执行流程》,在一切准备工作就绪后(我是指所有directive和service都装载完毕),接下来其实就是编译dom(从指定的根节点开始遍历dom树),通过dom节点的元素名(E),属性名(A),class值(C)甚至注释(M)匹配指令,进而完成指令的compile,preLink,postLink,这期间就有可能伴随着作用域的创建和继承(有些指令通过scope字段要求创建自己的(孤立)作用域),从而形成一个作用域(scope)的继承关系。

下面的代码:

  1. 调用compile(element)(scope);开始编译dom树,传递的element是应用的根节点(有ng-app属性的节点或者手动bootstrap(element,...)的节点),而传递的scope则是唯一的根作用域(实质上是$RootScopeProvider服务返回的一个单例),与根节点对应。
  2. 最后通过scope.$apply(..)进行digest进行脏检查,开始一些初始化工作。
injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector',
   function bootstrapApply(scope, element, compile, injector) {
    scope.$apply(function() {
      element.data('$injector', injector);
      compile(element)(scope);
    });
  }]
);

Scope

接下来我们讲的内容都是围绕rootScope.js,对于Scope的实现和一些概念,大家可以先参考这篇文章构建自己的AngularJS,第一部分:Scope和Digest,建议看原文。

Scope类

前面提到了根作用域$rootScope,其实就是Scope类的一个实例,我们通过简单的依赖注入的方式就可以获取到它,像这样:

var injector = angular.injector(['ng']);

injector.invoke(['$rootScope', function (scope) {
    console.log(scope);
}]);

从控制台中可以很清晰地看到$rootScope对象的全部属性和方法,所以我们直接看下Scope类的定义来进行下对照:

function Scope() {
  // 省略属性定义
}

Scope.prototype = {
  constructor: Scope,

  $new: function(isolate) {...},

  $watch: function(watchExp, listener, objectEquality) {...},

  $watchGroup: function(watchExpressions, listener) {...},

  $watchCollection: function(obj, listener) {...},

  $digest: function() {...},

  $destroy: function() {...},

  $eval: function(expr, locals) {...},

  $evalAsync: function(expr) {...},

  $apply: function(expr) {...},

  $applyAsync: function(expr) {...},

  $on: function(name, listener) {...},

  $emit: function(name, args) {...},

  $broadcast: function(name, args) {...}
};
  1. 从原型方法中,可以看到我们熟悉的$watch$apply$digest方法,以及处理自定义事件(消息传递)的$on, $emit$broadcaset方法,这些我们稍后会讲到。
  2. 而由Scope new出来的实例就是一个简单的object,没有任何的getter和setter,我们可以很方便的直接向里面添加修改任何自定义属性,像这样:scope.hello='world';
scope作用域树

为什么会说成作用域树?我们其实知道作用域之间是通过原型链继承的,又或者是没有任何继承关系的孤立作用域单独存在的。

带着这样的疑问,首先我们假设有以下的dom结构:
1. 节点A为根节点
2. 每个节点都有指令,且指令都会创建自己的(孤立)作用域
3. 节点E和节点F创建的是孤立作用域

<A>
  <B>
    <F></F>
  </B>
  <C>
    <D></D>
  </C>
  <E></E>
</A>

对照这样的dom结构和假设,我们可以画出这样的一张图(原图):

scope树

从这张图里面我们可以看出的不仅是作用域的继承关系还有作用域之间及父子兄弟关系:

  1. 普通的作用域通过原型链实现了继承关系,孤立作用域没有任何继承关系。
  2. 所有的作用域之间(也包括孤立作用域)根据自身所处的位置都存在以下这些关系:
    • $root来访问跟作用域
    • $parent来访问父作用域
    • $childHead$childTail)访问头(尾)子作用域
    • prevSibling$nextSibling)访问前(后)一个兄弟作用域

    这样的关系便形成了一个作用域树,通过它便可以完成作用域的向上(下)的遍历,从而实现后面的消息传递,$emit(向上冒泡),broadcast(向下广播)

  3. 所有的作用域都引用同一个$$asyncQueue$$postDigestQueue
$new方法构建作用域

上面这张图能够画出来都归功于自$new这个方法的实现。

代码其实很简单,就是返回一个(child)Scope的实例:

$new: function(isolate) {
  var child;

  // isolate参数用来作为是否创建孤立作用域的标志
  if (isolate) {
    child = new Scope();
    child.$root = this.$root;

    // 保持$$asyncQueue和$$postDigestQueue的唯一性
    child.$$asyncQueue = this.$$asyncQueue;
    child.$$postDigestQueue = this.$$postDigestQueue;
  } else {
    // 实现原型继承
    // $ChildScope构造器只在第一次调用$new方法时才会被创建
    if (!this.$$ChildScope) {
      this.$$ChildScope = function ChildScope() {
        this.$$watchers = this.$$nextSibling =
          this.$$childHead = this.$$childTail = null;
        this.$$listeners = {};
        this.$$listenerCount = {};
        this.$id = nextUid();
        this.$$ChildScope = null;
      };
      this.$$ChildScope.prototype = this;
    }
    child = new this.$$ChildScope();
  }

  // 维护作用域之间的父子兄弟关系
  child['this'] = child;
  child.$parent = this;
  child.$$prevSibling = this.$$childTail;
  if (this.$$childHead) {
    this.$$childTail.$$nextSibling = child;
    this.$$childTail = child;
  } else {
    this.$$childHead = this.$$childTail = child;
  }
  return child;
}
$watch方法监听作用域变化

我们在controller或者directive的link方法中经常会使用$watch方法,来监听当作用域的某个值发生变化时,采取什么样的操作。

我们可以在控制台里写一个例子(利用$rootScope),像这样:

var injector = angular.injector(['ng']);

injector.invoke(['$rootScope', function (scope) {
    // 获取scope对象到全局
    window.rootScope = scope;
}]);

rootScope.a = 'hello';

// 监听scope.a的值
rootScope.$watch('a', function (newVal, oldVal) {
    console.log(arguments)
});

// 程序初始化时digest
rootScope.$digest();

//修改scope.a的值,并进行digest脏检查
rootScope.$apply(function (scope) {
    scope.a = 'world';
});

看到控制台下面的日志信息如下:

["hello", "hello", Scope] // 初始化digest,触发回调,newVal和oldVal一样
["world", "hello", Scope] // 修改scope.a的值后,触发回调,newVal和oldVal不一样

所以我们经常会有这样的代码来区别第一次初始化和值改变:

rootScope.$watch('a', function (newVal, oldVal) {
    if (newVal !== oldVal) {
        console.log('change');
    }
});

从上面的代码,便可以看出我们使用$watch方法注册监听函数来响应当作用域中某个变量发生变化时的操作,利用$apply或者$digest方法来触发监听函数的执行。

所以$watch函数所做的工作其实就是作用域中变量和关联的监听函数的存储,

看看代码:

$watch: function(watchExp, listener, objectEquality) {

  // 参数objectEquality进行严格比较,像object,array这种进行非引用比较而是递归值比较

  // 利用$parse服务转换成函数,用于获取作用域里的变量值
  var get = $parse(watchExp);

  if (get.$$watchDelegate) {
    return get.$$watchDelegate(this, listener, objectEquality, get);
  }

  // watcher对象是存储的元单位
  // watch.fn 存储监听函数
  // watch.last 记录变量改变之前的值
  // watch.eq 是否进行严格匹配
  var scope = this,
    array = scope.$$watchers,
    watcher = {
      fn: listener,
      last: initWatchVal,
      get: get,
      exp: watchExp,
      eq: !!objectEquality
    };

  lastDirtyWatch = null;

  if (!isFunction(listener)) {
    watcher.fn = noop;
  }

  // 第一次初始化$$watchers为数组
  if (!array) {
    array = scope.$$watchers = [];
  }

  // 存储数据
  array.unshift(watcher);

  // 返回函数,可用于取解除该监听
  return function deregisterWatch() {
    arrayRemove(array, watcher);
    lastDirtyWatch = null;
  };
}
$digest方法进行脏检查

之前我们用$watch方法,存储了监听函数,当作用域里的变量发生变化时,调用$digest方法便会执行该作用域以及它的所有子作用域上的相关的监听函数,从而做一些操作(如:改变view)

不过一般情况下,我们不需要手动调用$digest或者$apply(如果一定需要手动调用的话,我们通常使用$apply,因为它里面除了调用$digest还做了异常处理),因为内置的directive和controller内部(即Angular Context之内)都已经做了$apply操作,只有在Angular Context之外的情况需要手动触发$digest,如: 使用setTimout修改scope(这种情况我们除了手动调用$digest,更推荐使用$timeout服务,因为它内部会帮我们调用$apply)。

举个controller的例子:

angular.module('myApp',[])
  .controller('MessageController', function($scope) {
    setTimeout(function() {
      $scope.message = 'Fetched after 2 seconds'; 
      //$scope.$apply(function() {
      //  $scope.message = 'Fetched after 2 seconds'; 
      //});
    }, 2000);
  });

正确的方式是注释掉的那一段(用$apply包裹),否则视图(如:{{message}})将不会得到更新。

看一下源代码(这里精简成最核心的代码片段):

$digest: function() {
  // ...省略若干代码

  // 外层循环至少执行一次
  // 如果scope中被监听的变量一直有改变(dirty为true),那么外层循环会一直下去(TTL减1),这是为了防止监听函数有可能改变scope的情况,
  // 另外考虑到性能问题,如果TTL从默认值10减为0时,则会抛出异常
  do {
    dirty = false;
    current = target;

    /// 执行异步操作evalAsync
    while (asyncQueue.length) {
      try {
        asyncTask = asyncQueue.shift();
        asyncTask.scope.$eval(asyncTask.expression);
      } catch (e) {
        $exceptionHandler(e);
      }
      lastDirtyWatch = null;
    }

    // 标签语句。用于随时跳出该循环
    // 该循环遍历当前作用域以及它的子作用域,并执行监听函数
    traverseScopesLoop: do {
      if ((watchers = current.$$watchers)) {

        length = watchers.length;

        //  遍历监听函数
        while (length--) {
          try {
            watch = watchers[length];

            if (watch) {
              // 进行值比较或者严格的递归比较,这里考虑到一个特殊情况NaN不等于自身的情况
              if ((value = watch.get(current)) !== (last = watch.last) &&
                !(watch.eq ? equals(value, last) : (typeof value === 'number' && typeof last === 'number' && isNaN(value) && isNaN(last)))) {
                dirty = true;  // 标记为dirty
                lastDirtyWatch = watch; // 保存最后一个dirty的watch,用于下面的判断watch === lastDirtyWatch
                watch.last = watch.eq ? copy(value, null) : value; // 保存被监听变量上一次的值
                watch.fn(value, ((last === initWatchVal) ? value : last), current); // 执行监听函数
                if (ttl < 5) {
                  logIdx = 4 - ttl;
                  if (!watchLog[logIdx]) watchLog[logIdx] = [];
                  logMsg = (isFunction(watch.exp)) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp;
                  logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
                  watchLog[logIdx].push(logMsg);
                }
              } else if (watch === lastDirtyWatch) {
                // 这里是一个性能优化的地方,利用上一次循环记录的lastDirtyWatch,如果当前watch与它相等,表示后面的watch所监听的变量
                // 都不会再变化了,所以直接标记dirty为false,并跳出循环
                dirty = false;
                break traverseScopesLoop;
              }
            }
          } catch (e) {
            $exceptionHandler(e);
          }
        }
      }

      // 遍历该作用域下面的所有子作用域(貌似是按照深度优先)
      if (!(next = (current.$$childHead ||
        (current !== target && current.$$nextSibling)))) {
        while (current !== target && !(next = current.$$nextSibling)) {
          current = current.$parent;
        }
      }
    } while ((current = next));

    // traverseScopesLoop循环被跳出的位置

    // 检测是否循环次数超过了TTL的限制
    if ((dirty || asyncQueue.length) && !(ttl--)) {
      clearPhase();
      throw $rootScopeMinErr('infdig',
        '{0} $digest() iterations reached. Aborting!\n' +
        'Watchers fired in the last 5 iterations: {1}',
        TTL, toJson(watchLog));
    }

  } while (dirty || asyncQueue.length);

  clearPhase();

  // ...省略若干代码
}

另外:$RootScopeProvider中提供了digestTtl方法,用于修改TTL的值(默认是10),可以这样修改:

angular.module('ng').config(['$rootScopeProvider', function ($RootScopeProvider) {
  $RootScopeProvider.digestTtl(20);
}]);

作用域事件(消息)机制

Angular在scope上通过$on$emit$broadcast方法实现了自定义事件(消息)机制,这是代码解耦,实现数据共享的神器。

区别于Backbone.Events,这里的事件(消息)传递和接收针对的不是单一对象,而是多个对象(作用域树)

$emit 传递消息是从当前scope对象开始,通过scope.$parent 将消息向上冒泡一直传递到rootScope对象

broadcast 传递消息也是从当前scope对象开始,通过复杂的作用域之间的关系,将消息向下广播到所有的childScope对象(貌似是深度优先的顺序)

另外,在消息传递并执行监听函数时,会有一个event对象会被作为参数传递给监听函数,里面有我们关心的几个字段:

{
    name: 'xxx', // 消息名
    targetScope: scope,  // 触发改事件的目标(起始)作用域
    currentScope: scope, // 正在执行监听函数的当前作用域
    stopPropagation: fn  // 阻止冒泡(只在emit时存在)
    ...
}

一个简单的例子:

html

<div ng-controller="ParentCtrl as parent" class="ng-scope">
    ParentCtrl
  <div ng-controller="SiblingOneCtrl as sib1" class="ng-scope">
      SiblingOneCtrl
  </div>
  <div ng-controller="SiblingTwoCtrl as sib2" class="ng-scope">
      SiblingTwoCtrl
    <div ng-controller="ChildCtrl as child" class="ng-scope">
        ChildCtrl
    </div>
  </div>
</div>

js

app.controller('ParentCtrl', function ($scope) {

  $scope.$on('ChildCtrl:emit', function () {
    console.log('ParentCtrl: ', arguments);
  });

  $scope.$on('ParentCtrl:broadcast', function () {
    console.log('ParentCtrl:', arguments);
  });

  // 延迟执行
  $scope.$evalAsync(function () {
    // 向下广播传递消息
    $scope.$broadcast('ParentCtrl:broadcast', 'Broadcast!');
  });
});

app.controller('SiblingOneCtrl', function ($scope) {

  $scope.$on('ChildCtrl:emit', function () {
    console.log('SiblingOneCtrl:', arguments);
  });

  $scope.$on('ParentCtrl:broadcast', function () {
    console.log('SiblingOneCtrl:', arguments);
  });
});

app.controller('SiblingTwoCtrl', function ($scope) {

  $scope.$on('ChildCtrl:emit', function () {
    console.log('SiblingTwoCtrl:', arguments);
  });

  $scope.$on('ParentCtrl:broadcast', function () {
    console.log('SiblingTwoCtrl:', arguments);
  });
});

app.controller('ChildCtrl', function ($scope) {

  $scope.$on('ChildCtrl:emit', function () {
    console.log('ChildCtrl:', arguments);
  });

  $scope.$on('ParentCtrl:broadcast', function () {
    console.log('ChildCtrl:', arguments);
  });

  // 向上冒泡传递消息
  $scope.$emit('ChildCtrl:emit', 'Emit!');
});

控制台里我们可以看到以下日志(Object为event对象):

ChildCtrl: [Object, "Emit!"]
SiblingTwoCtrl: [Object, "Emit!"]
ParentCtrl:  [Object, "Emit!"]
ParentCtrl: [Object, "Broadcast!"]
SiblingOneCtrl: [Object, "Broadcast!"]
SiblingTwoCtrl: [Object, "Broadcast!"]
ChildCtrl: [Object, "Broadcast!"]

消息传递路径:

emit: ChildCtrl -> SiblingTwoCtrl -> ParentCtrl

broadcast: ParentCtrl -> SiblingOneCtrl -> SiblingTwoCtrl -> ChildCtrl
$on方法注册自定义事件(消息)

实现很易懂,就是将消息名和监听函数一一对应地存储在scope.$$listeners对象里,这里唯一的亮点在于scope.$$listenerCount的维护和用途。

当一个子作用域注册新的自定义事件时,它自身和它所有祖先作用域的scope.$$listenerCount都会加1,而当事件被取消时,该作用域和它所有祖先作用域的scope.$$listenerCount也会减1,这是一个性能优化点,当进行scope.broadcast传递消息(深度优先遍历)时,就无需遍历到每一个叶子作用域(即叶子节点),所以说scope.$$listenerCount不是指该作用域上该事件(消息)名有多少个监听函数。

$on: function(name, listener) {
  var namedListeners = this.$$listeners[name];
  if (!namedListeners) {
    this.$$listeners[name] = namedListeners = [];
  }

  // 存储监听函数
  namedListeners.push(listener);

  var current = this;

 // 维护$$listenerCount,用于提高broadcast的性能
  do {
    if (!current.$$listenerCount[name]) {
      current.$$listenerCount[name] = 0;
    }
    current.$$listenerCount[name]++;
  } while ((current = current.$parent));

  var self = this;

  // 返回函数,用来取消监听函数
  return function() {
    namedListeners[namedListeners.indexOf(listener)] = null;
    decrementListenerCount(self, 1, name);
  };
}
$emit向上冒泡传递事件(消息)

通过作用域关系scope.$parent不断向父作用域传递消息,达到冒泡的效果,既然是冒泡,当然就有阻止冒泡的方法,angular在会传递给监听函数一个event对象,可以通过event.stopPropagation方法来做到这一点。

$emit: function(name, args) {
  var empty = [],
    namedListeners,
    scope = this,
    stopPropagation = false,
    event = { // 传递给监听函数的event对象
      name: name,
      targetScope: scope,   // 目标作用域,类似于jquery中的event.target
      stopPropagation: function() {
        stopPropagation = true;
      },
      preventDefault: function() {
        event.defaultPrevented = true;
      },
      defaultPrevented: false
    },
    listenerArgs = concat([event], arguments, 1),// 传递给监听函数的参数
    i, length;


  do { // 循环处理作用域上的监听函数
    namedListeners = scope.$$listeners[name] || empty;
    event.currentScope = scope; // 当前作用域,类似于jquery中的event.currentTarget
    for (i = 0, length = namedListeners.length; i < length; i++) {

      // 事件的取消,之前只是置为null,这里借此次循环做下清除处理工作
      if (!namedListeners[i]) {
        namedListeners.splice(i, 1);
        i--;
        length--;
        continue;
      }
      try {
        // 执行回调
        namedListeners[i].apply(null, listenerArgs);
      } catch (e) {
        $exceptionHandler(e);
      }
    }

    // 阻止冒泡
    if (stopPropagation) {
      event.currentScope = null;
      return event;
    }

    // 向上访问父作用域
    scope = scope.$parent;
  } while (scope);

  // 处理完监听函数后,去除作用域引用
  event.currentScope = null;

  return event;
}
$broadcast向下广播传递事件(消息)

$emit一样需要向其他作用域传递消息,这里的传递的目标作用域不再是$parent,而是所有的子作用域,避免深层次的循环嵌套,采用深度优先算法遍历作用域树,从而达到广播的效果,这里只看下核心代码:

$broadcast: function(name, args) {
  // ... 省略定义代码

  // 循环遍历所有子作用域
  while ((current = next)) {
    event.currentScope = current;
    listeners = current.$$listeners[name] || [];
    for (i = 0, length = listeners.length; i < length; i++) {

      // 依旧是清理工作
      if (!listeners[i]) {
        listeners.splice(i, 1);
        i--;
        length--;
        continue;
      }

      // 监听函数调用
      try {
        listeners[i].apply(null, listenerArgs);
      } catch (e) {
        $exceptionHandler(e);
      }
    }

    // 核心代码
    // 这里实现了深度优先遍历,利用了作用域树的父子兄弟关系,其中利用$$listenerCount做了性能优化(前面说到)
    if (!(next = ((current.$$listenerCount[name] && current.$$childHead) ||
      (current !== target && current.$$nextSibling)))) {
      while (current !== target && !(next = current.$$nextSibling)) {
        current = current.$parent;
      }
    }
  }

  event.currentScope = null;
  return event;
}

最后

差不多以上就是笔者对angular scope源码的一些理解,如果不对的地方,欢迎留言指正,新浪微博 – Lovesueee

订阅我们