javascript - Fördelar/nackdelar med att använda redux-saga med ES6-generators jämfört med redux-thunk med ES2017 async/await


Det pratas mycket om den senaste nykomlingen i redux-staden just nu, redux-saga/redux-saga. Den använder generatorfunktioner för att lyssna på/skicka handlingar.

Innan jag begriper det helt, skulle jag vilja veta för- och nackdelarna med att använda redux-saga istället för tillvägagångssättet nedan där jag använder redux-thunk med async/await.

En komponent kan se ut så här, skicka åtgärder som vanligt.

import { login } from 'redux/auth';

class LoginForm extends Component {

  onClick(e) {
    e.preventDefault();
    const { user, pass } = this.refs;
    this.props.dispatch(login(user.value, pass.value));
  }

  render() {
    return (<div>
        <input type="text" ref="user" />
        <input type="password" ref="pass" />
        <button onClick={::this.onClick}>Sign In</button>
    </div>);
  } 
}

export default connect((state) => ({}))(LoginForm);

Då ser mina handlingar ut ungefär så här:

// auth.js

import request from 'axios';
import { loadUserData } from './user';

// define constants
// define initial state
// export default reducer

export const login = (user, pass) => async (dispatch) => {
    try {
        dispatch({ type: LOGIN_REQUEST });
        let { data } = await request.post('/login', { user, pass });
        await dispatch(loadUserData(data.uid));
        dispatch({ type: LOGIN_SUCCESS, data });
    } catch(error) {
        dispatch({ type: LOGIN_ERROR, error });
    }
}

// more actions...
// user.js

import request from 'axios';

// define constants
// define initial state
// export default reducer

export const loadUserData = (uid) => async (dispatch) => {
    try {
        dispatch({ type: USERDATA_REQUEST });
        let { data } = await request.get(`/users/${uid}`);
        dispatch({ type: USERDATA_SUCCESS, data });
    } catch(error) {
        dispatch({ type: USERDATA_ERROR, error });
    }
}

// more actions...

Svar #1

I redux-saga skulle motsvarigheten till exemplet ovan vara

export function* loginSaga() {
  while(true) {
    const { user, pass } = yield take(LOGIN_REQUEST)
    try {
      let { data } = yield call(request.post, '/login', { user, pass });
      yield fork(loadUserData, data.uid);
      yield put({ type: LOGIN_SUCCESS, data });
    } catch(error) {
      yield put({ type: LOGIN_ERROR, error });
    }  
  }
}

export function* loadUserData(uid) {
  try {
    yield put({ type: USERDATA_REQUEST });
    let { data } = yield call(request.get, `/users/${uid}`);
    yield put({ type: USERDATA_SUCCESS, data });
  } catch(error) {
    yield put({ type: USERDATA_ERROR, error });
  }
}

Det första att märka är att vi anropar API-funktionerna med formen yield call(func, ...args). call kör inte effekten, det skapar bara ett vanligt objekt som {type: 'CALL', func, args}. Utförandet delegeras till redux-saga-middleware som tar hand om att köra funktionen och återuppta generatorn med dess resultat.

Huvudfördelen är att du kan testa generatorn utanför Redux med enkla likhetskontroller

const iterator = loginSaga()

assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST))

// resume the generator with some dummy action
const mockAction = {user: '...', pass: '...'}
assert.deepEqual(
  iterator.next(mockAction).value, 
  call(request.post, '/login', mockAction)
)

// simulate an error result
const mockError = 'invalid user/password'
assert.deepEqual(
  iterator.throw(mockError).value, 
  put({ type: LOGIN_ERROR, error: mockError })
)

Observera att vi låtsas att api-anropsresultatet genom att helt enkelt injicera de simulerade uppgifterna i next-metoden för iteratorn. Att simulera data är mycket enklare än att simulera funktioner.

Den andra saken att lägga märke till är anropet till yield take(ACTION). Thunks kallas av action-skaparen vid varje ny action (t.ex. LOGIN_REQUEST). Det vill säga, actions skickas kontinuerligt till thunks, och thunks har ingen kontroll över när de ska sluta hantera dessa actions.

I redux-saga drar generatorer nästa åtgärd. Det vill säga, de har kontroll över när de ska lyssna på en viss åtgärd och när de inte ska göra det. I exemplet ovan placeras flödesinstruktionerna inom en while(true)-loop, så den kommer att lyssna på varje inkommande åtgärd, vilket på sätt och vis efterliknar thunks push-beteende.

