Skip to content

Commit 21c1e14

Browse files
JiaLiPassionmatsko
authored andcommitted
feat: add a flag in bootstrap to enable coalesce event change detection to improve performance (#30533)
PR Close #30533
1 parent 4c63e6b commit 21c1e14

File tree

11 files changed

+171
-21
lines changed

11 files changed

+171
-21
lines changed

integration/_payload-limits.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"master": {
2222
"uncompressed": {
2323
"runtime": 1440,
24-
"main": 123904,
24+
"main": 125178,
2525
"polyfills": 45340
2626
}
2727
}

integration/side-effects/snapshots/core/esm5.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ var __global = "undefined" !== typeof global && global;
1414

1515
var _global = __globalThis || __global || __window || __self;
1616

17+
function getNativeRequestAnimationFrame() {
18+
var nativeRequestAnimationFrame = _global["requestAnimationFrame"];
19+
var nativeCancelAnimationFrame = _global["cancelAnimationFrame"];
20+
if ("undefined" !== typeof Zone && nativeRequestAnimationFrame && nativeCancelAnimationFrame) {
21+
var unpatchedRequestAnimationFrame = nativeRequestAnimationFrame[Zone.__symbol__("OriginalDelegate")];
22+
if (unpatchedRequestAnimationFrame) nativeRequestAnimationFrame = unpatchedRequestAnimationFrame;
23+
var unpatchedCancelAnimationFrame = nativeCancelAnimationFrame[Zone.__symbol__("OriginalDelegate")];
24+
if (unpatchedCancelAnimationFrame) nativeCancelAnimationFrame = unpatchedCancelAnimationFrame;
25+
}
26+
return {
27+
nativeRequestAnimationFrame: nativeRequestAnimationFrame,
28+
nativeCancelAnimationFrame: nativeCancelAnimationFrame
29+
};
30+
}
31+
32+
var nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame;
33+
1734
if (ngDevMode) _global.$localize = _global.$localize || function() {
1835
throw new Error("It looks like your application or one of its dependencies is using i18n.\n" + "Angular 9 introduced a global `$localize()` function that needs to be loaded.\n" + "Please add `import '@angular/localize/init';` to your polyfills.ts file.");
1936
};

packages/core/src/application_ref.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,27 @@ export interface BootstrapOptions {
219219
* - `noop` - Use `NoopNgZone` which does nothing.
220220
*/
221221
ngZone?: NgZone|'zone.js'|'noop';
222+
223+
/**
224+
* Optionally specify coalescing event change detections or not.
225+
* Consider the following case.
226+
*
227+
* <div (click)="doSomething()">
228+
* <button (click)="doSomethingElse()"></button>
229+
* </div>
230+
*
231+
* When button is clicked, because of the event bubbling, both
232+
* event handlers will be called and 2 change detections will be
233+
* triggered. We can colesce such kind of events to only trigger
234+
* change detection only once.
235+
*
236+
* By default, this option will be false. So the events will not be
237+
* colesced and the change detection will be triggered multiple times.
238+
* And if this option be set to true, the change detection will be
239+
* triggered async by scheduling a animation frame. So in the case above,
240+
* the change detection will only be trigged once.
241+
*/
242+
ngZoneEventCoalescing?: boolean;
222243
}
223244

224245
/**
@@ -269,7 +290,8 @@ export class PlatformRef {
269290
// So we create a mini parent injector that just contains the new NgZone and
270291
// pass that as parent to the NgModuleFactory.
271292
const ngZoneOption = options ? options.ngZone : undefined;
272-
const ngZone = getNgZone(ngZoneOption);
293+
const ngZoneEventCoalescing = (options && options.ngZoneEventCoalescing) || false;
294+
const ngZone = getNgZone(ngZoneOption, ngZoneEventCoalescing);
273295
const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}];
274296
// Attention: Don't use ApplicationRef.run here,
275297
// as we want to be sure that all possible constructor calls are inside `ngZone.run`!
@@ -365,14 +387,17 @@ export class PlatformRef {
365387
get destroyed() { return this._destroyed; }
366388
}
367389

368-
function getNgZone(ngZoneOption?: NgZone | 'zone.js' | 'noop'): NgZone {
390+
function getNgZone(
391+
ngZoneOption: NgZone | 'zone.js' | 'noop' | undefined, ngZoneEventCoalescing: boolean): NgZone {
369392
let ngZone: NgZone;
370393

371394
if (ngZoneOption === 'noop') {
372395
ngZone = new NoopNgZone();
373396
} else {
374-
ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) ||
375-
new NgZone({enableLongStackTrace: isDevMode()});
397+
ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) || new NgZone({
398+
enableLongStackTrace: isDevMode(),
399+
shouldCoalesceEventChangeDetection: ngZoneEventCoalescing
400+
});
376401
}
377402
return ngZone;
378403
}

packages/core/src/util/raf.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {global} from './global';
9+
10+
export function getNativeRequestAnimationFrame() {
11+
let nativeRequestAnimationFrame: (callback: FrameRequestCallback) => number =
12+
global['requestAnimationFrame'];
13+
let nativeCancelAnimationFrame: (handle: number) => void = global['cancelAnimationFrame'];
14+
if (typeof Zone !== 'undefined' && nativeRequestAnimationFrame && nativeCancelAnimationFrame) {
15+
// use unpatched version of requestAnimationFrame(native delegate) if possible
16+
// to avoid another Change detection
17+
const unpatchedRequestAnimationFrame =
18+
(nativeRequestAnimationFrame as any)[(Zone as any).__symbol__('OriginalDelegate')];
19+
if (unpatchedRequestAnimationFrame) {
20+
nativeRequestAnimationFrame = unpatchedRequestAnimationFrame;
21+
}
22+
const unpatchedCancelAnimationFrame =
23+
(nativeCancelAnimationFrame as any)[(Zone as any).__symbol__('OriginalDelegate')];
24+
if (unpatchedCancelAnimationFrame) {
25+
nativeCancelAnimationFrame = unpatchedCancelAnimationFrame;
26+
}
27+
}
28+
return {nativeRequestAnimationFrame, nativeCancelAnimationFrame};
29+
}

packages/core/src/zone/ng_zone.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*/
88

99
import {EventEmitter} from '../event_emitter';
10+
import {global} from '../util/global';
11+
import {getNativeRequestAnimationFrame} from '../util/raf';
12+
1013

1114
/**
1215
* An injectable service for executing work inside or outside of the Angular zone.
@@ -83,8 +86,11 @@ import {EventEmitter} from '../event_emitter';
8386
* @publicApi
8487
*/
8588
export class NgZone {
86-
readonly hasPendingMicrotasks: boolean = false;
89+
readonly hasPendingZoneMicrotasks: boolean = false;
90+
readonly lastRequestAnimationFrameId: number = -1;
91+
readonly shouldCoalesceEventChangeDetection: boolean = true;
8792
readonly hasPendingMacrotasks: boolean = false;
93+
readonly hasPendingMicrotasks: boolean = false;
8894

8995
/**
9096
* Whether there are no outstanding microtasks or macrotasks.
@@ -115,7 +121,8 @@ export class NgZone {
115121
*/
116122
readonly onError: EventEmitter<any> = new EventEmitter(false);
117123

118-
constructor({enableLongStackTrace = false}) {
124+
125+
constructor({enableLongStackTrace = false, shouldCoalesceEventChangeDetection = false}) {
119126
if (typeof Zone == 'undefined') {
120127
throw new Error(`In this configuration Angular requires Zone.js`);
121128
}
@@ -138,6 +145,7 @@ export class NgZone {
138145
self._inner = self._inner.fork((Zone as any)['longStackTraceZoneSpec']);
139146
}
140147

148+
self.shouldCoalesceEventChangeDetection = shouldCoalesceEventChangeDetection;
141149
forkInnerZoneWithAngularBehavior(self);
142150
}
143151

@@ -221,16 +229,19 @@ export class NgZone {
221229

222230
function noop() {}
223231
const EMPTY_PAYLOAD = {};
224-
232+
const {nativeRequestAnimationFrame} = getNativeRequestAnimationFrame();
225233

226234
interface NgZonePrivate extends NgZone {
227235
_outer: Zone;
228236
_inner: Zone;
229237
_nesting: number;
238+
_hasPendingMicrotasks: boolean;
230239

231-
hasPendingMicrotasks: boolean;
232240
hasPendingMacrotasks: boolean;
241+
hasPendingMicrotasks: boolean;
242+
lastRequestAnimationFrameId: number;
233243
isStable: boolean;
244+
shouldCoalesceEventChangeDetection: boolean;
234245
}
235246

236247
function checkStable(zone: NgZonePrivate) {
@@ -251,16 +262,35 @@ function checkStable(zone: NgZonePrivate) {
251262
}
252263
}
253264

265+
function delayChangeDetectionForEvents(zone: NgZonePrivate) {
266+
if (zone.lastRequestAnimationFrameId !== -1) {
267+
return;
268+
}
269+
zone.lastRequestAnimationFrameId = nativeRequestAnimationFrame.call(global, () => {
270+
zone.lastRequestAnimationFrameId = -1;
271+
updateMicroTaskStatus(zone);
272+
checkStable(zone);
273+
});
274+
updateMicroTaskStatus(zone);
275+
}
276+
254277
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
278+
const delayChangeDetectionForEventsDelegate = () => { delayChangeDetectionForEvents(zone); };
279+
const maybeDelayChangeDetection = !!zone.shouldCoalesceEventChangeDetection &&
280+
nativeRequestAnimationFrame && delayChangeDetectionForEventsDelegate;
255281
zone._inner = zone._inner.fork({
256282
name: 'angular',
257-
properties: <any>{'isAngularZone': true},
283+
properties:
284+
<any>{'isAngularZone': true, 'maybeDelayChangeDetection': maybeDelayChangeDetection},
258285
onInvokeTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any,
259286
applyArgs: any): any => {
260287
try {
261288
onEnter(zone);
262289
return delegate.invokeTask(target, task, applyThis, applyArgs);
263290
} finally {
291+
if (maybeDelayChangeDetection && task.type === 'eventTask') {
292+
maybeDelayChangeDetection();
293+
}
264294
onLeave(zone);
265295
}
266296
},
@@ -283,7 +313,8 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
283313
// We are only interested in hasTask events which originate from our zone
284314
// (A child hasTask event is not interesting to us)
285315
if (hasTaskState.change == 'microTask') {
286-
zone.hasPendingMicrotasks = hasTaskState.microTask;
316+
zone._hasPendingMicrotasks = hasTaskState.microTask;
317+
updateMicroTaskStatus(zone);
287318
checkStable(zone);
288319
} else if (hasTaskState.change == 'macroTask') {
289320
zone.hasPendingMacrotasks = hasTaskState.macroTask;
@@ -299,6 +330,15 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
299330
});
300331
}
301332

333+
function updateMicroTaskStatus(zone: NgZonePrivate) {
334+
if (zone._hasPendingMicrotasks ||
335+
(zone.shouldCoalesceEventChangeDetection && zone.lastRequestAnimationFrameId !== -1)) {
336+
zone.hasPendingMicrotasks = true;
337+
} else {
338+
zone.hasPendingMicrotasks = false;
339+
}
340+
}
341+
302342
function onEnter(zone: NgZonePrivate) {
303343
zone._nesting++;
304344
if (zone.isStable) {
@@ -317,13 +357,16 @@ function onLeave(zone: NgZonePrivate) {
317357
* to framework to perform rendering.
318358
*/
319359
export class NoopNgZone implements NgZone {
360+
readonly hasPendingZoneMicrotasks: boolean = false;
361+
readonly lastRequestAnimationFrameId = -1;
320362
readonly hasPendingMicrotasks: boolean = false;
321363
readonly hasPendingMacrotasks: boolean = false;
322364
readonly isStable: boolean = true;
323365
readonly onUnstable: EventEmitter<any> = new EventEmitter();
324366
readonly onMicrotaskEmpty: EventEmitter<any> = new EventEmitter();
325367
readonly onStable: EventEmitter<any> = new EventEmitter();
326368
readonly onError: EventEmitter<any> = new EventEmitter();
369+
readonly shouldCoalesceEventChangeDetection: boolean = false;
327370

328371
run(fn: (...args: any[]) => any, applyThis?: any, applyArgs?: any): any {
329372
return fn.apply(applyThis, applyArgs);

packages/core/test/fake_async_spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const ProxyZoneSpec: {assertPresent: () => void} = (Zone as any)['ProxyZoneSpec'
9595
resolvedPromise.then((_) => { throw new Error('async'); });
9696
flushMicrotasks();
9797
})();
98-
}).toThrowError(/Uncaught \(in promise\): Error: async/);
98+
}).toThrow();
9999
});
100100

101101
it('should complain if a test throws an exception', () => {

packages/core/testing/src/ng_zone_mock.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {EventEmitter, Injectable, NgZone} from '@angular/core';
1616
export class MockNgZone extends NgZone {
1717
onStable: EventEmitter<any> = new EventEmitter(false);
1818

19-
constructor() { super({enableLongStackTrace: false}); }
19+
constructor() { super({enableLongStackTrace: false, shouldCoalesceEventChangeDetection: false}); }
2020

2121
run(fn: Function): any { return fn(); }
2222

packages/core/testing/src/test_bed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,8 @@ export class TestBedViewEngine implements TestBed {
402402
overrideComponentView(component, compFactory);
403403
}
404404

405-
const ngZone = new NgZone({enableLongStackTrace: true});
405+
const ngZone =
406+
new NgZone({enableLongStackTrace: true, shouldCoalesceEventChangeDetection: false});
406407
const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}];
407408
const ngZoneInjector = Injector.create({
408409
providers: providers,

packages/platform-browser/test/dom/events/event_manager_spec.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util';
2020
let zone: NgZone;
2121

2222
describe('EventManager', () => {
23-
2423
beforeEach(() => {
2524
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
2625
zone = new NgZone({});
@@ -296,7 +295,7 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util';
296295
expect(receivedEvents).toEqual([]);
297296
});
298297

299-
it('should run blockListedEvents handler outside of ngZone', () => {
298+
it('should run blackListedEvents handler outside of ngZone', () => {
300299
const Zone = (window as any)['Zone'];
301300
const element = el('<div><div></div></div>');
302301
doc.body.appendChild(element);
@@ -312,13 +311,45 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util';
312311
let remover = manager.addEventListener(element, 'scroll', handler);
313312
getDOM().dispatchEvent(element, dispatchedEvent);
314313
expect(receivedEvent).toBe(dispatchedEvent);
315-
expect(receivedZone.name).toBe(Zone.root.name);
314+
expect(receivedZone.name).not.toEqual('angular');
316315

317316
receivedEvent = null;
318317
remover && remover();
319318
getDOM().dispatchEvent(element, dispatchedEvent);
320319
expect(receivedEvent).toBe(null);
321320
});
321+
322+
it('should only trigger one Change detection when bubbling', (done: DoneFn) => {
323+
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
324+
zone = new NgZone({shouldCoalesceEventChangeDetection: true});
325+
domEventPlugin = new DomEventsPlugin(doc, zone, null);
326+
const element = el('<div></div>');
327+
const child = el('<div></div>');
328+
element.appendChild(child);
329+
doc.body.appendChild(element);
330+
const dispatchedEvent = createMouseEvent('click');
331+
let receivedEvents: any = [];
332+
let stables: any = [];
333+
const handler = (e: any) => { receivedEvents.push(e); };
334+
const manager = new EventManager([domEventPlugin], zone);
335+
let removerChild: any;
336+
let removerParent: any;
337+
338+
zone.run(() => {
339+
removerChild = manager.addEventListener(child, 'click', handler);
340+
removerParent = manager.addEventListener(element, 'click', handler);
341+
});
342+
zone.onStable.subscribe((isStable: any) => { stables.push(isStable); });
343+
getDOM().dispatchEvent(child, dispatchedEvent);
344+
requestAnimationFrame(() => {
345+
expect(receivedEvents.length).toBe(2);
346+
expect(stables.length).toBe(1);
347+
348+
removerChild && removerChild();
349+
removerParent && removerParent();
350+
done();
351+
});
352+
});
322353
});
323354
})();
324355

@@ -332,12 +363,12 @@ class FakeEventManagerPlugin extends EventManagerPlugin {
332363

333364
addEventListener(element: any, eventName: string, handler: Function) {
334365
this.eventHandler[eventName] = handler;
335-
return () => { delete (this.eventHandler[eventName]); };
366+
return () => { delete this.eventHandler[eventName]; };
336367
}
337368
}
338369

339370
class FakeNgZone extends NgZone {
340-
constructor() { super({enableLongStackTrace: false}); }
371+
constructor() { super({enableLongStackTrace: false, shouldCoalesceEventChangeDetection: true}); }
341372
run<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T { return fn(); }
342373
runOutsideAngular(fn: Function) { return fn(); }
343374
}

packages/platform-browser/testing/src/browser_util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export function stringifyElement(el: any /** TODO #9100 */): string {
175175
}
176176

177177
export function createNgZone(): NgZone {
178-
return new NgZone({enableLongStackTrace: true});
178+
return new NgZone({enableLongStackTrace: true, shouldCoalesceEventChangeDetection: false});
179179
}
180180

181181
export function isCommentNode(node: Node): boolean {

0 commit comments

Comments
 (0)