-->

Pros/cons of using redux-saga with ES6 generators

2019-01-08 03:10发布

问题:

There is a lot of talk about the latest kid in redux town right now, redux-saga/redux-saga. It uses generator functions for listening to/dispatching actions.

Before I wrap my head around it, I would like to know the pros/cons of using redux-saga instead of the approach below where I'm using redux-thunk with async/await.

A component might look like this, dispatch actions like usual.

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

Then my actions look something like this:

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

回答1:

In redux-saga, the equivalent of the above example would be

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

The first thing to notice is that we're calling the api functions using the form yield call(func, ...args). call doesn't execute the effect, it just creates a plain object like {type: 'CALL', func, args}. The execution is delegated to the redux-saga middleware which takes care of executing the function and resuming the generator with its result.

The main advantage is that you can test the generator outside of Redux using simple equality checks

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

Note we're mocking the api call result by simply injecting the mocked data into the next method of the iterator. Mocking data is way simpler than mocking functions.

The second thing to notice is the call to yield take(ACTION). Thunks are called by the action creator on each new action (e.g. LOGIN_REQUEST). i.e. actions are continually pushed to thunks, and thunks have no control on when to stop handling those actions.

In redux-saga, generators pull the next action. i.e. they have control when to listen for some action, and when to not. In the above example the flow instructions are placed inside a while(true) loop, so it'll listen for each incoming action, which somewhat mimics the thunk pushing behavior.

The pull approach allows implementing complex control flows. Suppose for example we want to add the following requirements

  • Handle LOGOUT user action

  • upon the first successful login, the server returns a token which expires in some delay stored in a expires_in field. We'll have to refresh the authorization in the background on each expires_in milliseconds

  • Take into account that when waiting for the result of api calls (either initial login or refresh) the user may logout in-between.

How would you implement that with thunks; while also providing full test coverage for the entire flow? Here is how it may look with 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) )
    }
  }
}

In the above example, we're expressing our concurrency requirement using race. If take(LOGOUT) wins the race (i.e. user clicked on a Logout Button). The race will automatically cancel the authAndRefreshTokenOnExpiry background task. And if the authAndRefreshTokenOnExpiry was blocked in middle of a call(authorize, {token}) call it'll also be cancelled. Cancellation propagates downward automatically.

You can find a runnable demo of the above flow



回答2:

I will add my experience using saga in production system in addition to the library author's rather thorough answer.

Pro (using saga):

  • Testability. It's very easy to test sagas as call() returns a pure object. Testing thunks normally requires you to include a mockStore inside your test.

  • redux-saga comes with lots of useful helper functions about tasks. It seems to me that the concept of saga is to create some kind of background worker/thread for your app, which act as a missing piece in react redux architecture(actionCreators and reducers must be pure functions.) Which leads to next point.

  • Sagas offer independent place to handle all side effects. It is usually easier to modify and manage than thunk actions in my experience.

Con:

  • Generator syntax.

  • Lots of concepts to learn.

  • API stability. It seems redux-saga is still adding features (eg Channels?) and the community is not as big. There is a concern if the library makes a non backward compatible update some day.



回答3:

I'd just like to add some comments from my personal experience (using both sagas and thunk):

Sagas are great to test:

  • You don't need to mock functions wrapped with effects
  • Therefore tests are clean, readable and easy to write
  • When using sagas, action creators mostly return plain object literals. It is also easier to test and assert unlike thunk's promises.

Sagas are more powerful. All what you can do in one thunk's action creator you can also do in one saga, but not vice versa (or at least not easily). For example:

  • wait for an action/actions to be dispatched (take)
  • cancel existing routine (cancel, takeLatest, race)
  • multiple routines can listen to the same action (take, takeEvery, ...)

Sagas also offers other useful functionality, which generalize some common application patterns:

  • channels to listen on external event sources (e.g. websockets)
  • fork model (fork, spawn)
  • throttle
  • ...

Sagas are great and powerful tool. However with the power comes responsibility. When your application grows you can get easily lost by figuring out who is waiting for the action to be dispatched, or what everything happens when some action is being dispatched. On the other hand thunk is simpler and easier to reason about. Choosing one or another depends on many aspects like type and size of the project, what types of side effect your project must handle or dev team preference. In any case just keep your application simple and predictable.



回答4:

Having reviewed a few different large scale React/Redux projects in my experience Sagas provide developers a more structured way of writing code that is much easier to test and harder to get wrong.

Yes it is a little wierd to start with, but most devs get enough of an understanding of it in a day. I always tell people to not worry about what yield does to start with and that once you write a couple of test it will come to you.

I have seen a couple of projects where thunks have been treated as if they are controllers from the MVC patten and this quickly becomes an unmaintable mess.

My advice is to use Sagas where you need A triggers B type stuff relating to a single event. For anything that could cut across a number of actions, I find it is simpler to write customer middleware and use the meta property of an FSA action to trigger it.



回答5:

Here's a project that combines the best parts (pros) of both redux-saga and redux-thunk: you can handle all side-effects on sagas while getting a promise by dispatching the corresponding action: https://github.com/diegohaz/redux-saga-thunk

class MyComponent extends React.Component {
  componentWillMount() {
    // `doSomething` dispatches an action which is handled by some saga
    this.props.doSomething().then((detail) => {
      console.log('Yaay!', detail)
    }).catch((error) => {
      console.log('Oops!', error)
    })
  }
}


回答6:

An easier way is to use redux-auto.

from the documantasion

redux-auto fixed this asynchronous problem simply by allowing you to create an "action" function that returns a promise. To accompany your "default" function action logic.

  1. No need for other Redux async middleware. e.g. thunk, promise-middleware, saga
  2. Easily allows you to pass a promise into redux and have it managed for you
  3. Allows you to co-locate external service calls with where they will be transformed
  4. Naming the file "init.js" will call it once at app start. This is good for loading data from the server at start

The idea is to have each action in a specific file. co-locating the server call in the file with reducer functions for "pending", "fulfilled" and "rejected". This makes handling promises very easy.

It also automatically attaches a helper object(called "async") to the prototype of your state, allowing you to track in your UI, requested transitions.



回答7:

One quick note. Generators are cancellable, async/await — not. So for an example from the question, it does not really make sense of what to pick. But for more complicated flows sometimes there is no better solution than using generators.

So, another idea could be is to use generators with redux-thunk, but for me, it seems like trying to invent a bicycle with square wheels.

And of course, generators are easier to test.