perf(ngClass): avoid unnecessary .data() accesses, deep-watching and 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
This commit is contained in:
Georgios Kalpakas
2016-10-07 21:14:44 +03:00
parent 5fd5e131b6
commit b82097085d
2 changed files with 187 additions and 106 deletions
+143 -87
View File
@@ -8,47 +8,71 @@
function classDirective(name, selector) {
name = 'ngClass' + name;
var indexWatchExpression;
return [function() {
return ['$parse', function($parse) {
return {
restrict: 'AC',
link: function(scope, element, attr) {
var oldVal;
var expression = attr[name].trim();
var isOneTime = (expression.charAt(0) === ':') && (expression.charAt(1) === ':');
if (name !== 'ngClass') {
scope.$watch('$index', function($index, old$index) {
/* eslint-disable no-bitwise */
var mod = $index & 1;
if (mod !== (old$index & 1)) {
var classes = arrayClasses(oldVal);
if (mod === selector) {
addClasses(classes);
} else {
removeClasses(classes);
}
}
/* eslint-enable */
});
}
var watchInterceptor = isOneTime ? toFlatValue : toClassString;
var watchExpression = $parse(expression, watchInterceptor);
var watchAction = isOneTime ? ngClassOneTimeWatchAction : ngClassWatchAction;
scope.$watch(attr[name], ngClassWatchAction, true);
var classCounts = element.data('$classCounts');
var oldModulo = true;
var oldClassString;
function addClasses(classes) {
var newClasses = digestClassCounts(classes, 1);
attr.$addClass(newClasses);
}
function removeClasses(classes) {
var newClasses = digestClassCounts(classes, -1);
attr.$removeClass(newClasses);
}
function digestClassCounts(classes, count) {
if (!classCounts) {
// Use createMap() to prevent class assumptions involving property
// names in Object.prototype
var classCounts = element.data('$classCounts') || createMap();
classCounts = createMap();
element.data('$classCounts', classCounts);
}
if (name !== 'ngClass') {
if (!indexWatchExpression) {
indexWatchExpression = $parse('$index', function moduloTwo($index) {
// eslint-disable-next-line no-bitwise
return $index & 1;
});
}
scope.$watch(indexWatchExpression, ngClassIndexWatchAction);
}
scope.$watch(watchExpression, watchAction, isOneTime);
function addClasses(classString) {
classString = digestClassCounts(split(classString), 1);
attr.$addClass(classString);
}
function removeClasses(classString) {
classString = digestClassCounts(split(classString), -1);
attr.$removeClass(classString);
}
function updateClasses(oldClassString, newClassString) {
var oldClassArray = split(oldClassString);
var newClassArray = split(newClassString);
var toRemoveArray = arrayDifference(oldClassArray, newClassArray);
var toAddArray = arrayDifference(newClassArray, oldClassArray);
var toRemoveString = digestClassCounts(toRemoveArray, -1);
var toAddString = digestClassCounts(toAddArray, 1);
attr.$addClass(toAddString);
attr.$removeClass(toRemoveString);
}
function digestClassCounts(classArray, count) {
var classesToUpdate = [];
forEach(classes, function(className) {
forEach(classArray, function(className) {
if (count > 0 || classCounts[className]) {
classCounts[className] = (classCounts[className] || 0) + count;
if (classCounts[className] === +(count > 0)) {
@@ -56,74 +80,106 @@ function classDirective(name, selector) {
}
}
});
element.data('$classCounts', classCounts);
return classesToUpdate.join(' ');
}
function updateClasses(oldClasses, newClasses) {
var toAdd = arrayDifference(newClasses, oldClasses);
var toRemove = arrayDifference(oldClasses, newClasses);
toAdd = digestClassCounts(toAdd, 1);
toRemove = digestClassCounts(toRemove, -1);
function ngClassIndexWatchAction(newModulo) {
// This watch-action should run before the `ngClass[OneTime]WatchAction()`, thus it
// adds/removes `oldClassString`. If the `ngClass` expression has changed as well, the
// `ngClass[OneTime]WatchAction()` will update the classes.
if (newModulo === selector) {
addClasses(oldClassString);
} else {
removeClasses(oldClassString);
}
attr.$addClass(toAdd);
attr.$removeClass(toRemove);
oldModulo = newModulo;
}
function ngClassWatchAction(newVal) {
// eslint-disable-next-line no-bitwise
if (selector === true || (scope.$index & 1) === selector) {
var newClasses = arrayClasses(newVal || []);
if (!oldVal) {
addClasses(newClasses);
} else if (!equals(newVal,oldVal)) {
var oldClasses = arrayClasses(oldVal);
updateClasses(oldClasses, newClasses);
}
function ngClassOneTimeWatchAction(newClassValue) {
var newClassString = toClassString(newClassValue);
if (newClassString !== oldClassString) {
ngClassWatchAction(newClassString);
}
if (isArray(newVal)) {
oldVal = newVal.map(function(v) { return shallowCopy(v); });
} else {
oldVal = shallowCopy(newVal);
}
function ngClassWatchAction(newClassString) {
if (oldModulo === selector) {
updateClasses(oldClassString, newClassString);
}
oldClassString = newClassString;
}
}
};
function arrayDifference(tokens1, tokens2) {
var values = [];
outer:
for (var i = 0; i < tokens1.length; i++) {
var token = tokens1[i];
for (var j = 0; j < tokens2.length; j++) {
if (token === tokens2[j]) continue outer;
}
values.push(token);
}
return values;
}
function arrayClasses(classVal) {
var classes = [];
if (isArray(classVal)) {
forEach(classVal, function(v) {
classes = classes.concat(arrayClasses(v));
});
return classes;
} else if (isString(classVal)) {
return classVal.split(' ');
} else if (isObject(classVal)) {
forEach(classVal, function(v, k) {
if (v) {
classes = classes.concat(k.split(' '));
}
});
return classes;
}
return classVal;
}
}];
// Helpers
function arrayDifference(tokens1, tokens2) {
if (!tokens1 || !tokens1.length) return [];
if (!tokens2 || !tokens2.length) return tokens1;
var values = [];
outer:
for (var i = 0; i < tokens1.length; i++) {
var token = tokens1[i];
for (var j = 0; j < tokens2.length; j++) {
if (token === tokens2[j]) continue outer;
}
values.push(token);
}
return values;
}
function split(classString) {
return classString && classString.split(' ');
}
function toClassString(classValue) {
var classString = classValue;
if (isArray(classValue)) {
classString = classValue.map(toClassString).join(' ');
} else if (isObject(classValue)) {
classString = Object.keys(classValue).
filter(function(key) { return classValue[key]; }).
join(' ');
}
return classString;
}
function toFlatValue(classValue) {
var flatValue = classValue;
if (isArray(classValue)) {
flatValue = classValue.map(toFlatValue);
} else if (isObject(classValue)) {
var hasUndefined = false;
flatValue = Object.keys(classValue).filter(function(key) {
var value = classValue[key];
if (!hasUndefined && isUndefined(value)) {
hasUndefined = true;
}
return value;
});
if (hasUndefined) {
// Prevent the `oneTimeLiteralWatchInterceptor` from unregistering
// the watcher, by including at least one `undefined` value.
flatValue.push(undefined);
}
}
return flatValue;
}
}
/**
+44 -19
View File
@@ -568,46 +568,71 @@ describe('ngClass', function() {
);
describe('large objects', function() {
var getProp;
var veryLargeObj;
var verylargeobject, getProp;
beforeEach(function() {
getProp = jasmine.createSpy('getProp');
verylargeobject = {};
Object.defineProperty(verylargeobject, 'prop', {
veryLargeObj = {};
Object.defineProperty(veryLargeObj, 'prop', {
get: getProp,
enumerable: true
});
});
it('should not be copied if static', inject(function($rootScope, $compile) {
element = $compile('<div ng-class="{foo: verylargeobject}"></div>')($rootScope);
$rootScope.verylargeobject = verylargeobject;
$rootScope.$digest();
expect(getProp).not.toHaveBeenCalled();
}));
it('should not be copied if dynamic', inject(function($rootScope, $compile) {
$rootScope.fooClass = {foo: verylargeobject};
it('should not be copied when using an expression', inject(function($compile, $rootScope) {
element = $compile('<div ng-class="fooClass"></div>')($rootScope);
$rootScope.fooClass = {foo: veryLargeObj};
$rootScope.$digest();
expect(element).toHaveClass('foo');
expect(getProp).not.toHaveBeenCalled();
}));
it('should not be copied if inside an array', inject(function($rootScope, $compile) {
element = $compile('<div ng-class="[{foo: verylargeobject}]"></div>')($rootScope);
$rootScope.verylargeobject = verylargeobject;
it('should not be copied when using a literal', inject(function($compile, $rootScope) {
element = $compile('<div ng-class="{foo: veryLargeObj}"></div>')($rootScope);
$rootScope.veryLargeObj = veryLargeObj;
$rootScope.$digest();
expect(element).toHaveClass('foo');
expect(getProp).not.toHaveBeenCalled();
}));
it('should not be copied when one-time binding', inject(function($rootScope, $compile) {
element = $compile('<div ng-class="::{foo: verylargeobject}"></div>')($rootScope);
$rootScope.verylargeobject = verylargeobject;
it('should not be copied when inside an array', inject(function($compile, $rootScope) {
element = $compile('<div ng-class="[{foo: veryLargeObj}]"></div>')($rootScope);
$rootScope.veryLargeObj = veryLargeObj;
$rootScope.$digest();
expect(element).toHaveClass('foo');
expect(getProp).not.toHaveBeenCalled();
}));
it('should not be copied when using one-time binding', inject(function($compile, $rootScope) {
element = $compile('<div ng-class="::{foo: veryLargeObj, bar: bar}"></div>')($rootScope);
$rootScope.veryLargeObj = veryLargeObj;
$rootScope.$digest();
expect(element).toHaveClass('foo');
expect(element).not.toHaveClass('bar');
expect(getProp).not.toHaveBeenCalled();
$rootScope.$apply('veryLargeObj.bar = "bar"');
expect(element).toHaveClass('foo');
expect(element).not.toHaveClass('bar');
expect(getProp).not.toHaveBeenCalled();
$rootScope.$apply('bar = "bar"');
expect(element).toHaveClass('foo');
expect(element).toHaveClass('bar');
expect(getProp).not.toHaveBeenCalled();
$rootScope.$apply('veryLargeObj.bar = "qux"');
expect(element).toHaveClass('foo');
expect(element).toHaveClass('bar');
expect(getProp).not.toHaveBeenCalled();
}));
});