邹明潮 Angular Scope

邹明潮
KevinZou
Published in
7 min readNov 15, 2016

Scope

Scope is one of fundamental building blocks in the Angular1.x framework. Its main purposes are the followings: sharing data, broadcasting and listening events, and watching for changes in data. Poking around the source code of the scope assists me in having a better understanding of what it is going on under the hood. In addition, I will share what I learned from the implementation of the scope in this blog. The scope has five components:

  • Dirty-Checking Mechanism
  • Scope Methods
  • Scope Inheritance
  • Watching Collections
  • Scope Events

Dirty-Checking Mechanism

The Angular Scope makes use of two main functions to implement the dirty-checking mechanism: the $watch and $digest function.

$watch(watchFn, ListenFn)

  • A watch function(watchFn): return the piece of data you’re interested in.
  • A listen function(listenFn): when the observed data changes, this function will be invoked.
  • A watcher: an object that has a watch function and a listen function as its attributes.
  • With $watch you can attach a watcher to a scope. The scope maintains a queue called $$watchers, which stores all the watchers attached to it.

$digest

  • First, the $digest function iterates all the watchers in the scope.
  • Second, it compares the previous state of data to the current one during each iteration. If something changes, it calls the corresponding listener function.
  • Finally, this performs the above steps repeatedly until the watched data stops changing.

Optimization

The Angular Scope takes advantage of the Time To Live(TTL) and short circuiting the digest to avoid endless or useless iterations.

  • TTL: sets the maximum amount of iterations.
  • Short-Circuiting: uses a variable to keep track of the last dirty watch that we have seen. Each digest round goes on until it reaches this last watch.

Performance

As I mentioned, Angular Scope iterates over the watches as opposed to the properties of a scope in each digest round. Therefore, it is a good idea to pay attention to the number of watches you have, which will affect the performance of your application.

Scope Methods

In Angular Scope, you can use the following methods to execute some code in the context of a scope:

$eval(fun)

// Take a function as its argument
Scope.prototype.$eval = function(expr){
return expr(this); // call-site. pass the scope(this) context
};

$apply(fun): integrate code that is not aware of the Angular Scope

Scope.prototype.$apply = function(expr){
try{
return this.$eval(expr);
}finally{ // make sure a new digest will happen
this.$digest();
}
};

$evalAsync(fun): defers the execution of the assigned function, but it wil still be called in the same digest cycle.

$applyAsync(fun): unlike the $evalAsync, the assigned function will be called in a new digest cycle. Moreover, the main point of $applyAsync is to optimize things that happen in quick succession like Http responses.

/*
** Make sure a single digest dealing with all the successive
** executions occurs in a short period of time.

** 1. Use the $$applyAsyncId to keep track of whether a setTimeout to ** drain the queue has already been scheduled.
** 2. Use setTimeout to defer the beginning of the digest slightly.
** This way callers of $applyAsync can be ensured the function
** will always return immediately, which enqueues the following
** successive executions into the queue.
*/
Scope.prototype.$applyAsync = function(expr){
var self = this; //lexical this
self.$$applyAsyncQueue.push(function(){//save deferred functions
self.$eval(expr);
});

if(self.$$applyAsyncId === null){
self.$$applyAsyncId = setTimeout(function() {
self.$apply(function(){//$apply issues a new digest
while(this.$$applyAsyncQueue.length){
self.$$applyAsyncQueue.shift()();//call-site
}
self.$$applyAsyncId = null;
});
}, 0); //return immediately
}
};
//Call $applyAsync three times in a row
scope.$applyAsync(function(scope){
scope.aValue = 'abc';
});
scope.$applyAsync(function(scope){
scope.aValue = 'def';
});
scope.$applyAsync(function(scope){
scope.aValue = 'ghi';
});
//Results: $$applyAsyncQueue will store these three execution snippets, and then they will be executed in a new digest cycle.

$watchGroup(watchFns, listenerFn): watches several pieces of state and executes some code when any one of them changes.

/*
** $watchGroup is built on $watch and $evalAsync functions.
** 1. the listener function is given the old and new values of
** watches wrapped in arrays, in the order of the original watch
** functions using the variable i and arrays in the closure.
** 2. use $evalAsync to defer the listenerFn until at the end of the
** digest round. And call the $evalAsync once in each round of
** digest by checking the state of changeReactionScheduled.
*/

Scope.prototype.$watchGroup = function(watchFns, listenerFn){
var self = this;
var newValues = new Array(watchFns.length);
var oldValues = new Array(watchFns.length);
var changeReactionScheduled = false;
var destoryFunctions = _.map(watchFns, function(watchFn, i){
return self.$watch(watchFn, function(newValue, oldValue){
newValues[i] = newValue;
oldValues[i] = oldValue;

if(!changeReactionScheduled){
changeReactionScheduled = true;
self.$evalAsync(
listenerFn(newValues, oldValues, self));//call-site
}
});
});
return function(){
_.forEach(destoryFunctions, function(destoryFunction){
destoryFunction();
});
};
};
//Caller
scope.$watchGroup([
function(scope) { return scope.aValue; },
function(scope) { return scope.anotherValue; }
], function(newValues, oldValues, scope){}
);

Scope Inheritance

In Angular Scope, you can create a scope inherited from another scope or an isolated scope using $new.

$new

/*
** Inheritance: Javascript's prototype chain
** Isolated: a object is not in the prototype chain
*/

Scope.prototype.$new = function(isolated, parent){
var child;
parent = parent || this;
if(isolated){
child = new Scope(); //not in the prototype chain
}else{
var ChildScope = function() {};
ChildScope.prototype = this; //join in the prototype chain
child = new ChildScope();
}
parent.$$children.push(child);
//create private properties of a child scope by attribute shadowing
child.$$watchers = [];
child.$parent = parent;
return child;
};

Attribute Shadowing

To get around this, a common pattern is to wrap the attribute in an object.

var parent = new Scope();
var child = parent.$new(false, parent);
//Both scopes have a reference to the same user object
parent.user = {name:'Joe'};
child.user.name = 'Jill';
//Results:
parent.user.name: 'Jill'
child.user.name: 'Jill'

Recursion

/*
** $$everyScope will execute an arbitrary function once for each
** scope in the hierarchy until the function returns a false value.
** This function invokes fn once for the current scope, and then
** recursively calls itself on each child (depth-first search)
*/

Scope.prototype.$$everyScope = function(fn){
if(fn(this)){ //call-site
return this.$$children.every(function(child){
return child.$$everyScope(fn);
});
}else{
return false;
}
};

Watching Collections

The use case for $watchCollection is that you want to know when something either in an array or in a object has changed: when items or attributes have been added, removed, updated or reordered. Contrasted with the value-based version of $watch, $watchCollection is more efficient without going too deep.

邹明潮
Three ways of comparing

$watchCollection

/*
** $watchCollection delegates to the $watch function by supplying
** with its own, locally created version of a watch function and a
** listener function.
*/

Scope.prototype.$watchCollection = function(watchFn, listenFn){
var self = this;
var newValue;
var oldValue;
var oldLength;
var changeCount = 0; //increment whenever a change is detected
var internalWatchFn = function(scope){
var newLength;
newValue = watchFn(scope);
if(_.isObject(newValue)){
if(isArrayLike(newValue)){ //array-like object or array
}else{ //no array-like object
if(!_.isObject(oldValue) || isArrayLike(oldValue)){
changeCount++;
oldValue = {};
oldLength = 0;
}
/*iterate the attributes of the new obj to add or update the corresponding attributes in the old obj, if they are different*/
/*iterate the attributes of old obj to check if its attribute exists in the new obj. If not, delete this attribute from the old obj.*/
}
}else{ //no collection value
if(!self.$$areEqual(newValue, oldValue, false)){
changeCount++;
}
oldValue = newValue;
}
return changeCount;
};
var internalListenFn = function(){
listenFn(newValue, veryOldValue, self);
if(trackVeryOldValue){
veryOldValue = _.clone(newValue); //deep copy
}
};
return this.$watch(internalWatchFn, internalListenFn);
};

Scope Events

Publish-Subscribe Messaging Pattern

In software architecture, publish–subscribe is a messaging pattern where senders of messages, called publishers, do not program the messages to be sent directly to specific receivers, called subscribers, but instead characterize published messages into classes without knowledge of which subscribers, if any, there may be. Similarly, subscribers express interest in one or more classes and only receive messages that are of interest, without knowledge of which publishers, if any, there are.

In Angular Scope event, we use publish-subscribe messaging pattern to propagate events up and down within the scope tree. Calling the function $on registers a listener(subscriber) associated with a specific event. Moreover, we can invoke the function $emit or $broadcast(publisher) to propagate the event(messages wrapped in a class) throughout the scope tree. This scope tree acts as a mediator, decoupling publishers and subscribers.

$on

/*
** register event listeners
*/
Scope.prototype.$on = function(eventName, listener){
var listeners = this.$$listeners[eventName];
if(!listeners){
this.$$listeners[eventName] = listeners = [];
}
listeners.push(listener);return function(){ //deregister a listener
var index = listeners.indexOf(listener);
if(index >= 0){
/*
** Problem: a case where removing a listener by the function splice
** happens while we are iterating the listeners array. The result is
** that it jumps over one listener - the one immediately after the
** removed listener.
** Solution1: replace the removed listener with something indicating
** it has been removed. And filter it during the iteration.
** Solution2: insert a new entry at the end of the array, and start
** iterating the array from the end.
*/
listeners[index] = null;
}
};
};

$fireEventOnScope

/*
** call all the listeners that belong to a specific event
*/

Scope.prototype.$$fireEventOnScope = function(eventName, listenerArgs){
var listeners = this.$$listeners[eventName] || [];
var i = 0;
while(i < listeners.length){
if(listeners[i] === null){//remove the deregistered listener
listeners.splice(i, 1);
}else{
try {
listeners[i].apply(null, listenerArgs);//call-site
} catch (e) {
console.error(e);
}
i++;
}
}
};

$emit

/*
** Propagate a event up within a scope tree
*/

Scope.prototype.$emit = function(eventName){
var event = { //wrap messages into a class
name: eventName,
targetScope: this
};
var listenerArgs = [event].concat(_.tail(arguments));
var scope = this;
do{
scope.$$fireEventOnScope(eventName, listenerArgs);
scope = scope.$parent; //a direct path due to one parent
}while(scope);
return event;
};

$broadcast

/*
** Propagate a event down within a scope tree
*/

Scope.prototype.$broadcast = function(eventName){
var event = {
name: eventName,
targetScope: this,
};
var listenerArgs = [event].concat(_.tail(arguments));
//depth first traversal of tree
this.$$everyScope(function(scope){
scope.$$fireEventOnScope(eventName, listenerArgs);
return true;
});
return event;
};

--

--