Skip to content

Commit

Permalink
perf(material/core): delegate trigger events (#26147)
Browse files Browse the repository at this point in the history
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.

(cherry picked from commit efb7f2e)
  • Loading branch information
crisbeto committed Dec 2, 2022
1 parent c388758 commit 356aab7
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 26 deletions.
81 changes: 81 additions & 0 deletions src/material/core/ripple/ripple-event-manager.ts
@@ -0,0 +1,81 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform';
import {NgZone} from '@angular/core';

/** Options used to bind a passive capturing event. */
const passiveCapturingEventOptions = normalizePassiveListenerOptions({
passive: true,
capture: true,
});

/** Manages events through delegation so that as few event handlers as possible are bound. */
export class RippleEventManager {
private _events = new Map<string, Map<HTMLElement, Set<EventListenerObject>>>();

/** Adds an event handler. */
addHandler(ngZone: NgZone, name: string, element: HTMLElement, handler: EventListenerObject) {
const handlersForEvent = this._events.get(name);

if (handlersForEvent) {
const handlersForElement = handlersForEvent.get(element);

if (handlersForElement) {
handlersForElement.add(handler);
} else {
handlersForEvent.set(element, new Set([handler]));
}
} else {
this._events.set(name, new Map([[element, new Set([handler])]]));

ngZone.runOutsideAngular(() => {
document.addEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions);
});
}
}

/** Removes an event handler. */
removeHandler(name: string, element: HTMLElement, handler: EventListenerObject) {
const handlersForEvent = this._events.get(name);

if (!handlersForEvent) {
return;
}

const handlersForElement = handlersForEvent.get(element);

if (!handlersForElement) {
return;
}

handlersForElement.delete(handler);

if (handlersForElement.size === 0) {
handlersForEvent.delete(element);
}

if (handlersForEvent.size === 0) {
this._events.delete(name);
document.removeEventListener(name, this._delegateEventHandler, passiveCapturingEventOptions);
}
}

/** Event handler that is bound and which dispatches the events to the different targets. */
private _delegateEventHandler = (event: Event) => {
const target = _getEventTarget(event);

if (target) {
this._events.get(event.type)?.forEach((handlers, element) => {
if (element === target || element.contains(target as Node)) {
handlers.forEach(handler => handler.handleEvent(event));
}
});
}
};
}
63 changes: 38 additions & 25 deletions src/material/core/ripple/ripple-renderer.ts
Expand Up @@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ElementRef, NgZone} from '@angular/core';
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
import {Platform, normalizePassiveListenerOptions, _getEventTarget} from '@angular/cdk/platform';
import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y';
import {coerceElement} from '@angular/cdk/coercion';
import {RippleRef, RippleState, RippleConfig} from './ripple-ref';
import {RippleEventManager} from './ripple-event-manager';

/**
* Interface that describes the target for launching ripples.
Expand Down Expand Up @@ -45,8 +46,11 @@ export const defaultRippleAnimationConfig = {
*/
const ignoreMouseEventsTimeout = 800;

/** Options that apply to all the event listeners that are bound by the ripple renderer. */
const passiveEventOptions = normalizePassiveListenerOptions({passive: true});
/** Options used to bind a passive capturing event. */
const passiveCapturingEventOptions = normalizePassiveListenerOptions({
passive: true,
capture: true,
});

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

private static _eventManager = new RippleEventManager();

constructor(
private _target: RippleTarget,
private _ngZone: NgZone,
elementOrElementRef: HTMLElement | ElementRef<HTMLElement>,
platform: Platform,
private _platform: Platform,
) {
// Only do anything if we're on the browser.
if (platform.isBrowser) {
if (_platform.isBrowser) {
this._containerElement = coerceElement(elementOrElementRef);
}
}
Expand Down Expand Up @@ -252,15 +258,19 @@ export class RippleRenderer implements EventListenerObject {
setupTriggerEvents(elementOrElementRef: HTMLElement | ElementRef<HTMLElement>) {
const element = coerceElement(elementOrElementRef);

if (!element || element === this._triggerElement) {
if (!this._platform.isBrowser || !element || element === this._triggerElement) {
return;
}

// Remove all previously registered event listeners from the trigger element.
this._removeTriggerEvents();

this._triggerElement = element;
this._registerEvents(pointerDownEvents);

// Use event delegation for the trigger events since they're
// set up during creation and are performance-sensitive.
pointerDownEvents.forEach(type => {
RippleRenderer._eventManager.addHandler(this._ngZone, type, element, this);
});
}

/**
Expand All @@ -280,7 +290,17 @@ export class RippleRenderer implements EventListenerObject {
// We do this on-demand in order to reduce the total number of event listeners
// registered by the ripples, which speeds up the rendering time for large UIs.
if (!this._pointerUpEventsRegistered) {
this._registerEvents(pointerUpEvents);
// The events for hiding the ripple are bound directly on the trigger, because:
// 1. Some of them occur frequently (e.g. `mouseleave`) and any advantage we get from
// delegation will be diminished by having to look through all the data structures often.
// 2. They aren't as performance-sensitive, because they're bound only after the user
// has interacted with an element.
this._ngZone.runOutsideAngular(() => {
pointerUpEvents.forEach(type => {
this._triggerElement!.addEventListener(type, this, passiveCapturingEventOptions);
});
});

this._pointerUpEventsRegistered = true;
}
}
Expand Down Expand Up @@ -393,30 +413,23 @@ export class RippleRenderer implements EventListenerObject {
});
}

/** Registers event listeners for a given list of events. */
private _registerEvents(eventTypes: string[]) {
this._ngZone.runOutsideAngular(() => {
eventTypes.forEach(type => {
this._triggerElement!.addEventListener(type, this, passiveEventOptions);
});
});
}

private _getActiveRipples(): RippleRef[] {
return Array.from(this._activeRipples.keys());
}

/** Removes previously registered event listeners from the trigger element. */
_removeTriggerEvents() {
if (this._triggerElement) {
pointerDownEvents.forEach(type => {
this._triggerElement!.removeEventListener(type, this, passiveEventOptions);
});
const trigger = this._triggerElement;

if (trigger) {
pointerDownEvents.forEach(type =>
RippleRenderer._eventManager.removeHandler(type, trigger, this),
);

if (this._pointerUpEventsRegistered) {
pointerUpEvents.forEach(type => {
this._triggerElement!.removeEventListener(type, this, passiveEventOptions);
});
pointerUpEvents.forEach(type =>
trigger.removeEventListener(type, this, passiveCapturingEventOptions),
);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion tools/public_api_guard/material/core.md
Expand Up @@ -524,7 +524,7 @@ export class RippleRef {

// @public
export class RippleRenderer implements EventListenerObject {
constructor(_target: RippleTarget, _ngZone: NgZone, elementOrElementRef: HTMLElement | ElementRef<HTMLElement>, platform: Platform);
constructor(_target: RippleTarget, _ngZone: NgZone, elementOrElementRef: HTMLElement | ElementRef<HTMLElement>, _platform: Platform);
fadeInRipple(x: number, y: number, config?: RippleConfig): RippleRef;
fadeOutAll(): void;
fadeOutAllNonPersistent(): void;
Expand Down

0 comments on commit 356aab7

Please sign in to comment.