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 throughyield -
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:
-
yieldmakes 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:
-
Blocking method calls: call/apply See Declarative Effects
-
Non-blocking method calls: fork/spawn See redux-saga's fork model
-
Parallel execution task: all/race See Running Tasks In Parallel, Starting a race between multiple Effects
-
Read/write state: select/put See Pulling future actions
-
task control: join/cancel/cancelled See Task cancellation
Most creator semantics are very straightforward, only one needs extra explanation:
joinis 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
spawnis 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
forkis 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/takeLatestwill 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
No comments yet. Be the first to share your thoughts.