Optimasi penanganan acara di Angular

Pendahuluan


Angular menyediakan cara deklaratif yang mudah untuk berlangganan acara dalam templat menggunakan sintaks (eventName)="onEventName($event)" . Bersama dengan kebijakan pemeriksaan perubahan ChangeDetectionStrategy.OnPush , pendekatan ini secara otomatis memulai siklus pemeriksaan perubahan hanya untuk input pengguna yang menarik minat kami. Dengan kata lain, jika kita mendengarkan acara (input) pada elemen <input> , maka pemeriksaan perubahan tidak akan dipicu jika pengguna cukup mengklik bidang input. Ini sangat meningkat
kinerja dibandingkan dengan kebijakan default ( ChangeDetectionStrategy.Default ). Dalam arahan, kami juga dapat berlangganan acara pada elemen host melalui @HostListener('eventName') .


Dalam praktik saya, sering ada kasus di mana pemrosesan peristiwa tertentu diperlukan hanya jika suatu kondisi terpenuhi. yaitu pawang terlihat seperti ini:


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

Bahkan jika kondisi tidak terpenuhi dan tidak ada tindakan yang sebenarnya terjadi, siklus verifikasi perubahan masih akan dimulai. Dalam hal acara sering, seperti scroll atau mousemove , ini dapat mempengaruhi kinerja aplikasi.


Di pustaka UI komponen yang sedang saya kerjakan, berlangganan mousemove di dalam menu drop-down memicu penghitungan ulang seluruh komponen pohon untuk setiap gerakan mouse. Itu perlu untuk memantau mouse untuk menerapkan perilaku menu yang benar, tetapi jelas layak dioptimalkan. Lebih lanjut tentang ini di bawah ini.


Momen seperti itu sangat penting untuk elemen UI universal. Mungkin ada banyak dari mereka di halaman, dan aplikasi bisa sangat kompleks dan menuntut kinerja.


Anda dapat memperbaiki situasi dengan berlangganan acara yang melewati ngZone , misalnya, menggunakan Observable.fromEvent dan mulai memeriksa perubahan secara manual dengan memanggil changeDetectorRef.markForCheck() . Namun, ini menambah satu ton pekerjaan tambahan dan membuatnya tidak mungkin untuk menggunakan alat Angular bawaan yang nyaman.


Bukan rahasia lagi bahwa Angular memungkinkan Anda untuk berlangganan apa yang disebut pseudo-events, menentukan dengan tepat peristiwa yang kami minati. Kita dapat menulis (keydown.enter)="onEnter($event)" dan handler (dan dengan itu siklus pemeriksaan perubahan) akan dipanggil hanya ketika tombol Enter ditekan. Penekanan yang tersisa akan diabaikan. Pada artikel ini, kita akan melihat bagaimana Anda dapat menggunakan pendekatan yang sama seperti Angular untuk mengoptimalkan penanganan acara. Dan sebagai bonus, tambahkan .prevent dan .stop , yang akan membatalkan perilaku default dan menghentikan acara agar tidak .stop secara otomatis.


EventManagerPlugin



Angular menggunakan kelas EventManager untuk menangani acara. Ini memiliki satu set yang disebut plugins yang memperpanjang EventManagerPlugin abstrak dan mendelegasikan pemrosesan berlangganan acara ke plugin yang mendukung acara ini (dengan nama). Ada beberapa plugin di dalam Angular, termasuk penanganan event HammerJS dan sebuah plugin yang bertanggung jawab untuk acara komposit seperti keydown.enter . Ini adalah implementasi internal Angular, dan pendekatan ini dapat berubah. Namun, 3 tahun telah berlalu sejak penciptaan masalah pada pemrosesan solusi ini, dan tidak ada kemajuan yang telah dibuat dalam arah ini:


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


Apa yang menarik dalam hal ini bagi kita? Terlepas dari kenyataan bahwa kelas-kelas ini bersifat internal dan tidak dapat diwarisi dari mereka, token yang bertanggung jawab untuk menerapkan dependensi untuk plugin bersifat publik. Ini artinya kita dapat menulis plugin sendiri dan memperluas mekanisme penanganan kejadian bawaan.


