原始封面照片由 Hansjörg Keller 在 Unsplash 上拍攝。
在我之前的文章中,我們介紹了 NgRx 的存取限制和處理清單。今天,我們將解決一個主要使用 NgRx Effects(但也使用一些減速器和選擇器)的 Angular 應用程式的決策問題。在本文中,我們將討論以下主題:
讓我們開始吧!
錯誤處理是每個人都討厭的東西(而且常常只是忘記),但也是每個人真正非常需要的東西。對於 NgRx 應用程序,如果我們不適當小心地解決這個問題,錯誤處理的複雜性實際上會增加。通常,NgRx 中的錯誤是由效果引起的,而在大多數情況下,效果是由 HTTP 請求引起的。
通常有兩種處理錯誤的方法:本地處理或全域處理。本地處理意味著我們實際上解決了應用程式某些特定部分中發生的非常具體的錯誤。例如,如果使用者登入失敗,我們可能想要顯示非常特定的錯誤訊息,例如“無效的使用者名稱或密碼”,而不是一些通用的錯誤訊息,例如“出了問題”。
另一方面,全域處理意味著我們在某種程度上將所有錯誤集中到一個「管道」中,並使用我們之前提到的非常通用的錯誤訊息。當然,我們可以開始討論哪種想法更適合哪種場景,但殘酷的現實是,幾乎每個應用程式都需要兩全其美。如果發生任何錯誤,我們希望顯示錯誤訊息(「出了問題」仍然比靜默失敗更好),但我們也希望在發生任何錯誤時執行一些特定操作🎜>一些
錯誤。讓我們解決這兩種情況。
在 NgRx 中,發生的一切都是由動作觸發的。對於可能涉及錯誤的活動(HTTP 呼叫、WebSocket 等),我們通常會為單一活動建立多個操作:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': props<{ error: string }>(), }, });
如我們所見,僅從 API 載入一條數據,我們就需要三個操作。在給定的應用程式中,我們可能有數十個(如果不是數百個)此類「錯誤」和「成功」操作,因此我們可能希望在調度其中任何一個操作時顯示通用錯誤訊息。我們可以透過標準化錯誤操作的有效負載來做到這一點。例如,我們可能希望為表示錯誤的所有操作添加一些非常具體的屬性。在我們的例子中,操作有效負載中存在錯誤屬性足以知道該操作代表錯誤。
然後,我們可以訂閱所有表示錯誤的操作並顯示通用錯誤訊息。這是 NgRx 應用程式中非常常見的模式,通常被理解為「全域錯誤處理」。在我們的例子中,我們可以透過訂閱所有操作並過濾掉有效負載中具有錯誤屬性的操作來做到這一點:
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 });
在這種情況下,我們發送的任何「錯誤」操作都會導致顯示相同的通知,而且還會顯示自訂訊息。我們可以更進一步,標準化為錯誤創建動作道具的方法。這是一個很方便的小輔助函數:
export function errorProps(error: string) { return function() { return({error}); }; }
現在,我們可以使用此函數為我們的操作建立錯誤道具:
export const DataActions = createActionGroup({ source: 'Data', events: { 'Load Data': emptyProps(), 'Load Data Success': props<{ data: Data }>(), 'Load Data Error': errorProps('Failed to load data'), }, });
這會讓所有錯誤看起來相同,以避免拼字錯誤或混淆。接下來,讓我們改進它,使其能夠處理非常具體的錯誤。
在我們的例子中,我們可能希望能夠自訂通用錯誤處理程序針對某些特定情況的工作方式。我們希望能夠
讓我們從第一個開始。我們可以透過在錯誤操作有效負載中新增屬性來做到這一點:
export function errorProps(error: string, showNotififcation = true) { return function() { return({error, showNotification}); }; }
現在我們可以建立一個操作,稍後將告訴效果跳過通用通知訊息:
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), }, });
接下來,我們應該更新效果以反映這一點:
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 });
請注意,我們沒有將 showNotification 屬性的檢查加入到過濾器運算子中。這是因為我們會遇到不應顯示通知但仍必須執行另一個操作(例如重定向到錯誤頁面)的情況。讓我們透過向錯誤操作添加一個新參數來精確地做到這一點:
export function errorProps(error: string, showNotification = true, redirectTo?: string) { return function() { return({error, showNotification, redirectTo}); }; }
現在,我們可以建立一個操作,稍後將告訴效果重新導向到錯誤頁面:
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.
以上是NgRx 用例,第三部分:決策的詳細內容。更多資訊請關注PHP中文網其他相關文章!