Re-creating ng-repeat

There's rather a shroud of mystery when it comes to ng components in Anguar. They're accessible right out of the box and there's little reason to question why or how it works. And at the end of the day it's just javascript with a bunch of methods attached to an object and the ability to manipulate the DOM using attributes during a compilation phase is relatively foreign and it's easy to say...it just works because it just does.

In actuality, ng components should be familiar because they're constructed using directives, something every angular developer will have encountered at one point or another.

So, for this post, we'll be going over how to create my-repeat, which is simply a stripped down version of ng-repeat and we'll be utilizing as much of the source code as possible. my-repeat will not have the versatility of ng-repeat but hopefully it can serve as a good starting point for supplementing your understanding of directives or making greater optimizations such as recreating collection-repeat.

Directly below are all the components needed to implement my-repeat and is the same code as the codepen and below that I'll be breaking down the directive.

The markup

<div class="button-bar">  
  <a class="button button-royal" my-repeat="item in items">{{ item.text }}</a>
</div>  


The controller

.controller('MainCtrl', function($scope) {

  $scope.items = [{
      text: 'one'
    },{
      text: 'two'
    },{
      text: 'three'
    },{
      text: 'four'
    },{
      text: 'five'
  }];

})


The directive

.directive('myRepeat', [ '$animate', function($animate) {

  var updateScope = function(scope, index, valueIdentifier, value, key, arrayLength) {
    scope[valueIdentifier] = value;
    scope.$index = index;
    scope.$first = (index === 0);
    scope.$last = (index === (arrayLength - 1));
    scope.$middle = !(scope.$first || scope.$last);
    scope.$odd = !(scope.$even = (index&1) === 0);
  };

  return {
    restrict: 'A',
    transclude: 'element',
    compile: function($element, $attrs) {

      var expression = $attrs.myRepeat;

      var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)?\s*$/);

      var valueIdentifier = match[1];
      var collection = match[2];

      return function($scope, $element, $attr, ctrl, $transclude) {

        $scope.$watchCollection(collection, function(collection) {
          var index, length,
            previousNode = $element[0],
            collectionLength,
            key, value;

            collectionLength = collection.length;

            for (index = 0; index < collectionLength; index++) {
              key = index;
              value = collection[key];

              $transclude(function(clone, scope) {
                $animate.enter(clone, null, angular.element(previousNode));
                previousNode = clone;
                updateScope(scope, index, valueIdentifier, value, key, collectionLength);
              });

            }
        });

      }
    }
  }

}]);

The break down

  1. Directive Creation
  2. Helper Functions
  3. Directive Definition Object
  4. Compile Function
  5. Link Function


1. Directive Creation

First, we define the directive as myRepeat in camelCase which is then referred as a dash-delimited attribute, my-repeat, in the HTML.

We use explicit dependency injection, which avoids the problems associated with minification, to declare $animate as a dependency. Alternatively, it could have been written using the implicit dependency injection and then annotated using gulp-ng-annotate piped via a minification gulp task.

.directive('myRepeat', [ '$animate', function($animate) {
    ...
}]);


2. Helper Functions

Each template receives its own scope and the function expression, updateScope is a utility that assigns a set of properties to each item to allow greater customization of the repeat cycle. updateScope is placed later on within a $watchCollection and is executed on every item of the collection upon compilation. There should be no wonder as to why this is included, you'd fully expect to have access to such things as the $index in an iterator and ng-repeat is no different.

.directive('myRepeat', [ '$animate', function($animate) {
    var updateScope = function(scope, index, valueIdentifier, value, key, arrayLength) {
        scope[valueIdentifier] = value;
        scope.$index = index;
        scope.$first = (index === 0);
        scope.$last = (index === (arrayLength - 1));
        scope.$middle = !(scope.$first || scope.$last);
        scope.$odd = !(scope.$even = (index&1) === 0);
  };

    ... // Return Directive Definition Object

}]);
Variable Type Details
`$index` Number iterator offset of the repeated element (0..length-1)
`$first` Boolean true if the repeated element is first in the iterator.
`$last` Boolean true if the repeated element is last in the iterator.
`$middle` Boolean true if the repeated element is between the first and last in the iterator.
`$odd` Boolean true if the iterator position `$index` is odd (otherwise false).


