Skip to content

Commit bf0e073

Browse files
committed
perf(material/core): delegate trigger events
Ripples are used a lot throughout our components which makes them very performance-sensitive. These changes aim to reduce the cost of setting them up by delegating the `mousedown` and `touchstart` handlers. This shaved off 15 to 20 percent of the creation cost from buttons. I decided to only delegate the `mousedown` and `touchstart` events, rather than all ripple-related events, because ripples listen to some very frequent events like `mouseleave` and the cost of matching events to their targets would've offset any gains we would've gotten from delegating them. The code is written in a way where we can easily delegate them later if we change our minds.
1 parent 2e5fab6 commit bf0e073

File tree

2 files changed

+119
-25
lines changed

2 files changed

+119
-25
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
import {normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform';
10+
import {NgZone} from '@angular/core';
11+
12+
/** Options used to bind a passive capturing event. */
13+
const passiveCapturingEventOptions = normalizePassiveListenerOptions({
14+
passive: true,
15+
capture: true,
16+
});
17+
18+
/** Manages events through delegation so as few event handlers as possive are bound. */
19+
export class RippleEventManager {
20+
private _events = new Map<string, Map<HTMLElement, Set<EventListenerObject>>>();
21+
22+
/** Adds an event handler. */
23+
addHandler(ngZone: NgZone, name: string, element: HTMLElement, handler: EventListenerObject) {
24+
const handlersForEvent = this._events.get(name);
25+
26+
if (handlersForEvent) {
27+
const handlersForElement = handlersForEvent.get(element);
28+
29+
if (handlersForElement) {
30+
handlersForElement.add(handler);
31+
} else {
32+
handlersForEvent.set(element, new Set([handler]));
33+
}
34+
} else {
35+
this._events.set(name, new Map([[element, new Set([handler])]]));
36+
37+
ngZone.runOutsideAngular(() => {
38+
document.addEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions);
39+
});
40+
}
41+
}
42+
43+
/** Removes an event handler. */
44+
removeHandler(name: string, element: HTMLElement, handler: EventListenerObject) {
45+
const handlersForEvent = this._events.get(name);
46+
47+
if (!handlersForEvent) {
48+
return;
49+
}
50+
51+
const handlersForElement = handlersForEvent.get(element);
52+
53+
if (!handlersForElement) {
54+
return;
55+
}
56+
57+
handlersForElement.delete(handler);
58+
59+
if (handlersForElement.size === 0) {
60+
handlersForEvent.delete(element);
61+
}
62+
63+
if (handlersForEvent.size === 0) {
64+
this._events.delete(name);
65+
document.removeEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions);
66+
}
67+
}
68+
69+
/** Event handler that is bound and which dispatches the events to the different targets. */
70+
private _delegateEventHandler = (event: Event) => {
71+
const target = _getEventTarget(event);
72+
73+
if (target) {
74+
this._events.get(event.type)?.forEach((handlers, element) => {
75+
if (element === target || element.contains(target as Node)) {
76+
handlers.forEach(handler => handler.handleEvent(event));
77+
}
78+
});
79+
}
80+
};
81+
}

