javascript - Avantaje/dezavantaje ale utilizării redux-saga cu generatoare ES6 versus redux-thunk cu async/await în ES2017


Se vorbește mult despre noul copil din orașul redux chiar acum, redux-saga/redux-saga. Folosește funcții generator pentru a asculta/trimite acțiuni.

Înainte să înțeleg complet acest lucru, aș dori să cunosc avantajele și dezavantajele utilizării redux-saga în loc de abordarea de mai jos, unde folosesc redux-thunk cu async/await.

Un component ar putea arăta astfel, trimite acțiuni ca de obicei.

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);

Atunci acțiunile mele arată cam așa:

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

Răspuns #1

În redux-saga, echivalentul exemplului de mai sus ar fi

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 });
  }
}

Primul lucru de observat este că apelăm funcțiile api folosind forma yield call(func, ...args). call nu execută efectul, ci doar creează un obiect simplu ca {type: 'CALL', func, args}. Executarea este delegată middleware-ului redux-saga care se ocupă de executarea funcției și de reluarea generatorului cu rezultatul ei.

Principalul avantaj este că poți testa generatorul în afara Redux folosind verificări simple de egalitate

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 })
)

Observați că simulăm rezultatul apelului API prin injectarea simplă a datelor simulate în metoda next a iteratorului. Simularea datelor este mult mai simplă decât simularea funcțiilor.

Al doilea lucru de observat este apelul la yield take(ACTION). Thunk-urile sunt apelate de creatorul de acțiuni la fiecare acțiune nouă (de exemplu, LOGIN_REQUEST). Adică acțiunile sunt continuu împinse către thunk-uri, iar thunk-urile nu au control asupra momentului în care să înceteze să gestioneze aceste acțiuni.

În redux-saga, generatoarele trag următoarea acțiune. Adică ele controlează când să asculte pentru o anumită acțiune și când să nu o facă. În exemplul de mai sus, instrucțiunile fluxului sunt plasate într-un ciclu while(true), așa că va asculta pentru fiecare acțiune primită, ceea ce imită într-o oarecare măsură comportamentul de împingere al thunk-ului.

Abordarea de tip pull permite implementarea fluxurilor de control complexe. Să presupunem, de exemplu, că dorim să adăugăm următoarele cerințe

  • Gestionați acțiunea de deconectare a utilizatorului

  • la prima autentificare reușită, serverul returnează un token care expiră după un anumit interval stocat într-un câmp expires_in. Va trebui să reîmprospătăm autorizarea în fundal la fiecare expires_in milisecunde

  • Ține cont că, atunci când aștepți rezultatul apelurilor API (fie la autentificarea inițială, fie la reîmprospătare), utilizatorul se poate deconecta între timp.

Cum ai implementa asta cu thunks; oferind în același timp o acoperire completă a testelor pentru întregul flux? Iată cum ar putea arăta cu 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) )
    }
  }
}

În exemplul de mai sus, ne exprimăm cerința de concurență folosind race. Dacă take(LOGOUT) câștigă cursa (adică utilizatorul a făcut clic pe butonul de Logout). Cursa va anula automat sarcina de fundal authAndRefreshTokenOnExpiry. Și dacă authAndRefreshTokenOnExpiry era blocată în timpul unui apel call(authorize, {token}), și acesta va fi anulată. Anularea se propagă automat în jos.

Puteți găsi un demo executabil al fluxului de mai sus

Răspunsul #2

Voi adăuga experiența mea folosind saga în sistemul de producție, pe lângă răspunsul destul de complet al autorului bibliotecii.

Pro (folosind saga):

  • Testabilitate. Este foarte ușor să testezi saga-urile deoarece call() returnează un obiect pur. Testarea thunk-urilor necesită de obicei să incluzi un mockStore în testul tău.

  • redux-saga vine cu o mulțime de funcții ajutătoare utile despre task-uri. Mi se pare că conceptul de saga este să creeze un fel de worker/thread de fundal pentru aplicația ta, care acționează ca o piesă lipsă în arhitectura react redux (actionCreators și reducer-urile trebuie să fie funcții pure). Ceea ce conduce la următorul punct.

  • Saga-urile oferă un loc independent pentru a gestiona toate efectele secundare. Experiența mea arată că este de obicei mai ușor de modificat și de gestionat decât acțiunile thunk.

Contra :

  • Sintaxa generatorului.

  • O mulțime de concepte de învățat.

  • Stabilitatea API-ului. Se pare că redux-saga încă adaugă funcționalități (de exemplu, Channels?) și comunitatea nu este atât de mare. Există o îngrijorare dacă biblioteca va face vreodată o actualizare care nu este compatibilă cu versiunile anterioare.

Răspunsul #3

Aș dori doar să adaug câteva comentarii din experiența mea personală (folosind atât sagas, cât și thunk):

Sagele sunt grozave pentru a testa:

  • Nu trebuie să simulezi funcțiile învelite cu efecte
  • Prin urmare, testele sunt curate, ușor de citit și ușor de scris
  • Când se folosesc saga, creatorii de acțiuni returnează în mare parte literali de obiecte simple. De asemenea, este mai ușor de testat și de afirmat, spre deosebire de promisiunile thunk.

Sagele sunt mai puternice. Tot ce poți face într-un creator de acțiuni thunk poți face și într-o saga, dar nu invers (sau cel puțin nu ușor). De exemplu:

  • așteptați ca o acțiune/acțiuni să fie trimise (take)
  • anulează rutina existentă (cancel, takeLatest, race)
  • mai multe rutine pot asculta aceeași acțiune (take, takeEvery, ...)