Pull-metoden möjliggör implementering av komplexa styrflöden. Anta till exempel att vi vill lägga till följande krav

  • Hantera LOGOUT-användaråtgärd

  • vid den första lyckade inloggningen returnerar servern en token som går ut efter en viss tid lagrad i ett expires_in-fält. Vi måste uppdatera auktoriseringen i bakgrunden vid varje expires_in millisekunder

  • Ta hänsyn till att när man väntar på resultatet av API-anrop (antingen initial inloggning eller uppdatering) kan användaren logga ut under tiden.

Hur skulle du implementera det med thunks, samtidigt som du tillhandahåller full testtäckning för hela flödet? Här är hur det kan se ut med Sagas:

function* authorize(credentials) {
  const token = yield call(api.authorize, credentials)
  yield put( login.success(token) )
  return token
}

function* authAndRefreshTokenOnExpiry(name, password) {
  let token = yield call(authorize, {name, password})
  while(true) {
    yield call(delay, token.expires_in)
    token = yield call(authorize, {token})
  }
}

function* watchAuth() {
  while(true) {
    try {
      const {name, password} = yield take(LOGIN_REQUEST)

      yield race([
        take(LOGOUT),
        call(authAndRefreshTokenOnExpiry, name, password)
      ])

      // user logged out, next while iteration will wait for the
      // next LOGIN_REQUEST action

    } catch(error) {
      yield put( login.error(error) )
    }
  }
}

I exemplet ovan uttrycker vi vårt samtidighetskrav med race. Om take(LOGOUT) vinner racet (dvs. användaren klickade på en Logout-knapp). Kommer racet automatiskt att avbryta bakgrundsuppgiften authAndRefreshTokenOnExpiry. Och om authAndRefreshTokenOnExpiry var blockerad mitt i ett call(authorize, {token})-anrop kommer det också att avbrytas. Avbrytning sprids automatiskt nedåt.

Du kan hitta en körbar demo av ovanstående flöde

Svar #2

Jag kommer att lägga till min erfarenhet av att använda saga i ett produktionssystem utöver bibliotekets författares ganska grundliga svar.

Pro (använder saga):

  • Testbarhet. Det är väldigt lätt att testa sagor eftersom call() returnerar ett rent objekt. Att testa thunks kräver normalt att du inkluderar en mockStore i ditt test.

  • redux-saga levereras med många användbara hjälpfunktioner för uppgifter. Det verkar för mig som att konceptet med saga är att skapa någon form av bakgrundsarbetare/tråd för din app, som fungerar som en saknad del i react redux-arkitekturen (actionCreators och reducers måste vara rena funktioner). Vilket leder till nästa punkt.

  • Sagor erbjuder en oberoende plats för att hantera alla bieffekter. Det är vanligtvis lättare att modifiera och hantera än thunk-åtgärder enligt min erfarenhet.

Nackdel :

  • Generatorsyntax.

  • Många begrepp att lära sig.

  • API-stabilitet. Det verkar som att redux-saga fortfarande lägger till funktioner (t.ex. Channels?) och gemenskapen är inte lika stor. Det finns en oro för om biblioteket någon dag gör en uppdatering som inte är bakåtkompatibel.

Svar #3

Jag skulle bara vilja lägga till några kommentarer från min personliga erfarenhet (med både sagas och thunk):

Sagor är bra att testa:

  • Du behöver inte mocka funktioner omslutna av effekter
  • Därför är tester rena, läsbara och lätta att skriva
  • När man använder sagor returnerar action creators mestadels vanliga objektliteraler. Det är också lättare att testa och göra påståenden jämfört med thunks löften.

Sagor är mer kraftfulla. Allt du kan göra i en thunks action creator kan du också göra i en saga, men inte tvärtom (eller åtminstone inte så lätt). Till exempel:

  • vänta på att en åtgärd/åtgärder ska skickas (take)
  • avbryt befintlig rutin (cancel, takeLatest, race)
  • flera rutiner kan lyssna på samma åtgärd (take, takeEvery, ...)

Sagas erbjuder också annan användbar funktionalitet, som generaliserar några vanliga applikationsmönster:

  • kanaler att lyssna på externa händelsekällor (t.ex. websockets)
  • forkmodell (fork, spawn)
  • gasreglage
  • ...

Sagor är ett stort och kraftfullt verktyg. Men med makten följer ansvar. När din applikation växer kan du lätt gå vilse när du försöker ta reda på vem som väntar på att åtgärden ska skickas, eller vad som händer när någon åtgärd skickas. Å andra sidan är thunk enklare och lättare att resonera om. Att välja det ena eller det andra beror på många aspekter som typ och storlek på projektet, vilka typer av bieffekter ditt projekt måste hantera eller utvecklingsteamets preferenser. I alla fall, håll bara din applikation enkel och förutsägbar.

