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:
+143
-87
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user