Rumah > Artikel > hujung hadapan web > Kes Penggunaan NgRx, Bahagian III: Membuat keputusan
Foto muka depan asal oleh Hansjörg Keller di Unsplash.
Dalam artikel saya sebelum ini, kami merangkumi sekatan akses dan senarai pengendalian dengan NgRx. Hari ini, kami akan menangani masalah yang lebih umum dalam membuat keputusan merentas aplikasi Sudut yang menggunakan terutamanya Kesan NgRx (tetapi juga sedikit pengurang dan pemilih). Dalam Artikel ini, kami akan membincangkan topik berikut:
Jom mulakan!
Pengendalian ralat ialah sesuatu yang dibenci semua orang (dan selalunya hanya terlupa), tetapi juga sesuatu yang sangat diperlukan oleh semua orang. Dengan aplikasi NgRx, kerumitan pengendalian ralat sebenarnya meningkat jika kita tidak menangani perkara ini dengan penjagaan yang betul. Biasanya, ralat dalam NgRx timbul daripada kesan, yang, dalam kebanyakan senario, disebabkan oleh permintaan HTTP.
Biasanya terdapat dua pendekatan untuk mengendalikan ralat secara umum: pengendalian secara tempatan atau global. Mengendalikan secara setempat bermakna kami benar-benar menangani ralat yang sangat khusus yang berlaku dalam beberapa bahagian khusus apl. Sebagai contoh, jika pengguna gagal melog masuk, kami mungkin mahu menunjukkan mesej ralat yang sangat khusus seperti "Nama pengguna atau kata laluan tidak sah", dan bukannya beberapa mesej ralat generik seperti "Sesuatu telah berlaku".
Pengendalian secara global, sebaliknya, bermakna kami, dalam satu cara, menggabungkan semua ralat ke dalam satu "talian paip" dan menggunakan mesej ralat yang sangat generik yang kami nyatakan sebelum ini. Kita boleh, sudah tentu, memulakan perbincangan tentang idea yang lebih sesuai untuk senario apa, tetapi realiti yang teruk ialah hampir setiap aplikasi memerlukan sedikit kedua-dua dunia. Kami mahu menunjukkan mesej ralat jika sebarang ralat berlaku ("Sesuatu yang tidak kena" masih lebih baik daripada kegagalan senyap), tetapi kami juga ingin melakukan beberapa tindakan khusus sekiranya beberapa ralat. Mari kita atasi kedua-dua senario.
Dalam NgRx, semua yang berlaku dicetuskan oleh tindakan. Untuk aktiviti yang mungkin melibatkan gerring ralat (panggilan HTTP, WebSockets, dll), kami biasanya membuat beberapa tindakan untuk satu aktiviti:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': props<{ error: string }>(), }, });
Seperti yang kita lihat, hanya untuk memuatkan sekeping data daripada API, kita memerlukan tiga tindakan. Kami mungkin mempunyai berpuluh-puluh, jika tidak beratus-ratus, tindakan "ralat" dan "kejayaan" sedemikian dalam aplikasi tertentu, jadi kami mungkin mahu menunjukkan mesej ralat generik sekiranya mana-mana daripadanya dihantar. Kita boleh melakukan ini dengan menyeragamkan muatan tindakan ralat. Sebagai contoh, kami mungkin ingin menambahkan beberapa sifat yang sangat khusus pada semua tindakan yang mewakili ralat. Dalam kes kami, kehadiran sifat ralat dalam muatan tindakan boleh mencukupi untuk mengetahui bahawa tindakan itu mewakili ralat.
Kami boleh, kemudian, melanggan semua tindakan yang mewakili ralat dan menunjukkan mesej ralat generik. Ini adalah corak yang sangat biasa dalam apl NgRx, dan biasanya ia difahami sebagai "pengendalian ralat global". Dalam kes kami, kami boleh melakukan ini dengan melanggan semua tindakan dan menapis tindakan yang mempunyai sifat ralat dalam muatannya:
export const handleErrors$ = createEffect(() => { const actions$ = inject(Actions); const notificationsService = inject(NotificationsService); return actions$.pipe( filter((action) => !!action.payload.error), tap((action) => { notificationsService.add({ severity: 'error', summary: 'Error', detail, }); }), }, { functional: true, dispatch: false });
Dalam kes ini, sebarang tindakan "ralat" yang pernah kami hantar akan menyebabkan pemberitahuan yang sama ditunjukkan, tetapi juga dengan mesej tersuai. Kita boleh pergi lebih jauh dengan menyeragamkan pendekatan props tindakan dibuat untuk ralat. Berikut ialah fungsi pembantu kecil yang boleh digunakan:
export function errorProps(error: string) { return function() { return({error}); }; }
Sekarang, kita boleh menggunakan fungsi ini untuk mencipta prop ralat untuk tindakan kita:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': errorProps('Failed to load data'), }, });
Ini akan menjadikan semua ralat kelihatan sama untuk mengelakkan kesilapan menaip atau kekeliruan. Seterusnya, mari kita perbaiki ini untuk turut dapat menangani ralat yang sangat spesifik.
Dalam kes kami, kami mungkin mahu dapat menyesuaikan cara pengendali ralat generik berfungsi untuk beberapa kes tertentu. Kami mahu dapat
Mari kita mulakan dengan yang pertama. Kita boleh melakukan ini dengan menambahkan sifat baharu pada muatan tindakan ralat:
export function errorProps(error: string, showNotififcation = true) { return function() { return({error, showNotification}); }; }
Kini kita boleh membuat tindakan yang kemudiannya akan memberitahu kesan untuk melangkau mesej pemberitahuan generik:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': errorProps('Failed to load data', false), }, });
Seterusnya, kita harus mengemas kini kesan untuk mencerminkan ini:
export const handleErrors$ = createEffect(() => { const actions$ = inject(Actions); const notificationsService = inject(NotificationsService); return actions$.pipe( filter((action) => !!action.payload.error), tap((action) => { if (action.payload.showNotification) { notificationsService.add({ severity: 'error', summary: 'Error', detail, }); } }), ); }, { functional: true, dispatch: false });
Perhatikan kami tidak menambah penyemakan sifat showNotification pada pengendali penapis. Ini kerana kami akan mempunyai senario di mana pemberitahuan tidak sepatutnya ditunjukkan, tetapi tindakan lain masih perlu dilakukan (seperti mengubah hala ke halaman ralat). Mari lakukan ini dengan tepat dengan menambahkan parameter baharu pada tindakan ralat kami:
export function errorProps(error: string, showNotification = true, redirectTo?: string) { return function() { return({error, showNotification, redirectTo}); }; }
Kini, kita boleh membuat tindakan yang kemudiannya akan memberitahu kesan untuk mengubah hala ke halaman ralat:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': errorProps('Failed to load data', false, '/error'), }, });
Next, let's finalize our effect by adding a redirection to the error page if the redirectTo property is present in the action payload:
export const handleErrors$ = createEffect(() => { const actions$ = inject(Actions); const notificationsService = inject(NotificationsService); const router = inject(Router); return actions$.pipe( filter((action) => !!action.payload.error), tap((action) => { if (action.payload.showNotification) { notificationsService.add({ severity: 'error', summary: 'Error', detail, }); } if (action.payload.redirectTo) { router.navigateByUrl(action.payload.redirectTo); } }), ); }, { functional: true, dispatch: false });
And that is it to this. Of course, if we need something really custom for a particular error action, we can just write a completely separate effect to handle that. Sometimes, if we want to also do something in the UI in relation to an error, we can also add the error message (and any other data) to the store and use them via a selector anywhere.
Next, let us discuss loading data into our component, and several approaches to it.
Before we proceed, we should first understand that the approaches listed in this section are not better or worse than one another. Instead, they are approaches for different situations, depending on what we want for our UX. Let's examine them step by step.
The most straightforward way we can get some data (presumably from an API) is by just selecting it in the component. With the latest APIs, we can select a signal of our data and use it directly in the template. Here is a very simple example:
@Component({ selector: 'app-my', template: ` <div> <h1>Data</h1> <p>{{ data() }}</p> </div> `, }) export class MyComponent { data = this.store.selectSignal(dataFeature.selectData); }
Of course, in real life, we often need to deal with scenarios like loading, errors, and so on. In this case, our state might look like this:
export interface State { data: Data | null; loading: boolean; error: string | null; }
If we are using the createFeature function to register our state, we can make use of the selectDataState function that the feature automatically creates for us. This will return the entire state with loading, error, and data properties. We can then use this in our component:
@Component({ selector: 'app-my', template: ` <div> @if (vm().loading) { <p>Loading...</p> } @if (vm().error) { <p>Error: {{ vm().error }}</p> } @else { <h1>Data</h1> <p>{{ vm().data }}</p> } </div> `, }) export class MyComponent { vm = this.store.selectSignal(dataFeature.selectDataState); }
This is very useful in most scenarios. However, sometimes we might not want to display the entire page if this important piece of data is not loaded. In Angular, this is commonly achieved with the use of routing resolvers, functions that return some Observables that routing waits for to emit before displaying a particular page. This is easy with the use of the HttpClient service, however, this becomes a bit complicated with NgRx (because we only make HTTP calls inside effects), resulting in lots of developers skipping resolvers entirely. However, there is an easy way to achieve this functionality. Let's build a simple resolver that utiliuzes the Store and the Actions Observable to know when the data is actually loaded:
export const dataResolver: ResolveFn<Data[]> = () => { const store = inject(Store); const actions$ = inject(Actions); store.dispatch(DataActions.loadData()); return store.select(dataFeature.selectData).pipe( skipUntil(actions.pipe(ofType(DataActions.loadDataSuccess))), ); }
Here, we first dispatch the action that actually initiates the HTTP call, then we just return the selected data from the store as an Observable, but with a catch - we tell it to wait until the action signaling that the data has been loaded is dispatched. Given that effects are guaranteed to run after reducers, this will ensure that the data is actually put into the store before the resolver returns it. We can then just pick this data up in our component:
@Component({ selector: 'app-my', template: ` <div> <h1>Data</h1> <p>{{ vm.data() }}</p> </div> `, }) export class MyComponent { private readonly route = inject(ActivatedRoute); readonly vm = toSignal(this.route.data, { initialValue: null, }) as Signal<{data: Data}>; }
We used ActivatedRoute instead of the Store because we already returned this data in the resolver. This makes our components even leaner - we don't even have to inject the Store and in unit testing, it can be often easier to mock the ActivatedRoute than the Store.
Finally, let's take a look at advanced decision-making with NgRx actions and effects, and see how this can help work with complex cases in large applications.
NgRx is very useful when writing declarative code, as it allows us to just select the relevant state, and use it in our templates. However, sometimes, especially when dealing with third-party libraries, we need to perform some "imperative actions" which is tangentially related to our store. Consider this code which uses the Angular Material MatDialog service to open a confirmation dialog:
export class MyComponent { private readonly dialog = inject(MatDialog); private readonly store = inject(Store); openConfirmationDialog() { const dialogRef = this.dialog.open(ConfirmationDialogComponent, { data: { title: 'Confirmation', message: 'Are you sure you want to do this?', }, }); dialogRef.componentInstance.confirm.subscribe(() => { this.store.dispatch(DataActions.deleteData()); }); dialogRef.componentInstance.cancel.subscribe(() => { dialogRef.close(); }); } }
As we can see, there is a lot of imperative code in just this one method, there are two subscriptions, and they aren't even particularly simple (we just omitted unsubscription logic). Also, we must consider that in a normal application, we might have a dozen different places where the same confirmation dialog is used, with the only difference being the action that is performed when the user confirms/rejects.
Let's now approach this with an NgRx mindset, and try to create an action that can handle such a scenario, with callbacks as payloads.
export function confirmAction(callbacks: {confirm: () => void, reject: () => void}) { return function() { return({type: 'Open Confirmation Dialog', callbacks}); }; }
Now, we can create an action that will later tell the effect to redirect to an error page:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Delete Data': confirmAction({ confirm: () => { return({action: 'Delete Data Confirmed'}); }, reject: () => { return({action: 'Delete Data Rejected'}); }, }), }, });
Now, we can create an effect that will handle all such actions:
export const handleConfirmationDialog$ = createEffect(() => { const actions$ = inject(Actions); const dialog = inject(MatDialog); return actions$.pipe( ofType(DataActions.openConfirmationDialog), tap((action) => { const dialogRef = dialog.open(ConfirmationDialogComponent, { data: { title: 'Confirmation', message: 'Are you sure you want to do this?', }, }); dialogRef.componentInstance.confirm.subscribe(() => { action.payload.callbacks.confirm(); }); dialogRef.componentInstance.cancel.subscribe(() => { action.payload.callbacks.reject(); }); }), ); }, { functional: true, dispatch: false });
Finally, we can really simplify our component:
export class MyComponent { private readonly store = inject(Store); openConfirmationDialog() { this.store.dispatch(DataActions.openConfirmationDialog({ confirm: () => { this.store.dispatch(DataActions.deleteData()); }, reject: () => { // Do nothing }, })); } }
And that is it. However... we might face a problem if we try to make our application as fine as possible. In NgRx, it is a good practice to keep everything serializable, which is a fancy way of saying "easily convertible to JSON". In the app configuration, it is possible to set specific options to help safeguard us from, for example, putting functions in the store. This is done with two options, strictStoreSerializability and strictActionSerializability.
export const config: ApplicationConfig = { providers: [ provideStore({}, { runtimeChecks: { strictActionSerializability: true, strictStoreSerializability: true, }, }), };
This goes a long mile to help keep our applications maintainable and prevent hard-to-debug issues.
[!NOTE] You can read more about runtime checks in the NgRx docs.
However, if we make actions strictly serializable, our confirmAction action will not work with the callbacks we passed! So, what can we do about it? Well, the easiest way is to give it other actions for confirm/reject options to handle by the effect. Because the nested actions will also be required to be serializable, this will help us bring everything back to a workable state, an approach that I personally call "higher-order actions".
export function confirmAction(confirmAction: string, rejectAction: string, callbackActions: { confirm: ActionCreator<any, any>, reject: ActionCreator<any, any> }) { return function() { return({type: 'Open Confirmation Dialog', callbackActions}); }; }
Next, we need to do a major update to our effect:
export const handleConfirmationDialog$ = createEffect(() => { const actions$ = inject(Actions); const dialog = inject(MatDialog); return actions$.pipe( ofType(DataActions.openConfirmationDialog), map(({callbackActions}) => { const dialogRef = dialog.open(ConfirmationDialogComponent, { data: { title: 'Confirmation', message: 'Are you sure you want to do this?', }, }); return merge([ dialogRef.componentInstance.confirm.pipe( map(() => callbackActions.confirm()), ), dialogRef.componentInstance.cancel.pipe( tap(() => dialogRef.close()), map(() => callbackActions.reject()), ), ]) }), ); }, { functional: true, dispatch: false });
Let's deconstruct what goes on here.
With this implementation, it is easy to perform complex decision-making just using several actions in a component:
export class MyComponent { private readonly store = inject(Store); openConfirmationDialog() { this.store.dispatch(DataActions.openConfirmationDialog({ confirm: DataActions.deleteData, reject: DataActions.cancelDeleteData, })); } }
And that is it. With higher-order actions, we can easily delegate decision-making further to other effects and reducers as necessary, making the component code as declarative as possible.
In this article, we covered a few approaches to complex logic in Angular applications utilizing NgRx. NgRx is a huge tapestry of opportunities, and often we can easily transform ugly code into something understandable and maintainable. Such approaches are underappreciated, and with this piece, I try to bring them forth to help developers improve their state management solutions.
Atas ialah kandungan terperinci Kes Penggunaan NgRx, Bahagian III: Membuat keputusan. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!