Sagas oferă, de asemenea, și alte funcționalități utile, care generalizează unele tipare comune ale aplicațiilor:

  • canale pentru a asculta sursele de evenimente externe (de exemplu, websockets)
  • model fork (fork, spawn)
  • accelerator
  • ...

Sagas sunt un instrument grozav și puternic. Totuși, odată cu puterea vine responsabilitatea. Când aplicația ta crește, te poți pierde cu ușurință încercând să înțelegi cine așteaptă ca o acțiune să fie declanșată sau ce se întâmplă exact când o acțiune este declanșată. Pe de altă parte, thunk este mai simplu și mai ușor de înțeles. Alegerea unuia sau altuia depinde de mulți factori, cum ar fi tipul și dimensiunea proiectului, ce tipuri de efecte secundare trebuie gestionate de proiect sau preferința echipei de dezvoltare. În orice caz, păstrează-ți aplicația simplă și previzibilă.

Răspunsul #4

Actualizare în iulie 2020:

În ultimele 16 luni, poate cea mai notabilă schimbare în comunitatea React este React hooks.

Conform observațiilor mele, pentru a obține o compatibilitate mai bună cu componentele funcționale și hook-urile, proiectele (chiar și cele mari) tind să folosească:

  1. hook + async thunk (hook-ul face totul foarte flexibil astfel încât ai putea de fapt să plasezi async thunk oriunde vrei și să îl folosești ca funcții normale, de exemplu, încă poți scrie thunk în action.ts și apoi folosești useDispatch() pentru a declanșa thunk-ul: https://stackoverflow.com/a/59991104/5256695),
  2. foloseșteCerere,
  3. GraphQL/Apollo useQuery useMutation
  4. biblioteca-react-fetching
  5. alte alegeri populare de biblioteci pentru preluarea datelor/apeluri API, unelte, modele de design etc.

În comparație, redux-saga nu oferă cu adevărat un beneficiu semnificativ în majoritatea cazurilor normale de apeluri API comparativ cu abordările de mai sus până acum, în timp ce crește complexitatea proiectului prin introducerea multor fișiere/generatoare saga (de asemenea, deoarece ultima versiune v1.1.1 a redux-saga a fost pe 18 septembrie 2019, ceea ce a fost acum mult timp).

Dar totuși, redux-saga oferă câteva funcționalități unice, cum ar fi efectul de cursă și cererile paralele. Prin urmare, dacă ai nevoie de aceste funcționalități speciale, redux-saga rămâne o alegere bună.


Postare originală din martie 2019:

Doar câteva experiențe personale:

  1. Pentru stilul de codare și lizibilitate, unul dintre cele mai semnificative avantaje ale utilizării redux-saga în trecut a fost evitarea iadului callback-urilor în redux-thunk — nu mai este nevoie să folosești multe then/catch cu imbricare. Dar acum, odată cu popularitatea async/await în thunk, se poate scrie și cod asincron într-un stil sincron folosind redux-thunk, ceea ce poate fi considerat o îmbunătățire în redux-thunk.

  2. Este posibil să fie nevoie să scrieți mult mai mult cod standard atunci când utilizați redux-saga, mai ales în Typescript. De exemplu, dacă cineva dorește să implementeze o funcție asincronă de tip fetch, gestionarea datelor și a erorilor ar putea fi realizată direct într-o singură unitate thunk în action.js cu o singură acțiune FETCH. Dar în redux-saga, este posibil să fie nevoie să definiți acțiuni FETCH_START, FETCH_SUCCESS și FETCH_FAILURE și toate verificările de tip asociate, deoarece una dintre funcționalitățile redux-saga este să folosească acest tip de mecanism bogat de „token” pentru a crea efecte și a instrui magazinul redux pentru testare ușoară. Desigur, se poate scrie o saga fără a folosi aceste acțiuni, dar asta ar face-o similară cu un thunk.

  3. În ceea ce privește structura fișierelor, redux-saga pare să fie mai explicit în multe cazuri. Cineva ar putea găsi cu ușurință un cod legat de asincron în fiecare fișier sagas.ts, dar în redux-thunk, ar fi necesar să-l vadă în actions.

  4. Testarea ușoară poate fi o altă caracteristică ponderată în redux-saga. Acest lucru este cu adevărat convenabil. Dar un lucru care trebuie clarificat este că testul redux-saga „call” nu va efectua apeluri reale API în timpul testării, astfel că va fi necesar să se specifice rezultatul de eșantion pentru pașii care pot fi utilizați după apelul API. Prin urmare, înainte de a scrie în redux-saga, ar fi mai bine să planificați o saga și sagas.spec.ts corespunzătoare în detaliu.

  5. Redux-saga oferă, de asemenea, multe funcționalități avansate, cum ar fi rularea sarcinilor în paralel, ajutoare pentru concurență precum takeLatest/takeEvery, fork/spawn, care sunt mult mai puternice decât thunk-urile.

În concluzie, personal, aș dori să spun: în multe cazuri normale și aplicații de dimensiuni mici și medii, folosiți stilul async/await cu redux-thunk. Vă va economisi multe coduri/actțiuni/typedef-uri repetitive și nu va trebui să comutați între multe fișiere diferite sagas.ts și să mențineți un arbore specific de sagas. Dar dacă dezvoltați o aplicație mare cu logică asincronă mult mai complexă și cu necesitatea unor funcționalități precum concurența/pattern-ul paralel, sau aveți o cerere mare pentru testare și mentenanță (în special în dezvoltarea bazată pe teste), redux-sagas ar putea să vă salveze viața.

Oricum, redux-saga nu este mai dificil și complex decât redux în sine și nu are așa-numita curbă de învățare abruptă deoarece are concepte și API-uri de bază bine delimitate. Petrecerea unui mic timp pentru a învăța redux-saga poate să-ți fie de folos într-o zi în viitor.