Skip to content
Permalink
Browse files

feat(*): lazy one-time binding support

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 #7486
Closes #5408
  • Loading branch information...
lgalfaso authored and IgorMinar committed May 15, 2014
1 parent 701ed5f commit cee429f0aaebf32ef1c9aedd8447a48f163dd0a4
@@ -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>
```

@@ -1485,6 +1485,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
parentSet(scope, parentValue = isolateScope[scopeName]);
}
}
parentValueWatch.$$unwatch = parentGet.$$unwatch;
return lastValue = parentValue;
}, null, parentGet.literal);
break;
@@ -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) {
@@ -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)) || '');
@@ -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) {
@@ -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);
@@ -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;
}
}
};
}];
}
@@ -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 = [];
}
@@ -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;}
},


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

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

beginPhase('$digest');
@@ -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.
@@ -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);
}
}
},


@@ -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;
};
}
};

24 comments on commit cee429f

@thiagofelix

This comment has been minimized.

Copy link

replied May 23, 2014

great merge!
eager to see it!

@sgruhier

This comment has been minimized.

Copy link

replied May 24, 2014

awesome

@epegzz

This comment has been minimized.

Copy link

replied May 24, 2014

yaaaaay!!!

@chinchang

This comment has been minimized.

Copy link

replied May 24, 2014

Best performance merge :)

This won't work with directives like ng-class etc, right?

@HeberLZ

This comment has been minimized.

Copy link
Contributor

replied May 24, 2014

@chinchang excellent question! +1

@booleanbetrayal

This comment has been minimized.

Copy link
Contributor

replied May 25, 2014

RIP bindonce

@caitp

This comment has been minimized.

Copy link
Contributor

replied May 25, 2014

@chinchang it works with the parser, so you can prepend any parsed expression with it --- However, in the case of ng-class, you can't say ng-class="{someClass: ::someExpr}", because the :: needs to be at the front of the expression, not on specific parts of an expression.

Hope that clears that up for you. You could, however, say ng-class="::{someClass: someExpr}"

@AdirAmsalem

This comment has been minimized.

Copy link
Contributor

replied May 25, 2014

Great! Thanks.

@andershessellund

This comment has been minimized.

Copy link
Contributor

replied May 26, 2014

If i understand correctly, ng-class="::{someClass: someExpr}" will be bound on first digest cycle even if someExpr evaluates to undefined, so it may not work as expected. Would it not be possible to support the :: syntax nested inside an expression?

@HeberLZ

This comment has been minimized.

Copy link
Contributor

replied May 26, 2014

I'm not sure that it will evaluate to undefined as this feature is thought to be lazy one-time binding, at least i hope it doesn't:(

@andershessellund

This comment has been minimized.

Copy link
Contributor

replied May 26, 2014

If someExpr evaluates to undefined, the ng-class expression will evaluate to {someClass: undefined}, which is not undefined, so it will not be evaluated again.

@lgalfaso

This comment has been minimized.

Copy link
Member Author

replied May 26, 2014

@andershessellund even when it would not be a perfect solution, you can still do ng-class="::someExp && {someClass: someExp}".

The main goal of this feature is to have less watchers on an app and have faster digest cycles. Supporting expressions like ng-class="{someClass: ::someExpr}" would be odd as that would imply that the watch is over someExpr and not over the entire expression, and not having the watch over the entire expression will adds other limitations to ng-class (this would be out of the scope of the original conversation).

This is the first one-time binding mechanism that is part of the core, as more people start using it some refinements will be needed.

@gautelo

This comment has been minimized.

Copy link

replied May 27, 2014

I can't begin to express how relieved I am to see this getting done! Dealing with this through avoiding declarative syntax or utilizing a third party solution (as referenced in the design docs) made me seriously uneasy. With this I can make performant solutions without hacking and/or circumventing the core ideas of angular.

Somebody said that they weren't sure many people were using this for dart, but judging from the number of performance topics related to grids and lists of things found around the web, I feel pretty confident a lot of people will love this just as much as I do.

Thank you!

@schmod

This comment has been minimized.

Copy link
Contributor

replied May 30, 2014

As discussed in #6354, I do think that we need some sort of way to reset some or all "bind-once" expressions that have been interpolated.

i18n is the most immediately obvious use-case, given that i18n-enabled apps tend to have a ton of interpolation statements that only need to be executed once, or under very rare circumstances (when a user toggles languages).

That being said, I could easily see over-use of bind-once (and #6354's suggestion of bind-once namespacing) quickly becoming an anti-pattern, as developers spend too much time chasing after small performance gains, or using the functionality as a crutch to unnecessarily put computationally-expensive functions in bind-once interpolation statements, which is more likely than not an improper separation of MVC concerns...

This is a great feature, but IMO, it needs to be used judiciously.

@jsdw

This comment has been minimized.

Copy link

replied Jun 18, 2014

is this expected to work in the case of parsing functions so

{{ someFunction() }}

is parsed every time a digest occurs, but

{{ ::someFunction() }}

is only evaluated once?

I had a quick play and in both cases the function is fired every digest. I would suppose the only way to accomplish this is execute functions in the controller and put the resulting variable on scope, using it with ::?

@lgalfaso

This comment has been minimized.

Copy link
Member Author

replied Jun 18, 2014

@lytnus In both cases, the expression should be parsed once. In the later case, it should be evaluated in every digest until someFunction() returns something other than undefined (keep in mind that within a $digest, the function can be called multiple times.
If this is not working, please create a plunker that reproduces the error

@jsdw

This comment has been minimized.

Copy link

replied Jun 18, 2014

My bad, further testing seems to imply it works as expected :)

@SquadraCorse

This comment has been minimized.

Copy link

replied Aug 28, 2014

Really awesome, from performance perspective my dislike when using ng-repeat is now kinda gone. Really cool this feature is available. Thanks!

@maruf89

This comment has been minimized.

Copy link

replied Sep 4, 2014

Sounds like a much deserved feature. Unfortunately having issues with the bind not always working http://stackoverflow.com/questions/25658378/angularjs-1-3-one-time-binding-not-always-working

@lgalfaso

This comment has been minimized.

Copy link
Member Author

replied Sep 4, 2014

@maruf89 I am not able to make it fail with the plunker posted. Can you create a new one that shows the behavior you mention?

@maruf89

This comment has been minimized.

Copy link

replied Sep 8, 2014

@lgalfaso Yea I'll mess around some more.

@thammin

This comment has been minimized.

Copy link
Contributor

replied Sep 9, 2014

is this support in filters? exp:

<div>{{ ::filter_expression | filter : expression : comparator }}</div>
@caitp

This comment has been minimized.

Copy link
Contributor

replied Sep 9, 2014

@thammin --- sort of. the filter needs to return undefined when it receives an undefined value, otherwise it will cause the model to be watched again.

I recall we were talking about making builtin filters do this, and it's possible that a followup patch (which renders all filters pure and therefore not evaluated unnecessarily) will make it essentially automatic

@thammin

This comment has been minimized.

Copy link
Contributor

replied Sep 9, 2014

@caitp Will try it later, Thanks!

Please sign in to comment.
You can’t perform that action at this time.