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

Commit fca6be7

Browse files
jbedardIgorMinar
authored andcommitted
perf($parse): execute watched expressions only when the inputs change
With this change, expressions like "firstName + ' ' + lastName | uppercase" will be analyzed and only the inputs for the expression will be watched (in this case "firstName" and "lastName"). Only when at least one of the inputs change, the expression will be evaluated. This change speeds up simple expressions like `firstName | noop` by ~15% and more complex expressions like `startDate | date` by ~2500%. BREAKING CHANGE: all filters are assumed to be stateless functions Previously it was a good practice to make all filters stateless, but now it's a requirement in order for the model change-observation to pick up all changes. If an existing filter is statefull, it can be flagged as such but keep in mind that this will result in a significant performance-penalty (or rather lost opportunity to benefit from a major perf improvement) that will affect the $digest duration. To flag a filter as stateful do the following: myApp.filter('myFilter', function() { function myFilter(input) { ... }; myFilter.$stateful = true; return myFilter; }); Closes #9006 Closes #9082
1 parent ec9c0d7 commit fca6be7

File tree

6 files changed

+386
-24
lines changed

6 files changed

+386
-24
lines changed

benchmarks/parsed-expressions-bp/main.html

+16
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
<label for="operators">Binary/Unary operators</label>
3232
</li>
3333

34+
<li>
35+
<input type="radio" ng-model="expressionType" value="shortCircuitingOperators" id="shortCircuitingOperators">
36+
<label for="shortCircuitingOperators">AND/OR short-circuiting operators</label>
37+
</li>
38+
3439
<li>
3540
<input type="radio" ng-model="expressionType" value="filters" id="filters">
3641
<label for="filters">Filters</label>
@@ -134,6 +139,17 @@
134139
<span bm-pe-watch="-rowIdx * 2 * rowIdx + rowIdx / rowIdx + 1"></span>
135140
</li>
136141

142+
<li ng-switch-when="shortCircuitingOperators" ng-repeat="(rowIdx, row) in ::data">
143+
<span bm-pe-watch="rowIdx && row.odd"></span>
144+
<span bm-pe-watch="row.odd && row.even"></span>
145+
<span bm-pe-watch="row.odd && !row.even"></span>
146+
<span bm-pe-watch="row.odd || row.even"></span>
147+
<span bm-pe-watch="row.odd || row.even || row.index"></span>
148+
<span bm-pe-watch="row.index === 1 || row.index === 2"></span>
149+
<span bm-pe-watch="row.num0 < row.num1 && row.num1 < row.num2"></span>
150+
<span bm-pe-watch="row.num0 < row.num1 || row.num1 < row.num2"></span>
151+
</li>
152+
137153
<li ng-switch-when="filters" ng-repeat="(rowIdx, row) in ::data">
138154
<span bm-pe-watch="rowIdx | noop"></span>
139155
<span bm-pe-watch="rowIdx | noop"></span>

src/ng/compile.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -1725,7 +1725,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
17251725
attrs[attrName], newIsolateScopeDirective.name);
17261726
};
17271727
lastValue = isolateBindingContext[scopeName] = parentGet(scope);
1728-
var unwatch = scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) {
1728+
var parentValueWatch = function parentValueWatch(parentValue) {
17291729
if (!compare(parentValue, isolateBindingContext[scopeName])) {
17301730
// we are out of sync and need to copy
17311731
if (!compare(parentValue, lastValue)) {
@@ -1737,7 +1737,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
17371737
}
17381738
}
17391739
return lastValue = parentValue;
1740-
}), null, parentGet.literal);
1740+
};
1741+
parentValueWatch.$stateful = true;
1742+
var unwatch = scope.$watch($parse(attrs[attrName], parentValueWatch), null, parentGet.literal);
17411743
isolateScope.$on('$destroy', unwatch);
17421744
break;
17431745

src/ng/parse.js

+122-20
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,10 @@ Lexer.prototype = {
376376
};
377377

378378

379+
function isConstant(exp) {
380+
return exp.constant;
381+
}
382+
379383
/**
380384
* @constructor
381385
*/
@@ -493,7 +497,8 @@ Parser.prototype = {
493497
return extend(function(self, locals) {
494498
return fn(self, locals, right);
495499
}, {
496-
constant:right.constant
500+
constant:right.constant,
501+
inputs: [right]
497502
});
498503
},
499504

@@ -505,11 +510,12 @@ Parser.prototype = {
505510
});
506511
},
507512

508-
binaryFn: function(left, fn, right) {
513+
binaryFn: function(left, fn, right, isBranching) {
509514
return extend(function(self, locals) {
510515
return fn(self, locals, left, right);
511516
}, {
512-
constant:left.constant && right.constant
517+
constant: left.constant && right.constant,
518+
inputs: !isBranching && [left, right]
513519
});
514520
},
515521

@@ -557,7 +563,9 @@ Parser.prototype = {
557563
}
558564
}
559565

