Skip to content

Commit 6e0ea0c

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 7bf85a0 commit 6e0ea0c

File tree

13 files changed

+675
-90
lines changed

13 files changed

+675
-90
lines changed

docs/content/guide/expression.ngdoc

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

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

src/ng/compile.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1492,8 +1492,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
14921492
attrs[attrName], newIsolateScopeDirective.name);
14931493
};
14941494
lastValue = isolateScope[scopeName] = parentGet(scope);
1495-
isolateScope.$watch(function parentValueWatch() {
1496-
var parentValue = parentGet(scope);
1495+
scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) {
14971496
if (!compare(parentValue, isolateScope[scopeName])) {
14981497
// we are out of sync and need to copy
14991498
if (!compare(parentValue, lastValue)) {
@@ -1505,7 +1504,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
15051504
}
15061505
}
15071506
return lastValue = parentValue;
1508-
}, null, parentGet.literal);
1507+
}), null, parentGet.literal);
1508+
15091509
break;
15101510

15111511
case '&':

src/ng/directive/ngBind.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,14 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
173173
return function(scope, element, attr) {
174174
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);
175175

176-
var parsed = $parse(attr.ngBindHtml);
177-
function getStringValue() { return (parsed(scope) || '').toString(); }
176+
var parsed = $parse(attr.ngBindHtml),
177+
changeDetector = $parse(attr.ngBindHtml, function getStringValue(value) {
178+
return (value || '').toString();
179+
});
178180

179-
scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
181+
scope.$watch(changeDetector, function ngBindHtmlWatchAction() {
182+
// we re-evaluate the expr because we want a TrustedValueHolderType
183+
// for $sce, not a string
180184
element.html($sce.getTrustedHtml(parsed(scope)) || '');
181185
});
182186
};

src/ng/interpolate.js

