首页  >  文章  >  web前端  >  NgRx 用例,第三部分:决策

NgRx 用例,第三部分:决策

PHPz
PHPz原创
2024-08-16 06:17:06841浏览

NgRx Use Cases, Part III: Decision-making

原始封面照片由 Hansjörg Keller 在 Unsplash 上拍摄。

在我之前的文章中,我们介绍了 NgRx 的访问限制和处理列表。今天,我们将解决一个主要使用 NgRx Effects(但也使用一些减速器和选择器)的 Angular 应用程序的决策制定问题。在本文中,我们将讨论以下主题:

  • 使用 NgRx 进行错误处理
  • 处理重定向
  • 处理加载数据
  • 用户互动

让我们开始吧!

使用 NgRx 进行错误处理

错误处理是每个人都讨厌的东西(而且常常只是忘记),但也是每个人真正非常需要的东西。对于 NgRx 应用程序,如果我们不适当小心地解决这个问题,错误处理的复杂性实际上会增加。通常,NgRx 中的错误是由效果引起的,而在大多数情况下,效果是由 HTTP 请求引起的。

通常有两种处理错误的方法:本地处理或全局处理。本地处理意味着我们实际上解决了应用程序某些特定部分中发生的非常具体的错误。例如,如果用户登录失败,我们可能希望显示非常具体的错误消息,例如“无效的用户名或密码”,而不是一些通用的错误消息,例如“出了问题”。

另一方面,全局处理意味着我们在某种程度上将所有错误集中到一个“管道”中,并使用我们之前提到的非常通用的错误消息。当然,我们可以开始讨论哪种想法更适合哪种场景,但残酷的现实是,几乎每个应用程序都需要两全其美。如果发生任何错误,我们希望显示错误消息(“出了问题”仍然比静默失败更好),但我们也希望在发生任何错误时执行一些特定操作🎜>一些

错误。让我们解决这两种情况。

使用 NgRx 进行通用错误处理


在 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.

Handling loading data

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.

Selecting data in the component to use

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.

User interactions

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.

  1. The ConfirmationDialogComponent exposes the confirm and cancel observables.
  2. We open a MatDialog with the ConfirmationDialogComponent and every time an action created by the confirmAction is dispatched, we subscribe to the confirm observable and dispatch the action that was passed to the confirmAction function.
  3. We also subscribe to the cancel observable and dispatch the action that was passed to the confirmAction function.
  4. We return a merge of the two observables so that when either of them emits, the whole effect will emit.

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.

Conclusion

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中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn