Angular中的事件处理优化

引言


Angular使用语法(eventName)="onEventName($event)"提供了一种方便的声明性方式来订阅模板中的(eventName)="onEventName($event)" 。 与ChangeDetectionStrategy.OnPush更改检查策略一起,此方法仅针对我们感兴趣的用户输入自动启动更改检查周期。 换句话说,如果我们侦听<input>元素上的(input)事件,那么如果用户仅单击输入字段,则不会触发更改检查。 大大改善
性能与默认策略( ChangeDetectionStrategy.Default )的比较。 在指令中,我们还可以通过@HostListener('eventName')装饰器预订host元素上的事件。


在我的实践中,通常只有在满足条件的情况下才需要处理特定事件。 即 处理程序看起来像这样:


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

即使不满足条件并且实际上没有发生任何动作,更改验证周期仍将开始。 如果发生频繁事件,例如scrollscroll ,这可能会对应用程序性能产生不利影响。


在我正在使用的组件UI库中,在下拉菜单中订阅mousemove触发每次鼠标移动重新记录整个组件树的更改。 必须监视鼠标以实现正确的菜单行为,但是显然值得进行优化。 在下面的更多内容。


这样的时刻对于通用UI元素尤其重要。 页面上可能有很多,应用程序可能非常复杂且对性能要求很高。


您可以通过绕过ngZone订阅事件来纠正这种情况,例如,使用Observable.fromEvent并开始手动检查更改(调用changeDetectorRef.markForCheck() 。 但是,这增加了很多额外的工作,并且无法使用便捷的内置Angular工具。


Angular允许您订阅所谓的伪事件并准确指定我们感兴趣的事件已经不是什么秘密了。 我们可以编写(keydown.enter)="onEnter($event)"并且仅在按下Enter键时才调用处理程序(以及更改检查周期),其余的按键将被忽略。 在本文中,我们将研究如何使用与Angular相同的方法来优化事件处理。 另外,添加.prevent.stop ,这将取消默认行为并阻止事件自动.stop


EventManager插件



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


粗略地说,该插件应该能够确定它是否与此事件一起使用,并且应该能够添加一个事件处理程序和全局处理程序(在bodywindowdocument )。 我们将对.filter.prevent.stop修饰符感兴趣。 要将它们绑定到我们的插件,我们实现了必需的方法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正确的内置插件进行处理:


 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为自身目的创建的闭合链的末尾。


一种解决方案是在元素中添加一个属性,该属性负责是否调用处理程序。 有时,要做出决定,有必要分析事件本身:默认动作是否已取消,哪个元素是事件的来源,等等。 一个属性还不够,我们需要找到一种方法来设置一个接收事件并返回truefalse的过滤器函数。 然后我们可以将处理程序描述如下:


 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)); } }; 

解决方案


解决方案可以是单例服务,该服务存储元素与事件/过滤器对的对应关系以及用于设置这些对应关系的辅助实体。 当然,在一个元素上,同一事件可以有多个处理程序,但是通常,它既可以是@HostListener ,也可以是安装在更高一级模板中此组件上的处理程序。 我们将预见这种情况,而其他情况由于其特殊性而使我们不感兴趣。


主要服务非常简单,由地图和几种用于设置,接收和清洁过滤器的方法组成:


 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结合使用@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事件的不必要反应会触发数十个无用的更改检查周期。 通过将事件target元素的焦点设置为过滤器,我们可以消除这些不必要的触发,只保留那些真正具有焦点的触发器。



同样,此select组件对@HostListener订阅具有筛选。 当您在弹出窗口中按Esc ,它将关闭。 仅当在某些嵌套组件中不需要单击并且在其中未对其进行处理时,才应执行此操作。 在selectEsc关闭下拉菜单,并将焦点返回到字段本身,但是,如果该字段已关闭,则不应阻止事件弹出并在以后关闭模式窗口。 因此,处理可以由装饰器描述:


@HostListener('keydown.esc.filtered.stop')@HostListener('keydown.esc.filtered.stop')() => this.opened


由于select是具有多个可聚焦元素的组件,因此可以通过focusout弹出事件跟踪其总体聚焦。 它们将以所有焦点更改发生,包括那些不超出组件边界的更改。 该事件有一个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上的任务。 但是现在我们可以方便地过滤处理程序的执行并开始检查更改;现在,我们有机会在声明订阅时方便地应用事件处理中典型的preventDefaultstopPropagation方法。 从将来开始,使用装饰器为@HostListener声明过滤器会更方便。 在下一篇文章中,我计划讨论我们在家里创建的几个装饰器,并尝试实现此解决方案。

Source: https://habr.com/ru/post/zh-CN429692/


All Articles