Angular 4.x 事件管理器及自定義EventManagerPlugin

semlinker發表於2017-04-23

在 Angular 中如何為同一個表示式繫結多個事件呢?如果我們這樣做可能會是這樣的:

<div>
   <button (click, mouseover)="onClick()">Click me</button>
</div>複製程式碼

在繼續分析繫結多個事件之前,我們先來分析一下,如果在模板中繫結一個事件如 click 事件,Angular 是如何工作的?

<div>
   <button (click)="onClick()">Click me</button>
</div>複製程式碼

Angular 在解析 DOM 樹的時候,對於事件繫結它會呼叫 DomRenderer 例項的 listen() 方法,進行事件繫結,listen() 方法具體實現如下:

// angular2/packages/platform-browser/src/dom/dom_renderer.ts
class DefaultDomRenderer2 implements Renderer2 {
    ....
    listen(target: 'window'|'document'|'body'|any, event: string, 
      callback: (event: any) => boolean):
          () => void {
        checkNoSyntheticProp(event, 'listener');
        if (typeof target === 'string') {
          return <() => void>this.eventManager.addGlobalEventListener(
              target, event, decoratePreventDefault(callback));
        }
        return <() => void>this.eventManager.addEventListener(
                   target, event, decoratePreventDefault(callback)) as() => void;
    }
}複製程式碼

通過原始碼我們發現,不管走哪條分支,最終都是呼叫 this.eventManager 物件的方法設定事件監聽。這裡的 this.eventManager 是什麼?它是 Angular 中的事件管理器 EventManager,我們先來會會它。

EventManager (事件管理器)

在 Angular 中所有的事件繫結都是由一個事件管理器來驅動,事件管理器本身由多個事件外掛提供支援。Angular 中內建的事件外掛如下:

  • KeyEventsPlugin - 處理鍵盤事件
  • HammerGesturesPlugin - 處理手勢
  • DomEventsPlugin - 處理 DOM 事件

看完上面的內容,相信很多人也會有疑問 - EventManager 到底是如何管理不同事件的呢?要揭開這背後的祕密,我們的唯一途徑就是看原始碼,因為它是最誠實的,它對你毫無保留,此刻腦海中突然想起一首歌:

美麗的神話

解開我 最神祕的等待
星星墜落 風在吹動
終於再將你擁入懷中
….

愛是心中唯一不變美麗的神話

放鬆一下,馬上回到正題 - EventManager 類:

EventManager 類

// angular2/packages/platform-browser/src/dom/events/event_manager.ts
export class EventManager {
  // EventManagerPlugin列表
  private _plugins: EventManagerPlugin[]; 
  // 快取已匹配的eventName與對應的外掛
  private _eventNameToPlugin = new Map<string, EventManagerPlugin>();

  constructor(
    @Inject(EVENT_MANAGER_PLUGINS) plugins: EventManagerPlugin[], 
    private _zone: NgZone) {
        plugins.forEach(p => p.manager = this);
        /**
         * {provide: EVENT_MANAGER_PLUGINS, useClass: DomEventsPlugin, multi: true},
         * {provide: EVENT_MANAGER_PLUGINS, useClass: KeyEventsPlugin, multi: true},
         * {provide: EVENT_MANAGER_PLUGINS, useClass: HammerGesturesPlugin, multi: true}
         * 
         * slice(): 建立新的plugins陣列
         * reverse(): 讓DomEventsPlugin外掛作為列表最後一項,因為它能夠處理所有的事件。
        */
        this._plugins = plugins.slice().reverse();
  }

  // 獲取能處理eventName的外掛,並呼叫對應外掛提供的addEventListener()方法
  addEventListener(element: HTMLElement, eventName: string,
    handler: Function): Function {
        const plugin = this._findPluginFor(eventName);
        return plugin.addEventListener(element, eventName, handler);
  }

