Skip to main content

redux-saga

Free2017-10-13#JS#redux saga effect#redux saga join#redux saga教程#redux saga入门

Completely understand redux-saga

I. Target Positioning

redux-saga is a library that aims to make side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) in React/Redux applications easier and better.

As a Redux middleware, wants to make side effects in Redux applications (i.e. impure parts that depend on/affect external environment) easier to handle more elegantly

II. Design Philosophy

Saga acts like an independent thread, specifically responsible for handling side effects, multiple Sagas can be combined serially/in parallel, redux-saga is responsible for scheduling management

Saga has significant background (1W stars is not for nothing), it's a distributed transaction mechanism proposed in a paper, used to manage long-running business processes

P.S. For more information about Saga background, please check Background on the Saga concept

III. Core Implementation

Utilize generator to make asynchronous flow control readable, elegant, easy to test

In redux-saga, Sagas are implemented using Generator functions. To express the Saga logic we yield plain JavaScript Objects from the Generator.

In implementation, key points are:

  • Organize logic sequences in generator form (function* + yield), split a series of serial/parallel operations through yield

  • Utilize iterator's "pause/resume" characteristics (iter.next()) to execute step by step

  • Influence internal state through iterator (iter.next(result)), inject asynchronous operation results

  • Utilize iterator's error capture characteristics (iter.throw(error)), inject asynchronous operation exceptions

Use generator/iterator to implement because it's very suitable for flow control scenarios, reflected in:

  • yield makes describing serial/parallel asynchronous operations very elegant

  • Get asynchronous operation results in synchronous form, more in line with sequential execution intuition

  • Capture asynchronous errors in synchronous form, elegantly capture asynchronous errors

P.S. For relationship between generator and iterator and generator basic usage, can reference [generator (Generator)_ES6 Notes 2](/articles/generator(生成器)-es6 笔记 2/)

For example:

const ts = Date.now();
function asyncFn(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`${id} at ${Date.now() - ts}`);
            resolve(id);
        }, 1000);
    });
}

function* gen() {
    // Serial asynchronous
    let A = yield asyncFn('A');
    console.log(A);
    let B = yield asyncFn('B');
    console.log(B);
    // Parallel asynchronous
    let C = yield Promise.all([asyncFn('C1'), asyncFn('C2')]);
    console.log(C);
    // Serial/parallel combined asynchronous
    let D = yield Promise.all([
        asyncFn('D1-1').then(() => {
            return asyncFn('D1-2');
        }),
        asyncFn('D2')
    ]);
    console.log(D);
}

// test
let iter = gen();
// Tail trigger sequential execution iter.next
let next = function(prevResult) {
    let {value: result, done} = iter.next(prevResult);
    if (result instanceof Promise) {
        result.then((res) => {
            if (!done) next(res);
        }, (err) => {
            iter.throw(err);
        });
    }
    else {
        if (!done) next(result);
    }
};
next();

Actual result meets expectations:

A at 1002
A
B at 2012
B
C1 at 3015
C2 at 3015
["C1", "C2"]
D1-1 at 4019
D2 at 4020
D1-2 at 5022
["D1-2", "D2"]

Execution order is: A -> B -> C1,C2 -> D1-1 -> D2 -> D1-2