+26-42
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
}
@@ -255,12 +255,10 @@ function $InterpolateProvider() {
255255

256256
var getValue = function (value) {
257257
if (trustedContext) {
258-
value = $sce.getTrusted(trustedContext, value);
258+
return $sce.getTrusted(trustedContext, value);
259259
} else {
260-
value = $sce.valueOf(value);
260+
return $sce.valueOf(value);
261261
}
262-
263-
return value;
264262
};
265263

266264
var stringify = function (value) {
@@ -284,61 +282,47 @@ function $InterpolateProvider() {
284282
};
285283

286284
return extend(function interpolationFn(context) {
287-
var scopeId = (context && context.$id) || 'notAScope';
288-
var lastValues = lastValuesCache.values[scopeId];
289-
var lastResult = lastValuesCache.results[scopeId];
290285
var i = 0;
291286
var ii = expressions.length;
292287
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-
310288

311289
try {
312290
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;
291+
values[i] = parseFns[i](context);
322292
}
323293

324-
if (inputsChanged) {
325-
lastValuesCache.values[scopeId] = values;
326-
lastValuesCache.results[scopeId] = lastResult = compute(values);
327-
}
294+
return compute(values);
328295
} catch(err) {
329296
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text,
330297
err.toString());
331298
$exceptionHandler(newErr);
332299
}
333300

334-
return lastResult;
335301
}, {
336302
// all of these properties are undocumented for now
337303
exp: text, //just for compatibility with regular watchers created via $watch
338304
separators: separators,
339-
expressions: expressions
305+
expressions: expressions,
306+
$$beWatched: function (scope, listener, objectEquality, deregisterNotifier) {
307+
var lastValue;
308+
return scope.$watchGroup(parseFns, function interpolateFnWatcher(values, oldValues) {
309+
var currValue = compute(values);
310+
listener.call(this, currValue, values !== oldValues ? lastValue : currValue, scope);
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

+67-14
Original file line numberDiff line numberDiff line change
@@ -999,34 +999,87 @@ function $ParseProvider() {
999999
this.$get = ['$filter', '$sniffer', function($filter, $sniffer) {
10001000
$parseOptions.csp = $sniffer.csp;
10011001

1002-
return function(exp) {
1003-
var parsedExpression;
1002+
return function(exp, interceptorFn) {
1003+
var parsedExpression, oneTime,
1004+
cacheKey = exp;
10041005

10051006
switch (typeof exp) {
10061007
case 'string':
1008+
if (cache.hasOwnProperty(cacheKey)) {
1009+
parsedExpression = cache[cacheKey];
1010+
} else {
1011+
if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
1012+
oneTime = true;
1013+
exp = exp.substring(2);
1014+
}
10071015

1008-
if (cache.hasOwnProperty(exp)) {
1009-
return cache[exp];
1010-
}
1016+
var lexer = new Lexer($parseOptions);
1017+
var parser = new Parser(lexer, $filter, $parseOptions);
1018+
parsedExpression = parser.parse(exp);
10111019

1012-
var lexer = new Lexer($parseOptions);
1013-
var parser = new Parser(lexer, $filter, $parseOptions);
1014-
parsedExpression = parser.parse(exp);
1020+
if (oneTime) parsedExpression.$$beWatched = oneTimeWatch;
1021+
if (parsedExpression.constant) parsedExpression.$$beWatched = constantWatch;
10151022

1016-
if (exp !== 'hasOwnProperty') {
1023+
if (cacheKey !== 'hasOwnProperty') {
10171024
// Only cache the value if it's not going to mess up the cache object
10181025
// This is more performant that using Object.prototype.hasOwnProperty.call
1019-
cache[exp] = parsedExpression;
1026+
cache[cacheKey] = parsedExpression;
1027+
}
10201028
}
1021-
1022-
return parsedExpression;
1029+
return addInterceptor(parsedExpression, interceptorFn);
10231030

10241031
case 'function':
1025-
return exp;
1032+
return addInterceptor(exp, interceptorFn);
10261033

10271034
default:
1028-
return noop;
1035+
return addInterceptor(noop, interceptorFn);
10291036
}
10301037
};
1038+
1039+
function oneTimeWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) {
1040+
var unwatch;
1041+
return unwatch = scope.$watch(function oneTimeWatch(scope) {
1042+
return parsedExpression(scope);
1043+
}, function oneTimeListener(value, old, scope) {
1044+
if (isFunction(listener)) {
1045+
listener.call(this, value, old, scope);
1046+
}
1047+
if (isDefined(value)) {
1048+
scope.$$postDigest(function () {
1049+
if (isDefined(parsedExpression(scope))) {
1050+
unwatch();
1051+
}
1052+
});
1053+
}
1054+
}, objectEquality, deregisterNotifier);
1055+
}
1056+
1057+
function constantWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) {
1058+
var unwatch;
1059+
return unwatch = scope.$watch(function constantWatch(scope) {
1060+
return parsedExpression(scope);
1061+
}, function constantListener(value, old, scope) {
1062+
if (isFunction(listener)) {
1063+
listener.call(this, value, old, scope);
1064+
}
1065+
unwatch();
1066+
}, objectEquality, deregisterNotifier);
1067+
}
1068+
1069+
function addInterceptor(parsedExpression, interceptorFn) {
1070+
if (isFunction(interceptorFn)) {
1071+
var fn = function interceptedExpression(scope, locals) {
1072+
var value = parsedExpression(scope, locals);
1073+
var result = interceptorFn(value, scope, locals);
1074+
// we only return the interceptor's result if the
1075+
// initial value is defined (for bind-once)
1076+
return isDefined(value) ? result : value;
1077+
};
1078+
fn.$$beWatched = parsedExpression.$$beWatched;
1079+
return fn;
1080+
} else {
1081+
return parsedExpression;
1082+
}
1083+
}
10311084
}];
10321085
}

0 commit comments

Comments
 (0)