Angularでのイベント処理の最適化

はじめに



Angularは、構文(eventName)="onEventName($event)"



を使用して、テンプレート内のイベントをサブスクライブする便利な宣言的方法を提供します。 変更をチェックするChangeDetectionStrategy.OnPush



ポリシーとともに、このアプローチは、関心のあるユーザー入力に対してのみ変更をチェックするサイクルを自動的に開始します。 つまり、 <input>



要素で(input)



イベントをリッスンする場合、ユーザーが単に入力フィールドをクリックしただけでは、変更チェックはトリガーされません。 大幅に改善

デフォルトポリシーと比較したパフォーマンス( ChangeDetectionStrategy.Default



)。 ディレクティブでは、 @HostListener('eventName')



デコレーターを介してホスト要素のイベントをサブスクライブすることもできます。







私の実践では、特定のイベントの処理が条件が満たされた場合にのみ必要になる場合がよくあります。 つまり ハンドラーは次のようになります。







 class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } }
      
      





条件が満たされておらず、実際にアクションが発生していない場合でも、変更検証サイクルが開始されます。 scroll



mousemove



などの頻繁なイベントの場合、これはアプリケーションのパフォーマンスに悪影響を与える可能性があります。







私が取り組んでいるコンポーネントUIライブラリでは、ドロップダウンメニュー内でmousemove



にサブスクライブすると、マウスの動きごとにコンポーネントツリー全体で変更の再カウントがトリガーされました。 正しいメニューの動作を実装するためにマウスを監視する必要がありましたが、最適化する価値があることは明らかでした。 詳細については、以下をご覧ください。







このような瞬間は、ユニバーサルUI要素にとって特に重要です。 ページにはそれらの多くが存在する可能性があり、アプリケーションは非常に複雑で、パフォーマンスが要求される場合があります。







たとえば、 Observable.fromEvent



を使用してngZone



バイパスするイベントにサブスクライブし、 changeDetectorRef.markForCheck()



呼び出してchangeDetectorRef.markForCheck()



で変更のチェックを開始することで、状況を修正できます。 ただし、これにより大量の余分な作業が追加され、便利な組み込みのAngularツールを使用できなくなります。







Angularを使用して、いわゆる疑似イベントにサブスクライブさせ、興味のあるイベントを正確に指定することができます。 (keydown.enter)="onEnter($event)"



と書くことができ、ハンドラー(および変更チェックサイクル)はEnter



キーが押されたときにのみ呼び出されます。他の押下は無視されます。 この記事では、Angularと同じアプローチを使用してイベント処理を最適化する方法について説明します。 ボーナスとして、 .prevent



.stop



追加します。これらの.stop



はデフォルトの動作をオーバーライドし、イベントの自動.stop



を停止.stop



ます。







EventManagerPlugin









AngularはEventManager



クラスを使用してイベントを処理します。 これには、抽象EventManagerPlugin



を拡張し、イベントサブスクリプション処理をこのイベントをサポートするプラグインに(名前で)委任する、いわゆるプラグインのセットがあります。 Angularには、HammerJSイベント処理やkeydown.enterなどの複合イベントを処理するプラグインなど、いくつかのプラグインがあります。 これはAngularの内部実装であり、このアプローチは変更される可能性があります。 ただし、このソリューションの処理に関する問題が作成されてから3年が経過しており、この方向での進展はありません。







https://github.com/angular/angular/issues/3929







この点で私たちにとって興味深いことは何ですか? これらのクラスは内部であり、クラスから継承できないという事実にもかかわらず、プラグインの依存関係を実装するためのトークンはパブリックです。 これは、プラグインを作成し、組み込みのイベント処理メカニズムをプラグインで拡張できることを意味します。







EventManagerPlugin



のソースコードを見ると、継承できないことがわかります。ほとんどの部分は抽象的であり、要件を満たす独自のクラスを簡単に実装できます。







https://github.com/angular/angular/blob/master/packages/platform-b​​rowser/src/dom/events/event_manager.ts#L92







大まかに言うと、プラグインはこのイベントで動作しているかどうかを判断でき、イベントハンドラーとグローバルハンドラー( body



window



およびdocument



)を追加できる必要がありdocument



。 修飾子.filter



.prevent



および.stop



興味があります。 それらをプラグインにバインドするには、必要なメソッドsupports