redux-saga's core control part is similar to above example (yes, just like co), from implementation perspective, its asynchronous control key is tail trigger sequential execution iter.next. Example didn't add Effect layer description object, from functionality perspective Effect is not important (Effect's function see terminology concepts section below)

Effect layer needs to implement things including 2 parts:

  • Business operations -> Effect

Provided in Effect creator API form, provides various semantic tool functions for generating Effects, such as wrapping dispatch action into put, wrapping method calls into call/apply

  • Effect -> Business operations

Convert internally during execution, such as converting [Effect1, Effect2] to parallel calls

Similar to boxing (wrap business operations with Effect) unboxing (execute business operations inside Effect), additionally, complete redux-saga also needs to implement:

  • Access to Redux as middleware

  • Provide interfaces for reading/writing Redux state (select/put)

  • Provide interfaces for listening to actions (take/takeEvery/takeLatest)

  • Sagas combination, communication

  • task sequential control, cancellation

  • action concurrency control

  • ...

Almost a comprehensive asynchronous flow control library, from implementation perspective, equivalent to an enhanced version of co

IV. Terminology Concepts

Effect

Effect refers to description object, equivalent to redux-saga middleware recognizable operation instructions, such as calling specified business method (call(myFn)), dispatch specified action (put(action))

An Effect is simply an object which contains some information to be interpreted by the middleware.

Main significance of Effect layer existence is for testability, so use simple description objects to represent operations, add this layer of instructions

Although can directly yield Promise (such as in above core implementation example), but in test cases cannot compare whether two promises are equivalent. So add a layer of description object to solve this problem, in test cases can simply compare description objects, actual functioning Promises are generated internally by redux-saga

Benefit of doing this is unit tests don't need to mock asynchronous methods (generally in unit tests all asynchronous methods will be replaced, only compare whether input parameters are same, without actual operations), can simply compare operation instructions (Effect) whether equivalent. From unit testing perspective, Effect is equivalent to extracting parameters, lets "compare whether input parameters are same" step can be performed uniformly outside, without needing to mock replace one by one

P.S. For more information about testability, please check Testing Sagas

Additionally, mock testing is not only troublesome, but also unreliable, after all there are differences from real scenarios/processes. Through framework constraints, add a layer of description objects to avoid mock

Doing this is not entirely perfect, still exists 2 problems:

  • Business code slightly troublesome (not directly yield promise/dispatch action, but all need to use framework provided creators (call, put) to wrap)

  • Has extra learning cost (understand semantics of various creators, adapt to wrapping one layer first gameplay)

For example:

// Direct
const userInfo = yield API.fetch('user/info', userId);

// Wrap one layer creator
const userInfo = yield call(API.fetch, 'user/info', userId);
// And specify context, default is null
const userInfo = yield call([myContext, API.fetch], 'user/info', userId);

Formally similar to fn.call (actually also provides an apply creator, form similar to fn.apply), internal processing is also similar:

// Description object (Effect) returned by call
{
    @@redux-saga/IO: true,
    CALL: {
        args: ["user/info", userId],
        context: myContext,
        fn: fetch
    }
}

// Actual execution
result = fn.apply(context, args)

Writing is not so direct, but compared to benefits brought by testability (no need to mock asynchronous functions), this is not excessive

Note, no need to mock asynchronous functions only simplifies one link of unit testing, even using this description object comparison approach, still need to provide expected data, for example:

// Test scenario direct execution
const iterator = fetchProducts()

// expects a call instruction
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products'),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

// Expected interface return data
const products = {}

// expects a dispatch instruction
assert.deepEqual(
  iterator.next(products).value,
  put({ type: 'PRODUCTS_RECEIVED', products }),
  "fetchProducts should yield an Effect put({ type: 'PRODUCTS_RECEIVED', products })"
)

P.S. This description object pattern, is exactly like Flux/Redux's action: Effect is equivalent to Action, Effect creator is equivalent to Action Creator. Difference is Flux uses action to describe messages (what happened), while redux-saga uses Effect to describe operation instructions (what to do)

Effect creator

redux-saga/effects provides many tool methods for generating Effects. Commonly used Effect creators are as follows:

Most creator semantics are very straightforward, only one needs extra explanation:

  • join is used to get return results of non-blocking tasks

Among them fork and spawn are both non-blocking method calls, differences between the two are:

  • task executed through spawn is completely independent, unrelated to current saga

Current saga doesn't care whether it finished execution or not, occurring cancel/error also won't affect current saga

Effect is equivalent to letting specified task execute independently at top level, similar to middleware.run(rootSaga)

  • task executed through fork is related to current saga

Saga where fork is located will wait for forked task, only after all forked tasks finish execution, current saga will end

fork execution mechanism is completely consistent with all, including cancel and error propagation methods, so if any task has uncaught error, current saga will also end

Additionally, cancel mechanism is quite interesting:

For task sequences being executed, when all tasks naturally complete, pass results upward to queue head, as return value of some yield at upper level. If task sequence is cancelled during processing, will pass cancel signal downward, cancel execution of all pending tasks. Additionally, will also pass cancel signal upward along join chain, cancel execution of all tasks depending on this task

In short: complete signal propagates reversely along call chain, while cancel signal propagates forward along task chain, propagates reversely along join chain

Note: yield cancel(task) is also non-blocking (similar to fork), and tasks that are cancelled will return immediately after completing cleanup logic

P.S. Establish dependency relationship through join (get task results), for example:

function* rootSaga() {
  // Returns immediately with a Task object
  const task = yield spawn(serverHello, 'world');

  // Perform an effect in the meantime
  yield call(console.log, "waiting on server result...");

  // Block on the result of serverHello
  const result = yield join(task);
}

Saga

Term Saga refers to a collection of a series of operations, is a runtime abstract concept

Saga in redux-saga is formally a generator, used to describe a set of operations, while generator is a concrete static concept

P.S. Saga mentioned in redux-saga in most cases refers to a set of operations in generator form, rather than referring to redux-saga itself. Simple understanding: in redux-saga, Saga is generator, Sagas are multiple generators

Sagas have 2 sequential combination methods:

  • yield* saga()

  • call(saga)

Similarly, directly yield* iterator runtime expansion also faces inconvenient testing problem, so wrap one layer of Effect through call. Additionally, yield* only accepts one iterator, combining is not very convenient, for example:

function* saga1() {
    yield 1;
    yield 2;
}
function* saga2() {
    yield 3;
    yield 4;
}
function* rootSaga() {
    yield 0;
    // Combining multiple generators is inconvenient
    yield* (function*() {
        yield* saga1();
        yield* saga2();
    })();
    yield 5;
}

// test
for (let val of rootSaga()) {
    console.log(val);   // 0 1 2 3 4 5
}

Note: Actually, Effect returned by call(saga) and other types of Effects have no essential difference, can also be combined through all/race

Saga Helpers

Saga Helper is used to listen to actions, API form is takeXXX, its semantics is equivalent to addActionListener:

  • take: semantics equivalent to once

  • takeEvery: semantics equivalent to on, allows concurrent actions (starts next one immediately even if previous one hasn't completed)

  • takeLatest: restricted version of on, doesn't allow concurrent actions (when pending, if another comes cancel the pending one, only do the latest)

takeEvery, takeLatest are encapsulations on top of take, take is the underlying API, maximum flexibility, can manually satisfy various scenarios

P.S. For more information about relationship between the 3, please check Concurrency

pull action and push action

From control method perspective, take is pull method, takeEvery, takeLatest are push methods

pull and push refers to:

  • pull action: requires business side to actively fetch actions (yeild take() will return action)

  • push action: framework injects actions from external (Sagas registered by takeEvery/takeLatest will be injected action parameters)

Advantages of pull method lie in:

  • Allows finer control

For example can manually implement takeN effect (only care about certain actions, release after using up)

  • Describe control flow in synchronous form

takeEvery, takeLatest only support single action, if it's action sequence need to split apart, using take can preserve integrity of associated logic blocks, such as login/logout

  • Easier for others to understand

Control logic is in business code, rather than hidden inside framework internal mechanisms, to certain extent reduces maintenance cost

P.S. For more information about pull/push, please check Pulling future actions

V. Scenario Examples

There are several deeply impressed scenarios, fully demonstrate elegance of redux-saga

API Access

function* fetchProducts() {
  try {
    const products = yield call(Api.fetch, '/products')
    yield put({ type: 'PRODUCTS_RECEIVED', products })
  }
  catch(error) {
    yield put({ type: 'PRODUCTS_REQUEST_FAILED', error })
  }
}

Except needing to know put means dispatch action, almost doesn't need any comments, actual situation is just as you think

Login/Logout

function* loginFlow() {
  while (true) {
    yield take('LOGIN')
    // ... perform the login logic
    yield take('LOGOUT')
    // ... perform the logout logic
  }
}

pull action can maintain processing order of associated actions, without needing extra external state control. This guarantees LOGOUT always happens at some moment after executing LOGIN, code looks quite beautiful

Specific Operation Prompts

// When creating 3rd todo, give prompt message
function* watchFirstThreeTodosCreation() {
  for (let i = 0; i < 3; i++) {
    const action = yield take('TODO_CREATED')
  }
  yield put({type: 'SHOW_CONGRATULATION'})
}

// API access exception retry
function* updateApi(data) {
  for(let i = 0; i < 5; i++) {
    try {
      const apiResponse = yield call(apiRequest, { data });
      return apiResponse;
    } catch(err) {
      if(i < 4) {
        yield call(delay, 2000);
      }
    }
  }
  // attempts failed after 5 attempts
  throw new Error('API request failed');
}

This is takeN example, this way lifts side effects that should exist in reducer to outside, guarantees purity of reducer

VI. Pros and Cons

Advantages:

  • Easy to test, provides test solutions for various cases, including mock task, branch coverage etc.

  • Comprehensive asynchronous control library, from asynchronous flow control to concurrency control has everything

  • Complete error capture mechanism, blocking errors can try-catch, non-blocking will notify belonging Saga

  • Elegant flow control, readability/refinement level not much worse than async&await, very easy to describe parallel operations

Disadvantages:

  • Size slightly large, 1700 lines, min version 24KB, actually concurrency control etc. functions are hard to use

  • Depends on ES6 generator feature, may need polyfill

P.S. redux-saga can also access other environments (not bound to Redux), details see Connecting Sagas to external Input/Output

Reference Materials

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment