Skip to content

Commit

Permalink
feat(*): lazy one-time binding support
Browse files Browse the repository at this point in the history
Expressions that start with `::` will be binded once. The rule
that binding follows is that the binding will take the first
not-undefined value at the end of a $digest cycle.

Watchers from $watch, $watchCollection and $watchGroup will
automatically stop watching when the expression(s) are bind-once
and fulfill.

Watchers from text and attributes interpolations will
automatically stop watching when the expressions are fulfill.

All directives that use $parse for expressions will automatically
work with bind-once expressions. E.g.

<div ng-bind="::foo"></div>
<li ng-repeat="item in ::items">{{::item.name}};</li>

Paired with: Caitlin and Igor
Design doc: https://docs.google.com/document/d/1fTqaaQYD2QE1rz-OywvRKFSpZirbWUPsnfaZaMq8fWI/edit#
Closes angular#7486
Closes angular#5408
  • Loading branch information
lgalfaso authored and IgorMinar committed May 23, 2014
1 parent 701ed5f commit cee429f
Show file tree
Hide file tree
Showing 13 changed files with 503 additions and 18 deletions.
119 changes: 119 additions & 0 deletions docs/content/guide/expression.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,122 @@ expose a `$event` object within the scope of that expression.

Note in the example above how we can pass in `$event` to `clickMe`, but how it does not show up
in `{{$event}}`. This is because `$event` is outside the scope of that binding.


## One-time binding

An expression that starts with `::` is considered a one-time expression. One-time expressions
will stop recalculating once they are stable, which happens after the first digest if the expression
result is a non-undefined value (see value stabilization algorithm below).

<example module="oneTimeBidingExampleApp">
<file name="index.html">
<div ng-controller="EventController">
<button ng-click="clickMe($event)">Click Me</button>
<p id="one-time-binding-example">One time binding: {{::name}}</p>
<p id="normal-binding-example">Normal binding: {{name}}</p>
</div>
</file>
<file name="script.js">
angular.module('oneTimeBidingExampleApp', []).
controller('EventController', ['$scope', function($scope) {
var counter = 0;
var names = ['Igor', 'Misko', 'Chirayu', 'Lucas'];
/*
* expose the event object to the scope
*/
$scope.clickMe = function(clickEvent) {
$scope.name = names[counter % names.length];
counter++;
};
}]);
</file>
<file name="protractor.js" type="protractor">
it('should freeze binding after its value has stabilized', function() {
var oneTimeBiding = element(by.id('one-time-binding-example'));
var normalBinding = element(by.id('normal-binding-example'));

expect(oneTimeBiding.getText()).toEqual('One time binding:');
expect(normalBinding.getText()).toEqual('Normal binding:');
element(by.buttonText('Click Me')).click();

expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
expect(normalBinding.getText()).toEqual('Normal binding: Igor');
element(by.buttonText('Click Me')).click();

expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
expect(normalBinding.getText()).toEqual('Normal binding: Misko');

element(by.buttonText('Click Me')).click();
element(by.buttonText('Click Me')).click();

expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
expect(normalBinding.getText()).toEqual('Normal binding: Lucas');
});
</file>
</example>


### Why this feature

The main purpose of one-time binding expression is to provide a way to create a binding
that gets deregistered and frees up resources once the binding is stabilized.
Reducing the number of expressions being watched makes the digest loop faster and allows more
information to be displayed at the same time.


### Value stabilization algorithm

One-time binding expressions will retain the value of the expression at the end of the
digest cycle as long as that value is not undefined. If the value of the expression is set
within the digest loop and later, within the same digest loop, it is set to undefined,
then the expression is not fulfilled and will remain watched.

1. Given an expression that starts with `::` when a digest loop is entered and expression
is dirty-checked store the value as V
2. If V is not undefined mark the result of the expression as stable and schedule a task
to deregister the watch for this expression when we exit the digest loop
3. Process the digest loop as normal
4. When digest loop is done and all the values have settled process the queue of watch
deregistration tasks. For each watch to be deregistered check if it still evaluates
to value that is not `undefined`. If that's the case, deregister the watch. Otherwise
keep dirty-checking the watch in the future digest loops by following the same
algorithm starting from step 1


### How to benefit from one-time binding

When interpolating text or attributes. If the expression, once set, will not change
then it is a candidate for one-time expression.

```html
<div name="attr: {{::color}}">text: {{::name}}</div>
```

When using a directive with bidirectional binding and the parameters will not change

```js
someModule.directive('someDirective', function() {
return {
scope: {
name: '=',
color: '@'
},
template: '{{name}}: {{color}}'
};
});
```

```html
<div some-directive name=“::myName” color=“My color is {{::myColor}}”></div>
```


When using a directive that takes an expression

```html
<ul>
<li ng-repeat="item in ::items">{{item.name}};</li>
</ul>
```

4 changes: 4 additions & 0 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
parentSet(scope, parentValue = isolateScope[scopeName]);
}
}
parentValueWatch.$$unwatch = parentGet.$$unwatch;
return lastValue = parentValue;
}, null, parentGet.literal);
break;
Expand Down Expand Up @@ -1813,6 +1814,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
compile: valueFn(function textInterpolateLinkFn(scope, node) {
var parent = node.parent(),
bindings = parent.data('$binding') || [];
// Need to interpolate again in case this is using one-time bindings in multiple clones
// of transcluded templates.
interpolateFn = $interpolate(text);
bindings.push(interpolateFn);
safeAddClass(parent.data('$binding', bindings), 'ng-binding');
scope.$watch(interpolateFn, function interpolateFnWatchAction(value) {
Expand Down
6 changes: 5 additions & 1 deletion src/ng/directive/ngBind.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);

var parsed = $parse(attr.ngBindHtml);
function getStringValue() { return (parsed(scope) || '').toString(); }
function getStringValue() {
var value = parsed(scope);
getStringValue.$$unwatch = parsed.$$unwatch;
return (value || '').toString();
}

scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
element.html($sce.getTrustedHtml(parsed(scope)) || '');
Expand Down
3 changes: 3 additions & 0 deletions src/ng/interpolate.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,19 @@ function $InterpolateProvider() {


try {
interpolationFn.$$unwatch = true;
for (; i < ii; i++) {
val = getValue(parseFns[i](context));
if (allOrNothing && isUndefined(val)) {
interpolationFn.$$unwatch = undefined;
return;
}
val = stringify(val);
if (val !== lastValues[i]) {
inputsChanged = true;
}
values[i] = val;
interpolationFn.$$unwatch = interpolationFn.$$unwatch && parseFns[i].$$unwatch;
}

if (inputsChanged) {
Expand Down
41 changes: 38 additions & 3 deletions src/ng/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -1018,13 +1018,19 @@ function $ParseProvider() {
$parseOptions.csp = $sniffer.csp;

return function(exp) {
var parsedExpression;
var parsedExpression,
oneTime;

switch (typeof exp) {
case 'string':

if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
oneTime = true;
exp = exp.substring(2);
}

if (cache.hasOwnProperty(exp)) {
return cache[exp];
return oneTime ? oneTimeWrapper(cache[exp]) : cache[exp];
}

var lexer = new Lexer($parseOptions);
Expand All @@ -1037,14 +1043,43 @@ function $ParseProvider() {
cache[exp] = parsedExpression;
}

return parsedExpression;
if (parsedExpression.constant) {
parsedExpression.$$unwatch = true;
}

return oneTime ? oneTimeWrapper(parsedExpression) : parsedExpression;

case 'function':
return exp;

default:
return noop;
}

function oneTimeWrapper(expression) {
var stable = false,
lastValue;
oneTimeParseFn.literal = expression.literal;
oneTimeParseFn.constant = expression.constant;
oneTimeParseFn.assign = expression.assign;
return oneTimeParseFn;

function oneTimeParseFn(self, locals) {
if (!stable) {
lastValue = expression(self, locals);
oneTimeParseFn.$$unwatch = isDefined(lastValue);
if (oneTimeParseFn.$$unwatch && self && self.$$postDigestQueue) {
self.$$postDigestQueue.push(function () {
// create a copy if the value is defined and it is not a $sce value
if ((stable = isDefined(lastValue)) && !lastValue.$$unwrapTrustedValue) {
lastValue = copy(lastValue);
}
});
}
}
return lastValue;
}
}
};
}];
}
35 changes: 25 additions & 10 deletions src/ng/rootScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,6 @@ function $RootScopeProvider(){
watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);};
}

if (typeof watchExp == 'string' && get.constant) {
var originalFn = watcher.fn;
watcher.fn = function(newVal, oldVal, scope) {
originalFn.call(this, newVal, oldVal, scope);
arrayRemove(array, watcher);
};
}

if (!array) {
array = scope.$$watchers = [];
}
Expand Down Expand Up @@ -391,24 +383,37 @@ function $RootScopeProvider(){
var deregisterFns = [];
var changeCount = 0;
var self = this;
var unwatchFlags = new Array(watchExpressions.length);
var unwatchCount = watchExpressions.length;

forEach(watchExpressions, function (expr, i) {
deregisterFns.push(self.$watch(expr, function (value, oldValue) {
var exprFn = $parse(expr);
deregisterFns.push(self.$watch(exprFn, function (value, oldValue) {
newValues[i] = value;
oldValues[i] = oldValue;
changeCount++;
if (unwatchFlags[i] && !exprFn.$$unwatch) unwatchCount++;
if (!unwatchFlags[i] && exprFn.$$unwatch) unwatchCount--;
unwatchFlags[i] = exprFn.$$unwatch;
}));
}, this);

deregisterFns.push(self.$watch(function () {return changeCount;}, function () {
deregisterFns.push(self.$watch(watchGroupFn, function () {
listener(newValues, oldValues, self);
if (unwatchCount === 0) {
watchGroupFn.$$unwatch = true;
} else {
watchGroupFn.$$unwatch = false;
}
}));

return function deregisterWatchGroup() {
forEach(deregisterFns, function (fn) {
fn();
});
};

function watchGroupFn() {return changeCount;}
},


Expand Down Expand Up @@ -553,6 +558,7 @@ function $RootScopeProvider(){
}
}
}
$watchCollectionWatch.$$unwatch = objGetter.$$unwatch;
return changeDetected;
}

Expand Down Expand Up @@ -644,6 +650,7 @@ function $RootScopeProvider(){
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
stableWatchesCandidates = [],
logIdx, logMsg, asyncTask;

beginPhase('$digest');
Expand Down Expand Up @@ -694,6 +701,7 @@ function $RootScopeProvider(){
logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
watchLog[logIdx].push(logMsg);
}
if (watch.get.$$unwatch) stableWatchesCandidates.push({watch: watch, array: watchers});
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
Expand Down Expand Up @@ -740,6 +748,13 @@ function $RootScopeProvider(){
$exceptionHandler(e);
}
}

for (length = stableWatchesCandidates.length - 1; length >= 0; --length) {
var candidate = stableWatchesCandidates[length];
if (candidate.watch.get.$$unwatch) {
arrayRemove(candidate.array, candidate.watch);
}
}
},


Expand Down
4 changes: 3 additions & 1 deletion src/ng/sce.js
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,9 @@ function $SceProvider() {
return parsed;
} else {
return function sceParseAsTrusted(self, locals) {
return sce.getTrusted(type, parsed(self, locals));
var result = sce.getTrusted(type, parsed(self, locals));
sceParseAsTrusted.$$unwatch = parsed.$$unwatch;
return result;
};
}
};
Expand Down
Loading

0 comments on commit cee429f

Please sign in to comment.