Redux

Redux Saga ist schwer, bis man unter die Haube schaut

Redux Saga gilt als schwer verständlich und wird oft als Overkill angesehen.

Das Redux-Team empfiehlt sogar, ihre Bibliothek für Datenabruf und Caching, RTK Query, anstelle von Redux Saga zu verwenden.

Wenn man Redux Saga jedoch beherrscht, bietet es tatsächlich eine zuverlässige Methode, jedes noch so komplexe Problem zu zerlegen und in deterministischen und nebenwirkungsfreien Code zu verwandeln.

Ich wollte diesen Artikel über Sagas für Sie schreiben, weil:

  1. Sagas werden immer noch in vielen älteren Projekten verwendet und können Ihnen helfen, mehr Jobs zu bekommen,
  2. die Philosophie der Isolierung von Nebenwirkungen, die Sagas Ihnen vermitteln, wird Ihre Denkweise über Ihren Code verändern und Ihnen helfen, saubereren Code zu schreiben, und
  3. Ich liebe Sagas.

Dieser Artikel ist Teil 2 einer fünfteiligen Serie über Redux, die Ihr Verständnis auf das höchste Niveau heben wird, Sie auf jede Interviewfrage zu Redux vorbereitet und Sie befähigt, zu jeder Redux-basierten Codebasis beizutragen. Übrigens behandelt der dritte Artikel dieser Serie auch die Verwendung von RTK Query.

Der erste Teil der Serie behandelt Redux auf Senior-Niveau. In diesem Artikel lernen Sie, wie Redux funktioniert, indem Sie es von Grund auf implementieren, und dann, wie man den saubersten Redux-Code mithilfe von Funktionskomposition schreibt. Wenn Sie an irgendeiner Stelle in diesem Artikel nicht weiterkommen, lesen Sie zuerst über Was ist Redux zuerst.

Warum Redux Saga?

Beginnen wir mit der grundlegenden Frage:

„Warum möchten Sie Redux Saga verwenden?“

  1. Asynchrone Effekte isolieren - Verwalten Sie Ihre Nebenwirkungen getrennt von Ihrer Hauptanwendungslogik. Ihre Komponenten müssen nicht wissen, dass Nebenwirkungen existieren.
  2. Deterministisches Testen von I/O-bezogener Logik - Wenn Sie Code mit Nebenwirkungen testen, sind Sie häufig gezwungen zu mocken, besonders wenn Sie unsicher sind, was Sie tun. Aber Redux Saga macht es trivial, Tests für Ihre API-Aufrufe, Datenbankoperationen und andere I/O-Aufgaben zu schreiben.

Wenn Sie möchten, können Sie diesem Tutorial folgen und mitprogrammieren. Andernfalls springen Sie zu „Was ist Redux Saga?“ weiter unten.

Dieser Artikel zeigt die grundlegende Einrichtung Ihres Redux-Stores sehr schnell, da sie ausführlich im ersten Artikel (Was ist Redux?) dieser Reihe behandelt wird.

Erstellen Sie ein neues Next.js-Projekt und wählen Sie bei allem „Ja“, außer bei TypeScript.

