feat(core): add shouldCoalesceRunChangeDetection option to coalesce change detections in the same event loop. (#39422)

Close #39348

Now `NgZone` has an option `shouldCoalesceEventChangeDetection` to coalesce
multiple event handler's change detections to one async change detection.

And there are some cases other than `event handler` have the same issues.
In #39348, the case like this.

```
// This code results in one change detection occurring per
// ngZone.run() call. This is entirely feasible, and can be a serious
// performance issue.
for (let i = 0; i < 100; i++) {
  this.ngZone.run(() => {
    // do something
  });
}
```

So such kind of case will trigger multiple change detections.
And now with Ivy, we have a new `markDirty()` API will schedule
a requestAnimationFrame to trigger change detection and also coalesce
the change detections in the same event loop, `markDirty()` API doesn't
only take care `event handler` but also all other cases `sync/macroTask/..`

So this PR add a new option to coalesce change detections for all cases.

test(core): add test case for shouldCoalesceEventChangeDetection option

Add new test cases for current `shouldCoalesceEventChangeDetection` in `ng_zone.spec`, since
currently we only have integration test for this one.

PR Close #39422
This commit is contained in:
JiaLiPassion 2020-10-23 20:45:51 +09:00 committed by atscott
parent d68cac69ed
commit 5e92d649f2
6 changed files with 451 additions and 59 deletions

View File

@ -619,9 +619,10 @@ export declare class NgZone {
readonly onMicrotaskEmpty: EventEmitter<any>;
readonly onStable: EventEmitter<any>;
readonly onUnstable: EventEmitter<any>;
constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection }: {
constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection, shouldCoalesceRunChangeDetection }: {
enableLongStackTrace?: boolean | undefined;
shouldCoalesceEventChangeDetection?: boolean | undefined;
shouldCoalesceRunChangeDetection?: boolean | undefined;
});
run<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T;
runGuarded<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T;

View File

@ -3,7 +3,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 1485,
"main-es2015": 140899,
"main-es2015": 141516,
"polyfills-es2015": 36964
}
}
@ -39,7 +39,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2285,
"main-es2015": 241875,
"main-es2015": 242417,
"polyfills-es2015": 36709,
"5-es2015": 745
}
@ -48,10 +48,10 @@
"cli-hello-world-lazy-rollup": {
"master": {
"uncompressed": {
"runtime-es2015": 2285,
"main-es2015": 218340,
"polyfills-es2015": 36709,
"5-es2015": 777
"runtime-es2015": 2289,
"main-es2015": 218507,
"polyfills-es2015": 36723,
"5-es2015": 781
}
}
},

View File

