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

Commit cee429f

Browse files
lgalfasoIgorMinar
authored andcommitted
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
1 parent 701ed5f commit cee429f

13 files changed

+503
-18
lines changed

docs/content/guide/expression.ngdoc

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

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

src/ng/compile.js

+4
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
14851485
parentSet(scope, parentValue = isolateScope[scopeName]);
14861486
}
14871487
}
1488+
parentValueWatch.$$unwatch = parentGet.$$unwatch;
14881489
return lastValue = parentValue;
14891490
}, null, parentGet.literal);
14901491
break;
@@ -1813,6 +1814,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
18131814
compile: valueFn(function textInterpolateLinkFn(scope, node) {
18141815
var parent = node.parent(),
18151816
bindings = parent.data('$binding') || [];
1817+
// Need to interpolate again in case this is using one-time bindings in multiple clones
1818+
// of transcluded templates.
1819+
interpolateFn = $interpolate(text);
18161820
bindings.push(interpolateFn);
18171821
safeAddClass(parent.data('$binding', bindings), 'ng-binding');
18181822
scope.$watch(interpolateFn, function interpolateFnWatchAction(value) {

src/ng/directive/ngBind.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
174174
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);
175175

176176
var parsed = $parse(attr.ngBindHtml);
177-
function getStringValue() { return (parsed(scope) || '').toString(); }
177+
function getStringValue() {
178+
var value = parsed(scope);
179+
getStringValue.$$unwatch = parsed.$$unwatch;
180+
return (value || '').toString();
181+
}
178182

179183
scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
180184
element.html($sce.getTrustedHtml(parsed(scope)) || '');

src/ng/interpolate.js

+3
Original file line numberDiff line numberDiff line change
@@ -309,16 +309,19 @@ function $InterpolateProvider() {
309309

310310

311311
try {
312+
interpolationFn.$$unwatch = true;
312313
for (; i < ii; i++) {
313314
val = getValue(parseFns[i](context));
314315
if (allOrNothing && isUndefined(val)) {
316+
interpolationFn.$$unwatch = undefined;
315317
return;
316318
}
317319
val = stringify(val);
318320
if (val !== lastValues[i]) {
319321
inputsChanged = true;
320322
}
321323
values[i] = val;
324+
interpolationFn.$$unwatch = interpolationFn.$$unwatch && parseFns[i].$$unwatch;
322325
}
323326

324327
if (inputsChanged) {

src/ng/parse.js

+38-3
Original file line numberDiff line numberDiff line change
@@ -1018,13 +1018,19 @@ function $ParseProvider() {
10181018
$parseOptions.csp = $sniffer.csp;
10191019

10201020
return function(exp) {
1021-
var parsedExpression;
1021+
var parsedExpression,
1022+
oneTime;
10221023

10231024
switch (typeof exp) {
10241025
case 'string':
10251026

1027+
if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
1028+
oneTime = true;
1029+
exp = exp.substring(2);
1030+
}
1031+
10261032
if (cache.hasOwnProperty(exp)) {
1027-
return cache[exp];
1033+
return oneTime ? oneTimeWrapper(cache[exp]) : cache[exp];
10281034
}
10291035

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

1040-
return parsedExpression;
1046+
if (parsedExpression.constant) {
1047+
parsedExpression.$$unwatch = true;
1048+
}
1049+
1050+
return oneTime ? oneTimeWrapper(parsedExpression) : parsedExpression;
10411051

10421052
case 'function':
10431053
return exp;
10441054

10451055
default:
10461056
return noop;
10471057
}
1058+
1059+
function oneTimeWrapper(expression) {
1060+
var stable = false,
1061+
lastValue;
1062+
oneTimeParseFn.literal = expression.literal;
1063+
oneTimeParseFn.constant = expression.constant;
1064+
oneTimeParseFn.assign = expression.assign;
1065+
return oneTimeParseFn;
1066+
1067+
function oneTimeParseFn(self, locals) {
1068+
if (!stable) {
1069+
lastValue = expression(self, locals);
1070+
oneTimeParseFn.$$unwatch = isDefined(lastValue);
1071+
if (oneTimeParseFn.$$unwatch && self && self.$$postDigestQueue) {
1072+
self.$$postDigestQueue.push(function () {
1073+
// create a copy if the value is defined and it is not a $sce value
1074+
if ((stable = isDefined(lastValue)) && !lastValue.$$unwrapTrustedValue) {
1075+
lastValue = copy(lastValue);
1076+
}
1077+
});
1078+
}
1079+
}
1080+
return lastValue;
1081+
}
1082+
}
10481083
};
10491084
}];
10501085
}

src/ng/rootScope.js

+25-10
Original file line numberDiff line numberDiff line change
@@ -338,14 +338,6 @@ function $RootScopeProvider(){
338338
watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);};
339339
}
340340

341-
if (typeof watchExp == 'string' && get.constant) {
342-
var originalFn = watcher.fn;
343-
watcher.fn = function(newVal, oldVal, scope) {
344-
originalFn.call(this, newVal, oldVal, scope);
345-
arrayRemove(array, watcher);
346-
};
347-
}
348-
349341
if (!array) {
350342
array = scope.$$watchers = [];
351343
}
@@ -391,24 +383,37 @@ function $RootScopeProvider(){
391383
var deregisterFns = [];
392384
var changeCount = 0;
393385
var self = this;
386+
var unwatchFlags = new Array(watchExpressions.length);
387+
var unwatchCount = watchExpressions.length;
394388

395389
forEach(watchExpressions, function (expr, i) {
396-
deregisterFns.push(self.$watch(expr, function (value, oldValue) {
390+
var exprFn = $parse(expr);
391+
deregisterFns.push(self.$watch(exprFn, function (value, oldValue) {
397392
newValues[i] = value;
398393
oldValues[i] = oldValue;
399394
changeCount++;
395+
if (unwatchFlags[i] && !exprFn.$$unwatch) unwatchCount++;
396+
if (!unwatchFlags[i] && exprFn.$$unwatch) unwatchCount--;
397+
unwatchFlags[i] = exprFn.$$unwatch;
400398
}));
401399
}, this);
402400

403-
deregisterFns.push(self.$watch(function () {return changeCount;}, function () {
401+
deregisterFns.push(self.$watch(watchGroupFn, function () {
404402
listener(newValues, oldValues, self);
403+
if (unwatchCount === 0) {
404+
watchGroupFn.$$unwatch = true;
405+
} else {
406+
watchGroupFn.$$unwatch = false;
407+
}
405408
}));
406409

407410
return function deregisterWatchGroup() {
408411
forEach(deregisterFns, function (fn) {
409412
fn();
410413
});
411414
};
415+
416+
function watchGroupFn() {return changeCount;}
412417
},
413418

414419

@@ -553,6 +558,7 @@ function $RootScopeProvider(){
553558
}
554559
}
555560
}
561+
$watchCollectionWatch.$$unwatch = objGetter.$$unwatch;
556562
return changeDetected;
557563
}
558564

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

649656
beginPhase('$digest');
@@ -694,6 +701,7 @@ function $RootScopeProvider(){
694701
logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
695702
watchLog[logIdx].push(logMsg);
696703
}
704+
if (watch.get.$$unwatch) stableWatchesCandidates.push({watch: watch, array: watchers});
697705
} else if (watch === lastDirtyWatch) {
698706
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
699707
// have already been tested.
@@ -740,6 +748,13 @@ function $RootScopeProvider(){
740748
$exceptionHandler(e);
741749
}
742750
}
751+
752+
for (length = stableWatchesCandidates.length - 1; length >= 0; --length) {
753+
var candidate = stableWatchesCandidates[length];
754+
if (candidate.watch.get.$$unwatch) {
755+
arrayRemove(candidate.array, candidate.watch);
756+
}
757+
}
743758
},
744759

745760

src/ng/sce.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,9 @@ function $SceProvider() {
787787
return parsed;
788788
} else {
789789
return function sceParseAsTrusted(self, locals) {
790-
return sce.getTrusted(type, parsed(self, locals));
790+
var result = sce.getTrusted(type, parsed(self, locals));
791+
sceParseAsTrusted.$$unwatch = parsed.$$unwatch;
792+
return result;
791793
};
792794
}
793795
};

0 commit comments

Comments
 (0)