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

Commit 86d55c1

Browse files
committed
perf(*): more performant interpolation and lazy one-time binding
BEAKING CHANGE: Lazy-binding now happens on the scope watcher level. What this means is that given `parseFn = $parse('::foo')`, bind-once will only kick in when `parseFn` is being watched by a scope (i.e. `scope.$watch(parseFn)`) Bind-once will have no effect when directily invoking `parseFn` (i.e. `parseFn()`)
1 parent bcf72ca commit 86d55c1

13 files changed

+745
-94
lines changed

docs/content/guide/expression.ngdoc

+119
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,122 @@ expose a `$event` object within the scope of that expression.
202202

203203
Note in the example above how we can pass in `$event` to `clickMe`, but how it does not show up
204204
in `{{$event}}`. This is because `$event` is outside the scope of that binding.
205+
206+
207+
## One-time binding
208+
209+
An expression that starts with `::` is considered a one-time expression. One-time expressions
210+
will stop recalculating once they are stable, which happens after the first digest if the expression
211+
result is a non-undefined value (see value stabilization algorithm below).
212+
213+
<example module="oneTimeBidingExampleApp">
214+
<file name="index.html">
215+
<div ng-controller="EventController">
216+
<button ng-click="clickMe($event)">Click Me</button>
217+
<p id="one-time-binding-example">One time binding: {{::name}}</p>
218+
<p id="normal-binding-example">Normal binding: {{name}}</p>
219+
</div>
220+
</file>
221+
<file name="script.js">
222+
angular.module('oneTimeBidingExampleApp', []).
223+
controller('EventController', ['$scope', function($scope) {
224+
var counter = 0;
225+
var names = ['Igor', 'Misko', 'Chirayu', 'Lucas'];
226+
/*
227+
* expose the event object to the scope
228+
*/
229+
$scope.clickMe = function(clickEvent) {
230+
$scope.name = names[counter % names.length];
231+
counter++;
232+
};
233+
}]);
234+
</file>
235+
<file name="protractor.js" type="protractor">
236+
it('should freeze binding after its value has stabilized', function() {
237+
var oneTimeBiding = element(by.id('one-time-binding-example'));
238+
var normalBinding = element(by.id('normal-binding-example'));
239+
240+
expect(oneTimeBiding.getText()).toEqual('One time binding:');
241+
expect(normalBinding.getText()).toEqual('Normal binding:');
242+
element(by.buttonText('Click Me')).click();
243+
244+
expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
245+
expect(normalBinding.getText()).toEqual('Normal binding: Igor');
246+
element(by.buttonText('Click Me')).click();
247+
248+
expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
249+
expect(normalBinding.getText()).toEqual('Normal binding: Misko');
250+
251+
element(by.buttonText('Click Me')).click();
252+
element(by.buttonText('Click Me')).click();
253+
254+
expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
255+
expect(normalBinding.getText()).toEqual('Normal binding: Lucas');
256+
});
257+
</file>
258+
</example>
259+
260+
261+
### Why this feature
262+
263+
The main purpose of one-time binding expression is to provide a way to create a binding
264+
that gets deregistered and frees up resources once the binding is stabilized.
265+
Reducing the number of expressions being watched makes the digest loop faster and allows more
266+
information to be displayed at the same time.
267+
268+
269+
### Value stabilization algorithm
270+
271+
One-time binding expressions will retain the value of the expression at the end of the
272+
digest cycle as long as that value is not undefined. If the value of the expression is set
273+
within the digest loop and later, within the same digest loop, it is set to undefined,
274+
then the expression is not fulfilled and will remain watched.
275+
276+
1. Given an expression that starts with `::` when a digest loop is entered and expression
277+
is dirty-checked store the value as V
278+
2. If V is not undefined mark the result of the expression as stable and schedule a task
279+
to deregister the watch for this expression when we exit the digest loop
280+
3. Process the digest loop as normal
281+
4. When digest loop is done and all the values have settled process the queue of watch
282+
deregistration tasks. For each watch to be deregistered check if it still evaluates
283+
to value that is not `undefined`. If that's the case, deregister the watch. Otherwise
284+
keep dirty-checking the watch in the future digest loops by following the same
285+
algorithm starting from step 1
286+
287+
288+
### How to benefit from one-time binding
289+
290+
When interpolating text or attributes. If the expression, once set, will not change
291+
then it is a candidate for one-time expression.
292+
293+
```html
294+
<div name="attr: {{::color}}">text: {{::name}}</div>
295+
```
296+
297+
When using a directive with bidirectional binding and the parameters will not change
298+
299+
```js
300+
someModule.directive('someDirective', function() {
301+
return {
302+
scope: {
303+
name: '=',
304+
color: '@'
305+
},
306+
template: '{{name}}: {{color}}'
307+
};
308+
});
309+
```
310+
311+
```html
312+
<div some-directive name=“::myName” color=“My color is {{::myColor}}”></div>
313+
```
314+
315+
316+
When using a directive that takes an expression
317+
318+
```html
319+
<ul>
320+
<li ng-repeat="item in ::items">{{item.name}};</li>
321+
</ul>
322+
```
323+