Jika Anda melihat kode sumber EventManagerPlugin , Anda akan melihat bahwa kami tidak akan dapat mewarisinya, sebagian besar bersifat abstrak dan mudah untuk mengimplementasikan kelas kami sendiri yang memenuhi persyaratan:


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


Secara kasar, plugin harus dapat menentukan apakah itu berfungsi dengan acara ini dan harus dapat menambahkan pengendali acara dan penangan global (pada body , window dan document ). Kami akan tertarik pada pengubah .prevent , .prevent dan .stop . Untuk mengikat mereka ke plugin kami, kami menerapkan metode yang diperlukan 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) ); } } 

Jadi EventManager akan memahami bahwa peristiwa atas nama yang ada pengubah tertentu harus diteruskan ke plugin kami untuk diproses. Maka kita perlu menerapkan penambahan event handler. Kami tidak tertarik dengan penangan global, dalam hal mereka kebutuhan akan alat semacam itu jauh lebih jarang, dan implementasinya akan lebih rumit. Oleh karena itu, kami cukup menghapus pengubah kami dari nama acara dan mengembalikannya ke EventManager sehingga EventManager yang benar untuk diproses:


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

Dalam hal suatu peristiwa pada elemen reguler, kita perlu menulis logika kita sendiri. Untuk melakukan ini, kami membungkus pawang dalam penutupan dan meneruskan acara tanpa pengubah kami kembali ke EventManager , menyebutnya di luar ngZone , untuk menghindari memulai siklus pemeriksaan perubahan:


 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 { ... } */ } 

Pada tahap ini, kita memiliki: nama acara, acara itu sendiri, dan elemen yang didengarkan. Pawang yang sampai di sini bukanlah pawang yang ditugaskan untuk acara ini, tetapi akhir dari rantai penutupan yang dibuat oleh Angular untuk tujuannya sendiri.


Salah satu solusinya adalah dengan menambahkan atribut ke elemen, yang bertanggung jawab untuk memanggil pawang atau tidak. Terkadang, untuk membuat keputusan, perlu untuk menganalisis peristiwa itu sendiri: apakah tindakan default dibatalkan, elemen mana yang menjadi sumber acara, dll. Atribut tidak cukup untuk ini, kita perlu menemukan cara untuk mengatur fungsi filter yang menerima acara dan mengembalikan true atau false . Kemudian kita bisa menggambarkan pawang kita sebagai berikut:


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

Solusi


Solusinya mungkin layanan tunggal yang menyimpan korespondensi elemen untuk pasangan peristiwa / filter dan entitas tambahan untuk mengatur korespondensi ini. Tentu saja, pada satu elemen dapat ada beberapa penangan untuk acara yang sama, tetapi, sebagai aturan, dapat berupa @HostListener dan penangan yang diinstal pada komponen ini di templat satu tingkat lebih tinggi. Kami akan meramalkan situasi ini, sementara kasus-kasus lain kurang menarik bagi kami karena kekhususannya.


Layanan utama cukup sederhana dan terdiri dari peta dan beberapa metode untuk mengatur, menerima dan membersihkan filter:


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

Dengan demikian, kami dapat mengimplementasikan layanan ini dalam plugin dan menerima filter dengan meneruskan elemen dan nama acara. Untuk digunakan bersama dengan @HostListener kami menambahkan layanan kecil lain yang akan hidup dengan komponen dan menghapus filter yang sesuai ketika dihapus:


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

Untuk menambahkan filter ke elemen, Anda dapat membuat arahan yang serupa:


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

Jika ada layanan untuk memfilter peristiwa di dalam komponen, kami tidak akan membiarkan filter digantung melalui arahan. Pada akhirnya, ini hampir selalu dapat dilakukan dengan hanya membungkus komponen dengan elemen yang akan mengarahkan arahan kita. Untuk memahami bahwa suatu layanan sudah ada pada elemen ini, kami secara opsional akan mengimplementasikannya dalam arahan:


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

