Heim  >  Artikel  >  Web-Frontend  >  NgRx-Anwendungsfälle, Teil III: Entscheidungsfindung

NgRx-Anwendungsfälle, Teil III: Entscheidungsfindung

PHPz
PHPzOriginal
2024-08-16 06:17:06842Durchsuche

NgRx Use Cases, Part III: Decision-making

Original-Coverfoto von Hansjörg Keller auf Unsplash.

In meinen vorherigen Artikeln haben wir Zugriffsbeschränkungen und Handhabungslisten mit NgRx behandelt. Heute werden wir uns mit einem allgemeineren Problem der Entscheidungsfindung in einer Angular-App befassen, die hauptsächlich NgRx-Effekte (aber auch einige Reduzierer und Selektoren) nutzt. In diesem Artikel werden wir die folgenden Themen behandeln:

  • Fehlerbehandlung mit NgRx
  • Verarbeitung von Weiterleitungen
  • Verwaltung von Ladedaten
  • Benutzerinteraktionen

Lasst uns anfangen!

Fehlerbehandlung mit NgRx

Fehlerbehandlung ist etwas, das jeder hasst (und oft einfach vergisst), aber auch etwas, das jeder wirklich, wirklich braucht. Bei NgRx-Apps nimmt die Komplexität der Fehlerbehandlung tatsächlich zu, wenn wir nicht mit der gebotenen Sorgfalt darauf eingehen. Normalerweise entstehen Fehler in NgRx durch Effekte, die in den meisten Szenarien durch HTTP-Anfragen verursacht werden.

Im Allgemeinen gibt es zwei Ansätze zur Fehlerbehandlung: lokale oder globale Behandlung. Die lokale Behandlung bedeutet, dass wir tatsächlich den ganz spezifischen Fehler beheben, der in einem bestimmten Teil der App aufgetreten ist. Wenn sich der Benutzer beispielsweise nicht anmelden konnte, möchten wir möglicherweise eine ganz spezifische Fehlermeldung wie „Ungültiger Benutzername oder ungültiges Passwort“ anstelle einer allgemeinen Fehlermeldung wie „Es ist ein Fehler aufgetreten“ anzeigen.

Globale Handhabung bedeutet andererseits, dass wir alle Fehler gewissermaßen in einer „Pipeline“ zusammenfassen und die sehr allgemeinen Fehlermeldungen verwenden, die wir zuvor erwähnt haben. Wir könnten natürlich eine Diskussion darüber beginnen, welche Idee für welche Szenarien besser geeignet ist, aber die harte Realität ist, dass fast jede App ein bisschen von beiden Welten braucht. Wir möchten Fehlermeldungen anzeigen, wenn irgendwelche Fehler auftreten („Etwas ist schief gelaufen“ ist immer noch besser als ein stiller Fehler), aber wir möchten auch einige spezifische Aktionen im Falle von einige Fehler. Lassen Sie uns beide Szenarien ansprechen.

Generische Fehlerbehandlung mit NgRx

In NgRx wird alles, was passiert, durch eine Aktion ausgelöst. Für Aktivitäten, bei denen möglicherweise ein Fehler auftritt (HTTP-Aufrufe, WebSockets usw.), erstellen wir normalerweise mehrere Aktionen für eine einzelne Aktivität:


export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': props<{ error: string }>(),
  },
});
Wie wir sehen können, benötigen wir nur drei Aktionen, um ein Datenelement von einer API zu laden. In einer bestimmten Anwendung gibt es möglicherweise Dutzende, wenn nicht Hunderte solcher „Fehler“- und „Erfolgs“-Aktionen. Daher möchten wir möglicherweise eine allgemeine Fehlermeldung anzeigen, falls eine dieser Aktionen ausgelöst wird. Wir können dies erreichen, indem wir die Nutzlast der Fehleraktion standardisieren. Beispielsweise möchten wir möglicherweise allen Aktionen, die Fehler darstellen, eine ganz bestimmte Eigenschaft hinzufügen. In unserem Fall kann das Vorhandensein der Fehlereigenschaft in der Aktionsnutzlast ausreichen, um zu wissen, dass die Aktion einen Fehler darstellt.

Wir können dann alle Aktionen abonnieren, die Fehler darstellen und eine generische Fehlermeldung anzeigen. Dies ist ein sehr häufiges Muster in NgRx-Apps und wird normalerweise als „globale Fehlerbehandlung“ verstanden. In unserem Fall können wir dies tun, indem wir alle Aktionen abonnieren und diejenigen herausfiltern, deren Nutzlast eine Fehlereigenschaft enthält:


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 });
In diesem Fall führt jede „Fehler“-Aktion, die wir jemals auslösen, dazu, dass dieselbe Benachrichtigung angezeigt wird, jedoch auch mit einer benutzerdefinierten Nachricht. Wir können noch einen Schritt weiter gehen und den Ansatz standardisieren, mit dem die Aktions-Requisiten für einen Fehler erstellt werden. Hier ist eine kleine Hilfsfunktion, die nützlich sein kann:


export function errorProps(error: string) {
  return function() {
    return({error});
  };
}
Jetzt können wir diese Funktion verwenden, um die Fehlerstützen für unsere Aktionen zu erstellen:


export const DataActions = createActionGroup({
  source: 'Data',
  events: {
    'Load Data': emptyProps(),
    'Load Data Success': props<{ data: Data }>(),
    'Load Data Error': errorProps('Failed to load data'),
  },
});
Dadurch sehen alle Fehler gleich aus, um Tippfehler oder Verwirrung zu vermeiden. Als nächstes verbessern wir dies, um auch sehr spezifische Fehler behandeln zu können.

Umgang mit spezifischen Fehlern

In unserem Fall möchten wir möglicherweise die Funktionsweise eines generischen Fehlerhandlers für bestimmte Fälle anpassen. Wir wollen es können

    um den Effekt anzugeben, ob eine allgemeine Fehlermeldung angezeigt werden soll oder nicht
  • Weiterleitung zu Fehlerseiten mit einigen vordefinierten Daten
  • Fehlerbenachrichtigung innerhalb einer Seite anzeigen
Beginnen wir mit dem ersten. Wir können dies tun, indem wir der Fehleraktionsnutzlast eine neue Eigenschaft hinzufügen:


export function errorProps(error: string, showNotififcation = true) {
    return function() {
        return({error, showNotification});
    };
}
Jetzt können wir eine Aktion erstellen, die den Effekt später anweist, die allgemeine Benachrichtigungsnachricht zu überspringen:


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),
  },
});
Als nächstes sollten wir den Effekt aktualisieren, um Folgendes widerzuspiegeln:


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 });
Beachten Sie, dass wir die Überprüfung der showNotification-Eigenschaft nicht zum Filteroperator hinzugefügt haben. Dies liegt daran, dass es Szenarien geben wird, in denen eine Benachrichtigung nicht angezeigt werden sollte, aber dennoch eine andere Aktion ausgeführt werden muss (z. B. die Weiterleitung zu einer Fehlerseite). Genau das tun wir, indem wir einen neuen Parameter zu unserer Fehleraktion hinzufügen:


export function errorProps(error: string, showNotification = true, redirectTo?: string) {
  return function() {
    return({error, showNotification, redirectTo});
  };
}
Jetzt können wir eine Aktion erstellen, die den Effekt später anweist, auf eine Fehlerseite umzuleiten:


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.

Das obige ist der detaillierte Inhalt vonNgRx-Anwendungsfälle, Teil III: Entscheidungsfindung. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn