注: 文中所有涉及到的AngularJS源码均来自angular-1.2.8版本。
注2: 本文结构为一段代码配一段说明,代码在上,说明在下。

从3月3号到现在,进入新公司也有1个月了。过去这一个月里主要负责公司内部某个DevOps工具开发过程中的前端部分。由于涉及到较多的网页交互,该项目的前端部分使用了AngularJS这个框架。本人这正好借助这个机会进一步了解了AngularJS的相关知识。

这两天项目主体完成,准备上线,算是有了一些自由时间,正好借此机会学习一下AngularJS内部的实现机制。

本篇来说说最常见的directive之一 ngRepeat

该文件的整体结构如下:

var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) {
  var NG_REMOVED = '$$NG_REMOVED';
  var ngRepeatMinErr = minErr('ngRepeat');
  return {
    transclude: 'element',
    priority: 1000,
    terminal: true,
    $$tlb: true,
    link: function($scope, $element, $attr, ctrl, $transclude){
      .....
    }
  };

  function getBlockStart(block) {
    return block.clone[0];
  }

  function getBlockEnd(block) {
    return block.clone[block.clone.length - 1];
  }
}];

ngRepeatDirective是一个数组,它会被传递给directive方法用来生成ngRepeat这个directive。可以看到ngRepeat依赖于$parse$animate两个服务。$parse用来将字符串解析成javascript函数。$animate用来给DOM改变附加上动画效果。文件最后声明了两个helper方法,会在稍后的分析中介绍到。

接下来把最大的篇幅交给这个directive的核心——link方法。

var expression = $attr.ngRepeat;
var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/),
  trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn,
  lhs, rhs, valueIdentifier, keyIdentifier,
  hashFnLocals = {$id: hashKey};
if (!match) {
  throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.",
    expression);
}
lhs = match[1];
rhs = match[2];
trackByExp = match[3];

首先从directive元素的attribute中取出ng-repeat属性设置的字符串。接着会尝试用取到的字符串与一个正则表达式匹配,用来检查ng-repeat语句是否符合语法。如果语法正确,则将字符串拆分为3部分。lhs保存了循环中的临时变量的名字,rhs保存了被循环的collection的名字,trackByExp则保存了可选的track by字符串。

if (trackByExp) {
  trackByExpGetter = $parse(trackByExp);
  trackByIdExpFn = function(key, value, index) {
    // assign key, value, and $index to the locals so that they can be used in hash functions
    if (keyIdentifier) hashFnLocals[keyIdentifier] = key;
    hashFnLocals[valueIdentifier] = value;
    hashFnLocals.$index = index;
    return trackByExpGetter($scope, hashFnLocals);
  };
} else {
  trackByIdArrayFn = function(key, value) {
    return hashKey(value);
  };
  trackByIdObjFn = function(key) {
    return key;
  };
}

如果指定了trackByExp则根据该字符串构造一个id生成函数,否则为数组和哈希构造缺省的id生成函数。这些函数生成的id会被用来把每个item与页面中唯一的元素绑定。

match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);
if (!match) {
  throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.",
                                                            lhs);
}
valueIdentifier = match[3] || match[1];
keyIdentifier = match[2];

// Store a list of elements from previous run. This is a hash where key is the item from the
// iterator, and the value is objects with following properties.
// - scope: bound scope
// - element: previous element.
// - index: position
var lastBlockMap = {};

判断临时变量的格式,angular支持两种格式album in artist.albums用来循环一个数组,(key, value) in expression用来循环一个哈希。两种格式中都有表示将要作为临时变量值的valueIdentifier,而只有在第二种情况中有作为临时变量键的keyIdentifier

lastBlockMap是一个用来保存最近一次view更新完后各个循环item状态的hash。该hash的键是各个被循环的item,值则是一个保存了该item相应属性的对象:

$scope.$watchCollection(rhs, function ngRepeatAction(collection){
  var index, length,
      previousNode = $element[0], // current position of the node 初始值为angular创建的一个helper元素,在页面中表现为一段注释,例如<!--  ngRepeat: item in items -->
      nextNode,
      // Same as lastBlockMap but it has the current state. It will become the
      // lastBlockMap on the next iteration.
      nextBlockMap = {},
      arrayLength,
      childScope,
      key, value, // key/value of iteration
      trackById,
      trackByIdFn,
      collectionKeys,
      block, // last object information {scope, element, id}
      nextBlockOrder = [],
      elementsToRemove;

接着便是link方法最核心的部分——监听model变化并更新view。这里使用了$watchCollection方法监听我们指定的collection,并在每次collection更新时调用ngRepeatAction方法。

if (isArrayLike(collection)) {
  collectionKeys = collection;
  trackByIdFn = trackByIdExpFn || trackByIdArrayFn;
} else {
  trackByIdFn = trackByIdExpFn || trackByIdObjFn;
  // if object, extract keys, sort them and use to determine order of iteration over obj props
  collectionKeys = [];
  for (key in collection) {
    if (collection.hasOwnProperty(key) && key.charAt(0) != '$') {
      collectionKeys.push(key);
    }
  }
  collectionKeys.sort();
}

arrayLength = collectionKeys.length;

首先确定collectionKeystrackByIdFn。为将要被循环的集合中的每一个item创建一个key,保存在collectionKeys中。如果collection是数组,则key就是item自身;如果collection是对象,则每个键值对的键会被用作对于item的key(同时会对key排序,排序的结果就是collection中item在页面上显示的顺序)

arrayLength用来保存本次将要循环的item的数目。也就是此次更新后页面中显示的item的数目。

// locate existing items
length = nextBlockOrder.length = collectionKeys.length;
for(index = 0; index < length; index++) {
 key = (collection === collectionKeys) ? index : collectionKeys[index];
 value = collection[key];
 trackById = trackByIdFn(key, value, index);
 assertNotHasOwnProperty(trackById, '`track by` id');
 if(lastBlockMap.hasOwnProperty(trackById)) {
   block = lastBlockMap[trackById];
   delete lastBlockMap[trackById];
   nextBlockMap[trackById] = block;
   nextBlockOrder[index] = block;
 } else if (nextBlockMap.hasOwnProperty(trackById)) {
   // restore lastBlockMap
   forEach(nextBlockOrder, function(block) {
     if (block && block.scope) lastBlockMap[block.id] = block;
   });
   // This is a duplicate and we need to throw an error throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}",
   nextBlockOrder[index] = { id: trackById };
   nextBlockMap[trackById] = false;
 }
}

按照collectionKeys中保存的key依次取出要被循环处理的value。trackById是使用trackByIdFn计算出来的每个item唯一的标识,用来建立item与页面中元素间的关联。

如果lastBlockMap中有trackById这个属性,则说明该item在上次循环中已经存在,则将相应的属性/值设置到nextBlockMap对象中,同时在nextBlockOrder数组中保存顺序。
如果在lastBlockMap中找不到trackById但在nextBlockMap中找到了,则说明在collection中有两个item的trackById是相同的,这时会抛出异常,因为不可能两个item对应页面中的同一个element。
如果在两个map对象中都没有找到,则说明这个item是首次出现,那么则在nextBlockMap中将对应的值设置为false,表明没有scope与之对应。

// remove existing items
for (key in lastBlockMap) {
  // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn
  if (lastBlockMap.hasOwnProperty(key)) {
    block = lastBlockMap[key];
    elementsToRemove = getBlockElements(block.clone);
    $animate.leave(elementsToRemove);
    forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; }); // 标记该元素已经从DOM中移除
    block.scope.$destroy(); // 销毁对应的scope
  }
}

在经历前面一次检查后,现在还留在lastBlockMap中的item就是被从collection中移除的item。要做的就是将对应的element从DOM中移除并销毁item对应的scope。

// we are not using forEach for perf reasons (trying to avoid #call)
for (index = 0, length = collectionKeys.length; index < length; index++) {
  key = (collection === collectionKeys) ? index : collectionKeys[index];
  value = collection[key];
  block = nextBlockOrder[index];
  if (nextBlockOrder[index - 1]) previousNode = getBlockEnd(nextBlockOrder[index - 1]);

  if (block.scope) {
    // if we have already seen this object, then we need to reuse the
    // associated scope/element
    childScope = block.scope; // 如果是已经存在的item,则使用之前生成的scope

    nextNode = previousNode;
    do {
      nextNode = nextNode.nextSibling;
    } while(nextNode && nextNode[NG_REMOVED]); // 跳过被标记为已删除的元素

    if (getBlockStart(block) != nextNode) { // 如果当前block对应的元素并没有紧接在previousNode后边
      // existing item which got moved
      $animate.move(getBlockElements(block.clone), null, jqLite(previousNode)); // 那么就将元素移动到previousNode之后的位置
    }
    previousNode = getBlockEnd(block); // 同时设置previousNode为当前循环中的元素,为下一次循环做准备
  } else {
    // new item which we don't know about
    childScope = $scope.$new(); // 如果是新item,生成一个新scope
  }

最后一个循环用来处理已有item的DOM移动以及新item对应的DOM插入。在这个循环中previousNode代表了上一次循环item元素在DOM中的位置,angular会顺次将各个block插入到前一个block的后面(对于已经存在的元素则是移动)。

childScope[valueIdentifier] = value;
if (keyIdentifier) childScope[keyIdentifier] = key;
childScope.$index = index;
childScope.$first = (index === 0);
childScope.$last = (index === (arrayLength - 1));
childScope.$middle = !(childScope.$first || childScope.$last);
// jshint bitwise: false
childScope.$odd = !(childScope.$even = (index&1) === 0);
// jshint bitwise: true

接着设置了一些方便在directive中方便判断状态的变量。如当前元素是否是第一个/最后一个出现在页面上的item。方便用户为相应item元素做特殊处理。

  if (!block.scope) {
    $transclude(childScope, function(clone) {
      clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' ');
      $animate.enter(clone, null, jqLite(previousNode));
      previousNode = clone;
      block.scope = childScope;
      // Note: We only need the first/last node of the cloned nodes.
      // However, we need to keep the reference to the jqlite wrapper as it might be changed later
      // by a directive with templateUrl when it's template arrives.
      block.clone = clone;
      nextBlockMap[block.id] = block;
    });
  }
}
lastBlockMap = nextBlockMap;

对于新item,将新生成的scope应用到template上生成相应的元素,并插入DOM中。同时设置previousNode为新生成的元素,并将scope等相关信息保存至nextBlockMap数组中。

最后将nextBlockMap的值赋给lastBlockMap,结束本次循环。

以上就是对ngRepeat代码的简要分析,顺手画了一个简单说明ngRepeat工作方式的流程图。可以与上文的分析结合来看:

如果有任何问题,欢迎留言讨论。