Jika layanan ini hadir, kami akan menampilkan pesan yang menyatakan bahwa arahan tidak berlaku untuk itu:


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


Aplikasi praktis


Semua kode yang dijelaskan dapat ditemukan di Stackblitz:


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


Sebagai contoh penggunaan, select imajiner ditampilkan di sana - komponen di dalam jendela modal - dan menu konteks dalam peran dropdown-nya. Dalam kasus menu konteks, jika Anda memeriksa implementasi apa pun, Anda akan melihat bahwa perilaku selalu sebagai berikut: ketika Anda mengarahkan kursor ke item, fokus, ketika Anda menekan panah pada keyboard, fokus bergerak melalui item, tetapi jika Anda menggerakkan mouse, fokus kembali ke elemen. terletak di bawah penunjuk tetikus. Tampaknya perilaku ini mudah diterapkan, namun, reaksi yang tidak perlu terhadap acara mousemove bisa memicu lusinan siklus pemeriksaan perubahan yang tidak berguna. Dengan menetapkan sebagai filter centang untuk fokus elemen target acara, kami dapat memotong pemicu yang tidak perlu ini, hanya menyisakan yang benar-benar membawa fokus.



Juga, komponen select ini memiliki pemfilteran pada langganan @HostListener . Ketika Anda menekan tombol Esc di dalam popup, itu akan menutup. Ini harus terjadi hanya jika klik ini tidak diperlukan di beberapa komponen bersarang dan tidak diproses di dalamnya. Dalam select menekan Esc menutup dropdown dan mengembalikan fokus ke bidang itu sendiri, tetapi jika sudah ditutup, itu seharusnya tidak mencegah acara muncul dan jendela modal menjadi ditutup nanti. Dengan demikian, pemrosesan dapat dijelaskan oleh dekorator:


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


Karena select adalah komponen dengan beberapa elemen yang dapat fokus, pelacakan fokus umumnya dimungkinkan melalui peristiwa sembulan focusout . Mereka akan terjadi dengan semua perubahan dalam fokus, termasuk yang tidak meninggalkan batas-batas komponen. Acara ini memiliki bidang relatedTarget yang relatedTarget mana fokus bergerak. Setelah menganalisanya, kita dapat memahami apakah akan memanggil analog acara blur untuk komponen kami:


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

Filter, pada saat yang sama, terlihat seperti ini:


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

Kesimpulan


Sayangnya, pemrosesan penekanan tombol majemuk di Angular masih akan dimulai di NgZone , yang berarti akan memeriksa perubahan. Jika kami mau, kami tidak mungkin terpaksa menggunakan pemrosesan bawaan, tetapi perolehan kinerja akan kecil, dan ceruk di “dapur” internal Angular dapat rusak selama peningkatan. Oleh karena itu, kita harus meninggalkan acara komposit, atau menggunakan filter yang mirip dengan operator batas dan tidak memanggil pawang yang tidak relevan.


Menarik ke dalam pemrosesan acara internal Angular adalah pekerjaan yang penuh petualangan, karena implementasi internal dapat berubah di masa depan. Ini mengharuskan kami untuk mengikuti pembaruan, khususnya, tugas di GitHub, yang diberikan di bagian kedua artikel. Tetapi sekarang kita dapat dengan mudah memfilter pelaksanaan penangan dan mulai memeriksa perubahan, kita sekarang memiliki kesempatan untuk menerapkan dengan stopPropagation metode preventDefault dan stopPropagation yang khas dari pemrosesan acara tepat saat menyatakan berlangganan. Dari masa depan, akan lebih mudah untuk mendeklarasikan filter untuk @HostListener tepat di sebelahnya menggunakan dekorator. Pada artikel berikutnya, saya berencana untuk berbicara tentang beberapa dekorator yang kami buat di rumah, dan mencoba menerapkan solusi ini.

Source: https://habr.com/ru/post/id429692/


All Articles