Sunday, February 9, 2014

Use Zone to Trigger Angular Digest Loop for External Functions

To continue my series on the power of Zone, I examine yet another powerful and useful way you can use zones to improve your application. If this is your first time learning about Zone, read my introduction to Zone titled Taming Asynchronous Tasks in JavaScript with Zone.js. Anyone familiar with Angular apps has run into the concept of the $digest loop. This is essentially a pass to update data-binding. When the model is mutated, which can happen a number of ways, anything observing the model is notified. The watchers may also mutate the model further which results in a recursive call until either the changes settle or the maximum recursion is reached.

The problem with this approach is that Angular is only aware of changes that happen within the loop. Directives that ship with Angular are automatically called within the loop so their changes are propagated. It is not uncommon to introduce a third-party library that may mutate the model somehow. When this happens, Angular is not aware of the changes because it happens outside of the loop. When you are able to intercept the update yourself, you can make it inside of a call to $apply which will notify Angular of the change. However, you don’t always have the luxury of intercepting third-party modules.

To demonstrate how Zone can help, I created a simple (contrived) scenario. Assume you have a simple clock you wish to display:

<div ng-app="myApp" ng-controller="myController">
    {{timer.time | date:'HH:mm:ss'}}
</div>

The timer, however, comes from a third party control. It exposes a timer object and runs on a timer but you can only kick it off – you have no control of the actual code that makes the updates. Again, to keep it simple, assume this amazing control works like this (remember, you only have access to the externalTimeObj, and something else kicks it off).

var externalTimeObj = {
    time: new Date()
};
setInterval(function() {
    externalTimeObj.time = new Date();
}, 1000);

In Angular, you can capture the timer and place it on your scope:

var app = angular.module("myApp", []);
app.value("timerObj", externalTimeObj);
app.controller("myController", function($scope, timerObj) {
    $scope.timer = timerObj;
});

But the problem is you don’t see any updates (here’s the proof). Even though the timer object is updating, it happens outside of the digest loop so Angular isn’t aware. What’s worse is that the only way you can update your timer to call $apply is to get in and modify the source code which isn’t ever a great idea. If only there was a way to create an execution context that would automatically update the digest loop when done. Then you could call the initialization methods for the third-party timer control from within that execution context and capture the updates. Wait, there is! We have Zone.

First, create a zone that is dedicated to initiating the digest loop when it’s work is finished. OK, don’t, because I’ve already done it and it looks like this:

var digestCapture = null;
 
var digestZone = (function () {
    return {
        digest: function() { },
        onZoneEnter: function () {
            if (digestCapture) {
                zone.digest = digestCapture;
                zone.onZoneEnter = function() {};
            }
        },
        onZoneLeave: function () {
            zone.digest();
        }
    };
}());

The digestCapture variable is necessary to allow Angular to initialize the call into the digest loop. If we bootstrap Angular in this zone we’ll create too much overhead because Angular methods that already execute in the digest loop will trigger a redundant call when the task is complete. Notice how once the digest call is captured, the check for it is removed by replacing the onZoneEnter function with a no-op.

There is no need to change any of the Angular code (that’s why I love this approach – open to extensibility, closed to change). Simply add the code to capture the digest function:

app.run(function($rootScope){
    digestCapture = function() {
        $rootScope.$digest();
    };
});

There is also no need to change the third-party control. Instead, we just move our third-party control initialization into the zone (again, we’re using the interval here but this could be any call into the third-party API as once it happens in a zone it is captured for that zone).

zone.fork(digestZone).run(function() {
    setInterval(function() {
        externalTimeObj.time = new Date();
    }, 1000);
});

That’s it! Now we’ve got a way to initialize our third-party controls and ensure any time they return from an asynchronous task that we are able to notify Angular our model has mutated by running the digest loop. See the code running yourself.

No comments:

Post a Comment