  // 獲取能處理eventName的外掛,並呼叫對應外掛提供的addGlobalEventListener()方法
  addGlobalEventListener(target: string, eventName: string, 
    handler: Function): Function {
        const plugin = this._findPluginFor(eventName);
        return plugin.addGlobalEventListener(target, eventName, handler);
  }

  // 獲取NgZone
  getZone(): NgZone { return this._zone; }

  /** @internal */
  _findPluginFor(eventName: string): EventManagerPlugin {
    // 優先從_eventNameToPlugin物件中獲取eventName對應的EventManagerPlugin
    const plugin = this._eventNameToPlugin.get(eventName);  
    if (plugin) {
      return plugin;
    }

    // 遍歷外掛列表,判斷當前外掛是否支援eventName對應的事件名
    const plugins = this._plugins;
    for (let i = 0; i < plugins.length; i++) {
      const plugin = plugins[i];
      if (plugin.supports(eventName)) {
        this._eventNameToPlugin.set(eventName, plugin);
        return plugin;
      }
    }
    throw new Error(`No event manager plugin found for event ${eventName}`);
  }
}複製程式碼

相關說明

  • 在 addEventListener() 或 addGlobalEventListener() 方法內部都會呼叫 _findPluginFor() 方法,查詢對應的能夠處理 eventName 對應的 EventManagerPlugin 外掛物件。
  • _findPluginFor() 方法中,會遍歷外掛列表,然後以 eventName 作為引數呼叫外掛物件提供的 supports() 方法,判斷當前是否能夠處理 eventName 對應的事件。因此對於 EventManagerPlugin 外掛物件,如果要宣告能夠處理某類事件,就需要在 supports() 方法中進行相應處理。
  • DomEventsPlugin 外掛作為列表最後一項,因為它能夠處理所有的事件。
  • KeyEventsPlugin、HammerGesturesPlugin、DomEventsPlugin 外掛類都繼承於 EventManagerPlugin 抽象類。

EventManagerPlugin 抽象類

export abstract class EventManagerPlugin {
  constructor(private _doc: any) {}

  manager: EventManager;

  // 判斷是否支援eventName對應的事件
  abstract supports(eventName: string): boolean;

  // 新增事件監聽
  abstract addEventListener(element: HTMLElement, eventName: string, 
    handler: Function): Function;

  // 新增全域性的事件監聽
  addGlobalEventListener(element: string, eventName: string, 
    handler: Function): Function {
      const target: HTMLElement = getDOM().getGlobalEventTarget(this._doc, element);
       if (!target) {
           throw new Error(`Unsupported event target ${target} for event 
            ${eventName}`);
       }
       return this.addEventListener(target, eventName, handler);
  };
}複製程式碼

Angular 4.x 事件管理器及自定義EventManagerPlugin

時機已成熟,接下來我們開始實現上述的功能。

自定義外掛

Step 1: Creating a new plugin

正如上面提到的,我們希望在我們的 Angular 模板上有多個事件繫結到同一個表示式:

<div>
   <button (click, mouseover)="onClick()">Click me</button>
</div>複製程式碼

如果是這樣,我們的 supports() 函式的內部規則應該很清楚。我們需要一個字串,其中有一個或多個逗號,分隔事件名稱。當人們把一些愚蠢的東西放在(,click)中時,我們也應該處理。所以我們的 supports() 函式如下:

getMultiEventArray(eventName: string): string[] {
  return eventName.split(",")
    .filter((item, index): boolean => { return item && item != '' })
}

supports(eventName: string): boolean {
  return this.getMultiEventArray(eventName).length > 1
}複製程式碼

這將允許 EventManager 將事件字串如 (click, mouseover) 委派給此外掛。

Step 2: Implementing the eventListeners

現在我們已經實現了supports() 方法,EventManager 將呼叫 plugin.addEventListener() 方法,因此外掛需要實現 addEventListener() 方法,從而實現我們的自定義行為。我們的自定義行為很簡單 - 為我們解析的eventArray 中的所有事件新增事件偵聽器。