@ -266,6 +266,25 @@ export interface BootstrapOptions {
* the change detection will only be triggered once.
*/
ngZoneEventCoalescing?: boolean;
/**
* Optionally specify if `NgZone#run()` method invocations should be coalesced
* into a single change detection.
*
* Consider the following case.
*
* for (let i = 0; i < 10; i ++) {
* ngZone.run(() => {
* // do something
* });
* }
*
* This case triggers the change detection multiple times.
* With ngZoneRunCoalescing options, all change detections in an event loop trigger only once.
* In addition, the change detection executes in requestAnimation.
*
*/
ngZoneRunCoalescing?: boolean;
}
/**
@ -316,10 +335,13 @@ export class PlatformRef {
// pass that as parent to the NgModuleFactory.
const ngZoneOption = options ? options.ngZone : undefined;
const ngZoneEventCoalescing = (options && options.ngZoneEventCoalescing) || false;
const ngZone = getNgZone(ngZoneOption, ngZoneEventCoalescing);
const ngZoneRunCoalescing = (options && options.ngZoneRunCoalescing) || false;
const ngZone = getNgZone(ngZoneOption, {ngZoneEventCoalescing, ngZoneRunCoalescing});
const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}];
// Attention: Don't use ApplicationRef.run here,
// as we want to be sure that all possible constructor calls are inside `ngZone.run`!
// Note: Create ngZoneInjector within ngZone.run so that all of the instantiated services are
// created within the Angular zone
// Do not try to replace ngZone.run with ApplicationRef#run because ApplicationRef would then be
// created outside of the Angular zone.
return ngZone.run(() => {
const ngZoneInjector = Injector.create(
{providers: providers, parent: this.injector, name: moduleFactory.moduleType.name});
@ -426,7 +448,8 @@ export class PlatformRef {
}
function getNgZone(
ngZoneOption: NgZone|'zone.js'|'noop'|undefined, ngZoneEventCoalescing: boolean): NgZone {
ngZoneOption: NgZone|'zone.js'|'noop'|undefined,
extra?: {ngZoneEventCoalescing: boolean, ngZoneRunCoalescing: boolean}): NgZone {
let ngZone: NgZone;
if (ngZoneOption === 'noop') {
@ -434,7 +457,8 @@ function getNgZone(
} else {
ngZone = (ngZoneOption === 'zone.js' ? undefined : ngZoneOption) || new NgZone({
enableLongStackTrace: isDevMode(),
shouldCoalesceEventChangeDetection: ngZoneEventCoalescing
shouldCoalesceEventChangeDetection: !!extra?.ngZoneEventCoalescing,
shouldCoalesceRunChangeDetection: !!extra?.ngZoneRunCoalescing
});
}
return ngZone;

View File

@ -119,7 +119,11 @@ export class NgZone {
readonly onError: EventEmitter<any> = new EventEmitter(false);
constructor({enableLongStackTrace = false, shouldCoalesceEventChangeDetection = false}) {
constructor({
enableLongStackTrace = false,
shouldCoalesceEventChangeDetection = false,
shouldCoalesceRunChangeDetection = false
}) {
if (typeof Zone == 'undefined') {
throw new Error(`In this configuration Angular requires Zone.js`);
}
@ -137,8 +141,11 @@ export class NgZone {
if (enableLongStackTrace && (Zone as any)['longStackTraceZoneSpec']) {
self._inner = self._inner.fork((Zone as any)['longStackTraceZoneSpec']);
}
self.shouldCoalesceEventChangeDetection = shouldCoalesceEventChangeDetection;
// if shouldCoalesceRunChangeDetection is true, all tasks including event tasks will be
// coalesced, so shouldCoalesceEventChangeDetection option is not necessary and can be skipped.
self.shouldCoalesceEventChangeDetection =
!shouldCoalesceRunChangeDetection && shouldCoalesceEventChangeDetection;
self.shouldCoalesceRunChangeDetection = shouldCoalesceRunChangeDetection;
self.lastRequestAnimationFrameId = -1;
self.nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame;
forkInnerZoneWithAngularBehavior(self);
@ -237,11 +244,49 @@ interface NgZonePrivate extends NgZone {
hasPendingMicrotasks: boolean;
lastRequestAnimationFrameId: number;
isStable: boolean;
/**
* Optionally specify coalescing event change detections or not.
* Consider the following case.
*
* <div (click)="doSomething()">
* <button (click)="doSomethingElse()"></button>
* </div>
*
* When button is clicked, because of the event bubbling, both
* event handlers will be called and 2 change detections will be
* triggered. We can coalesce such kind of events to trigger
* change detection only once.
*
* By default, this option will be false. So the events will not be
* coalesced and the change detection will be triggered multiple times.
* And if this option be set to true, the change detection will be
* triggered async by scheduling it in an animation frame. So in the case above,
* the change detection will only be trigged once.
*/
shouldCoalesceEventChangeDetection: boolean;
/**
* Optionally specify if `NgZone#run()` method invocations should be coalesced
* into a single change detection.
*
* Consider the following case.
*
* for (let i = 0; i < 10; i ++) {
* ngZone.run(() => {
* // do something
* });
* }
*
* This case triggers the change detection multiple times.
* With ngZoneRunCoalescing options, all change detections in an event loops trigger only once.
* In addition, the change detection executes in requestAnimation.
*
*/
shouldCoalesceRunChangeDetection: boolean;
nativeRequestAnimationFrame: (callback: FrameRequestCallback) => number;
// Cache of "fake" top eventTask. This is done so that we don't need to schedule a new task every
// time we want to run a `checkStable`.
// Cache a "fake" top eventTask so you don't need to schedule a new task every
// time you run a `checkStable`.
fakeTopEventTask: Task;
}
@ -293,12 +338,9 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
const delayChangeDetectionForEventsDelegate = () => {
delayChangeDetectionForEvents(zone);
};
const maybeDelayChangeDetection = !!zone.shouldCoalesceEventChangeDetection &&
zone.nativeRequestAnimationFrame && delayChangeDetectionForEventsDelegate;
zone._inner = zone._inner.fork({
name: 'angular',
properties:
<any>{'isAngularZone': true, 'maybeDelayChangeDetection': maybeDelayChangeDetection},
properties: <any>{'isAngularZone': true},
onInvokeTask:
(delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any,
applyArgs: any): any => {
@ -306,14 +348,14 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
onEnter(zone);
return delegate.invokeTask(target, task, applyThis, applyArgs);
} finally {
if (maybeDelayChangeDetection && task.type === 'eventTask') {
maybeDelayChangeDetection();
if ((zone.shouldCoalesceEventChangeDetection && task.type === 'eventTask') ||
zone.shouldCoalesceRunChangeDetection) {
delayChangeDetectionForEventsDelegate();
}
onLeave(zone);
}
},
onInvoke:
(delegate: ZoneDelegate, current: Zone, target: Zone, callback: Function, applyThis: any,
applyArgs?: any[], source?: string): any => {
@ -321,6 +363,9 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
onEnter(zone);
return delegate.invoke(target, callback, applyThis, applyArgs, source);
} finally {
if (zone.shouldCoalesceRunChangeDetection) {
delayChangeDetectionForEventsDelegate();
}
onLeave(zone);
}
},
@ -351,7 +396,8 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
function updateMicroTaskStatus(zone: NgZonePrivate) {
if (zone._hasPendingMicrotasks ||
(zone.shouldCoalesceEventChangeDetection && zone.lastRequestAnimationFrameId !== -1)) {
((zone.shouldCoalesceEventChangeDetection || zone.shouldCoalesceRunChangeDetection) &&
zone.lastRequestAnimationFrameId !== -1)) {
zone.hasPendingMicrotasks = true;
} else {
zone.hasPendingMicrotasks = false;

View File

@ -12,6 +12,7 @@ import {AsyncTestCompleter, beforeEach, describe, expect, inject, it, Log, xit}
import {browserDetection} from '@angular/platform-browser/testing/src/browser_util';
import {scheduleMicroTask} from '../../src/util/microtask';
import {getNativeRequestAnimationFrame} from '../../src/util/raf';
import {NoopNgZone} from '../../src/zone/ng_zone';
const needsLongerTimers = browserDetection.isSlow || browserDetection.isEdge;
@ -929,4 +930,240 @@ function commonTests() {
});
});
});
describe('coalescing', () => {
describe(
'shouldCoalesceRunChangeDetection = false, shouldCoalesceEventChangeDetection = false',
() => {
let notCoalesceZone: NgZone;
let logs: string[] = [];
beforeEach(() => {
notCoalesceZone = new NgZone({});
logs = [];
notCoalesceZone.onMicrotaskEmpty.subscribe(() => {
logs.push('microTask empty');
});
});
it('should run sync', () => {
notCoalesceZone.run(() => {});
expect(logs).toEqual(['microTask empty']);
});
it('should emit onMicroTaskEmpty multiple times within the same event loop for multiple ngZone.run',
() => {
notCoalesceZone.run(() => {});
notCoalesceZone.run(() => {});
expect(logs).toEqual(['microTask empty', 'microTask empty']);
});
it('should emit onMicroTaskEmpty multiple times within the same event loop for multiple tasks',
() => {
const tasks: Task[] = [];
notCoalesceZone.run(() => {
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask1');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask2');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleMacroTask('myMacro', () => {
logs.push('macroTask');
}, undefined, () => {}));
});
tasks.forEach(t => t.invoke());
expect(logs).toEqual([
'microTask empty', 'eventTask1', 'microTask empty', 'eventTask2',
'microTask empty', 'macroTask', 'microTask empty'
]);
});
});
describe('shouldCoalesceEventChangeDetection = true, shouldCoalesceRunChangeDetection = false', () => {
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
if (!(global as any).requestAnimationFrame) {
nativeRequestAnimationFrame = function(fn: Function) {
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
};
} else {
nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame;
}
let patchedImmediate: any;
let coalesceZone: NgZone;
let logs: string[] = [];
beforeEach(() => {
patchedImmediate = setImmediate;
(global as any).setImmediate = (global as any)[Zone.__symbol__('setImmediate')];
coalesceZone = new NgZone({shouldCoalesceEventChangeDetection: true});
logs = [];
coalesceZone.onMicrotaskEmpty.subscribe(() => {
logs.push('microTask empty');
});
});
afterEach(() => {
(global as any).setImmediate = patchedImmediate;
});
it('should run in requestAnimationFrame async', (done: DoneFn) => {
let task: Task|undefined = undefined;
coalesceZone.run(() => {
task = Zone.current.scheduleEventTask('myEvent', () => {
logs.push('myEvent');
}, undefined, () => {});
});
task!.invoke();
expect(logs).toEqual(['microTask empty', 'myEvent']);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(['microTask empty', 'myEvent', 'microTask empty']);
done();
});
});
it('should only emit onMicroTaskEmpty once within the same event loop for multiple event tasks',
(done: DoneFn) => {
const tasks: Task[] = [];
coalesceZone.run(() => {
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask1');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask2');
}, undefined, () => {}));
});
tasks.forEach(t => t.invoke());
expect(logs).toEqual(['microTask empty', 'eventTask1', 'eventTask2']);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(
['microTask empty', 'eventTask1', 'eventTask2', 'microTask empty']);
done();
});
});
it('should emit onMicroTaskEmpty once within the same event loop for not only event tasks, but event tasks are before other tasks',
(done: DoneFn) => {
const tasks: Task[] = [];
coalesceZone.run(() => {
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask1');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask2');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleMacroTask('myMacro', () => {
logs.push('macroTask');
}, undefined, () => {}));
});
tasks.forEach(t => t.invoke());
expect(logs).toEqual(['microTask empty', 'eventTask1', 'eventTask2', 'macroTask']);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(
['microTask empty', 'eventTask1', 'eventTask2', 'macroTask', 'microTask empty']);
done();
});
});
it('should emit multiple onMicroTaskEmpty within the same event loop for not only event tasks, but event tasks are after other tasks',
(done: DoneFn) => {
const tasks: Task[] = [];
coalesceZone.run(() => {
tasks.push(Zone.current.scheduleMacroTask('myMacro', () => {
logs.push('macroTask');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask1');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask2');
}, undefined, () => {}));
});
tasks.forEach(t => t.invoke());
expect(logs).toEqual(
['microTask empty', 'macroTask', 'microTask empty', 'eventTask1', 'eventTask2']);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual([
'microTask empty', 'macroTask', 'microTask empty', 'eventTask1', 'eventTask2',
'microTask empty'
]);
done();
});
});
});
describe('shouldCoalesceRunChangeDetection = true', () => {
let nativeRequestAnimationFrame: (fn: FrameRequestCallback) => void;
if (!(global as any).requestAnimationFrame) {
nativeRequestAnimationFrame = function(fn: Function) {
(global as any)[Zone.__symbol__('setTimeout')](fn, 16);
};
} else {
nativeRequestAnimationFrame = getNativeRequestAnimationFrame().nativeRequestAnimationFrame;
}
let patchedImmediate: any;
let coalesceZone: NgZone;
let logs: string[] = [];
beforeEach(() => {
patchedImmediate = setImmediate;
(global as any).setImmediate = (global as any)[Zone.__symbol__('setImmediate')];
coalesceZone = new NgZone({shouldCoalesceRunChangeDetection: true});
logs = [];
coalesceZone.onMicrotaskEmpty.subscribe(() => {
logs.push('microTask empty');
});
});
afterEach(() => {
(global as any).setImmediate = patchedImmediate;
});
it('should run in requestAnimationFrame async', (done: DoneFn) => {
coalesceZone.run(() => {});
expect(logs).toEqual([]);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(['microTask empty']);
done();
});
});
it('should only emit onMicroTaskEmpty once within the same event loop for multiple ngZone.run',
(done: DoneFn) => {
coalesceZone.run(() => {});
coalesceZone.run(() => {});
expect(logs).toEqual([]);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(['microTask empty']);
done();
});
});
it('should only emit onMicroTaskEmpty once within the same event loop for multiple tasks',
(done: DoneFn) => {
const tasks: Task[] = [];
coalesceZone.run(() => {
tasks.push(Zone.current.scheduleMacroTask('myMacro', () => {
logs.push('macroTask');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask1');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleEventTask('myEvent', () => {
logs.push('eventTask2');
}, undefined, () => {}));
tasks.push(Zone.current.scheduleMacroTask('myMacro', () => {
logs.push('macroTask');
}, undefined, () => {}));
});
tasks.forEach(t => t.invoke());
expect(logs).toEqual(['macroTask', 'eventTask1', 'eventTask2', 'macroTask']);
nativeRequestAnimationFrame(() => {
expect(logs).toEqual(
['macroTask', 'eventTask1', 'eventTask2', 'macroTask', 'microTask empty']);
done();
});
});
});
});
}