560-
return function $parseFilter(self, locals) {
566+
var inputs = [inputFn].concat(argsFn || []);
567+
568+
return extend(function $parseFilter(self, locals) {
561569
var input = inputFn(self, locals);
562570
if (args) {
563571
args[0] = input;
@@ -571,7 +579,10 @@ Parser.prototype = {
571579
}
572580

573581
return fn(input);
574-
};
582+
}, {
583+
constant: !fn.$stateful && inputs.every(isConstant),
584+
inputs: !fn.$stateful && inputs
585+
});
575586
},
576587

577588
expression: function() {
@@ -588,9 +599,11 @@ Parser.prototype = {
588599
this.text.substring(0, token.index) + '] can not be assigned to', token);
589600
}
590601
right = this.ternary();
591-
return function $parseAssignment(scope, locals) {
602+
return extend(function $parseAssignment(scope, locals) {
592603
return left.assign(scope, right(scope, locals), locals);
593-
};
604+
}, {
605+
inputs: [left, right]
606+
});
594607
}
595608
return left;
596609
},
@@ -615,7 +628,7 @@ Parser.prototype = {
615628
var left = this.logicalAND();
616629
var token;
617630
while ((token = this.expect('||'))) {
618-
left = this.binaryFn(left, token.fn, this.logicalAND());
631+
left = this.binaryFn(left, token.fn, this.logicalAND(), true);
619632
}
620633
return left;
621634
},
@@ -624,7 +637,7 @@ Parser.prototype = {
624637
var left = this.equality();
625638
var token;
626639
if ((token = this.expect('&&'))) {
627-
left = this.binaryFn(left, token.fn, this.logicalAND());
640+
left = this.binaryFn(left, token.fn, this.logicalAND(), true);
628641
}
629642
return left;
630643
},
@@ -759,7 +772,6 @@ Parser.prototype = {
759772
// This is used with json array declaration
760773
arrayDeclaration: function () {
761774
var elementFns = [];
762-
var allConstant = true;
763775
if (this.peekToken().text !== ']') {
764776
do {
765777
if (this.peek(']')) {
@@ -768,9 +780,6 @@ Parser.prototype = {
768780
}
769781
var elementFn = this.expression();
770782
elementFns.push(elementFn);
771-
if (!elementFn.constant) {
772-
allConstant = false;
773-
}
774783
} while (this.expect(','));
775784
}
776785
this.consume(']');
@@ -783,13 +792,13 @@ Parser.prototype = {
783792
return array;
784793
}, {
785794
literal: true,
786-
constant: allConstant
795+
constant: elementFns.every(isConstant),
796+
inputs: elementFns
787797
});
788798
},
789799

790800
object: function () {
791801
var keys = [], valueFns = [];
792-
var allConstant = true;
793802
if (this.peekToken().text !== '}') {
794803
do {
795804
if (this.peek('}')) {
@@ -801,9 +810,6 @@ Parser.prototype = {
801810
this.consume(':');
802811
var value = this.expression();
803812
valueFns.push(value);
804-
if (!value.constant) {
805-
allConstant = false;
806-
}
807813
} while (this.expect(','));
808814
}
809815
this.consume('}');
@@ -816,7 +822,8 @@ Parser.prototype = {
816822
return object;
817823
}, {
818824
literal: true,
819-
constant: allConstant
825+
constant: valueFns.every(isConstant),
826+
inputs: valueFns
820827
});
821828
}
822829
};
@@ -1043,6 +1050,8 @@ function $ParseProvider() {
10431050
parsedExpression = wrapSharedExpression(parsedExpression);
10441051
parsedExpression.$$watchDelegate = parsedExpression.literal ?
10451052
oneTimeLiteralWatchDelegate : oneTimeWatchDelegate;
1053+
} else if (parsedExpression.inputs) {
1054+
parsedExpression.$$watchDelegate = inputsWatchDelegate;
10461055
}
10471056

10481057
cache[cacheKey] = parsedExpression;
@@ -1057,6 +1066,88 @@ function $ParseProvider() {
10571066
}
10581067
};
10591068