addEventListener

addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
        let zone = this.manager.getZone();
        let eventsArray = this.getMultiEventArray(eventName);

        // Entering back into angular to trigger changeDetection
        let outsideHandler = (event: any) => {
            zone.runGuarded(() => handler(event));
        };

        // Executed outside of angular so that change detection is not 
        // constantly triggered.
        let addAndRemoveHostListenersForOutsideEvents = () => {
            eventsArray.forEach((singleEventName: string) => {
                this.manager.addEventListener(element, singleEventName, outsideHandler);
            });
        }

        return this.manager.getZone()
                   .runOutsideAngular(addAndRemoveHostListenersForOutsideEvents);
    }複製程式碼

addGlobalEventListener

 addGlobalEventListener(target: string, eventName: string, handler: Function): Function {
        let zone = this.manager.getZone();
        let eventsArray = this.getMultiEventArray(eventName);
        let outsideHandler = (event: any) => zone.runGuarded(() => handler(event));

        return this.manager.getZone().runOutsideAngular(() => {
            eventsArray.forEach((singleEventName: string) => {
                this.manager.addGlobalEventListener(target, singleEventName, 
                    outsideHandler);
            })
        });
}複製程式碼

Step 3: Register plugin

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

@NgModule({
  ...
  providers: [
    { provide: EVENT_MANAGER_PLUGINS, useClass: MultiEventPlugin, multi: true }
  ]
})
export class AppModule { }複製程式碼

完整示例

multi-event.plugin.ts

import { Injectable, Inject } from '@angular/core';
import { EventManager, DOCUMENT, ɵd as EventManagerPlugin } from '@angular/platform-browser';

/**
 * Support Multi Event
 */
@Injectable()
export class MultiEventPlugin extends EventManagerPlugin {
    manager: EventManager;

    constructor( @Inject(DOCUMENT) doc: any) { super(doc); }

    getMultiEventArray(eventName: string): string[] { 
        return eventName.split(",")   // click,mouseover => [click,mouseover]
            .filter((item, index): boolean => { return item && item != '' })
    }

    supports(eventName: string): boolean {
        return this.getMultiEventArray(eventName).length > 1;
    }

    addEventListener(element: HTMLElement, eventName: string, 
      handler: Function): Function {
        let zone = this.manager.getZone();
        let eventsArray = this.getMultiEventArray(eventName);

        // Entering back into angular to trigger changeDetection
        let outsideHandler = (event: any) => {
            zone.runGuarded(() => handler(event));
        };

        // Executed outside of angular so that change detection is
        // not constantly triggered.
        let addAndRemoveHostListenersForOutsideEvents = () => {
            eventsArray.forEach((singleEventName: string) => {
                this.manager.addEventListener(element, singleEventName, outsideHandler);
            });
        }

        return this.manager.getZone()
                .runOutsideAngular(addAndRemoveHostListenersForOutsideEvents);
    }

    addGlobalEventListener(target: string, eventName: string, 
      handler: Function): Function {
        let zone = this.manager.getZone();
        let eventsArray = this.getMultiEventArray(eventName);
        let outsideHandler = (event: any) => zone.runGuarded(() => handler(event));

        return this.manager.getZone().runOutsideAngular(() => {
            eventsArray.forEach((singleEventName: string) => {
                this.manager.addGlobalEventListener(target, singleEventName, 
                    outsideHandler);
            });
        });
    }
}複製程式碼

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <div>
      <button (click,mouseover)="onClick()">Click me</button>
    </div>
  `
})
export class AppComponent {
  onClick() {
    console.log('Click');
  }
}複製程式碼

app.module.ts

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { MultiEventPlugin } from './plugins/multi-event.plugin';

@NgModule({
  imports: [BrowserModule],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: [
    { provide: EVENT_MANAGER_PLUGINS, useClass: MultiEventPlugin, multi: true }
  ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }複製程式碼

參考資源

相關文章