3. Directive Definition Object

We're going to restrict my-repeat to attributes only and set transclude to element so that the entirety of the element including its children are transcluded after compilation and linking. An alternative to such would be to set translude to true; however, this would translude only the children of the directive and not the parent element as well. If you have a Google Stack Overflow degree, you might have already found this thread on the subject.

Since we are transforming the DOM, we'll be making use of the compile function which accepts two arguments, the element itself and it's attributes. This function used to accept a third argument, the transclude function, but it is now recommended to use the 4th parameter of the link function instead.

.directive('myRepeat', [ '$animate', function($animate) {
    ... // Helper functions
    return {
        restrict: 'A',
        transclude: 'element',
        compile: function($element, $attrs) {
            ... // Compile logic
        }
    }
}]);


4. Compile Function

Once nside our compile function, we'll need to get the contents of the myRepeat attribute which in this case will equal item in items. We need to tell our directive how to interpret this information and so a RegEx is used to match the value in expression format of the expression into two key pieces of information: the value identifer and the collection.

.directive('myRepeat', [ '$animate', function($animate) {
    ... // Helper functions
    return {
        restrict: 'A',
        transclude: 'element',
        compile: function($element, $attrs) {
            var expression = $attrs.myRepeat;
            var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)?\s*$/);

            var valueIdentifier = match[1];
            var collection = match[2];

            ... // Link function
        }
}]);


5. Link Function

From the the compile function, we must then return the link function, which is home to our directive logic. We'll be requesting several arguments from our link function but the most notable is the $transclude function which is a special function that returns the compiled contents with a brand new scope.

To be more specific, the $transclude returns a jqLite object and additionally, a clone attach function may be passed in which is described in the docs as:

This function accepts two parameters, function(clone, scope) { ... }, where the clone is a fresh compiled copy of your transcluded content and the scope is the newly created transclusion scope, to which the clone is bound.

Creating a copy of our transcluded content with a new scope is particularly useful because we're using scope.$watchCollection to monitor the items in our array/object and whenever it detects a change, it will fire a listener callback which will it turn loop through all the elements in the array/object and invoke $transclude so that the rendered content is accurately up to date.

It's also important to note that $transclude only returns the compiled content and a brand new scope, it does not render it to the DOM. For that, we use the $animate service and specifically a DOM manipulation method called $animate.enter to insert the element into the DOM. The first parameter is the element to be inserted, the second is the parent which the child will be appended to, and the third is the sibling element that the element will be appended after.

The third parameter, angular.element(previousNode), is important for re-constructing ng-repeat in this case because we want to ensure that the element is only appended after the previous one. Without it, the elements would actually be rendered in reverse order.

.directive('myRepeat', [ '$animate', function($animate) {
    ... // Helper functions
    ... // Directive Definition Object
    compile: function($element, $attrs) {
        return function($scope, $element, $attr, ctrl, $transclude) {
            $scope.$watchCollection(collection, function(collection) {
                var index, length,
                    previousNode = $element[0],
                    collectionLength,
                    key, value;

                collectionLength = collection.length;

                for (index = 0; index < collectionLength; index++) {
                    key = index;
                    value = collection[key];
                });

                $transclude(function(clone, scope) {
                    $animate.enter(clone, null, angular.element(previousNode));
                    previousNode = clone;
                    updateScope(scope, index, valueIdentifier, value, key, collectionLength);
                }
            });
        }
    });
}]);

Conclusion

That's it! This is a simple rendition of how to achieve my-repeat, a repeat directive with a smaller subset of optimizations as compared to ng-repeat. This tutorial was created with the intention of defogging the mystery behind ng-repeat and hopefully it has been deconstructed enough that you can now mentally break it down further to achieve even greater optimizations.

You could add $$hashId to every item and $transclude only when it is a new or modified item. Or you could implement track by tracking_expression in the directive attribute. Or go a different route and implement collection-repeat, an optimization found only in Ionic.

And if you get stuck, remember, the answer is in the source code...