src/material/core/ripple/ripple-renderer.ts

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import {ElementRef, NgZone} from '@angular/core';
9-
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
9+
import {Platform, normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform';
1010
import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y';
1111
import {coerceElement} from '@angular/cdk/coercion';
1212
import {RippleRef, RippleState, RippleConfig} from './ripple-ref';
13+
import {RippleEventManager} from './ripple-event-manager';
1314

1415
/**
1516
* Interface that describes the target for launching ripples.
@@ -45,8 +46,11 @@ export const defaultRippleAnimationConfig = {
4546
*/
4647
const ignoreMouseEventsTimeout = 800;
4748

48-
/** Options that apply to all the event listeners that are bound by the ripple renderer. */
49-
const passiveEventOptions = normalizePassiveListenerOptions({passive: true});
49+
/** Options used to bind a passive capturing event. */
50+
const passiveCapturingEventOptions = normalizePassiveListenerOptions({
51+
passive: true,
52+
capture: true,
53+
});
5054

5155
/** Events that signal that the pointer is down. */
5256
const pointerDownEvents = ['mousedown', 'touchstart'];
@@ -94,14 +98,16 @@ export class RippleRenderer implements EventListenerObject {
9498
*/
9599
private _containerRect: ClientRect | null;
96100

101+
private static _eventManager = new RippleEventManager();
102+
97103
constructor(
98104
private _target: RippleTarget,
99105
private _ngZone: NgZone,
100106
elementOrElementRef: HTMLElement | ElementRef<HTMLElement>,
101-
platform: Platform,
107+
private _platform: Platform,
102108
) {
103109
// Only do anything if we're on the browser.
104-
if (platform.isBrowser) {
110+
if (_platform.isBrowser) {
105111
this._containerElement = coerceElement(elementOrElementRef);
106112
}
107113
}
@@ -252,15 +258,19 @@ export class RippleRenderer implements EventListenerObject {
252258
setupTriggerEvents(elementOrElementRef: HTMLElement | ElementRef<HTMLElement>) {
253259
const element = coerceElement(elementOrElementRef);
254260

255-
if (!element || element === this._triggerElement) {
261+
if (!this._platform.isBrowser || !element || element === this._triggerElement) {
256262
return;
257263
}
258264

259265
// Remove all previously registered event listeners from the trigger element.
260266
this._removeTriggerEvents();
261-
262267
this._triggerElement = element;
263-
this._registerEvents(pointerDownEvents);
268+
269+
// Use event delegation for the trigger events since they're
270+
// set up during creation and are performance-sensitive.
271+
pointerDownEvents.forEach(type => {
272+
RippleRenderer._eventManager.addHandler(this._ngZone, type, element, this);
273+
});
264274
}
265275

266276
/**
@@ -280,7 +290,17 @@ export class RippleRenderer implements EventListenerObject {
280290
// We do this on-demand in order to reduce the total number of event listeners
281291
// registered by the ripples, which speeds up the rendering time for large UIs.
282292
if (!this._pointerUpEventsRegistered) {
283-
this._registerEvents(pointerUpEvents);
293+
// The events for hiding the ripple are bound directly on the trigger, because:
294+
// 1. Some of them occur frequently (e.g. `mouseleave`) and any advantage we get from
295+
// delegation will be diminished by having to look through all the data structures often.
296+
// 2. They aren't as performance-sensitive, because they're bound only after the user
297+
// has interacted with an element.
298+
this._ngZone.runOutsideAngular(() => {
299+
pointerUpEvents.forEach(type => {
300+
this._triggerElement!.addEventListener(type, this, passiveCapturingEventOptions);
301+
});
302+
});
303+
284304
this._pointerUpEventsRegistered = true;
285305
}
286306
}
@@ -393,30 +413,23 @@ export class RippleRenderer implements EventListenerObject {
393413
});
394414
}
395415

396-
/** Registers event listeners for a given list of events. */
397-
private _registerEvents(eventTypes: string[]) {
398-
this._ngZone.runOutsideAngular(() => {
399-
eventTypes.forEach(type => {
400-
this._triggerElement!.addEventListener(type, this, passiveEventOptions);
401-
});
402-
});
403-
}
404-
405416
private _getActiveRipples(): RippleRef[] {
406417
return Array.from(this._activeRipples.keys());
407418
}
408419

409420
/** Removes previously registered event listeners from the trigger element. */
410421
_removeTriggerEvents() {
411-
if (this._triggerElement) {
412-
pointerDownEvents.forEach(type => {
413-
this._triggerElement!.removeEventListener(type, this, passiveEventOptions);
414-
});
422+
const trigger = this._triggerElement;
423+
424+
if (trigger) {
425+
pointerDownEvents.forEach(type =>
426+
RippleRenderer._eventManager.removeHandler(type, trigger, this),
427+
);
415428

416429
if (this._pointerUpEventsRegistered) {
417-
pointerUpEvents.forEach(type => {
418-
this._triggerElement!.removeEventListener(type, this, passiveEventOptions);
419-
});
430+
pointerUpEvents.forEach(type =>
431+
trigger.removeEventListener(type, this, passiveCapturingEventOptions),
432+
);
420433
}
421434
}
422435
}

0 commit comments

Comments
 (0)