src/ng/compile.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1505,8 +1505,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
15051505
attrs[attrName], newIsolateScopeDirective.name);
15061506
};
15071507
lastValue = isolateScope[scopeName] = parentGet(scope);
1508-
isolateScope.$watch(function parentValueWatch() {
1509-
var parentValue = parentGet(scope);
1508+
var unwatch = scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) {
15101509
if (!compare(parentValue, isolateScope[scopeName])) {
15111510
// we are out of sync and need to copy
15121511
if (!compare(parentValue, lastValue)) {
@@ -1518,7 +1517,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
15181517
}
15191518
}
15201519
return lastValue = parentValue;
1521-
}, null, parentGet.literal);
1520+
}), null, parentGet.literal);
1521+
isolateScope.$on('$destroy', unwatch);
15221522
break;
15231523

15241524
case '&':

src/ng/directive/ngBind.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,13 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
181181
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);
182182

183183
var parsed = $parse(attr.ngBindHtml);
184-
function getStringValue() { return (parsed(scope) || '').toString(); }
184+
var changeDetector = $parse(attr.ngBindHtml, function getStringValue(value) {
185+
return (value || '').toString();
186+
});
185187

186-
scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
188+
scope.$watch(changeDetector, function ngBindHtmlWatchAction() {
189+
// we re-evaluate the expr because we want a TrustedValueHolderType
190+
// for $sce, not a string
187191
element.html($sce.getTrustedHtml(parsed(scope)) || '');
188192
});
189193
};

src/ng/interpolate.js

