
Terkadang implementasi fungsionalitas yang paling sederhana pada akhirnya menciptakan lebih banyak masalah daripada yang baik, hanya meningkatkan kompleksitas di tempat lain. Hasil akhirnya adalah arsitektur zag yang tidak ingin disentuh oleh siapa pun.
Catatan PenerjemahArtikel ini ditulis pada tahun 2017, tetapi relevan untuk hari ini. Ini ditujukan untuk orang yang berpengalaman dalam RxJS dan Ngrx, atau yang ingin mencoba Redux di Angular.
Cuplikan kode diperbarui berdasarkan sintaks RxJS saat ini dan sedikit dimodifikasi untuk meningkatkan keterbacaan dan kemudahan pemahaman.
Ngrx / store adalah perpustakaan Angular yang membantu mengandung kompleksitas fungsi individu. Salah satu alasannya adalah bahwa ngrx / store mencakup pemrograman fungsional, yang membatasi apa yang dapat dilakukan di dalam suatu fungsi untuk mencapai lebih banyak kewajaran di luarnya. Di ngrx / store, hal-hal seperti reduksi (selanjutnya disebut reduksi), penyeleksi (selanjutnya disebut sebagai penyeleksi) dan operator RxJS adalah fungsi murni.
Fungsi murni lebih mudah untuk menguji, men-debug, menganalisis, memparalelkan, dan menggabungkan. Suatu fungsi bersih jika:
- dengan input yang sama, selalu mengembalikan output yang sama;
- tidak ada efek samping.
Efek samping tidak dapat dihindari, tetapi mereka terisolasi di ngrx / store, sehingga sisa aplikasi dapat terdiri dari fungsi murni.
Efek samping
Saat pengguna mengirimkan formulir, kami perlu membuat perubahan di server. Mengubah server dan merespons klien adalah efek samping. Ini dapat ditangani dalam komponen:
this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data)
Alangkah baiknya jika kita bisa mengirimkan tindakan (selanjutnya tindakan) di dalam komponen ketika pengguna mengirimkan formulir dan menangani efek samping di tempat lain.
Ngrx / effects adalah middleware untuk menangani efek samping di ngrx / store. Ia mendengarkan tindakan yang disampaikan dalam utas yang dapat diamati, melakukan efek samping, dan mengembalikan tindakan baru segera atau tidak sinkron. Tindakan yang dikembalikan diteruskan ke peredam.
Kemampuan untuk menangani efek samping dengan cara RxJS membuat kode lebih bersih. Setelah mengirim tindakan awal SAVE_DATA
dari komponen, Anda membuat kelas efek untuk menangani sisanya:
@Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), );
Ini menyederhanakan operasi komponen hanya sebelum mengirim tindakan dan berlangganan dapat diamati.
Mudah disalahgunakan Ngrx / efek
Ngrx / efek adalah solusi yang sangat kuat, sehingga mudah disalahgunakan. Berikut adalah beberapa pola anti-pola ngrx / store umum yang disederhanakan oleh Ngrx / efek:
1. Status rangkap
Misalkan Anda sedang mengerjakan beberapa jenis aplikasi multimedia, dan Anda memiliki properti berikut di pohon negara:
export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; }
Karena audio adalah jenis media, setiap kali pemutaran audio benar, mediaPlaying
juga harus benar. Jadi, inilah pertanyaannya: "Bagaimana cara memastikan mediaPlaying diperbarui ketika audioPlaying diperbarui?"
Jawaban tidak valid : gunakan Ngrx / efek!
@Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), );
Jawaban yang benar adalah : jika keadaan mediaPlaying
sepenuhnya diprediksi oleh bagian lain dari pohon negara, maka ini bukan keadaan yang benar. Ini adalah kondisi turunan. Itu milik pemilih, bukan toko.
audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), );
Sekarang kondisi kami dapat tetap bersih dan normal , dan kami tidak menggunakan Ngrx / efek untuk sesuatu yang bukan efek samping.
2. Tindakan rantai dengan peredam
Bayangkan Anda memiliki properti ini di pohon negara Anda:
export interface State { items: { [index: number]: Item }; favoriteItems: number[]; }
Kemudian pengguna menghapus item. Ketika permintaan penghapusan dikembalikan, tindakan DELETE_ITEM_SUCCESS
dikirim untuk memperbarui status aplikasi kami. Dalam peredam items
, Item
individual dihapus dari objek items
. Tetapi jika pengidentifikasi elemen ini ada dalam array favoriteItems
, elemen yang dirujuknya akan tidak ada. Jadi pertanyaannya adalah, bagaimana saya bisa memastikan pengidentifikasi dihapus dari favoriteItems
saat mengirim tindakan DELETE_ITEM_SUCCESS
?
Jawaban tidak valid : gunakan Ngrx / efek!
@Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), );
Jadi, sekarang kita akan memiliki dua tindakan yang dikirim satu demi satu, dan dua reducers mengembalikan negara baru satu demi satu.
Jawaban yang benar : DELETE_ITEM_SUCCESS
dapat diproses oleh peredam items
dan peredam items
favoriteItems
.
export function favoriteItemsReducer(state = initialState, action: Action) { switch (action.type) { case 'REMOVE_FAVORITE_ITEM': case 'DELETE_ITEM_SUCCESS': const itemId = action.payload; return state.filter(id => id !== itemId); default: return state; } }
Tujuan dari tindakan ini adalah untuk memisahkan apa yang terjadi dari bagaimana negara harus berubah. Apa yang terjadi adalah DELETE_ITEM_SUCCESS
. Tugas reduksi adalah menyebabkan perubahan yang sesuai di negara.
Menghapus pengidentifikasi dari favoriteItems
bukan efek samping dari menghapus Item
. Seluruh proses sepenuhnya disinkronkan dan dapat diproses oleh reduksi. Ngrx / efek tidak diperlukan.
3. Minta data untuk komponen
Komponen Anda membutuhkan data dari toko, tetapi pertama-tama Anda harus mendapatkannya dari server. Pertanyaannya adalah, bagaimana kita bisa meletakkan data di toko sehingga komponen dapat menerimanya?
Cara menyakitkan : gunakan Ngrx / efek!
Dalam komponen, kami memulai permintaan dengan mengirimkan tindakan:
ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); }
Di kelas efek, kami mendengarkan GET_USERS
:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), switchMap(() => this.getUsers()), map(users => ({ type: 'RECEIVE_USERS', users })), );
Sekarang anggaplah pengguna memutuskan bahwa rute tertentu membutuhkan waktu terlalu lama untuk dimuat, sehingga ia akan beralih dari satu rute ke rute lainnya. Agar efisien dan tidak memuat data yang tidak perlu, kami ingin membatalkan permintaan ini. Ketika komponen dihancurkan, kami akan berhenti berlangganan dari permintaan dengan mengirimkan tindakan:
ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); }
Sekarang di kelas efek kita mendengarkan kedua tindakan:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS', 'CANCEL_GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), map(([action, needUsers]) => action), switchMap( action => action.type === 'CANCEL_GET_USERS' ? of() : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))), ), );
Bagus Sekarang pengembang lain menambahkan komponen yang memerlukan permintaan HTTP yang sama (kami tidak akan membuat asumsi tentang komponen lain). Komponen mengirimkan tindakan yang sama di tempat yang sama. Jika kedua komponen aktif pada saat yang sama, komponen pertama memulai permintaan HTTP untuk menginisialisasi. Ketika komponen kedua diinisialisasi, tidak ada tambahan yang akan terjadi, karena needUsers
akan false
. Hebat!
Kemudian, ketika komponen pertama dihancurkan, itu akan mengirim CANCEL_GET_USERS
. Tetapi komponen kedua masih membutuhkan data ini. Bagaimana kita bisa mencegah permintaan agar tidak dibatalkan? Mungkin kita akan memulai penghitung semua pelanggan? Saya tidak akan mengimplementasikan ini, tapi saya kira Anda mengerti intinya. Kami mulai curiga bahwa ada cara yang lebih baik untuk mengelola dependensi data ini.
Sekarang anggaplah komponen lain muncul, dan itu tergantung pada data yang tidak dapat diambil sampai data users
muncul di toko. Ini mungkin koneksi ke soket web untuk mengobrol, informasi tambahan tentang beberapa pengguna, atau sesuatu yang lain. Kami tidak tahu apakah komponen ini akan diinisialisasi sebelum atau setelah berlangganan dua komponen lain kepada users
.
Bantuan terbaik yang saya temukan untuk skenario khusus ini adalah pos yang luar biasa ini . Dalam contohnya, callApiY
mengharuskan callApiX
sudah selesai. Saya menghapus komentar agar tidak terlalu mengintimidasi, tetapi jangan ragu untuk membaca posting asli untuk mengetahui lebih lanjut:
@Effect() actionX$ = this.actions$.pipe( ofType('ACTION_X'), map(toPayload), switchMap(payload => this.api.callApiX(payload).pipe( map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })), catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })), ), ), ); @Effect() actionY$ = this.actions$.pipe( ofType('ACTION_Y'), map(toPayload), withLatestFrom(this.store.select(state => state.someBoolean)), switchMap(([payload, someBoolean]) => { const callHttpY = v => { return this.api.callApiY(v).pipe( map(data => ({ type: 'ACTION_Y_SUCCESS', payload: data, })), catchError(err => of({ type: 'ACTION_Y_FAIL', payload: err, }), ), ); }; if (someBoolean) { return callHttpY(payload); } return of({ type: 'ACTION_X', payload }).merge( this.actions$.pipe( ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'), first(), switchMap(action => { if (action.type === 'ACTION_X_FAIL') { return of({ type: 'ACTION_Y_FAIL', payload: 'Because ACTION_X failed.', }); } return callHttpY(payload); }), ), ); }), );
Sekarang tambahkan persyaratan bahwa permintaan HTTP harus dibatalkan ketika komponen tidak lagi membutuhkannya, dan ini akan menjadi lebih kompleks.
. . .
Jadi, mengapa ada begitu banyak masalah dengan manajemen ketergantungan data ketika RxJS harus membuatnya sangat mudah?
Meskipun data yang berasal dari server secara teknis adalah efek samping, menurut saya Ngrx / efek adalah cara terbaik untuk menangani ini.
Komponen adalah antarmuka input / output pengguna. Mereka menunjukkan data dan mengirim tindakan yang dilakukan olehnya. Ketika komponen dimuat, itu tidak mengirim tindakan apa pun yang dilakukan oleh pengguna ini. Dia ingin menunjukkan data. Ini lebih seperti langganan daripada efek samping.
Sangat sering Anda dapat melihat aplikasi yang menggunakan tindakan untuk memulai permintaan data. Aplikasi ini menerapkan antarmuka khusus untuk dapat diamati melalui efek samping. Dan, seperti yang kita lihat, antarmuka ini bisa menjadi sangat merepotkan dan tidak praktis. Berlangganan, berhenti berlangganan, dan menghubungkan diri yang diamati itu jauh lebih mudah.
. . .
Cara yang tidak menyakitkan : komponen akan mendaftarkan minatnya pada data dengan berlangganan kepada mereka melalui diamati.
Kami akan membuat observable yang berisi permintaan HTTP yang diperlukan. Kita akan melihat betapa lebih mudahnya mengelola beberapa langganan dan rantai kueri yang saling bergantung menggunakan RxJS murni, daripada melakukan ini melalui efek.
Buat ini dapat diamati di layanan:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), );
Berlangganan ke users$
akan dikirimkan baik untuk requireUsers$
dan this.store.pipe(select(selectUsers))
, tetapi data hanya akan diterima dari this.store.pipe(select(selectUsers))
( muteFirst
implementasi muteFirst
dan muteFirst
implementasi tetap dengan ujiannya .)
Dalam komponen:
ngOnInit() { this.users$ = this.userService.users$; }
Karena ketergantungan data ini sekarang mudah diamati, kami dapat berlangganan dan berhenti berlangganan di templat menggunakan pipa async
, dan kami tidak perlu lagi mengirim tindakan. Jika aplikasi meninggalkan rute komponen terakhir yang ditandatangani untuk data, permintaan HTTP dibatalkan atau soket web ditutup.
Rantai ketergantungan data dapat diproses seperti ini:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); requireUsersExtraData$ = this.users$.pipe( withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))), filter(([users, needData]) => Boolean(users.length) && needData), tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS_EXTRA_DATA', users, }), ), share(), ); public usersExtraData$ = muteFirst( this.requireUsersExtraData$.pipe(startWith(null)), this.store.pipe(select(selectUsersExtraData)), );
Berikut ini adalah perbandingan paralel dari metode di atas dengan metode ini:

Menggunakan murni yang dapat diamati membutuhkan lebih sedikit baris kode dan secara otomatis berhenti berlangganan dari ketergantungan data di seluruh rantai. (Saya melewatkan pernyataan finalize
yang awalnya termasuk untuk membuat perbandingan lebih dimengerti, tetapi bahkan tanpa mereka, permintaan masih akan dibatalkan.)

Kesimpulan
Ngrx / efek adalah alat yang hebat! Tetapi pertimbangkan pertanyaan-pertanyaan ini sebelum menggunakannya:
- Apakah ini benar-benar efek samping?
- Apakah Ngrx / efek cara terbaik untuk melakukan ini?