View File

@ -335,7 +335,8 @@ describe('EventManager', () => {
expect(receivedEvent).toBe(null);
});
it('should only trigger one Change detection when bubbling', (done: DoneFn) => {
it('should only trigger one Change detection when bubbling with shouldCoalesceEventChangeDetection = true',
(done: DoneFn) => {
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
zone = new NgZone({shouldCoalesceEventChangeDetection: true});
domEventPlugin = new DomEventsPlugin(doc);
@ -371,6 +372,43 @@ describe('EventManager', () => {
});
});
it('should only trigger one Change detection when bubbling with shouldCoalesceRunChangeDetection = true',
(done: DoneFn) => {
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
zone = new NgZone({shouldCoalesceRunChangeDetection: true});
domEventPlugin = new DomEventsPlugin(doc);
const element = el('<div></div>');
const child = el('<div></div>');
element.appendChild(child);
doc.body.appendChild(element);
const dispatchedEvent = createMouseEvent('click');
let receivedEvents: any = [];
let stables: any = [];
const handler = (e: any) => {
receivedEvents.push(e);
};
const manager = new EventManager([domEventPlugin], zone);
let removerChild: any;
let removerParent: any;
zone.run(() => {
removerChild = manager.addEventListener(child, 'click', handler);
removerParent = manager.addEventListener(element, 'click', handler);
});
zone.onStable.subscribe((isStable: any) => {
stables.push(isStable);
});
getDOM().dispatchEvent(child, dispatchedEvent);
requestAnimationFrame(() => {
expect(receivedEvents.length).toBe(2);
expect(stables.length).toBe(1);
removerChild && removerChild();
removerParent && removerParent();
done();
});
});
it('should not drain micro tasks queue too early with shouldCoalesceEventChangeDetection=true',
(done: DoneFn) => {
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
@ -393,6 +431,52 @@ describe('EventManager', () => {
let removerParent: any;
let removerChildFocus: any;
zone.run(() => {
removerParent = manager.addEventListener(element, 'click', handler);
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);
});
const sub = zone.onStable.subscribe(() => {
logs.push('begin');
Promise.resolve().then(() => {
logs.push('promise resolved');
});
element.appendChild(child);
getDOM().dispatchEvent(child, dispatchedBlurEvent);
sub.unsubscribe();
logs.push('end');
});
getDOM().dispatchEvent(element, dispatchedClickEvent);
requestAnimationFrame(() => {
expect(logs).toEqual(['begin', 'blur', 'end', 'promise resolved']);
removerParent && removerParent();
removerChildFocus && removerChildFocus();
done();
});
});
it('should not drain micro tasks queue too early with shouldCoalesceRunChangeDetection=true',
(done: DoneFn) => {
doc = getDOM().supportsDOMEvents() ? document : getDOM().createHtmlDocument();
zone = new NgZone({shouldCoalesceRunChangeDetection: true});
domEventPlugin = new DomEventsPlugin(doc);
const element = el('<div></div>');
const child = el('<div></div>');
doc.body.appendChild(element);
const dispatchedClickEvent = createMouseEvent('click');
const dispatchedBlurEvent: FocusEvent =
getDOM().getDefaultDocument().createEvent('FocusEvent');
dispatchedBlurEvent.initEvent('blur', true, true);
let logs: any = [];
const handler = () => {};
const blurHandler = (e: any) => {
logs.push('blur');
};
const manager = new EventManager([domEventPlugin], zone);
let removerParent: any;
let removerChildFocus: any;
zone.run(() => {
removerParent = manager.addEventListener(element, 'click', handler);
removerChildFocus = manager.addEventListener(child, 'blur', blurHandler);