Svar #4

Uppdatering i juli 2020:

Under de senaste 16 månaderna är kanske den mest anmärkningsvärda förändringen i React-communityn React hooks.

Enligt vad jag observerar, för att uppnå bättre kompatibilitet med funktionella komponenter och hooks, tenderar projekt (även stora sådana) att använda:

  1. hook + async thunk (hook gör allt väldigt flexibelt så att du faktiskt kan placera async thunk där du vill och använda det som vanliga funktioner, till exempel, skriva fortfarande thunk i action.ts och sedan använda useDispatch() för att trigga thunk: https://stackoverflow.com/a/59991104/5256695),
  2. useRequest,
  3. GraphQL/Apollo useQuery useMutation
  4. react-hämtnings-bibliotek
  5. andra populära val av bibliotek för datainhämtning/API-anrop, verktyg, designmönster, etc

I jämförelse ger redux-saga inte riktigt någon betydande fördel i de flesta normala fall av API-anrop jämfört med ovanstående metoder för tillfället, samtidigt som projektkomplexiteten ökar genom att införa många saga-filer/generatorer (också eftersom den senaste versionen v1.1.1 av redux-saga var den 18 sep 2019, vilket var för länge sedan).

Men ändå erbjuder redux-saga några unika funktioner som racing-effekt och parallella förfrågningar. Därför, om du behöver dessa speciella funktioner, är redux-saga fortfarande ett bra val.


Originalinlägg i mars 2019:

Bara lite personlig erfarenhet:

  1. För kodningsstil och läsbarhet är en av de mest betydande fördelarna med att använda redux-saga tidigare att undvika callback-helvetet i redux-thunk - man behöver inte längre använda många nästlade then/catch. Men nu, med async/await-thunks popularitet, kan man också skriva asynkron kod i synkron stil när man använder redux-thunk, vilket kan betraktas som en förbättring i redux-thunk.

  2. Man kan behöva skriva mycket mer standardkod när man använder redux-saga, särskilt i Typescript. Till exempel, om man vill implementera en asynkron fetch-funktion, kan data- och felhantering direkt utföras i en enda thunk-enhet i action.js med en enda FETCH-åtgärd. Men i redux-saga kan man behöva definiera FETCH_START, FETCH_SUCCESS och FETCH_FAILURE-åtgärder och alla deras relaterade typkontroller, eftersom en av funktionerna i redux-saga är att använda denna typ av rika "token"-mekanism för att skapa effekter och instruera redux-storen för enkel testning. Självklart kan man skriva en saga utan att använda dessa åtgärder, men det skulle göra den lik en thunk.

  3. När det gäller filstrukturen verkar redux-saga vara mer uttrycklig i många fall. Man skulle enkelt kunna hitta asynkron relaterad kod i varje sagas.ts, men i redux-thunk skulle man behöva se den i actions.

  4. Enkel testning kan vara en annan viktig funktion i redux-saga. Detta är verkligen bekvämt. Men en sak som behöver klargöras är att redux-saga "call" test inte skulle utföra ett faktiskt API-anrop under testning, vilket innebär att man måste specificera det exempelresultat för de steg som kan användas efter API-anropet. Därför, innan man skriver i redux-saga, vore det bättre att planera en saga och dess motsvarande sagas.spec.ts i detalj.

  5. Redux-saga erbjuder också många avancerade funktioner, såsom att köra uppgifter parallellt, samtidighethjälpmedel som takeLatest/takeEvery, fork/spawn, som är mycket kraftfullare än thunks.

Avslutningsvis skulle jag personligen vilja säga: i många normala fall och små till medelstora appar, välj async/await-stilen med redux-thunk. Det skulle spara dig mycket boilerplate-kod/åtgärder/typedefs, och du skulle inte behöva hoppa mellan många olika sagas.ts och underhålla ett specifikt sagas-träd. Men om du utvecklar en stor app med mycket komplex asynkron logik och behov av funktioner som samtidighet/parallellmönster, eller har hög efterfrågan på testning och underhåll (särskilt i testdriven utveckling), skulle redux-sagas möjligen kunna rädda dig.

Hur som helst, redux-saga är inte mer svårt och komplext än redux självt, och det har inte en så kallad brant inlärningskurva eftersom det har välbegränsade kärnkoncept och API:er. Att spendera en liten mängd tid på att lära sig redux-saga kan gynna dig en dag i framtiden.

Relaterade artiklar