1069+
function collectExpressionInputs(inputs, list) {
1070+
for (var i = 0, ii = inputs.length; i < ii; i++) {
1071+
var input = inputs[i];
1072+
if (!input.constant) {
1073+
if (input.inputs) {
1074+
collectExpressionInputs(input.inputs, list);
1075+
} else if (list.indexOf(input) === -1) { // TODO(perf) can we do better?
1076+
list.push(input);
1077+
}
1078+
}
1079+
}
1080+
1081+
return list;
1082+
}
1083+
1084+
function expressionInputDirtyCheck(newValue, oldValueOfValue) {
1085+
1086+
if (newValue == null || oldValueOfValue == null) { // null/undefined
1087+
return newValue === oldValueOfValue;
1088+
}
1089+
1090+
if (typeof newValue === 'object') {
1091+
1092+
// attempt to convert the value to a primitive type
1093+
// TODO(docs): add a note to docs that by implementing valueOf even objects and arrays can
1094+
// be cheaply dirty-checked
1095+
newValue = newValue.valueOf();
1096+
1097+
if (typeof newValue === 'object') {
1098+
// objects/arrays are not supported - deep-watching them would be too expensive
1099+
return false;
1100+
}
1101+
1102+
// fall-through to the primitive equality check
1103+
}
1104+
1105+
//Primitive or NaN
1106+
return newValue === oldValueOfValue || (newValue !== newValue && oldValueOfValue !== oldValueOfValue);
1107+
}
1108+
1109+
function inputsWatchDelegate(scope, listener, objectEquality, parsedExpression) {
1110+
var inputExpressions = parsedExpression.$$inputs ||
1111+
(parsedExpression.$$inputs = collectExpressionInputs(parsedExpression.inputs, []));
1112+
1113+
var lastResult;
1114+
1115+
if (inputExpressions.length === 1) {
1116+
var oldInputValue = expressionInputDirtyCheck; // init to something unique so that equals check fails
1117+
inputExpressions = inputExpressions[0];
1118+
return scope.$watch(function expressionInputWatch(scope) {
1119+
var newInputValue = inputExpressions(scope);
1120+
if (!expressionInputDirtyCheck(newInputValue, oldInputValue)) {
1121+
lastResult = parsedExpression(scope);
1122+
oldInputValue = newInputValue && newInputValue.valueOf();
1123+
}
1124+
return lastResult;
1125+
}, listener, objectEquality);
1126+
}
1127+
1128+
var oldInputValueOfValues = [];
1129+
for (var i = 0, ii = inputExpressions.length; i < ii; i++) {
1130+
oldInputValueOfValues[i] = expressionInputDirtyCheck; // init to something unique so that equals check fails
1131+
}
1132+
1133+
return scope.$watch(function expressionInputsWatch(scope) {
1134+
var changed = false;
1135+
1136+
for (var i = 0, ii = inputExpressions.length; i < ii; i++) {
1137+
var newInputValue = inputExpressions[i](scope);
1138+
if (changed || (changed = !expressionInputDirtyCheck(newInputValue, oldInputValueOfValues[i]))) {
1139+
oldInputValueOfValues[i] = newInputValue && newInputValue.valueOf();
1140+
}
1141+
}
1142+
1143+
if (changed) {
1144+
lastResult = parsedExpression(scope);
1145+
}
1146+
1147+
return lastResult;
1148+
}, listener, objectEquality);
1149+
}
1150+
10601151
function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression) {
10611152
var unwatch, lastValue;
10621153
return unwatch = scope.$watch(function oneTimeWatch(scope) {
@@ -1122,7 +1213,18 @@ function $ParseProvider() {
11221213
// initial value is defined (for bind-once)
11231214
return isDefined(value) ? result : value;
11241215
};
1125-
fn.$$watchDelegate = parsedExpression.$$watchDelegate;
1216+
1217+
// Propagate $$watchDelegates other then inputsWatchDelegate
1218+
if (parsedExpression.$$watchDelegate &&
1219+
parsedExpression.$$watchDelegate !== inputsWatchDelegate) {
1220+
fn.$$watchDelegate = parsedExpression.$$watchDelegate;
1221+
} else if (!interceptorFn.$stateful) {
1222+
// If there is an interceptor, but no watchDelegate then treat the interceptor like
1223+
// we treat filters - it is assumed to be a pure function unless flagged with $stateful
1224+
fn.$$watchDelegate = inputsWatchDelegate;
1225+
fn.inputs = [parsedExpression];
1226+
}
1227+
11261228
return fn;
11271229
}
11281230
}];

src/ng/rootScope.js

+2
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ function $RootScopeProvider(){
515515
* de-registration function is executed, the internal watch operation is terminated.
516516
*/
517517
$watchCollection: function(obj, listener) {
518+
$watchCollectionInterceptor.$stateful = true;
519+
518520
var self = this;
519521
// the current value, updated on each dirty-check run
520522
var newValue;

test/ng/directive/ngRepeatSpec.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -867,12 +867,15 @@ describe('ngRepeat', function() {
867867
// This creates one item, but it has no parent so we can't get to it
868868
$rootScope.items = [1, 2];
869869
$rootScope.$apply();
870+
expect(logs).toContain(1);
871+
expect(logs).toContain(2);
872+
logs.length = 0;
870873

871874
// This cleans up to prevent memory leak
872875
$rootScope.items = [];
873876
$rootScope.$apply();
874877
expect(angular.mock.dump(element)).toBe('<!-- ngRepeat: i in items -->');
875-
expect(logs).toEqual([1, 2, 1, 2]);
878+
expect(logs.length).toBe(0);
876879
}));
877880

878881

@@ -894,12 +897,15 @@ describe('ngRepeat', function() {
894897
// This creates one item, but it has no parent so we can't get to it
895898
$rootScope.items = [1, 2];
896899
$rootScope.$apply();
900+
expect(logs).toContain(1);
901+
expect(logs).toContain(2);
902+
logs.length = 0;
897903

898904
// This cleans up to prevent memory leak
899905
$rootScope.items = [];
900906
$rootScope.$apply();
901907
expect(sortedHtml(element)).toBe('<span>-</span><!-- ngRepeat: i in items --><span>-</span>');
902-
expect(logs).toEqual([1, 2, 1, 2]);
908+
expect(logs.length).toBe(0);
903909
}));
904910

905911

0 commit comments

Comments
 (0)