Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

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 #7486
Closes #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

24 comments on commit cee429f

@thiagofelix
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great merge!
eager to see it!

@sgruhier
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome

@dsc8x
Copy link

@dsc8x dsc8x commented on cee429f May 24, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yaaaaay!!!

@chinchang
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best performance merge :)

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

@HeberLZ
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chinchang excellent question! +1

@booleanbetrayal
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIP bindonce

@caitp
Copy link
Contributor

@caitp caitp commented on cee429f May 25, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Thanks.

@andershessellund
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

@schmod schmod commented on cee429f May 30, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

@jsdw jsdw commented on cee429f Jun 18, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

@jsdw jsdw commented on cee429f Jun 18, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@SquadraCorse
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@maruf89
Copy link

@maruf89 maruf89 commented on cee429f Sep 4, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

@maruf89 maruf89 commented on cee429f Sep 8, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lgalfaso Yea I'll mess around some more.

@thammin
Copy link
Contributor

@thammin thammin commented on cee429f Sep 9, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this support in filters? exp:

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

@caitp
Copy link
Contributor

@caitp caitp commented on cee429f Sep 9, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link
Contributor

@thammin thammin commented on cee429f Sep 9, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caitp Will try it later, Thanks!

Please sign in to comment.