-
Notifications
You must be signed in to change notification settings - Fork 27.4k
perf(ngClass): refactor to optimize the case of static map of classes with large objects #14404
Conversation
@@ -409,6 +409,78 @@ describe('ngClass', function() { | |||
expect(e2.hasClass('even')).toBeTruthy(); | |||
expect(e2.hasClass('odd')).toBeFalsy(); | |||
})); | |||
|
|||
it('should do value stabilization as expected when one-time binding', inject(function($rootScope, $compile) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, wrap lines at 100 chars.
For one time binding is getting hard, I just cannot previously convert to a plain string because of: it('stabilizes when in a literal are not undefineds', inject(function($rootScope, $compile) {
$rootScope.foo = true;
element = $compile('<div ng-class="::{foo:foo,bar:bar}"></div>')($rootScope);
$rootScope.$digest();
expect(element).toHaveClass('foo');
$rootScope.bar = true;
$rootScope.$digest();
expect(element).toHaveClass('bar');
$rootScope.bar = false;
$rootScope.$digest();
expect(element).toHaveClass('bar');
})); In the current implementation the watcher watches a string which cannot contain undefineds. I will change the strategy. Fortunately it is only one level: // this test fails (nested undefineds)
it('stabilises when nested?', inject(function($rootScope, $compile) {
$rootScope.foo = true;
element = $compile('<div ng-class="::[{foo:foo,bar:bar}]"></div>')($rootScope);
$rootScope.$digest();
expect(element).toHaveClass('foo');
$rootScope.bar = true;
$rootScope.$digest();
expect(element).toHaveClass('bar');
$rootScope.bar = false;
$rootScope.$digest();
expect(element).toHaveClass('bar');
})); from function oneTimeLiteralWatchDelegate(scope, listener, objectEquality, parsedExpression) {
var unwatch, lastValue;
return unwatch = scope.$watch(function oneTimeWatch(scope) {
return parsedExpression(scope);
}, function oneTimeListener(value, old, scope) {
lastValue = value;
if (isFunction(listener)) {
listener.call(this, value, old, scope);
}
if (isAllDefined(value)) {
scope.$$postDigest(function() {
if (isAllDefined(lastValue)) unwatch();
});
}
}, objectEquality);
function isAllDefined(value) {
var allDefined = true;
forEach(value, function(val) {
if (!isDefined(val)) allDefined = false;
});
return allDefined;
}
} |
d01e0c9
to
6b4ba2b
Compare
@gkalpak ¿can you take a look to: it seems that the following test fails randomly in Firefox:
|
64bae8d
to
0a5b49f
Compare
Yeah, I've seen it too. I thought these were flakes. I mean, they are flakes - but they seem to be more common than other flakes. These tests are "flake-prone". Maybe #14912 will make them go away. |
0a5b49f
to
4d4c88c
Compare
Indeed. Firefox 28 is quite old :D |
It's green now. |
Did this LGTY back in July @gkalpak ? |
@drpicox can you please rebase this? |
Not if I didn't say so 😃 |
Ops! I'll do it now. |
4d4c88c
to
5fe61c2
Compare
Rebased. Waiting for travis tests. |
Green. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There a couple of issues with this implementation.
TBH, I feel like there should be a more straight-forward way to achieve the same. I'll poke around.
attr.$observe('class', function(value) { | ||
ngClassWatchAction(scope.$eval(attr[name])); | ||
compile: function(tElement, tAttr) { | ||
var classFn = $parse(tAttr[name], function(value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having this in compile
is an (unnecessary) breaking chage: Previously other directives would be able to modify the value before ngClass
' linking. (Unlikely that anyone is actually relying on it, but we don't gain anything by breaking it either.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did it because of performance reasons: ng-class are often used inside ngRepeat in which compile is executed once but link many. It can be undone.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The overhead should be negligible (and not enough to justify the breaking change).
(Note that $parse
has a cache, so it doesn't have to actually lex/parse the same expressiom twice. It only has to retrieve the parsed expression and apply the interceptor.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll put it again inside a link function.
I suggest that it can be considered a "major upgrade" to do such refactor of all parse expression code from link functions to compile functions, in order to improve performance when ng-repeat & others are involved.
/* eslint-enable */ | ||
}); | ||
} | ||
scope.$watch(classFn, ngClassWatchAction, classArrayEquals); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 3rd argument is expected to be boolean. Passing a function is interpreted as true
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sure? It dit it some time ago, so I'm not sure, but I remember look for this code inside angular and the third argument become the comparator if was not true (some kind of undocumented function). I'll check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there is a PR for adding that feature, but right now it is treated a boolean.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll check, I was wrong. It takes classArrayEquals, using true, all tests passes as expected, no extra copies performed (I have also check the $watch code).
So it is updated, Thanks.
} else if (!classArrayEquals(oldClasses, newClasses)) { | ||
updateClasses(oldClasses, newClasses); | ||
} | ||
oldClasses = shallowCopy(newClasses); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By not storing the oldClasses
when (scope.$index & 1) !== selector
, you could end up with an outdated oldClasses
value when the $index
changes. There is a similar bug currently in the scope.$index
watch above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try to write a test to make it fail and fix it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to find a test to make it break, I found a case that breaks angular in older versions (at least since 1.3.8 -I didn't tried more-). Is the case of the first scope.$watch that you comment.
I have the plnkr here: https://plnkr.co/edit/W1ck8dS08TCJpirWiGVA?p=preview
I solve it here, but I do not know if I should create another PR for solving it outside this PR, what did you suggest?
I tried to find a failing test for the shallow copy inside the condition so I should move it. But I have found that moving the shallow copy outside the condition interferes with the $index $watch and make the new test fails.
}) | ||
); | ||
|
||
it('should do value remove the watcher when static array one-time binding', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should do value remove --> should remove
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks
}) | ||
); | ||
|
||
it('should do value remove the watcher when static map one-time binding', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should do value remove --> should remove
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks
$rootScope.classVar[0].orange = false; | ||
$rootScope.$digest(); | ||
|
||
expect(element).not.toHaveClass('orange'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check that the class was present before changing orange
to false.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
e0d7ea8
to
510021d
Compare
…ide arrays Mostly taken from angular#14404.
The cases that should benefit most are: 1. When using large objects as keys (e.g.: `{loaded: $ctrl.data}`). 2. When using objects/arrays and there are frequent changes. 3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s). The differences in operations per digest include: 1. `Regular expression (when not changed)` **Before:** `equals()` **After:** `toClassString()` 2. `Regular expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** 2 x `split()` 3. `One-time expression (when not changed)` **Before:** `equals()` **After:** `toFlatValue()` + `equals()`* 4. `One-time expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** `copy()`* + `toClassString()`* + 2 x `split()` 5. `$index modulo changed` **Before:** `arrayClasses()` **After:** - (*): on flatter structure In large based on angular#14404. Kudos to @drpicox for the initial idea and a big part of the implementation. Closes angular#14404
I have an alternative PR (#15228) which includes several refactorings, the fix mentioned above and some perf improvements (that should cover the ones in this PR and more). I have "borrowed" the tests and part of the implementation 😁 WDYT? |
The cases that should benefit most are: 1. When using large objects as values (e.g.: `{loaded: $ctrl.data}`). 2. When using objects/arrays and there are frequent changes. 3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s). The differences in operations per digest include: 1. `Regular expression (when not changed)` **Before:** `equals()` **After:** `toClassString()` 2. `Regular expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** 2 x `split()` 3. `One-time expression (when not changed)` **Before:** `equals()` **After:** `toFlatValue()` + `equals()`* 4. `One-time expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** `copy()`* + `toClassString()`* + 2 x `split()` 5. `$index modulo changed` **Before:** `arrayClasses()` **After:** - (*): on flatter structure In large based on angular#14404. Kudos to @drpicox for the initial idea and a big part of the implementation. Closes angular#14404
You have done a really good job 👍 |
510021d
to
01e395a
Compare
The cases that should benefit most are: 1. When using large objects as values (e.g.: `{loaded: $ctrl.data}`). 2. When using objects/arrays and there are frequent changes. 3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s). The differences in operations per digest include: 1. `Regular expression (when not changed)` **Before:** `equals()` **After:** `toClassString()` 2. `Regular expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** 2 x `split()` 3. `One-time expression (when not changed)` **Before:** `equals()` **After:** `toFlatValue()` + `equals()`* 4. `One-time expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** `copy()`* + `toClassString()`* + 2 x `split()` 5. `$index modulo changed` **Before:** `arrayClasses()` **After:** - (*): on flatter structure In large based on angular#14404. Kudos to @drpicox for the initial idea and a big part of the implementation. Closes angular#14404
…d copies Includes the following commits (see #15246 for details): - **perf(ngClass): only access the element's `data` once** - **refactor(ngClass): simplify conditions** - **refactor(ngClass): move helper functions outside the closure** - **refactor(ngClass): exit `arrayDifference()` early if an input is empty** - **perf(ngClass): avoid deep-watching (if possible) and unnecessary copies** The cases that should benefit most are: 1. When using large objects as values (e.g.: `{loaded: $ctrl.data}`). 2. When using objects/arrays and there are frequent changes. 3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s). The differences in operations per digest include: 1. `Regular expression (when not changed)` **Before:** `equals()` **After:** `toClassString()` 2. `Regular expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** 2 x `split()` 3. `One-time expression (when not changed)` **Before:** `equals()` **After:** `toFlatValue()` + `equals()`* 4. `One-time expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** `copy()`* + `toClassString()`* + 2 x `split()` 5. `$index modulo changed` **Before:** `arrayClasses()` **After:** - (*): on flatter structure In large based on #14404. Kudos to @drpicox for the initial idea and a big part of the implementation. Closes #14404 Closes #15246
…d copies Includes the following commits (see angular#15246 for details): - **perf(ngClass): only access the element's `data` once** - **refactor(ngClass): simplify conditions** - **refactor(ngClass): move helper functions outside the closure** - **refactor(ngClass): exit `arrayDifference()` early if an input is empty** - **perf(ngClass): avoid deep-watching (if possible) and unnecessary copies** The cases that should benefit most are: 1. When using large objects as values (e.g.: `{loaded: $ctrl.data}`). 2. When using objects/arrays and there are frequent changes. 3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s). The differences in operations per digest include: 1. `Regular expression (when not changed)` **Before:** `equals()` **After:** `toClassString()` 2. `Regular expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** 2 x `split()` 3. `One-time expression (when not changed)` **Before:** `equals()` **After:** `toFlatValue()` + `equals()`* 4. `One-time expression (when changed)` **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()` **After:** `copy()`* + `toClassString()`* + 2 x `split()` 5. `$index modulo changed` **Before:** `arrayClasses()` **After:** - (*): on flatter structure In large based on angular#14404. Kudos to @drpicox for the initial idea and a big part of the implementation. Closes angular#14404 Closes angular#15246
What kind of change does this PR introduce? (Bug fix, feature, docs update, ...): performance improvement
What is the current behavior? (You can also link to an open issue here): it makes copies of very large objects:
What is the new behavior (if this is a feature change)?: the user perceives no change but that it is faster with very large objects
Does this PR introduce a breaking change?: no
Please check if the PR fulfills these requirements
Other information:
This PR responds to #14394 (comment)
It is alternative to #14394