Skip to content

Commit 032b2bd

Browse files
perf(common): avoid excessive DOM mutation in NgClass (#48433)
This commit represents rewrite of the NgClass directive to address severe performance problem (excessive DOM mutation). The modified algorithm removes all the known performance clifs and has number of desirable properties: - it is shorter and (arguably) easier to follow; - drops usage of existing differs thus limiting dependencies on other part of the code without increasing size of the directive; - doesn't degrade any other performance metrics. Fixes #25518 PR Close #48433
1 parent 3f440d9 commit 032b2bd

File tree

4 files changed

+169
-83
lines changed

4 files changed

+169
-83
lines changed

goldens/size-tracking/aio-payloads.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"aio-local": {
1313
"uncompressed": {
1414
"runtime": 4325,
15-
"main": 457970,
15+
"main": 457343,
1616
"polyfills": 33952,
1717
"styles": 74752,
1818
"light-theme": 88046,

packages/common/src/directives/ng_class.ts

Lines changed: 101 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,30 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {Directive, DoCheck, ElementRef, Input, IterableChanges, IterableDiffer, IterableDiffers, KeyValueChanges, KeyValueDiffer, KeyValueDiffers, Renderer2, ɵisListLikeIterable as isListLikeIterable, ɵstringify as stringify} from '@angular/core';
8+
import {Directive, DoCheck, ElementRef, Input, IterableDiffers, KeyValueDiffers, Renderer2, ɵstringify as stringify} from '@angular/core';
99

1010
type NgClassSupportedTypes = string[]|Set<string>|{[klass: string]: any}|null|undefined;
1111

12+
const WS_REGEXP = /\s+/;
13+
14+
const EMPTY_ARRAY: string[] = [];
15+
16+
/**
17+
* Represents internal object used to track state of each CSS class. There are 3 different (boolean)
18+
* flags that, combined together, indicate state of a given CSS class:
19+
* - enabled: indicates if a class should be present in the DOM (true) or not (false);
20+
* - changed: tracks if a class was toggled (added or removed) during the custom dirty-checking
21+
* process; changed classes must be synchronized with the DOM;
22+
* - touched: tracks if a class is present in the current object bound to the class / ngClass input;
23+
* classes that are not present any more can be removed from the internal data structures;
24+
*/
25+
interface CssClassState {
26+
// PERF: could use a bit mask to represent state as all fields are boolean flags
27+
enabled: boolean;
28+
changed: boolean;
29+
touched: boolean;
30+
}
31+
1232
/**
1333
* @ngModule CommonModule
1434
*
@@ -42,116 +62,116 @@ type NgClassSupportedTypes = string[]|Set<string>|{[klass: string]: any}|null|un
4262
standalone: true,
4363
})
4464
export class NgClass implements DoCheck {
45-
private _iterableDiffer: IterableDiffer<string>|null = null;
46-
private _keyValueDiffer: KeyValueDiffer<string, any>|null = null;
47-
private _initialClasses: string[] = [];
48-
private _rawClass: NgClassSupportedTypes = null;
65+
private initialClasses = EMPTY_ARRAY;
66+
private rawClass: NgClassSupportedTypes;
67+
68+
private stateMap = new Map<string, CssClassState>();
4969

5070
constructor(
71+
// leaving references to differs in place since flex layout is extending NgClass...
5172
private _iterableDiffers: IterableDiffers, private _keyValueDiffers: KeyValueDiffers,
5273
private _ngEl: ElementRef, private _renderer: Renderer2) {}
5374

54-
5575
@Input('class')
5676
set klass(value: string) {
57-
this._removeClasses(this._initialClasses);
58-
this._initialClasses = typeof value === 'string' ? value.split(/\s+/) : [];
59-
this._applyClasses(this._initialClasses);
60-
this._applyClasses(this._rawClass);
77+
this.initialClasses = value != null ? value.trim().split(WS_REGEXP) : EMPTY_ARRAY;
6178
}
6279

6380
@Input('ngClass')
6481
set ngClass(value: string|string[]|Set<string>|{[klass: string]: any}|null|undefined) {
65-
this._removeClasses(this._rawClass);
66-
this._applyClasses(this._initialClasses);
67-
68-
this._iterableDiffer = null;
69-
this._keyValueDiffer = null;
70-
71-
this._rawClass = typeof value === 'string' ? value.split(/\s+/) : value;
72-
73-
if (this._rawClass) {
74-
if (isListLikeIterable(this._rawClass)) {
75-
this._iterableDiffer = this._iterableDiffers.find(this._rawClass).create();
76-
} else {
77-
this._keyValueDiffer = this._keyValueDiffers.find(this._rawClass).create();
78-
}
79-
}
82+
this.rawClass = typeof value === 'string' ? value.trim().split(WS_REGEXP) : value;
8083
}
8184

82-
ngDoCheck() {
83-
if (this._iterableDiffer) {
84-
const iterableChanges = this._iterableDiffer.diff(this._rawClass as string[]);
85-
if (iterableChanges) {
86-
this._applyIterableChanges(iterableChanges);
87-
}
88-
} else if (this._keyValueDiffer) {
89-
const keyValueChanges = this._keyValueDiffer.diff(this._rawClass as {[k: string]: any});
90-
if (keyValueChanges) {
91-
this._applyKeyValueChanges(keyValueChanges);
92-
}
85+
/*
86+
The NgClass directive uses the custom change detection algorithm for its inputs. The custom
87+
algorithm is necessary since inputs are represented as complex object or arrays that need to be
88+
deeply-compared.
89+
90+
This algorithm is perf-sensitive since NgClass is used very frequently and its poor performance
91+
might negatively impact runtime performance of the entire change detection cycle. The design of
92+
this algorithm is making sure that:
93+
- there is no unnecessary DOM manipulation (CSS classes are added / removed from the DOM only when
94+
needed), even if references to bound objects change;
95+
- there is no memory allocation if nothing changes (even relatively modest memory allocation
96+
during the change detection cycle can result in GC pauses for some of the CD cycles).
97+
98+
The algorithm works by iterating over the set of bound classes, staring with [class] binding and
99+
then going over [ngClass] binding. For each CSS class name:
100+
- check if it was seen before (this information is tracked in the state map) and if its value
101+
changed;
102+
- mark it as "touched" - names that are not marked are not present in the latest set of binding
103+
and we can remove such class name from the internal data structures;
104+
105+
After iteration over all the CSS class names we've got data structure with all the information
106+
necessary to synchronize changes to the DOM - it is enough to iterate over the state map, flush
107+
changes to the DOM and reset internal data structures so those are ready for the next change
108+
detection cycle.
109+
*/
110+
ngDoCheck(): void {
111+
// classes from the [class] binding
112+
for (const klass of this.initialClasses) {
113+
this._updateState(klass, true);
93114
}
94-
}
95115

96-
private _applyKeyValueChanges(changes: KeyValueChanges<string, any>): void {
97-
changes.forEachAddedItem((record) => this._toggleClass(record.key, record.currentValue));
98-
changes.forEachChangedItem((record) => this._toggleClass(record.key, record.currentValue));
99-
changes.forEachRemovedItem((record) => {
100-
if (record.previousValue) {
101-
this._toggleClass(record.key, false);
116+
// classes from the [ngClass] binding
117+
const rawClass = this.rawClass;
118+
if (Array.isArray(rawClass) || rawClass instanceof Set) {
119+
for (const klass of rawClass) {
120+
this._updateState(klass, true);
102121
}
103-
});
104-
}
105-
106-
private _applyIterableChanges(changes: IterableChanges<string>): void {
107-
changes.forEachAddedItem((record) => {
108-
if (typeof record.item === 'string') {
109-
this._toggleClass(record.item, true);
110-
} else {
111-
throw new Error(`NgClass can only toggle CSS classes expressed as strings, got ${
112-
stringify(record.item)}`);
122+
} else if (rawClass != null) {
123+
for (const klass of Object.keys(rawClass)) {
124+
this._updateState(klass, Boolean(rawClass[klass]));
113125
}
114-
});
126+
}
115127

116-
changes.forEachRemovedItem((record) => this._toggleClass(record.item, false));
128+
this._applyStateDiff();
117129
}
118130

119-
/**
120-
* Applies a collection of CSS classes to the DOM element.
121-
*
122-
* For argument of type Set and Array CSS class names contained in those collections are always
123-
* added.
124-
* For argument of type Map CSS class name in the map's key is toggled based on the value (added
125-
* for truthy and removed for falsy).
126-
*/
127-
private _applyClasses(rawClassVal: NgClassSupportedTypes) {
128-
if (rawClassVal) {
129-
if (Array.isArray(rawClassVal) || rawClassVal instanceof Set) {
130-
(<any>rawClassVal).forEach((klass: string) => this._toggleClass(klass, true));
131-
} else {
132-
Object.keys(rawClassVal).forEach(klass => this._toggleClass(klass, !!rawClassVal[klass]));
131+
private _updateState(klass: string, nextEnabled: boolean) {
132+
const state = this.stateMap.get(klass);
133+
if (state !== undefined) {
134+
if (state.enabled !== nextEnabled) {
135+
state.changed = true;
136+
state.enabled = nextEnabled;
133137
}
138+
state.touched = true;
139+
} else {
140+
this.stateMap.set(klass, {enabled: nextEnabled, changed: true, touched: true});
134141
}
135142
}
136143

137-
/**
138-
* Removes a collection of CSS classes from the DOM element. This is mostly useful for cleanup
139-
* purposes.
140-
*/
141-
private _removeClasses(rawClassVal: NgClassSupportedTypes) {
142-
if (rawClassVal) {
143-
if (Array.isArray(rawClassVal) || rawClassVal instanceof Set) {
144-
(<any>rawClassVal).forEach((klass: string) => this._toggleClass(klass, false));
145-
} else {
146-
Object.keys(rawClassVal).forEach(klass => this._toggleClass(klass, false));
144+
private _applyStateDiff() {
145+
for (const stateEntry of this.stateMap) {
146+
const klass = stateEntry[0];
147+
const state = stateEntry[1];
148+
149+
if (state.changed) {
150+
this._toggleClass(klass, state.enabled);
151+
state.changed = false;
152+
} else if (!state.touched) {
153+
// A class that was previously active got removed from the new collection of classes -
154+
// remove from the DOM as well.
155+
if (state.enabled) {
156+
this._toggleClass(klass, false);
157+
}
158+
this.stateMap.delete(klass);
147159
}
160+
161+
state.touched = false;
148162
}
149163
}
150164

151165
private _toggleClass(klass: string, enabled: boolean): void {
166+
if (ngDevMode) {
167+
if (typeof klass !== 'string') {
168+
throw new Error(
169+
`NgClass can only toggle CSS classes expressed as strings, got ${stringify(klass)}`);
170+
}
171+
}
152172
klass = klass.trim();
153-
if (klass) {
154-
klass.split(/\s+/g).forEach(klass => {
173+
if (klass.length > 0) {
174+
klass.split(WS_REGEXP).forEach(klass => {
155175
if (enabled) {
156176
this._renderer.addClass(this._ngEl.nativeElement, klass);
157177
} else {

packages/common/test/directives/ng_class_spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ describe('binding to CSS class list', () => {
331331

332332
getComponent().strExpr = 'bar';
333333
detectChangesAndExpectClassName(`bar small`);
334+
335+
getComponent().strExpr = undefined;
336+
detectChangesAndExpectClassName(`small`);
334337
}));
335338

336339
it('should co-operate with the class attribute and binding to it', waitForAsync(() => {
@@ -409,6 +412,48 @@ describe('binding to CSS class list', () => {
409412
detectChangesAndExpectClassName('color-red');
410413
});
411414

415+
it('should not write to the native node when values are the same (obj reference change)',
416+
() => {
417+
fixture = createTestComponent(`<div [ngClass]="objExpr"></div>`);
418+
detectChangesAndExpectClassName('foo');
419+
420+
// Overwrite CSS classes so that we can check if ngClass performed DOM manipulation to
421+
// update it
422+
fixture.debugElement.children[0].nativeElement.className = '';
423+
// Assert that the DOM node still has the same value after change detection
424+
detectChangesAndExpectClassName('');
425+
426+
// change the object reference (without changing values)
427+
fixture.componentInstance.objExp = {...fixture.componentInstance.objExp};
428+
detectChangesAndExpectClassName('');
429+
});
430+
431+
it('should not write to the native node when values are the same (array reference change)',
432+
() => {
433+
fixture = createTestComponent(`<div [ngClass]="arrExpr"></div>`);
434+
detectChangesAndExpectClassName('foo');
435+
436+
// Overwrite CSS classes so that we can check if ngClass performed DOM manipulation to
437+
// update it
438+
fixture.debugElement.children[0].nativeElement.className = '';
439+
// Assert that the DOM node still has the same value after change detection
440+
detectChangesAndExpectClassName('');
441+
442+
// change the object reference (without changing values)
443+
fixture.componentInstance.arrExpr = [...fixture.componentInstance.arrExpr];
444+
detectChangesAndExpectClassName('');
445+
});
446+
447+
it('should not add css class when bound initial class is removed by ngClass binding', () => {
448+
fixture = createTestComponent(`<div [class]="'bar'" [ngClass]="objExpr"></div>`);
449+
detectChangesAndExpectClassName('foo');
450+
});
451+
452+
it('should not add css class when static initial class is removed by ngClass binding', () => {
453+
fixture = createTestComponent(`<div class="bar" [ngClass]="objExpr"></div>`);
454+
detectChangesAndExpectClassName('foo');
455+
});
456+
412457
it('should allow classes with trailing and leading spaces in [ngClass]', () => {
413458
@Component({
414459
template: `
@@ -430,6 +475,27 @@ describe('binding to CSS class list', () => {
430475
expect(trailing.className).toBe('foo');
431476
});
432477

478+
it('should mix class and ngClass bindings with the same value', () => {
479+
@Component({
480+
selector: 'test-component',
481+
imports: [NgClass],
482+
template: `<div class="{{'option-' + level}}" [ngClass]="'option-' + level"></div>`,
483+
standalone: true,
484+
})
485+
class TestComponent {
486+
level = 1;
487+
}
488+
489+
const fixture = TestBed.createComponent(TestComponent);
490+
fixture.detectChanges();
491+
492+
expect(fixture.nativeElement.firstChild.className).toBe('option-1');
493+
494+
fixture.componentInstance.level = 5;
495+
fixture.detectChanges();
496+
expect(fixture.nativeElement.firstChild.className).toBe('option-5');
497+
});
498+
433499
it('should be available as a standalone directive', () => {
434500
@Component({
435501
selector: 'test-component',

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1764,7 +1764,7 @@
17641764
"name": "stringify"
17651765
},
17661766
{
1767-
"name": "stringify10"
1767+
"name": "stringify11"
17681768
},
17691769
{
17701770
"name": "stringifyCSSSelector"

0 commit comments

Comments
 (0)