$ npx create-next-app@latest
✔ **What is your project named?** … 2024-08-20-redux-saga
✔ **Would you like to use** **TypeScript****?** … No / Yes
No
✔ **Would you like to use** **ESLint****?** … No / Yes
Yes
✔ **Would you like to use** **Tailwind CSS****?** … No / Yes
Yes
✔ **Would you like to use** **`src/` directory****?** … No / Yes
Yes
✔ **Would you like to use** **App Router****? (recommended)** … No / Yes
Yes
✔ **Would you like to customize the default** **import alias** **(@/*)?** … No / Yes
No

Der dritte Artikel dieser Reihe erklärt Redux mit TypeScript.

Installieren Sie nun Redux, React Redux und Ramda.

npm i redux react-redux ramda

Erstellen Sie eine Datei in src/features/example/example-reducer.js um Ihr Beispiel-Slice zu speichern.

import { pipe, prop } from 'ramda';

// slice (the name of the example-substate)
export const slice = 'example';

// action creators
export const increment = () => ({ type: `${slice}/increment` });
export const incrementBy = payload => ({
  type: `${slice}/incrementBy`,
  payload,
});

const initialState = {
  count: 0,
};

// example reducer using the action creators, taking in state and actions
export const reducer = (state = initialState, { type, payload } = {}) => {
  switch (type) {
    case increment().type: {
      return { ...state, count: state.count + 1 }; // "action handler"
    }
    case incrementBy().type: {
      return { ...state, count: state.count + payload };
    }
    default: {
      return state;
    }
  }
};

// composed selector(s)
const selectExampleState = prop(slice);

export const selectCount = pipe(selectExampleState, prop('count'));

Importieren Sie pipe und prop aus Ramda, die Sie für Ihre Selektoren verwenden werden.

Exportieren Sie dann den Slice-Namen und erstellen Sie ein increment , ein incrementBy und ein init Action Creator.

Ihr initialer Zustand sollte einen count -Schlüssel haben, der 0ist. Anschließend definieren Sie Ihren Beispiel-Reducer, der die increment - und die incrementBy -Action verarbeiten kann.

Erstellen Sie dann einen Selektor, der den Beispiel-Slice zurückgibt, und exportieren Sie einen Selektor, der den Zähler zurückgibt.

Erstellen Sie Ihren rootReducer in src/redux/root-reducer.js.

import { combineReducers } from 'redux';

import {
  reducer as exampleReducer,
  slice as exampleSlice,
} from '../../features/example/example-reducer';

export const rootReducer = combineReducers({
  [exampleSlice]: exampleReducer,
});

Sie haben nur den Beispiel Slice, sodass Sie nur dieses eine Slice konfigurieren müssen.

Erstellen Sie dann Ihre makeStore Funktion in src/redux/store.js , um die Erstellung Ihres Redux-Stores zu umschließen.

import { legacy_createStore as createStore } from 'redux';

import { rootReducer } from './root-reducer';

export const makeStore = () => {
  return createStore(rootReducer, rootReducer());
};

Sie können Ihren Root-Reducer ohne Argumente aufrufen, um den initialen Zustand zu erhalten.

Erstellen Sie nun Ihren Store-Provider in src/redux/store-provider.js.

'use client'; // The Redux provider is only available client side because
// it uses the context API under the hood.

import { useRef } from 'react';
import { Provider } from 'react-redux';

import { makeStore } from '../redux/store';

export function StoreProvider({ children }) {
  const storeRef = useRef();

  if (!storeRef.current) {
    storeRef.current = makeStore();
  }

  return <Provider store={storeRef.current}>{children}</Provider>;
}

Importieren Sie die makeStore Funktion und verwenden Sie useRef , damit Ihr Store nur einmal erstellt wird. Übergeben Sie dann Ihren Store an den Provider von React Redux.

import { useDispatch, useSelector, useStore } from 'react-redux';

export const useAppDispatch = useDispatch.withTypes();
export const useAppSelector = useSelector.withTypes();
export const useAppStore = useStore.withTypes();

Richten Sie außerdem die Hooks zum Dispatching von Aktionen und zum Abrufen von Daten aus dem Store ein.

Importieren Sie dann Ihren StoreProvider in Ihrem Root-Layout in src/app/layout.js und umschließen Sie den StoreProvider um Ihr RootLayout.

import './globals.css';

import { Inter } from 'next/font/google';

import { StoreProvider } from './store-provider';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'Jan Hesters Sagas Tutorial',
  description: 'Part two of five to master Redux.',
};

export default function RootLayout({ children }) {
  return (
    <StoreProvider>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </StoreProvider>
  );
}

Sie können nun über Selektoren auf Store-Werte zugreifen und diese mithilfe von Dispatch von jeder Kindkomponente des StoreProvideraus manipulieren.

Was ist Redux Saga?

Redux Saga ist eine Middleware, die Generatoren verwendet, um:

  • Nebenwirkungen zu verwalten und Aktionen zu manipulieren,
  • Ihren Code zu entkoppeln und
  • komplexe Logikabläufe, Sequenzen und Datenprozesse zu definieren.

Sagas wandeln typischerweise unreine Funktionen – wie Datenbankaufrufe oder API-Anfragen – in reine, deterministische um, indem sie Nebenwirkungen isolieren durch Verzögerung ihrer Berechnung.

„Code entkoppeln“ bedeutet, Ihre App in kleine, unabhängige Blöcke zu zerlegen, sodass die Komponenten Ihrer App weniger voneinander abhängen. Wenn Komponenten entkoppelt sind, können Sie an ihnen arbeiten, ohne das Risiko einzugehen, etwas anderes zu beschädigen. Wenn Komponenten gekoppelt sind, könnte eine Änderung an einer Komponente unbeabsichtigt etwas anderes beschädigen.

Mit Redux Saga können Sie Ihren Code entkoppeln, da Sie komplexe Abfolgen asynchroner Operationen so definieren können, dass sie von Ihrer UI- und Zustandsverwaltungslogik getrennt bleiben. Sagas können beispielsweise komplexe Abfolgen in useEffect mit leicht implementierbarer und entkoppelter Logik.

Werfen Sie einen Blick auf die folgende Saga.

import { call, put, select, take } from 'redux-saga/effects';

import { increment, incrementBy, selectCount, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

const fetchUser = async id => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
  );
  return await response.json();
};

export function* exampleSaga() {
  yield take(init().type);
  yield put(increment());
  const currentCount = yield select(selectCount);
  const user = yield call(fetchUser, currentCount);
  yield put(incrementBy(user.name.length));
}

Keine Sorge, wenn Sie das noch nicht verstehen. Dieser Artikel wird alles für Sie aufschlüsseln.

Alle ausgelösten Aktionen überall in Ihrer App laufen durch alle Sagas, und wenn die Aktion der Bedingung einer Saga entspricht, wird die Saga ausgelöst. Wenn die init Aktion ausgelöst wird, beginnt diese Saga zu laufen.

Dann löst diese Saga die Put-Aktion aus, holt den neuesten Zählerstand aus dem Store und ruft einen Benutzer ab, dessen ID dem aktuellen Zählerstand entspricht. Schließlich erhöht sie den Zähler um die Länge des Benutzernamens.

Die Logik, die all diese Effekte verarbeitet, wird Ihnen später im Kapitel über Saga-Middleware gezeigt und erklärt.

Wenn Sie protokollieren, was diese Saga tut, erhalten Sie diese Ausgabe.

dispatching { "type": "example/init" }
next state { "example": { "count": 0 } }
dispatching { "type": "example/increment" }
next state { "example": { "count": 1 } }
dispatching { "type": "example/incrementBy", "payload": 13 }
next state { "example": { "count": 14 } }

Zuerst wird die Init-Aktion irgendwo in Ihrer App ausgelöst, wodurch Ihre Saga getriggert wird. Und dann sehen Sie die Erhöhung des Zählers um 1 und dann die Erhöhung um 13, weil der Benutzer mit der ID 1 "Leanne Graham" heißt.

Wenn Sie versuchen würden, diesen Ablauf ohne Sagas als React-Komponente zu schreiben, würden Sie vielleicht etwas schreiben, das useEffects und verschiedene andere Hooks verwendet. Nur um Ihnen eine Vorstellung zu geben, so könnte es aussehen:

import { useEffect, useRef } from 'react';

import { increment, incrementBy, init, selectCount } from './example-reducer';
import { useAppDispatch, useAppSelector } from './hooks';

async function fetchUser(id) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
  );
  return await response.json();
}

export function MyComponent() {
  const dispatch = useAppDispatch();
  const currentCount = useAppSelector(selectCount);
  const hasFetched = useRef(false);

  useEffect(() => {
    dispatch(init());
    dispatch(increment());
  }, [dispatch]);

  useEffect(() => {
    const fetchAndIncrement = async () => {
      try {
        const user = await fetchUser(currentCount);
        dispatch(incrementBy(user.name.length));
        hasFetched.current = true;
      } catch (error) {
        console.error('Failed to fetch user:', error);
      }
    };

    if (currentCount > 0 && !hasFetched.current) {
      fetchAndIncrement();
    }
  }, [currentCount, dispatch]);

  return <p>Current Count: {currentCount}</p>;
}

Ich erspare Ihnen die Details, aber wie Sie sehen können, ist das Schreiben dieser Logik als React-Komponente bereits komplexer und nicht die gesamte Logik bereitstellt, die wir wollen und von Sagas erhalten.

"Ich möchte die Details tatsächlich wissen."

Sicher, hier sind sie:

Im ersten Artikel dieser Reihe haben Sie gelernt, wie man Hooks verwendet, um auf dispatch und den Zustand des Stores.

Wenn die Komponente nun läuft, sendet sie init und increment. Nachdem der Zähler erhöht wurde, ruft es den Benutzer ab und erhöht den Zähler um die Länge des Benutzernamens. Sie verwenden useRef , um Endlosschleifen zu verhindern, da das Erhöhen des Zählers dazu führen würde, dass der zweite useEffect erneut ausgeführt wird.

Die Logik dieser React-Komponente unterscheidet sich tatsächlich von der Saga, die Sie zuvor gesehen haben. Die Saga beginnt, wenn init gesendet wird. Um dieses Verhalten jedoch mit Hooks zu replizieren, müssten Sie eine Bedingung im Zustand des Stores erstellen, die auf „true“ gesetzt wird, wenn init gesendet wird, und dann innerhalb von useEffectdarauf reagieren. Sie sehen also bereits, wie die Implementierung komplexer Sequenzen mit Sagas einfacher ist als mit React-Komponenten und Hooks.

Ich weiß, das war viel, aber jetzt haben Sie einen Gesamtüberblick, sodass Sie alle Details leichter lernen können.

Aus welchen Komponenten besteht Redux Saga?

Redux Saga besteht aus drei Komponenten:

  1. Effekte - Effekte sind Action Creators, die zukünftige Aktionen beschreiben.
  2. Sagas - Sagas sind Generatorfunktionen, die Nebenwirkungen verwalten.
  3. Middleware - Die Middleware, die Effekt-Handler enthält, verwaltet die Ausführung der Sagas und der entsprechenden Effekte.

Effekte

Redux Saga verwendet spezielle Arten von Action Creators, die als „Effekte“ bezeichnet werden. Effekte sind Anweisungen für die Handler in der Saga-Middleware. Diese Effekt-Handler führen den eigentlichen Mechanismus des Effekts aus.

Dieser Artikel behandelt die wichtigsten Effekte. Sobald Sie diesen Artikel gelesen haben, wird es Ihnen leichtfallen, die weniger gebräuchlichen Effekte in der Redux Saga-Dokumentation.

Sie beginnen mit dem take -Effekt.

Hier ist, was passiert, wenn Sie den take -Effekt importieren, aufrufen und protokollieren.

import { take } from 'redux-saga/effects';

console.log(take('increment'));

Es wird ein Objekt in der Konsole ausgegeben.

{
  '@@redux-saga/IO': true,
  combinator: false,
  type: 'TAKE',
  payload: { pattern: 'increment' }
}

Wie Sie sehen können, da alle Effekte Action-Creator sind, enthalten sie eine Typ und Payload Eigenschaft.

Es gibt aber auch zwei weitere Eigenschaften.

  • @@redux-saga/IO: Diese Eigenschaft zeigt an, dass das Objekt ein Redux-Saga-Effekt ist. Die Redux-Saga-Middleware erkennt und verarbeitet nur Aktionen mit dieser Eigenschaft, ignoriert aber alle anderen Aktionen. Auf diese Weise verarbeitet die Middleware nur Aktionen, die sie verarbeiten soll.
  • Combinator: Diese Eigenschaft gibt an, ob der Effekt ein kombinatorischer Effekt ist, der verwendet wird, um mehrere Effekte parallel oder sequenziell auszuführen. Da take ein einfacher Effekt ist, um eine Saga zu pausieren, ist diese Eigenschaft false.

take

Was ist das Besondere am take Effekt? Oder anders ausgedrückt, wofür wird take verwendet?

Der take Effekt dient dazu, eine Saga zu pausieren, bis eine bestimmte Aktion gesendet wird an den Redux-Store. Wenn der angegebene Aktionstyp eintrifft, wird die Ausführung der Saga fortgesetzt.

Während Sie die verschiedenen Effekte erkunden, erstellen Sie diese in einer Datei unter src/redux/effects.js.

export const take = pattern => ({
  '@@redux-saga/IO': true,
  type: 'TAKE',
  payload: { pattern },
});

Muster ist ein Aktionstyp. Es ist der Typ der Aktion, die gesendet werden muss, damit die Saga fortgesetzt wird. (Wenn ein take-Effekt in Ihrer Saga erzeugt wurde, teilt das Muster in dessen Payload den Effekt-Handlern in der Saga-Middleware mit, welcher Aktionstyp die Saga fortsetzen soll.)

Sie können die Kombinator Eigenschaft in Ihren eigenen Aktionen weglassen, da die Implementierung dieses Verhaltens komplex und für ein tiefes Verständnis von Sagas unnötig ist. Wenn Sie neugierig sind, können Sie den Redux-Saga-Quellcode später erkunden.

put

Werfen Sie einen Blick auf einen weiteren Effekt namens put.

import { put } from 'redux-saga/effects';

console.log(put({ type: 'increment' }));

Wie jeder Effekt gibt es auch eine Aktion zurück, wenn Sie es aufrufen und protokollieren.

{
  '@@redux-saga/IO': true,
  combinator: false,
  type: 'PUT',
  payload: { channel: undefined, action: { type: 'increment' } }
}

Der put Effekt beschreibt eine Aktion, die von den Effekt-Handlern ausgelöst werden soll, sobald die Saga-Iteration den Aufruf dieses Effekts triggert. (TODO: dies aufzeichnen)

Die Aktion hat eine ähnliche Form wie das zurückgegebene Objekt von dem take Effekt, aber die Payload enthält einen channel und einen action.

Kanäle in Redux-Saga können für komplexere Kommunikationsmuster verwendet werden, aber für standardmäßige put Effekte, die Aktionen an den Store senden, bleibt dies undefined.

Die action Eigenschaft enthält das Aktionsobjekt, das ausgelöst wird. In Ihrem Beispiel ist es { type: 'increment' }.

Fügen Sie put zu Ihren benutzerdefinierten Effekten.

export const take = pattern => ({
  '@@redux-saga/IO': true,
  type: 'TAKE',
  payload: { pattern },
});

export const put = action => ({
  '@@redux-saga/IO': true,
  type: 'PUT',
  payload: { action },
});

Wie bereits erwähnt, können Sie das channel -Property weglassen, da es undefined ohnehin für einfache put -Effekte ist.

Sagas

Der Begriff „Saga“ in Redux Saga stammt aus einem 1987 paper by Hector Garcia-Molina and Kenneth Salem. Ihre Arbeit beschreibt eine Methode zur Handhabung langlebiger Datenbanktransaktionen, indem diese in kleinere Teile zerlegt werden, mit Notfallplänen, falls etwas schiefgeht.

Redux Saga adaptiert diese Idee, um komplexe asynchrone Aufgaben und Nebenwirkungen im App-Zustandsmanagement mithilfe von Generatorfunktionen zu verwalten. Der Name „Saga“ spiegelt die langwierigen, komplexen Prozesse wider, die diese Middleware handhabt, ähnlich den komplexen Geschichten, die in historischen Sagen zu finden sind.

Wenn Sie mit Generatoren nicht vertraut sind, lesen Sie „JavaScript Generators Explained, But On A Senior-Level“. Dieser Artikel vermittelt Ihnen das gesamte grundlegende Wissen, das Sie zum Verständnis von Sagas benötigen.

Es ist an der Zeit, Ihre benutzerdefinierten Effekte in einem Saga-Generator in src/features/example/example-sagas.jszu verwenden.

import { put, take } from '../../redux/effects';
import { increment, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

export function* exampleSaga() {
  yield take(init().type);
  yield put(increment());
}

Importieren Sie die increment Action Creator und Ihre benutzerdefinierten Effekte.

Erstellen Sie anschließend einen init Action Creator, den Sie verwenden werden, um Ihre Saga auszulösen. Diese Action benötigt lediglich eine 'type'-Eigenschaft, um die Saga zu starten, sobald sie zum ersten Mal in Ihrer App ausgelöst wird.

Verwenden Sie in Ihrer Saga den take Effekt, um auf die init Action zu warten, die ausgelöst wird, und verwenden Sie dann den put Effekt, um die increment Action auszulösen.

import { put, take } from '../../redux/effects';
import { increment, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

export function* exampleSaga() {
  const action = yield take(init().type);
  console.log('action', action);
  yield put(increment());
}

// In reality, this call 👇 happens inside of the middleware (covered later).
const iterator = exampleSaga(); 

 // Start the saga and log the first yield.
console.log('First next(): ', iterator.next());
 // Simulate receiving the init action and log the second yield.
console.log('Second next(): ', iterator.next(init()));
 // Complete the saga.
console.log('Third next(): ', iterator.next());

Erstellen Sie nun ein Generator-Objekt mit Ihrer Saga. Rufen Sie dann .next() dreimal auf. Wenn Sie .next() zum zweiten Mal aufrufen, übergeben Sie die init Action Creator an den .next() Methode, die simuliert, was die Saga-Middleware im Hintergrund tun würde.

$ npx tsx src/features/example/example-sagas.js
First next():  { value: { '@@redux-saga/IO': true, type: 'TAKE', payload: { pattern: 'example/init' } }, done: false }
action { type: 'example/init' }
Second next():  { value: { '@@redux-saga/IO': true, type: 'PUT', payload: { action: { type: 'example/increment' } } }, done: false }
Third next():  { value: undefined, done: true }

Wenn Sie diesen Code ausführen, sehen Sie den take Effekt mit dem „init“-Muster. Als Nächstes sehen Sie den put Effekt mit der Increment-Aktion. Und schließlich ist der Generator fertig.

Wie Sie sehen, steckt hier keine Magie dahinter. Die Saga ist lediglich ein Generator.

Saga-Middleware

Die „Magie“ der Saga geschieht tatsächlich in der Middleware. Die Middleware ist dafür verantwortlich, die Effekte korrekt zu verarbeiten und an alle Ihre Sagas weiterzuleiten.

Erstellen Sie die createSagaMiddleware Funktion in src/redux/saga-middleware.js.

import { put, take } from './effects';

export function createSagaMiddleware() {
  let sagas = [];

  const middleware = store => next => action => {
    const result = next(action);

    sagas.forEach(saga => {
      const sagaIterator = saga();

      let effectHandled = false;

      const handleEffect = async effect => {
        if (!effect || !effect['@@redux-saga/IO']) {
          effectHandled = true;
          return;
        }

        switch (effect.type) {
          case take().type: {
            if (effect.payload.pattern === action.type) {
              return action;
            } else {
              effectHandled = true;
              return;
            }
          }
          case put().type: {
            store.dispatch(effect.payload.action);
            return;
          }
          default: {
            effectHandled = true;
            return;
          }
        }
      };

      const processSaga = async () => {
        let lastValue;

        try {
          while (!effectHandled) {
            // lastValue is the return value of the previously handled effect.
            const { value, done } = sagaIterator.next(lastValue);

            if (done) {
              break;
            }

            lastValue = await handleEffect(value);
          }
        } catch (error) {
          sagaIterator.throw(error);
        }
      };

      processSaga();
    });

    return result;
  };

  middleware.run = saga => {
    sagas.push(saga);
  };

  return middleware;
}

Importieren Sie Ihre beiden benutzerdefinierten Effekte.

Als Nächstes definieren und exportieren Sie eine neue Funktion namens createSagaMiddleware.

Es definiert ein Array von Sagas in seinem Closure.

Anschließend definiert es die Middleware mit den typischen drei Parametern: store, next und action. Es ruft die next Middleware mit der action auf, um ein resultzu erstellen. Es wird später als endgültige Ausgabe zurückgegeben, nachdem alle Effekte verarbeitet wurden, um die Middleware-Kette am Laufen zu halten.

Nun iteriert es über alle Sagas.

Für jede Saga generiert es einen neuen Iterator. Anschließend richtet es effectHandledein, das es auf falseinitialisiert.

Nun definiert es die handleEffect Funktion. Das effect Argument ist der Wert der Generatorobjekte, die Ihre Sagas yield.

Wenn der Effekt undefined ist oder den @@redux-saga/IO Schlüssel fehlt, setzt es effectHandled auf true und kehrt frühzeitig zurück, sodass die Saga den Effekt ignoriert.

Als Nächstes erstellt es eine switch Anweisung zur Behandlung spezifischer Effekte.

Es beginnt mit einem Handler für den take Effekt. Wenn ein take Effekt erzeugtwurde in Ihrer Saga, bedeutet das, dass er ein Muster in seiner Payload hat, das Ihnen den Typ der Aktion mitteilt, die die Saga fortsetzen soll. Wenn die aktuelle Aktion diesem Typ entspricht, gibt der Handler sie zurück. Wenn nicht, markiert er den Effekt als verarbeitet und kehrt zurück.

Für den put Effekt dispatchet der Handler die Aktion aus der Payload des Effekts einfach mit store.dispatch.

Für jeden unbehandelten Effekttyp markiert die Middleware den Effekt ebenfalls als verarbeitet.

Als Nächstes definiert es eine processSaga Funktion. Solange der Effekt NICHT verarbeitet wird, ruft es die .next() Methode auf dem aktuellen Saga-Iterator auf und übergibt ihr den letzten Wert. Der erste .next() wird immer ohne Argument aufgerufen, da der letzte Wert als undefined. Wenn die Saga abgeschlossen, wird die while Schleife mit dem break Schlüsselwort beendet. Anschließend wird der neueste Wert mithilfe der handleEffect Funktion berechnet. Dieser lastValue wird als Argument in den nächsten .next() Aufruf eingefügt. Später werden Sie sehen, dass handleEffect mit Promises arbeiten kann, daher muss processSagaawaiten es. Tritt ein Fehler auf, fängt es diesen ab und übergibt ihn an die Saga mittels des .throw() Methode.

Schließlich gibt die Middleware das Ergebnis zurück, wie man es von jeder Middleware erwarten würde.

Definieren Sie eine .run() Methode für Ihre Saga-Middleware. Dies ist der Mechanismus, den Sie verwenden, um neue Sagas in das Array zu pushen. Schließlich createSagaMiddleware gibt die erstellte Middleware zurück.

Diese Implementierung der Saga-Middleware ist zu Lernzwecken vereinfacht. Zum Beispiel können take Effekte nur als erster Effekt in einer Saga verwendet werden, da jeder Dispatch alle Saga-Iteratoren von Grund auf neu über die forEach Schleife aufruft. Mit der echten Implementierung aus dem Redux Saga-Paket können Sie take überall in der Saga verwenden. Diese Version vermittelt Ihnen jedoch ein solides Grundverständnis.

Ändern Sie nun Ihre makeStore Funktion, um Ihre createSagaMiddleware Funktion.

import { applyMiddleware, legacy_createStore as createStore } from 'redux';

import { exampleSaga } from '../features/example/example-sagas';
import { rootReducer } from './root-reducer';
import { createSagaMiddleware } from './saga-middleware';

const logger = store => next => action => {
  console.log('dispatching', action);
  let result = next(action);
  console.log('next state', store.getState());
  return result;
};

export const makeStore = () => {
  const sagaMiddleware = createSagaMiddleware();
  const store = createStore(
    rootReducer,
    rootReducer(),
    applyMiddleware(logger, sagaMiddleware),
  );
  sagaMiddleware.run(exampleSaga);
  return store;
};

Importieren Sie Ihre exampleSaga und Ihre createSagaMiddleware Funktion.

Erstellen Sie dann eine Logger -Middleware. Sie werden sie verwenden, um das Verhalten der Saga zu demonstrieren.

Verwenden Sie Ihre createSagaMiddleware -Funktion innerhalb Ihrer makeStore -Funktion, um Ihre Middleware zu erstellen. Übergeben Sie dann beide Middleware an Redux' createStore -Funktion. Rufen Sie die .run() -Methode auf und übergeben Sie Ihre exampleSaga.

Jetzt müssen Sie die init Aktion in Ihrem Store-Provider auslösen.

'use client';
import { useRef } from 'react';
import { Provider } from 'react-redux';

import { init } from '../features/example/example-sagas';
import { makeStore } from '../redux/store';

export function StoreProvider({ children }) {
  const storeRef = useRef();

  if (!storeRef.current) {
    storeRef.current = makeStore();
    storeRef.current.dispatch(init());
  }

  return <Provider store={storeRef.current}>{children}</Provider>;
}

Sie lösen nur die init aus Ihrem StoreProvider in diesem Tutorial aus, um Ihre Saga zu starten. In der realen Welt würden Sie dies wahrscheinlich nicht tun.

Wenn Sie Ihre App nun ausführen und http://localhost:3000/besuchen, sehen Sie die increment -Aktion, die nach Ihrer init -Aktion ausgelöst wird.

dispatching { "type": "example/init" }
next state { "example": { "count": 0 } }
dispatching { "type": "example/increment" }
next state { "example": { "count": 1 } }

Das bedeutet, dass Ihre exampleSaga funktioniert. Ihr take fängt die init Aktion, und dann läuft Ihre Saga weiter und dispatchet die increment Aktion unter Verwendung des put Effekts.

select

Sie haben gelernt, wie man Aktionen mit take abfängt und wie Sie Aktionen mit put in Ihrer Saga dispatchen können.

Was ist, wenn Sie einen Zustand mithilfe eines Selektors abrufen möchten, um ihn in Ihrer Saga zu verwenden?

Hier kommt der select Effekt ins Spiel.

import { select } from 'redux-saga/effects';

console.log(select(selectCount, 42, 'foo', true));

Wenn Sie es protokollieren, gibt es ebenfalls eine Aktion zurück.

{
  '@@redux-saga/IO': true,
  combinator: false,
  type: 'SELECT',
  payload: { selector: [Function (anonymous)], args: [42, 'foo', true] }
}

Der select Effekt wird verwendet, um Daten aus dem Redux-Store abzurufen. Seine Payload enthält ein Selektor Funktion und optionalen Argumenten.

Die Argumente sind zusätzliche Argumente, die bei Bedarf an den Selektor übergeben werden. Dies kann nützlich sein, wenn Sie einen Selektor haben, der einen bestimmten Benutzer anhand seiner ID auswählt.

export const take = pattern => ({
  '@@redux-saga/IO': true,
  type: 'TAKE',
  payload: { pattern },
});

export const put = action => ({
  '@@redux-saga/IO': true,
  type: 'PUT',
  payload: { action },
});

export const select = (selector, ...arguments_) => ({
  '@@redux-saga/IO': true,
  type: 'SELECT',
  payload: { selector, args: arguments_ },
});

Fügen Sie select zu Ihrer Sammlung benutzerdefinierter select Effekte hinzu.

import { put, select, take } from '../../redux/effects';
import { increment, selectCount, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

export function* exampleSaga() {
  yield take(init().type);
  yield put(increment());
  const currentCount = yield select(selectCount);
  console.log('currentCount', currentCount);
}

Importieren Sie als Nächstes Ihren selectCount Selektor und den select Effekt in Ihre Beispiel-Saga. Verwenden Sie diese, um die aktuelle Anzahl aus Ihrem Store innerhalb der Saga abzurufen.

Wenn Sie mitprogrammieren, versuchen Sie, die Handhabung des select Effekts in Ihrer saga-middleware auf eigene Faust.

...

Bist du fertig? Hier ist die Lösung.

import { put, select, take } from './effects';

export function createSagaMiddleware() {
  let sagas = [];

  const middleware = store => next => action => {
    const result = next(action);

    sagas.forEach(saga => {
      const sagaIterator = saga();

      let effectHandled = false;

      const handleEffect = async effect => {
        if (!effect || !effect['@@redux-saga/IO']) {
          effectHandled = true;
          return;
        }

        switch (effect.type) {
          case take().type: {
            if (effect.payload.pattern === action.type) {
              return action;
            } else {
              effectHandled = true;
              return;
            }
          }
          case put().type: {
            store.dispatch(effect.payload.action);
            return;
          }
          case select().type: {
            return effect.payload.selector(
              store.getState(),
              ...effect.payload.args,
            );
          }
          default: {
            effectHandled = true;
            return;
          }
        }
      };

      const processSaga = async () => {
        let lastValue;

        try {
          while (!effectHandled) {
            const { value, done } = sagaIterator.next(lastValue);

            if (done) {
              break;
            }

            lastValue = await handleEffect(value);
          }
        } catch (error) {
          sagaIterator.throw(error);
        }
      };

      processSaga();
    });

    return result;
  };

  middleware.run = saga => {
    sagas.push(saga);
  };

  return middleware;
}

Um den select -Effekt zu unterstützen, schalte auf seinen Typ um und rufe dann store.getState() auf, um ihm den aktuellen Zustand zu übergeben und die an den select -Effekt angehängten Argumente als zweites Argument einzufügen.

dispatching { "type": "example/init" }
next state { "example": { "count": 0 } }
dispatching { "type": "example/increment" }
next state { "example": { "count": 1 } }
currentCount 1

Wenn du deinen Code jetzt ausführst, solltest du die aktuelle Anzahl in deiner Browserkonsole ausgegeben sehen.

call

Wie isolierst du Side-Effects mit Redux Saga? Das machst du mit dem call -Effekt.

Bei der Verwendung von Redux Saga, ähnlich wie bei Aktionen und Selektoren, verwendest du Objekte, die zukünftige Berechnungen darstellen, anstatt Berechnungen direkt mit I/O auszulösen. call ruft niemals tatsächlich eine Funktion auf. Stattdessen gibt es ein Objekt mit einem Verweis auf eine Funktion und deren Argumente zurück, und die Saga-Middleware ruft diese für dich auf.

Ausgeben call erneut, um es zu überprüfen.

import { call } from 'redux-saga/effects';

console.log(call(() => {}));

An dieser Stelle sollte Ihnen die Ausgabe bekannt vorkommen.

{
  '@@redux-saga/IO': true,
  combinator: false,
  type: 'CALL',
  payload: { context: null, fn: [Function (anonymous)], args: [] }
}

Die fn -Eigenschaft enthält die Funktion, die das erste Argument des call -Effekts ist, und args sind die an die Funktion übergebenen Argumente. context enthält den this -Kontext, in dem die Funktion ausgeführt wird, welcher null ist, wenn die Funktion NICHT davon abhängt.

export const take = pattern => ({
  '@@redux-saga/IO': true,
  type: 'TAKE',
  payload: { pattern },
});

export const put = action => ({
  '@@redux-saga/IO': true,
  type: 'PUT',
  payload: { action },
});

export const select = (selector, ...arguments_) => ({
  '@@redux-saga/IO': true,
  type: 'SELECT',
  payload: { selector, args: arguments_ },
});

export function call(functionOrContextAndFunction, ...arguments_) {
  if (Array.isArray(functionOrContextAndFunction)) {
    const [context, function_] = functionOrContextAndFunction;
    
    return {
      '@@redux-saga/IO': true,
      type: 'CALL',
      payload: { fn: function_, args: arguments_, context },
    };
  }
  
  return {
    '@@redux-saga/IO': true,
    type: 'CALL',
    payload: {
      fn: functionOrContextAndFunction,
      args: arguments_,
      context: null,
    },
  };
}

Sie können call auf zwei Arten verwenden. Wenn das erste Argument ein Array ist, ist das erste Element der Kontext und das zweite die Funktion. Wenn das erste Argument eine Funktion ist, ist der Kontext null.

Verwenden Sie nun call in Ihrer Beispiel-Saga, die sich in src/features/example/example-sagas.js.

import { call, put, select, take } from '../../redux/effects';
import { increment, incrementBy, selectCount, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

const fetchUser = async id => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
  );
  return await response.json();
};

export function* exampleSaga() {
  // sagaIterator.next(undefined); 1st
  yield take(init().type);
  // sagaIterator.next(init()); 2nd
  yield put(increment());
  // sagaIterator.next(undefined); 3rd
  const currentCount = yield select(selectCount);
  // sagaIterator.next(1); 4th
  const user = yield call(fetchUser, currentCount);
  // { type: 'call', payload: { context: null, fn: fetchUser, args: [currentCount] }}
  // sagaIterator.next({ name: 'Leane Graham' }); // 5th
  yield put(incrementBy(user.name.length));
  // sagaIterator.next(undefined);
}

Um etwas zu isolieren, erstellen Sie eine fetchUser Funktion, die einen Benutzer über eine Platzhalter-API anhand seiner idabruft.

Verwenden Sie dann call , um den Benutzer abzurufen, dessen ID dem aktuellen Zähler entspricht. Senden Sie schließlich eine incrementBy Aktion, um den Zähler um die Länge des Benutzernamens zu erhöhen.

call ist eine reine Funktion, die eine aufzurufende Funktion und die Argumente für den Aufruf dieser Funktion entgegennimmt. Sie verzögert dann den Aufruf der Funktion. Der Aufruf der Funktion erfolgt durch die Saga-Middleware zu einem späteren Zeitpunkt. Auf diese Weise wandelt call eine unreine Funktion, wie einen API-Aufruf oder einen Datenbankaufruf, in eine reine Funktion um, indem es den Nebeneffekt isoliert.

Diese Saga ist übrigens Unsinn. Echte Anwendungsfälle werden Sie in den Artikeln 3, 4 und 5 dieser Reihe sehen. Der Zweck dieser Beispiel-Saga ist es, Ihnen zu helfen zu verstehen, wie Redux Saga und seine Effekte funktionieren.

Als Nächstes ändern Sie Ihre createSagaMiddleware Funktion, um die call Effekt in src/redux/saga-middleware.js. Wenn Sie möchten, können Sie es als Übung noch einmal versuchen, aber seien Sie gewarnt – dieses hier ist etwas schwieriger.

import { call, put, select, take } from './effects';

export function createSagaMiddleware() {
  let sagas = [];

  const middleware = store => next => action => {
    const result = next(action);

    sagas.forEach(saga => {
      const sagaIterator = saga();

      let effectHandled = false;

      const handleEffect = async effect => {
        if (!effect || !effect['@@redux-saga/IO']) {
          effectHandled = true;
          return;
        }

        switch (effect.type) {
          case take().type: {
            if (effect.payload.pattern === action.type) {
              return action;
            } else {
              effectHandled = true;
              return;
            }
          }
          case put().type: {
            store.dispatch(effect.payload.action);
            return;
          }
          case select().type: {
            return effect.payload.selector(
              store.getState(),
              ...effect.payload.args,
            );
          }
          case call().type: {
            try {
              const { fn, args, context } = effect.payload;
              const functionResult = fn.apply(context, args);
              if (functionResult instanceof Promise) {
                return await functionResult;
              }
              return functionResult;
            } catch (error) {
              effectHandled = true;
              return sagaIterator.throw(error);
            }
          }
          default: {
            effectHandled = true;
            return;
          }
        }
      };

      const processSaga = async () => {
        let lastValue;

        try {
          while (!effectHandled) {
            const { value, done } = sagaIterator.next(lastValue);

            if (done) {
              break;
            }

            lastValue = await handleEffect(value);
          }
        } catch (error) {
          sagaIterator.throw(error);
        }
      };

      processSaga();
    });

    return result;
  };

  middleware.run = saga => {
    sagas.push(saga);
  };

  return middleware;
}

Im Effekt-Handler für call, destrukturieren Sie die Funktion, ihre Argumente und den Kontext aus der Payload des Effekts.

Rufen Sie die Funktion dann mit der apply Methode auf. Dadurch kann die Funktion mit dem angegebenen Kontext und Argumentenaufgerufen werden. Beachten Sie: Wenn die Funktion NICHT auf einen bestimmten Kontext angewiesen ist, ist Kontext null.

Wenn die ausgeführte Funktion ein Promise zurückgibt (d.h. es sich um einen asynchronen Vorgang handelt), warten Sie auf dieses Promise und geben Sie dann den aufgelösten Wert zurück. Andernfalls geben Sie das Ergebnis direkt zurück.

Tritt während der Ausführung der Funktion (entweder synchron oder innerhalb des Promises) ein Fehler auf, fangen Sie diesen ab. Verwenden Sie dann sagaIterator.throw(error) um den Fehler an die Saga zurückzugeben, damit die Saga ihn entsprechend behandeln kann (z. B. in einem try/catch-Block).

Wie oben erwähnt, als Sie definiert haben processSaga erwarten Sie handleEffect weil durch call es Promises verarbeiten kann.

Führen Sie Ihre App jetzt erneut aus.

dispatching { "type": "example/init" }
next state { "example": { "count": 0 } }
dispatching { "type": "example/increment" }
next state { "example": { "count": 1 } }
dispatching { "type": "example/incrementBy", "payload": 13 }
next state { "example": { "count": 14 } }

Ihre Browserkonsole sollte nun die incrementBy Aktion mit einer Payload von 13 ausgelöst werden, da der Benutzer mit der ID eins „Leanne Graham“ heißt.

Redux Saga bietet viele weitere Effekte. Sie können diese in der Dokumentation erkunden und sollten unbedingt den nächsten Artikel dieser Reihe lesen, in dem Sie weitere Effekte verwenden und in reale Beispiele eintauchen werden.

Redux Saga-Paket

Es ist an der Zeit, Ihren benutzerdefinierten Code durch die tatsächlichen Funktionen von Redux Saga zu ersetzen.

Installieren Sie Redux Saga.

$ npm i redux-saga

Ersetzen Sie als Nächstes Ihre benutzerdefinierte createSagaMiddleware Funktion in Ihrer Store mit dem von Redux Saga bereitgestellten.

import { applyMiddleware, legacy_createStore as createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';

import { exampleSaga } from '../features/example/example-sagas';  
import { rootReducer } from './root-reducer';

const logger = store => next => action => {
  console.log('dispatching', action);
  let result = next(action);
  console.log('next state', store.getState());
  return result;
};

export const makeStore = () => {
  const sagaMiddleware = createSagaMiddleware();
  const store = createStore(
    rootReducer,
    rootReducer(),
    applyMiddleware(sagaMiddleware, logger),
  );
  sagaMiddleware.run(exampleSaga);
  return store;
};

Jetzt können Sie die benutzerdefinierten Effekte in Ihrer Beispiel-Saga durch die tatsächlichen Effekte von Redux Saga ersetzen.

import { call, put, select, take } from 'redux-saga/effects';

import { increment, incrementBy, selectCount, slice } from './example-reducer';

export const init = () => ({ type: `${slice}/init` });

const fetchUser = async id => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
  );
  return await response.json();
};

export function* exampleSaga() {
  yield take(init().type);
  yield put(increment());
  const currentCount = yield select(selectCount);
  const user = yield call(fetchUser, currentCount);
  yield put(incrementBy(user.name.length));
}

Und wenn Sie Ihre App erneut ausführen, erhalten Sie immer noch dieselbe Ausgabe in der Konsole Ihres Browsers.

dispatching { "type": "example/init" }
next state { "example": { "count": 0 } }
dispatching { "type": "example/increment" }
next state { "example": { "count": 1 } }
dispatching { "type": "example/incrementBy", "payload": 13 }
next state { "example": { "count": 14 } }

Zu Beginn dieses Artikels haben Sie gelernt, dass Redux Saga hervorragend geeignet ist für:

  • die Isolierung asynchroner Effekte und
  • deterministisches Testen von I/O-bezogener Logik.

Bisher haben Sie in diesem Artikel nur den ersten Punkt behandelt. Wie Sie Sagas für deterministisches Testen verwenden, erfahren Sie im vierten Artikel dieser Reihe, in dem Sie eine reale Anwendung mit Redux erstellen.

Zuvor müssen Sie jedoch lernen, wie man Redux für die Produktion einrichtet, was im dritten Artikel behandelt wird. Lesen Sie also als Nächstes diesen.

P.S. Suchen Sie Redux-Entwickler? ReactSquad ist für Sie da. Vereinbaren Sie einen Termin mit uns und erhalten Sie innerhalb von 48 Stunden eine Vermittlung.

Hire reliable React Developers without breaking the bank
Match me with a dev
About the Author
Jan Hesters
CTO, ReactSquad
What's up, this is Jan, CTO of ReactSquad. After studying physics, I ventured into the startup world and became a programmer. As the 7th employee at Hopin, I helped grow the company from a $6 million to a $7.7 billion valuation until it was partly sold in 2023.

Get actionable tips from the ReactSquad team

5-Minute Read. Every Tuesday. For Free