CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-195 · Angular 高级教程

Angular 22+ 教程 30:EventManagerPlugin 与手势事件

模板里写 (click)="handle()" 时,事件绑定到底经历了什么?多数开发者不需要关心答案,但当你想给整个应用加上 (long-press) 这样的自定义手势事件、想知道高频事件为什么不会拖垮变更检测时,就必须下探到 Angular 事件系统的底层:EventManagerEventManagerPlugin。本文从机制讲到手势插件完整实现,最后对比 zone.js 时代与 zoneless 下事件处理的差异。

事件绑定的真实路径

Angular 的模板编译器把 (click)="handle($event)" 编译成对 Renderer 的 listen 调用,而 DefaultDomRenderer 自己并不直接调用 addEventListener,而是委托给 EventManager

graph TD
    T[模板事件绑定 click 等] --> C[编译为 listen 调用]
    C --> R[DomRenderer.listen]
    R --> M[EventManager.addEventListener]
    M --> F{遍历插件 supports}
    F -- 命中 --> P1[KeyEventsPlugin - keydown.enter]
    F -- 命中 --> P2[自定义插件 - long-press]
    F -- 兜底 --> P3[DomEventsPlugin - 普通 DOM 事件]
    P1 --> D[原生 addEventListener]
    P2 --> D
    P3 --> D
    D --> H[handler 执行 - 触发变更检测调度]

EventManager 是个非常小的调度器,核心逻辑只有一句话:拿着事件名挨个问插件”你支持吗”,第一个说支持的负责注册监听

// @angular/platform-browser 中的简化示意
export class EventManager {
  addEventListener(element: HTMLElement, eventName: string, listener: EventListener): () => void {
    const plugin = this._findPluginFor(eventName);
    return plugin.addEventListener(element, eventName, listener);
  }

  addGlobalEventListener(target: string, eventName: string, listener: EventListener): () => void {
    const plugin = this._findPluginFor(eventName);
    return plugin.addGlobalEventListener(target, eventName, listener);
  }

  private _findPluginFor(eventName: string): EventManagerPlugin {
    for (const plugin of this._plugins) {
      if (plugin.supports(eventName)) {
        return plugin;
      }
    }
    throw new Error(`No event manager plugin found for event ${eventName}`);
  }
}

返回的注销函数会并入模板的清理逻辑,组件销毁时自动移除监听——所以自定义插件也必须遵守”返回注销函数”的契约。

内置插件:事件名的”语法糖”从哪来

Angular 自带两个关键插件,理解它们就理解了插件能做什么:

  • DomEventsPlugin:兜底插件,处理所有普通 DOM 事件(clickinputpointerdown……),本质就是透传 addEventListener
  • KeyEventsPlugin:处理形如 (keydown.enter)(keydown.control.shift.k) 的组合键事件。它把 keydown.enter 解析成”keydown + key 为 Enter”,内部注册一个 keydown 监听,命中组合时才调用 handler

KeyEventsPlugin 揭示了插件机制的典型用法:对外提供一个”虚拟事件名”,对内用若干真实 DOM 事件合成它。我们要实现的 long-press 手势正是同一模式——用 pointerdown/pointermove/pointerup 合成一个 long-press 事件。

EventManagerPlugin 契约

自定义插件是一个满足以下接口的类(可以继承 EventManagerPlugin 基类,也可以直接实现):

export class EventManagerPlugin {
  // EventManager 构造时会自动注入自身,插件内可通过它拿到 NgZone
  manager!: EventManager;

  // 是否支持该事件名——通常用前缀或精确匹配
  supports(eventName: string): boolean {
    return false;
  }

  // 注册监听,返回注销函数
  addEventListener(element: HTMLElement, eventName: string, handler: Function): () => void {
    return () => {};
  }

  // 支持 (window:xxx) / (document:xxx) 形式的全局绑定
  addGlobalEventListener(target: string, eventName: string, handler: Function): () => void {
    return () => {};
  }
}

注册方式是经典的 multi provider:

import { bootstrapApplication } from '@angular/platform-browser';
import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';

bootstrapApplication(AppComponent, {
  providers: [
    { provide: EVENT_MANAGER_PLUGINS, useClass: LongPressPlugin, multi: true },
  ],
});

完整实现:long-press 手势插件

需求:按下并保持 500ms 触发 (long-press);期间移动超过 10px(手指抖动容差)则取消;抬起或取消事件同样作废。实现要点:

import { Injectable, NgZone } from '@angular/core';
import { EventManager, EventManagerPlugin } from '@angular/platform-browser';

const EVENT_NAME = 'long-press';
const DEFAULT_DURATION = 500;
const MOVE_TOLERANCE = 10;

export interface LongPressEvent extends CustomEvent {
  readonly detail: { duration: number; x: number; y: number };
}

@Injectable()
export class LongPressPlugin extends EventManagerPlugin {
  supports(eventName: string): boolean {
    return eventName === EVENT_NAME;
  }

  override addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: (event: LongPressEvent) => void,
  ): () => void {
    const zone = this.manager.getZone();

    let timer: ReturnType<typeof setTimeout> | null = null;
    let startX = 0;
    let startY = 0;

    const cancel = () => {
      if (timer !== null) {
        clearTimeout(timer);
        timer = null;
      }
    };

    const fire = (x: number, y: number) => {
      const event: LongPressEvent = new CustomEvent(EVENT_NAME, {
        bubbles: false,
        detail: { duration: DEFAULT_DURATION, x, y },
      }) as LongPressEvent;
      // 在 Angular Zone 内触发 handler,保证 zone.js 模式下变更检测正常调度
      zone.run(() => handler(event));
    };

    const onPointerDown = (down: PointerEvent) => {
      startX = down.clientX;
      startY = down.clientY;
      timer = setTimeout(() => {
        timer = null;
        fire(down.clientX, down.clientY);
      }, DEFAULT_DURATION);
    };

    const onPointerMove = (move: PointerEvent) => {
      if (timer === null) {
        return;
      }
      const distance = Math.hypot(move.clientX - startX, move.clientY - startY);
      if (distance > MOVE_TOLERANCE) {
        cancel();
      }
    };

    const onPointerUp = () => cancel();

    // 高频事件注册在 Zone 外,避免每次 pointermove 都触发变更检测
    zone.runOutsideAngular(() => {
      element.addEventListener('pointerdown', onPointerDown);
      element.addEventListener('pointermove', onPointerMove);
      element.addEventListener('pointerup', onPointerUp);
      element.addEventListener('pointercancel', onPointerUp);
    });

    // 契约:返回注销函数
    return () => {
      cancel();
      element.removeEventListener('pointerdown', onPointerDown);
      element.removeEventListener('pointermove', onPointerMove);
      element.removeEventListener('pointerup', onPointerUp);
      element.removeEventListener('pointercancel', onPointerUp);
    };
  }
}

代码里两个 zone 调用是性能关键:

  • runOutsideAngular 把四个原生监听注册在 Zone 外。否则 zone.js 模式下每次 pointermove 都会”可能触发变更检测”,白耗性能——这就是为什么手势类插件的模板里滑动手势丝滑与否差别巨大
  • zone.run(handler) 让合成事件最终在 Zone 内派发,模板绑定 (long-press) 后的状态更新能被变更检测看到

可配置化:读取事件参数

硬编码 500ms 不够用?事件名可以带参数,比如 (long-press.1000) 表示 1 秒。让 supports 支持前缀匹配:

private static readonly PREFIX = 'long-press';

supports(eventName: string): boolean {
  return eventName === LongPressPlugin.PREFIX
    || eventName.startsWith(`${LongPressPlugin.PREFIX}.`);
}

private parseDuration(eventName: string): number {
  const [, duration] = eventName.split('.');
  return duration ? Number(duration) || DEFAULT_DURATION : DEFAULT_DURATION;
}

在模板中使用

<ul>
  @for (msg of messages(); track msg.id) {
    <li (long-press)="archive(msg)">{{ msg.title }}</li>
  }
</ul>

也能配合全局形式 (window:long-press)——前提是插件实现了 addGlobalEventListener,通常直接转发到 window 对象上的注册逻辑即可。

zone.js 时代 vs zoneless 下的事件处理

同一份插件代码,在两种模式下行为为什么都正确?先看差异:

维度zone.js 时代Zoneless(v22 默认)
变更检测触发Zone 补丁事件回调,回调返回后调度全树检查框架包装 handler:执行后 markViewDirtyAndSchedule 精确调度
runOutsideAngular 的意义必须:逃离 Zone 才能避开高频事件风暴近似恒等调用,保留它无害且兼容旧模式
zone.run(handler) 的意义必须:回到 Zone 内才能触发变更检测近似恒等调用,handler 自带调度语义
高频事件防护成本靠开发者自觉 runOutsideAngular天然安全:只有注册进 EventManager 的绑定才参与调度
插件迁移成本-零修改:NgZone 在 zoneless 下是保持接口的兼容实现

这解释了一个容易被忽略的事实:在 zoneless 模式下,NgZone 并没有被删掉,而是变成了一个 run/runOutsideAngular 都直接执行的兼容层。老代码里的 zone 调用不会报错,只是不再有实际作用;而新代码里继续保留这两个调用,是为了让组件库能同时服务两类应用。

另一个差异值得强调:zoneless 下 Angular 对”哪些事件要触发变更检测”有精确账本——只有通过模板绑定(即 Renderer → EventManager 路径)注册的监听才携带调度语义。你在组件里手写的 el.addEventListener('click', ...) 在两种模式下都不会触发变更检测(zone.js 下会触发,因为它补丁了原生 API;zoneless 下不会)。所以 zoneless 迁移时最容易踩的坑就是:裸 addEventListener 的回调里改了信号绑定的状态以外的 DOM,或在 zone.js 下依赖其隐式检查的代码。手势插件的启示是通用的:让事件回调走信号写路径,UI 自然刷新:

handler: (event: LongPressEvent) => void;
// 回调内部只做 this.state.set(...),变更检测由信号图接管

常见误区

  • supports 写得太宽:用 startsWith('long') 这类宽松匹配会意外截胡别的插件。精确匹配或带点号的前缀(long-press.)更安全
  • 忘记返回注销函数:手势监听泄漏比普通事件更难排查,因为它往往绑定在长生命周期元素上
  • 在 handler 里直接操作 DOM:手势回调应该改信号状态,让模板声明式地响应;命令式 DOM 操作与 zoneless 相性极差
  • 试图覆盖内置事件:给 click 写自定义插件技术上可行(先注册者胜出),但会让全团队的 (click) 语义变得不可预期,慎重

小结

EventManager 用一个不到百行的调度器换来了事件系统的开放性:KeyEventsPlugin 证明”合成事件”是官方认可的模式,自定义手势只是同一机制的私有扩展。写插件时记住三个契约——supports 精确、注册返回注销、handler 触发点对齐变更检测语义——剩下的就是纯粹的手势状态机实现。

系列导航

← 算法 030:算法思想:贪心算法 目录 JHipster 开发 30:复盘——JHipster 三年使用总结 →
← 返回文章列表