を実装しsupports









 const FILTER = '.filter'; const PREVENT = '.prevent'; const STOP = '.stop'; class FilteredEventPlugin { supports(event: string): boolean { return ( event.includes(FILTER) || event.includes(PREVENT) || event.includes(STOP) ); } }
      
      





そのため、 EventManager



は、特定の修飾子がある名前のイベントをプラグインに渡して処理する必要があることを理解します。 次に、イベントハンドラーの追加を実装する必要があります。 グローバルハンドラーには興味がありません。その場合、そのようなツールの必要性はそれほど一般的ではなく、実装はより複雑になります。 したがって、イベント名から修飾子を削除してEventManager



返すだけで、 EventManager



ためEventManager



正しい組み込みプラグインEventManager



されます。







 class FilteredEventPlugin { supports(event: string): boolean { // ... } addGlobalEventListener( element: string, eventName: string, handler: Function, ): Function { const event = eventName .replace(FILTER, '') .replace(PREVENT, '') .replace(STOP, ''); return this.manager.addGlobalEventListener(element, event, handler); } }
      
      





通常の要素のイベントの場合、独自のロジックを記述する必要があります。 これを行うには、ハンドラーをクロージャーでラップし、修飾子なしでイベントをEventManager



ngZone



外部で呼び出して、変更チェックサイクルの開始を回避します。







 class FilteredEventPlugin { supports(event: string): boolean { // ... } addEventListener( element: HTMLElement, eventName: string, handler: Function, ): Function { const event = eventName .replace(FILTER, '') .replace(PREVENT, '') .replace(STOP, ''); //     const filtered = (event: Event) => { // ... }; const wrapper = () => this.manager.addEventListener(element, event, filtered); return this.manager.getZone().runOutsideAngular(wrapper); } /* addGlobalEventListener(...): Function { ... } */ }
      
      





この段階では、イベントの名前、イベント自体、およびリスニングされている要素があります。 ここで取得するハンドラーは、このイベントに割り当てられたソースハンドラーではなく、Angularが独自の目的で作成したクロージャーチェーンの終わりです。







1つの解決策は、要素に属性を追加することです。この属性は、ハンドラーを呼び出すかどうかを決定します。 場合によっては、決定を下すために、イベント自体を分析する必要があります。デフォルトのアクションがキャンセルされたかどうか、どの要素がイベントのソースであるかなどです。 これでは属性だけでは不十分です。イベントを受信してtrue



またはfalse



を返すフィルター関数を設定する方法を見つける必要があります。 次に、ハンドラーを次のように記述できます。







 const filtered = (event: Event) => { const filter = getOurHandler(some_arguments); if ( !eventName.includes(FILTER) || !filter || filter(event) ) { if (eventName.includes(PREVENT)) { event.preventDefault(); } if (eventName.includes(STOP)) { event.stopPropagation(); } this.manager.getZone().run(() => handler(event)); } };
      
      





解決策



ソリューションは、要素とイベント/フィルターのペアとの対応を保存するシングルトンサービス、およびこれらの対応を設定するための補助エンティティである場合があります。 もちろん、1つの要素には同じイベントの複数のハンドラーが存在できますが、原則として、 @HostListener



と、1レベル上のテンプレートのこのコンポーネントにインストールされたハンドラーの両方になります。 私たちはこの状況を予見しますが、他のケースはその特異性のために私たちにはほとんど興味がありません。







メインサービスは非常にシンプルで、マップと、フィルターの設定、受信、およびクリーニングのいくつかの方法で構成されています。







 export type Filter = (event: Event) => boolean; export type Filters = {[key: string]: Filter}; class FilteredEventMainService { private elements: Map<Element, Filters> = new Map(); register(element: Element, filters: Filters) { this.elements.set(element, filters); } unregister(element: Element) { this.elements.delete(element); } getFilter(element: Element, event: string): Filter | null { const map = this.elements.get(element); return map ? map[event] || null : null; } }
      
      





したがって、このサービスをプラグインに実装し、要素とイベントの名前を渡すことでフィルターを受け取ることができます。 @HostListener



と組み合わせて使用​​するために、コンポーネントとともに使用する別の小さなサービスを追加し、削除されたときに適切なフィルターをクリアします。







 export class EventFiltersService { constructor( @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(FilteredEventMainService) private readonly mainService: FilteredEventMainService, ) {} ngOnDestroy() { this.mainService.unregister(this.elementRef.nativeElement); } register(filters: Filters) { this.mainService.register(this.elementRef.nativeElement, filters); } }
      
      





要素にフィルターを追加するには、同様のディレクティブを作成できます。







 class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { this.mainService.register(this.elementRef.nativeElement, filters); } constructor( @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(FilteredEventMainService) private readonly mainService: FilteredEventMainService, ) {} ngOnDestroy() { this.mainService.unregister(this.elementRef.nativeElement); } }
      
      





コンポーネント内にイベントをフィルタリングするサービスがある場合、ディレクティブを介してフィルターをハングさせることはできません。 最終的に、これはほとんどの場合、ディレクティブを割り当てる要素でコンポーネントをラップするだけで実行できます。 サービスがこの要素に既に存在することを理解するために、オプションでディレクティブに実装します:







 class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... }
      
      





このサービスが存在する場合、ディレクティブが適用できないことを示すメッセージが表示されます。







 class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); } // ... }
      
      