+29-45
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,7 @@ function $InterpolateProvider() {
195195
hasInterpolation = false,
196196
hasText = false,
197197
exp,
198-
concat = [],
199-
lastValuesCache = { values: {}, results: {}};
198+
concat = [];
200199

201200
while(index < textLength) {
202201
if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) &&
@@ -205,7 +204,7 @@ function $InterpolateProvider() {
205204
separators.push(text.substring(index, startIndex));
206205
exp = text.substring(startIndex + startSymbolLength, endIndex);
207206
expressions.push(exp);
208-
parseFns.push($parse(exp));
207+
parseFns.push($parse(exp, parseStringifyInterceptor));
209208
index = endIndex + endSymbolLength;
210209
hasInterpolation = true;
211210
} else {
@@ -246,6 +245,7 @@ function $InterpolateProvider() {
246245

247246
var compute = function(values) {
248247
for(var i = 0, ii = expressions.length; i < ii; i++) {
248+
if (allOrNothing && isUndefined(values[i])) return;
249249
concat[2*i] = separators[i];
250250
concat[(2*i)+1] = values[i];
251251
}
@@ -254,13 +254,9 @@ function $InterpolateProvider() {
254254
};
255255

256256
var getValue = function (value) {
257-
if (trustedContext) {
258-
value = $sce.getTrusted(trustedContext, value);
259-
} else {
260-
value = $sce.valueOf(value);
261-
}
262-
263-
return value;
257+
return trustedContext ?
258+
$sce.getTrusted(trustedContext, value) :
259+
$sce.valueOf(value);
264260
};
265261

266262
var stringify = function (value) {
@@ -284,61 +280,49 @@ function $InterpolateProvider() {
284280
};
285281

286282
return extend(function interpolationFn(context) {
287-
var scopeId = (context && context.$id) || 'notAScope';
288-
var lastValues = lastValuesCache.values[scopeId];
289-
var lastResult = lastValuesCache.results[scopeId];
290283
var i = 0;
291284
var ii = expressions.length;
292285
var values = new Array(ii);
293-
var val;
294-
var inputsChanged = lastResult === undefined ? true: false;
295-
296-
297-
// if we haven't seen this context before, initialize the cache and try to setup
298-
// a cleanup routine that purges the cache when the scope goes away.
299-
if (!lastValues) {
300-
lastValues = [];
301-
inputsChanged = true;
302-
if (context && context.$on) {
303-
context.$on('$destroy', function() {
304-
lastValuesCache.values[scopeId] = null;
305-
lastValuesCache.results[scopeId] = null;
306-
});
307-
}
308-
}
309-
310286

311287
try {
312288
for (; i < ii; i++) {
313-
val = getValue(parseFns[i](context));
314-
if (allOrNothing && isUndefined(val)) {
315-
return;
316-
}
317-
val = stringify(val);
318-
if (val !== lastValues[i]) {
319-
inputsChanged = true;
320-
}
321-
values[i] = val;
289+
values[i] = parseFns[i](context);
322290
}
323291

324-
if (inputsChanged) {
325-
lastValuesCache.values[scopeId] = values;
326-
lastValuesCache.results[scopeId] = lastResult = compute(values);
327-
}
292+
return compute(values);
328293
} catch(err) {
329294
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text,
330295
err.toString());
331296
$exceptionHandler(newErr);
332297
}
333298

334-
return lastResult;
335299
}, {
336300
// all of these properties are undocumented for now
337301
exp: text, //just for compatibility with regular watchers created via $watch
338302
separators: separators,
339-
expressions: expressions
303+
expressions: expressions,
304+
$$watchDelegate: function (scope, listener, objectEquality, deregisterNotifier) {
305+
var lastValue;
306+
return scope.$watchGroup(parseFns, function interpolateFnWatcher(values, oldValues) {
307+
var currValue = compute(values);
308+
if (isFunction(listener)) {
309+
listener.call(this, currValue, values !== oldValues ? lastValue : currValue, scope);
310+
}
311+
lastValue = currValue;
312+
}, objectEquality, deregisterNotifier);
313+
}
340314
});
341315
}
316+
317+
function parseStringifyInterceptor(value) {
318+
try {
319+
return stringify(getValue(value));
320+
} catch(err) {
321+
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text,
322+
err.toString());
323+
$exceptionHandler(newErr);
324+
}
325+
}
342326
}
343327

344328

src/ng/parse.js

+73-18
Original file line numberDiff line numberDiff line change
@@ -991,33 +991,88 @@ function $ParseProvider() {
991991
this.$get = ['$filter', '$sniffer', function($filter, $sniffer) {
992992
$parseOptions.csp = $sniffer.csp;
993993

994-
return function(exp) {
995-
var parsedExpression;
994+
return function(exp, interceptorFn) {
995+
var parsedExpression, oneTime,
996+
cacheKey = (exp = trim(exp));
996997

997998
switch (typeof exp) {
998999
case 'string':
999-
1000-
if (cache.hasOwnProperty(exp)) {
1001-
return cache[exp];
1000+
if (cache.hasOwnProperty(cacheKey)) {
1001+
parsedExpression = cache[cacheKey];
1002+
} else {
1003+
if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
1004+
oneTime = true;
1005+
exp = exp.substring(2);
1006+
}
1007+
1008+
var lexer = new Lexer($parseOptions);
1009+
var parser = new Parser(lexer, $filter, $parseOptions);
1010+
parsedExpression = parser.parse(exp);
1011+
1012+
if (parsedExpression.constant) parsedExpression.$$watchDelegate = constantWatch;
1013+
else if (oneTime) parsedExpression.$$watchDelegate = oneTimeWatch;
1014+
1015+
if (cacheKey !== 'hasOwnProperty') {
1016+
// Only cache the value if it's not going to mess up the cache object
1017+
// This is more performant that using Object.prototype.hasOwnProperty.call
1018+
cache[cacheKey] = parsedExpression;
1019+
}
10021020
}
1021+
return addInterceptor(parsedExpression, interceptorFn);
10031022

1004-
var lexer = new Lexer($parseOptions);
1005-
var parser = new Parser(lexer, $filter, $parseOptions);
1006-
parsedExpression = parser.parse(exp);
1023+
case 'function':
1024+
return addInterceptor(exp, interceptorFn);
10071025

1008-
if (exp !== 'hasOwnProperty') {
1009-
// Only cache the value if it's not going to mess up the cache object
1010-
// This is more performant that using Object.prototype.hasOwnProperty.call
1011-
cache[exp] = parsedExpression;
1012-
}
1026+
default:
1027+
return addInterceptor(noop, interceptorFn);
1028+
}
1029+
};
10131030

1014-
return parsedExpression;
1031+
function oneTimeWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) {
1032+
var unwatch, lastValue;
1033+
return unwatch = scope.$watch(function oneTimeWatch(scope) {
1034+
return parsedExpression(scope);
1035+
}, function oneTimeListener(value, old, scope) {
1036+
lastValue = value;
1037+
if (isFunction(listener)) {
1038+
listener.apply(this, arguments);
1039+
}
1040+
if (isDefined(value)) {
1041+
scope.$$postDigest(function () {
1042+
if (isDefined(lastValue)) {
1043+
unwatch();
1044+
}
1045+
});
1046+
}
1047+
}, objectEquality, deregisterNotifier);
1048+
}
10151049

1016-
case 'function':
1017-
return exp;
1050+
function constantWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) {
1051+
var unwatch;
1052+
return unwatch = scope.$watch(function constantWatch(scope) {
1053+
return parsedExpression(scope);
1054+
}, function constantListener(value, old, scope) {
1055+
if (isFunction(listener)) {
1056+
listener.apply(this, arguments);
1057+
}
1058+
unwatch();
1059+
}, objectEquality, deregisterNotifier);
1060+
}
10181061

1019-
default:
1020-
return noop;
1062+
function addInterceptor(parsedExpression, interceptorFn) {
1063+
if (isFunction(interceptorFn)) {
1064+
var fn = function interceptedExpression(scope, locals) {
1065+
var value = parsedExpression(scope, locals);
1066+
var result = interceptorFn(value, scope, locals);
1067+
// we only return the interceptor's result if the
1068+
// initial value is defined (for bind-once)
1069+
return isDefined(value) ? result : value;
1070+
};
1071+
fn.$$watchDelegate = parsedExpression.$$watchDelegate;
1072+
return fn;
1073+
} else {
1074+
return parsedExpression;
10211075
}
1076+
}
10221077
}];
10231078
}

0 commit comments

Comments
 (0)