実用化



説明されているすべてのコードは、Stackblitzにあります。







https://stackblitz.com/edit/angular-event-filter







使用例として、仮想のselect



(モーダルウィンドウ内のコンポーネント)とそのドロップダウンの役割のコンテキストメニューが表示されます。 コンテキストメニューの場合、実装を確認すると、動作が常に次のようになることがわかります:アイテム上でマウスを動かすと、キーボード上の矢印を押すと、フォーカスがアイテム間を移動しますが、マウスを動かすと、フォーカスは要素に戻りますマウスポインターの下。 この動作は簡単に実装できるように見えmousemove



が、 mousemove



イベントに対する不必要な反応により、 mousemove



変更チェックサイクルが発生する可能性がありmousemove



。 イベントのtarget



要素のフォーカスのチェックをフィルターとして設定することにより、これらの不要なトリガーを遮断し、実際にフォーカスを保持するトリガーのみを残すことができます。













また、このselect



コンポーネントには@HostListener



サブスクリプションのフィルタリングが@HostListener



ます。 ポップアップ内でEsc



を押すと、ポップアップが閉じます。 これは、一部のネストされたコンポーネントでこのクリックが不要で、処理されなかった場合にのみ発生します。 select



Esc



を押すとドロップダウンが閉じ、フォーカスはフィールド自体に戻りますが、既に閉じている場合、イベントがポップアップしてモーダルウィンドウを閉じないようにする必要はありません。 したがって、処理はデコレータによって記述できます。







@HostListener('keydown.esc.filtered.stop')



@HostListener('keydown.esc.filtered.stop')



時: () => this.opened









select



はいくつかのフォーカス可能な要素を持つコンポーネントであるため、フォーカスfocusout



ポップアップイベントをfocusout



、一般的なフォーカスを追跡できます。 それらは、コンポーネントの境界を離れないものを含む、フォーカスのすべての変更で発生します。 このイベントには、フォーカスの移動先をrelatedTarget



するrelatedTarget



フィールドがありrelatedTarget



。 分析した後、コンポーネントのblur



イベントのアナログを呼び出すかどうかを理解できます。







 class SelectComponent { // ... @HostListener('focusout.filtered') onBlur() { this.opened = false; } // ... }
      
      





同時に、フィルターは次のようになります。







 const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget);
      
      





おわりに



残念ながら、Angularの複合キーストロークの組み込み処理はNgZone



で引き続き開始されます。つまり、変更をチェックします。 必要に応じて、組み込みの処理に頼ることはできませんが、パフォーマンスの向上はわずかであり、Angularの内部「キッチン」のくぼみはアップグレード中に破損します。 したがって、複合イベントを破棄するか、境界演算子に似たフィルターを使用して、関連しないハンドラーを呼び出さないようにする必要があります。







内部実装は将来変更される可能性があるため、Angularの内部イベント処理に取り組むことは冒険的な試みです。 これにより、更新、特にGitHubのタスク(記事の2番目のセクションで説明)に従う必要があります。 ただし、ハンドラーの実行を簡単にフィルター処理して変更のチェックを開始できるようになりました。サブスクリプションを宣言するときに、イベント処理に典型的なpreventDefault



stopPropagation



メソッドを簡単に適用できるようになりました。 将来の土台から、デコレータを使用して@HostListener



すぐ隣にフィルタを宣言する方が便利です。 次回の記事では、自宅で作成したいくつかのデコレータについて話し、このソリューションを実装しようとします。








All Articles