There are many good articles about Redux-saga on Zhihu, such as summary of redux-Saga practice, brief analysis of redux-Saga implementation principle and Ramble on Redux-Saga. This article introduces how redux-Saga works and builds little-Saga, a simple version of Redux-Saga, in code step by step. Hopefully, through this article, more people can understand the operation principle behind Redux-Saga.

This paper is an analysis of the principle of Redux-Saga, and will not introduce the related concepts of Redux-Saga. So make sure you have some knowledge of Redux-Saga before you read the article.

The article directories

  • 0.1 Article Structure
  • 0.2 Noun Interpretation
  • 0.3 about little – saga
  • 1.1 Generator Functions
  • 1.2 Use while-true to consume iterators
  • 1.3 Use recursive functions to consume iterators
  • 1.4 Two-way Communication
  • 1.5 Types and meanings of effect
  • 1.6 the result – the first callback style
  • 1.7 cancellation
  • 1.8 effect of state
  • 1.9 Proc Preliminary implementation
  • 2.1 the Task
  • 2.2 the fork model
  • 2.3 class ForkQueue
  • 2.4 the task context
  • 2.5 Effect Type expansion
  • 2.6 Complete implementation of the core of little-Saga
  • 2.7 Example for Task Status Change
  • 2.8 class Env
  • 2.9 Section 2
  • 3.1 commonEffects expand
  • 3.2 channelEffects expand
  • 3.3 compat expand
  • 3.4 the scheduler
  • 3.5 Other details

0.1 Article Structure

This paper is very long, roughly divided into four parts. Each chapter in this article has a corresponding x.y mark for mutual reference.

  • 0. X Introduces some information about the article.
  • 1. X explains some basic concepts and implements a simple version of the Proc function.
  • 2. X introduced some core concepts of Redux-Saga/little-Saga, such as Task, fork Model, effect type extension, and implemented the core part of Little-Saga.
  • 3. X implements race/ All, Channel, integrated Redux and other functions using little-Saga extension mechanism, and also discusses some other related issues.

0.2 Noun Interpretation

Side effect comes from the concept of functional programming, and the word effect literally translates to “effect.” “Effect” is a very vague word, which is not suitable for technical articles, so the English word “effect” will be used in this article. But what exactly is effect? Again, this is a difficult question to explain. In this article, we relax the concept of effect so that any value that is yield can be called an effect. For example, in yield 1, the number 1 is effect, but the effect of the number type lacks a clear meaning. For example, in yield Fetch (‘ some-URL ‘), the fetch(‘ some-URL ‘) Promise object is effect.

When we use Redux-Saga, our business code tends to act as an effect producer: generating an effect and passing it to Saga-Middleware using yield statements. Saga-middleware acts as a consumer of effects: it takes an effect, interpreates it based on its type, and returns the result to the producer. In this paper, effect-producer and effect-runner are used to represent producers and consumers. Note that the effect-runner needs to return the results to the effect-producer, so sometimes redux-saga needs to be regarded as a “request-response” model. Our business code produces effect to initiate “request”. Saga-middleware takes care of the consumption and returns the response to the business code.

Saga is another confusing word (· translation ·), and here’s a brief explanation. The “saga functions” in this article refer to JavaScript generator functions, but specifically to those that “are passed as arguments to a function called proc or sagamiddleware.run and then run.” The “saga instance” refers to the iterator object obtained by calling the saga function. Task refers to the object describing the running state of a Saga instance.

0.3 about little – saga

Little-saga makes extensive reference to the redux-Saga source code. The reference version is Redux-Saga V1.0.0-beta.1. Redux-saga deals with a lot of boundary cases, and the code is more obscure, while Little-Saga simplifies a lot, so there are many implementation details that differ. The code in this article is from little-Saga, but I occasionally include links to the redux-Saga source code for reference.

Little-saga has already passed most of the tests for Redux-Saga (skipping the tests for features that little-Saga didn’t implement), and little-Saga/Compat is now available to replace Redux-Saga, For example, the tank Wars remake I wrote about last time used little-Saga instead of Redux-Saga.

Little-saga doesn’t have Redux tied to it, so if you don’t want to use Redux but still want to fork the model and channel to manage asynchronous logic (for example, when writing web crawlers and game logic), you can try little-Saga. The original intention of Little-Saga is to simplify Redux-Saga so that more people can understand the principles behind Redux-Saga.

1.1 Generator Functions

Let’s start with the most common yield syntax in Redux-saga. Generator functions are declared using function*, and the yield syntax can only occur in generator functions. During generator execution, a yield expression is immediately paused and the execution state can be restored later. With redux-saga, all effects are passed to the effe-runner via yield syntax, and the effe-runner processes the effect and decides when to resume the generator. Generators Generators is a very good article, highly recommended if you don’t know anything about Generators.

By calling the generator function we get an iterator object (the article on iterators is recommended for ES6 (2) : iterators and for-of loops). In the simpler case, we use a for-of loop to “consume” the iterator, and the following code is a simple example.

function* range(start, end) { for (let i = start; i < end; I ++) {yield I}} for (let x of range(1, 10)) {console.log(x)} 8, 9Copy the code

For-of is convenient, but has limited functions. The iterator object contains three methods: next/throw/return. The for-of loop only calls the next method over and over again. The next method can take arguments, and the for-of loop calls to this method take no arguments. For example, a for-of loop cannot handle a generator function like the one below.

Function * saga() {const someValue = yield ['echo', 3] The value is undefined yield promise. reject(someError) // effectRunner encounters rejected Promise. The rejected Promise should be thrown by the throw method of the iterator For a for-of loop, we can't call the iterator throw}Copy the code

1.2 Use while-true to consume iterators

We could do a lot more if, instead of for-of, we implemented the consumer ourselves using a while-true loop, manually calling the next/throw/return methods. The following code implements an effec-runner that throws an error when it encounters the number 5.

const iterator = range(1, 10) while (true) { const { done, Iterator.next (/* we can determine the argument */) if (done) {break} if (value === 5) {iterator.throw(new Error('5 is bad Input '))} console.log(value)} // output 1, 2, 3, 4, then throw the exception '5 is bad input'Copy the code

However, while-true still has one drawback: while-true is synchronous. This means that the generator cannot pause execution until an asynchronous task (such as a network request) completes, which means that redux-saga cannot be implemented using while-true.

1.3 Use recursive functions to consume iterators

The end-runner’s answer: recursive functions. Recursive functions fulfill all the requirements of being an effect-runner, being able not only to call the iterator’s next/throw/return methods, to call them with specified parameters, but also to call them synchronously or asynchronously.

In the previous example all of our code was synchronized; In the following example, if we find that value is even, instead of calling next immediately, we delay calling next with setTimeout.

const iterator = range(1, 10) function next() { const { done, value } = iterator.next() if (done) { return } console.log(value) if (value % 2 === 0) { setTimeout(next, Value * 300)} else {next()}} next() 8, 9 // After the even number is printed, it takes a while to print the next odd number // After the odd number is printed, it immediately prints the next even numberCopy the code

This example is relatively simple, and only evaluates value with parity. However, it is not difficult to imagine the following use methods: Effect-producer produces promises, and effect-runner deals with promises as follows: The iterator’s next method is called when a promise resolve is called, and its throw method is called when a promise Reject is called. We can then implement async/await with generator syntax, which is why generators are more powerful than async/await. Redux-saga/little-Saga not only implements promise processing, but also implements the more powerful fork Model.

Later in this article, we refer to this recursive function as the “driver function.” Note that the driver function next is not to be confused with the iterator’s next method. The iterator’s next method is called iterator.next(someValue), while the driver function is called in a different form. In redux-saga/little-saga, the only cases where the function name is next are the driver and the iterator next method, so if a function called next is found that is not an iterator method, then the function is the driver.

1.4 Two-way Communication

In the previous example, we used only single communication: the effe-runner called iterator.next() to get effect, but the effe-runner did not pass the data to the effe-producer. In Redux-Saga, we often need to use the return value of the yield statement, the meaning of which depends on the type of effect. Take this example:

Function * someSaga() {// yield a promise to return a promise resolve const response = yield fetch('https://example.com/') Const Action = yield take('SOME_ACTION') const Action = yield take('SOME_ACTION') Const allResult = yield all([effect1, effect2])}Copy the code

To achieve two-way communication, the effect-runner calls iterator.next(ARG) by providing appropriate arguments. When iterator.next(arg) is called, the arg argument is returned as the yield XXX statement, and the paused iterator continues execution (until the next yield statement is encountered). To do this, we modify the previous code as follows:

function* range2(start, end) { for (let i = start; i < end; i++) { const response = yield i console.log(`response of ${i} is ${response}`) } } const iterator = range2(1, 10) function next(arg, IsErr let result if (isErr) {result = iterator.throw(arg)} else {// Here we pass arg as an argument to The iterator. Next, Result = iterator.next(arg)} const {done, Value} = result if (done) {return} console.log('getting:', value) if (value === 5) { Next (new Error('5 is bad input'), true)} else {// Delay the call to the driver function; SetTimeout (() => next(value * 2), value * 1000)}} next() 1 // response of 1 is 2 // getting: 2 // response of 2 is 4 // getting: 3 // response of 3 is 6 // getting: 4 // Response of 4 is 8 // getting: 5 // Uncaught Error: 5 is bad input // Output After getting: x, output will be suspended for some timeCopy the code

1.5 Types and meanings of effect

In the previous examples, we used an effect-producer as a simple range, where effect (the yield value) is a number. Because numbers don’t have any exact meaning, the effect-runner simply prints the numbers and calls the driver function at the appropriate time.

If effect has a clear meaning, the effect-runner can decide on the specific execution logic based on that meaning. Redux-saga can handle effects of the types promise, iterator, take, put, and so on. The proper combination of different types of effects can express very complex asynchronous logic. Now let’s add some simple effect processing power to little-Saga. Do you think this is similar to CO?

function* gen() { console.log('enter ... ') const a = yield ['promise', fetch('/')] console.assert(a instanceof Response) const b = yield ['delay', 500] console.assert(b === '500ms elapsed') const c = yield ['ping'] console.assert(c === 'pong') console.log('exit ... ') } const iterator = gen() function next(arg, isErr) { let result if (isErr) { result = iterator.throw(arg) } else { result = iterator.next(arg) } const { done, Value} = result if (done) {return} If (value[0] === 'promise') {const promise = value[1] promise. Then (resolvedValue =>) next(resolvedValue), error => next(error, true)) } else if (value[0] === 'delay') { const timeout = value[1] setTimeout(() => next(`${timeout}ms elapsed`), Timeout)} else if (value[0] === 'ping') {next('pong')} else {iterator.throw(new Error(' effect'))}} next()Copy the code

In Redux-Saga, effect is an object generated by the function effect with the [IO] field true. Little-saga uses arrays to represent effect: The first element of the array is a string representing the type of effect, and the remaining elements are parameters for effect.

The previous sections introduced the features of the ES2015 generator and showed you how to use recursive functions to implement Effect-Runner. We found that by agreeing on a few common effect types and using them appropriately, we could write expressive code using generator syntax.

1.6 the result – the first callback style

In Node.js, asynchronous callback functions tend to use error-first mode: the first parameter is err, and if an asynchronous operation fails, the error is passed back through the ERR parameter. The second parameter is used to pass the correct result of the operation, which is passed if the asynchronous operation does not fail. The error-First pattern is used extensively in Node core modules (such as FS) and third-party libraries (such as Async). Read this article for more information.

In Redux-Saga/little-Saga, we use result-first. The first argument to an asynchronous callback is the result of the operation, and the second argument is a Boolean value indicating whether an error occurred. We call this style result-first callback style and TypeScript the type information as follows:

type Callback = (result: any, isErr: boolean) => void
Copy the code

Almost all callback functions in the redux-Saga source code are in this style, and there are several corresponding variable names:

  • An abbreviation for cont continuation, commonly used to indicate the successor of Task/MainTask/ForkQueue
  • Cb Callback or currCb should be short for currentCallback. Typically used for effect’s successor/callback function
  • Next is the preceding recursive function, which also conforms to the result-first callback style

These variable names appear frequently in redux-saga, dozens of times in a single proc.js file. We will use this style of callback function later in little-Saga code.

1.7 cancellation

One of the great features of Redux-Saga is that effects are cancelable and support the try-catch-finally syntax for placing cleanup logic in finally blocks. Mission cancellations are also explained in the official documentation.

In the redux-saga implementation, the caller (caller) will pass the callback function CB to the callee (callee). When callee completes the asynchronous task, cb will be called to tell the caller the result. If an operation is cancelable, Callee needs to place “cancellation logic” on cb.cancel, so that when the caller wants to cancel the asynchronous operation, he can simply call cb.Cancel (). Function calls are nested, and cB.cancel is set up layer by layer following the function call. Many of the following functions will have cB.cancel = XXX, which is a cancellation.

How does the article cancel your Promise? There are several ways to cancel promises, but generators are the most expansive way to do so for those interested.

1.8 effect of state

The effect state can be running, completed (complete if it ends normally or if it throws an error), or cancelled.

Once a promise is resolve/reject, it cannot change its state. Effect is similar in that once completed or cancelled, the state cannot be changed, and the completed callback and cancelled callback together can only be called once at most. In other words, the “completed” and “cancelled” aspects of effect are mutually exclusive.

Each effect is processed by the function digestEffect before it is run. This function records whether an effect is settled with the variable effectSettled, ensuring the above exclusivity.

DigestEffect also calls normalizeEffect to normalize an effect, so that for promises/iterators, we can simply yield the objects in effect-producer, You don’t have to wrap them in an array.

The code for the digestEffect and normalizeEffect functions is as follows:

Const noop = () => null const is = {func: /* Determines whether the argument is a function */ string: /* Determines whether the argument is a string */ /*...... */ } function digestEffect(rawEffect, cb) { let effectSettled = false function currCb(res, isErr) { if (effectSettled) { return } effectSettled = true cb.cancel = noop cb(res, isErr) } currCb.cancel = noop cb.cancel = () => { if (effectSettled) { return } effectSettled = true try { currCb.cancel() } catch (err) { console.error(err) } currCb.cancel = noop } runEffect(normalizeEffect(rawEffect), CurrCb)} function normalizeEffect(effect, currCb) { if (is.string(effect)) { return [effect] } else if (is.promise(effect)) { return ['promise', effect] } else if (is.iterator(effect)) { return ['iterator', effect] } else if (is.array(effect)) { return effect } else { const error = new Error('Unable to normalize effect') error.effect = effect currCb(error, true) } }Copy the code

1.9 Proc Preliminary implementation

With this foundation in place, we can begin to implement the Proc function. All saga instances are started through this function, which is one of the most important functions in Redux-Saga.

const TASK_CANCEL = Symbol('TASK_CANCEL') const CANCEL = Symbol('CANCEL') function proc(iterator, parentContext, Const task = {cancel: const task = {cancel: const task = {cancel: const task = {cancel: Cont.cancel = task.cancel next() return task function next(arg, arg, arg) isErr) { try { let result if (isErr) { result = iterator.throw(arg) } else if (arg === TASK_CANCEL) { // next.cancel Next. Cancel () result = iterator.return(TASK_CANCEL)} else {result = iterator.next(arg)} if set by the currently executing effectRunner (! Result.done) {digestEffect(result.value, next)} else { Cont (result.value)}} Catch (error) { // function digestEffect(rawEffect, cb) {/*...... */ / execute effect, EffectRunner function runEffect(effect, currCb) { const effectType = effect[0] if (effectType === 'promise') { resolvePromise(effect, ctx, currCb) } else if (effectType === 'iterator') { resolveIterator(iterator, ctx, Throw new Error('Unknown effect type')}} function resolvePromise([effectType, promise], ctx, Cb) {const cancelPromise = promise[CANCEL] if (is.func(cancelPromise)) {// Set the CANCEL logic of the promise cb.cancel = cancelPromise } promise.then(cb, error => cb(error, true)) } function resolveIterator([effectType, iterator], ctx, cb) { proc(iterator, ctx, cb) } }Copy the code

2.1 the Task

The proc function (source code for Redux-Saga) runs an iterator and returns a Task object. The Task object describes the runtime state of the iterator. Let’s first look at the Task’s interface (which uses TypeScript to represent type information). In Little-Saga, we will use a similar Task interface. (Note similar interface, not the same interface)

type Callback = (result: any, isErr: boolean) => void
type Joiner = { task: Task; cb: Callback }

interface Task {
  cancel(): void
  toPromise(): Promise<any>

  result: any
  error: Error
  isRunning: boolean
  isCancelled: boolean
  isAborted: boolean
}
Copy the code

The Task contains the toPromise() method, which returns the promise corresponding to the saga instance. The cancel() method allows saga instances to be cancelled; Fields such as isXXX reflect the running state of saga instance; The result/error field can record the running results of saga instances.

After async functions are called, the internal logic may be very complex and eventually return a Promise object to represent the asynchronous result. In addition to asynchronous results, the Saga instance includes additional functionality: “cancel” and “query the running status”. So the Task interface is more complex than the Promise interface, and the internal implementation requires more data structure and logic.

2.2 the fork model

Redux-saga provides the fork effect for non-blocking calls, yield fork(…) A Task object is returned representing the saga instance executed in the background. More generally, if a saga instance is run multiple times with yield fork effects, a parent-saga instance will have multiple Child-saga instances. RootSaga starts with sagamiddleware.run (). During rootSaga runs, several child-saga modules are forked, Each child-saga will fork to get several grandchild-saga. If we plot all parent-child relationships, we can get a saga tree similar to the following figure.


The documentation for redux-Saga also explains the fork model in detail. Here’s a quick translation:

  • Completed: A saga instance enters the completed state when:
    • The iterator’s own statement completes execution
    • All child-saga completed

A node is complete when all of its children are complete and its own iterator code has been executed.

  • Error propagation: A saga instance breaks and throws an error if:
    • The iterator itself throws an exception
    • One child-Saga threw an error

When an error occurs at one node, the error is propagated up the tree to the root node until a node catches the error.

  • Cancel: Canceling a Saga instance can also cause the following things to happen:
    • Cancel mainTask, which cancels the effect of waiting for the current saga instance
    • Cancels all child-saga that are still running

When a node is cancelled, the entire subtree corresponding to that node is cancelled.

2.3 classForkQueue

The ForkQueue class is a concrete implementation of the fork Model. Redux-saga is implemented using the forkQueue function. In little-Saga we define the forkQueue class using the class syntax.

Each saga instance can be described by a Task object. To implement the fork model, we need an array to hold child-Tasks when each saga instance starts running. Let’s look at the forkQueue interface:

Interface ForkQueue {constructor(mainTask: mainTask) // cont: Callback This is a private field addTask(task: task): void cancelAll(): void abort(err: Error): void }Copy the code

The ForkQueue constructor takes a mainTask that represents the execution state of the iterator’s own code. ForkQueue. Cont is set after the ForkQueue is constructed. When all child-tasks and mainTasks are complete, we need to call forkqueue. cont to notify parent-saga (corresponding to “done” in the 2.2 fork model).

The ForkQueue object contains three methods. The addTask method is used to add new child-task; CancelAll cancels all child-tasks; The abort method not only cancellations all child-tasks, but also calls forkqueue. cont to notify parent-Task of an error.

The ForkQueue implementation of Little-Saga is as follows:

Class ForkQueue {tasks = [] result = undefined // Use the completed variable to ensure that completed and error are mutually exclusive completed = false // cont will be set After calling constructor() cont = undefined constructor(mainTask) {this.mainTask = mainTask // mainTask is added to the array from the start This.addtask (this.mainTask)} // Cancel all child-tasks, Abort (err) {this.cancelall () this.cont(err, True)} addTask(task) {this.tasks.push(task) // Specify the behavior of child-task completion task.cont = (res, IsErr) => {if (this.pleted) {return} // Remove child-task remove(this.tasks, Task. cont = noop if (isErr) {// One of the child-tasks has an error, This. Abort (res)} else {// If mainTask is done, If (task === this.mainTask) {this.result = res} if (this.tasks.length === 0) {// Both conditions for task completion are met this.completed = true this.cont(this.result) } } } } cancelAll() { if (this.completed) { return } this.completed = true // Call the child-task cancel method in turn to cascade down, This.tasks. ForEach (t => {t.ont = noop t.ancel ()}) this.tasks = []}}Copy the code

2.4 the task context

Each task has its own context object, which is used to store the context information of the task at runtime. In Redux-Saga we can read and write this object using getContext/setContext. One feature of context is that child-Task inherits parent-Task context using a chain of prototypes. When an attempt is made to access a property in the context, the property is searched not only in the current Task context, but also in the parent-task context, and then in parent-Task’s parent-task hierarchy. Until the property is found or rootSaga is reached. This inheritance mechanism is also very simple to implement in Redux-Saga, with just one line of code: const taskContext = object.create (parentContext).

React Context is a powerful mechanism. For example, React Context is widely used in React. React-redux/react-router libraries are implemented based on this mechanism. In Redux-Saga, however, context seems to be rarely mentioned.

In Little-Saga, we will take full advantage of the context mechanism and use it for “Effect type extension”, “connecting to the Redux Store”, etc. The implementation of these mechanisms is described later in this article.

2.5 Effect Type expansion

In the initial implementation of 1.9 Proc, the function runEffect threw an exception when it encountered an unknown effect type. Here we make some changes to this to enable effect type extension. When we encounter an unknown effect type, we use ctx.Translator’s getRunner method to obtain and invoke the effectRunner for the effect. As long as we set up ctx.Translator in advance, we can use extended types in future code. To make it easier to set up ctx.translator, little-Saga has added an effect of type def to associate the extension type with its corresponding effectRunner.

According to the nature of the context, child-task inherits the parent-saga context, so extension types defined in parent-Task can also be used for child-task. In Little-Saga, types such as Race/All/Take/PUT are implemented using this extension mechanism.

function runEffect(effect, currCb) { const effectType = effect[0] if (effectType === 'promise') { resolvePromise(effect, ctx, currCb) } else if (effectType === 'iterator') { resolveIterator(iterator, ctx, currCb) } else if (effectType === 'def') { runDefEffect(effect, ctx, CurrCb) / * other known type effect * / / *... */} else {// Effect of unknown type const effectRunner = ctx.translator.getRunner(effect) if (effectRunner == null) {const error  = new Error(`Cannot resolve effect-runner for type: ${effectType}`) error.effect = effect currCb(error, true) } else { effectRunner(effect, ctx, currCb, { digestEffect }) } } } function runDefEffect([_, name, handler], ctx, cb) { def(ctx, name, Handler) cb()} function def(CTX, type, type) Handler) {const old = ctx.translator // Replace ctx.translator ctx.translator = {getRunner(effect) {return effect[0] === type ? handler : old.getRunner(effect) } }, }Copy the code

2.6 Complete implementation of the core of little-Saga

The implementation code for the little-Saga core is located in the/SRC /core folder. Compared with the 1.9 Proc implementation, the full implementation adds context, Task, fork model, effect type extension, error handling, and other functions to improve the task lifecycle (start/finish/error/cancel).

The implementation code for redux-saga is all in a single file, proc.js, resulting in a large file; Little-saga splits the implementation into several files, which we’ll examine one by one.

2.6.1 Overall idea

Later in 2.6, we will use the following code as an example. In this example, the Parent fork Child1 and Child2, which take 100ms and 300ms respectively. The Parent iterator itself (mainTask) takes 200ms to complete. The entire Parent Task takes 300ms to complete.

Function * Parent() {const Child1 = yield fork(api.xxxx) // Const Child2 = yield fork(api.yyyy) // Line-2 requires 300ms to complete the yield delay(200)Copy the code

The following diagram shows the relationship between tasks/Maintasks/Forkqueues at the Parent runtime. The solid arrows in the figure represent the conT between two objects: “A points to B” means “when A is done, the result needs to be passed to B”.

In 1.7 Cancellation, we know that the order of Cancellation is opposite to that of CONT. In specific code implementation, we need not only to build conT relationship as shown in the figure below, but also the reverse Cancellation relationship.


The code in this section is complex, and if you find it difficult to understand, compare it to the 2.7 Task State Changes example.

2.6.2 functionproc

The proc function is the entry function to run the Saga instance. In combination with the figure above, the proc function creates the individual Task-mainTask objects in the figure and creates cont and Cancellation relationships between them. The code is as follows:

// /src/core/proc.js function proc(iterator, parentContext, Create (parentContext) const CTX = object.create (parentContext) // mainTask to track the execution status of the current iterator const mainTask = {  // cont: **will be set when passed to ForkQueue** isRunning: true, isCancelled: false, cancel() { if (mainTask.isRunning && ! Maintask.iscancelled) {maintask.iscancelled = true next(TASK_CANCEL)}},} ForkQueue and Task are created. Const taskQueue = new ForkQueue(mainTask) const task = new task (taskQueue) // Set the descendant relationship taskqueue.cont = Cont. cancel = task.cancel next() return task // The following codes are function definitions // In the figure, the driver function is only associated with mainTask // Then we can also find the following code in the next function, Function next(arg, isErr) { console.assert(mainTask.isRunning, 'Trying to resume an already finished generator') try { let result if (isErr) { result = iterator.throw(arg) } else if (arg === TASK_CANCEL) {maintask.iscancelled = true Next-cancel () // Cancel effect currently executed // jump to finally block of iterator, Return (TASK_CANCEL)} else {result = iterator.next(arg)} if (! result.done) { digestEffect(result.value, next) } else { mainTask.isRunning = false mainTask.cont(result.value) } } catch (error) { if (! Maintask.isrunning) {throw error} if (maintask.iscancelled) {// An error occurred while executing the cancel logic, Console. error(error)} maintask.isrunning = false maintask.cont (error, true) } } // function digestEffect(rawEffect, cb) { /* ...... */ } // function runEffect(effect, currCb) { /* ...... */ } // function resolvePromise([effectType, promise], ctx, cb) { /* ... */ } // function resolveIterator([effectType, iterator], ctx, cb) { /* ... * /} / /... A variety of built-in types effect - runner / / fork - the model of the new fork/spawn/join/cancel/cancelled / / the five types of effec - see below runner code}Copy the code

2.6.3 Fork-model related effect-runner

The runForkEffect function performs an effect of type fork and returns a subTask object to the caller. Proc (iterator, CTX, noop) may return a subTask that has already completed or an error occurred. Instead of putting the subTask in a fork-queue, we need to do something else.

// /src/core/proc.js function runForkEffect([effectType, fn, ...args], ctx, Cb) {const iterator = createTaskIterator(fn, args) try {suspend() // see ctx, noop) if (subTask.isRunning) { task.taskQueue.addTask(subTask) cb(subTask) } else if (subTask.error) { Task.taskqueue.abort (subtask.error)} else {cb(subTask)}} finally {flush() // See 3.4 Scheduler}}Copy the code

The remaining four types of effect-runner (spawn/Join/Cancel/cancelled) are simpler and will not be covered here.

2.6.4 classTask

The Task class is a concrete implementation of 2.1 Task.

// /src/core/Task.js class Task { isRunning = true isCancelled = false isAborted = false result = undefined error = undefined joiners = [] // cont will be set after calling constructor() cont = undefined constructor(taskQueue) { This. taskQueue = taskQueue} // Call the cancel function to cancel the Task. This will cancel all child-task and mainTask that are currently executing. = () => {// If the Task has already completed or been cancelled, the Cancellation will be applied to all the Saga tree subtrees of the Task. Skip if (this.isrunning &&! This.iscancelled) {this.iscancelled = true this.taskQueue.cancelAll() // Pass the TASK_CANCEL to all joiners This.end (TASK_CANCEL)}} // End the current Task // set the result/error of the Task and call task.cont, When both child-task and mainTask are finished (fork-queue), the result is passed to the Joiners. End = (result, isErr) => {this.isRunning = false if (! isErr) { this.result = result } else { this.error = result this.isAborted = true } this.cont(result, ForEach (j => j.b (result, isErr)) this.joiners = null} toPromise() { The code is omitted here}}Copy the code

2.6.5 section

There is a lot of code in this section, and the logic density of the code is high. To fully understand the implementation of Little-Saga, you need to read the source code carefully.

2.7 Example for Task Status Change

The following code is an example from 2.6.

Function * Parent() {const Child1 = yield fork(api.xxxx) // Const Child2 = yield fork(api.yyyy) // Line-2 requires 300ms to complete the yield delay(200)Copy the code

The following table shows the execution of this example at some key time points and the corresponding state changes. Note that in the following table, if task/forkQueue/mainTask is not specified as belonging to Parent or Child1/Child2, the default is to belong to Parent. The following table only shows the normal completion of this example. We can also consider how the code would perform if the Parent was disabled at different points such as t=50 / t=150 / t=250 / t=350.


2.8 classEnv

The Env class is used to configure the root Task’s runtime environment before running rootSaga. Env uses a chain-call-style API to concatenate multiple configurations.

We can use Env to pre-add common effect types such as all/race/take/ PUT so that all subsequent Saga functions can use these effect types directly. For example, the following code defines both delay and echo effects before running rootSaga.

new Env() .def('delay', ([_, timeout], _ctx, cb) => setTimeout(cb, timeout)) .def('echo', ([_, arg], _ctx, Cb) => cb(arg)). Run (rootSaga) function* rootSaga() {yield ['delay', 500] 'hello'] // yield returns string 'hello'}Copy the code

2.9 Section 2

This completes the core of Little-Saga. Core part implements the fork model, realized the fork/join/cancel/promise/iterator effect of built-in types – runner, and reserved the interface. In Part 3, we will use this extended interface to implement the remaining Effect types in Redux-Saga (all/ Race/PUT/Take, etc.).

3.1 commonEffects expand

The behavior of an all-effect is very similar to that of Promise#all: An all-effect accepts some effects as sub-effects when constructed, and is not complete until all sub-effects are completed. When one of the sub-effects throws an error, the all-effect immediately throws an error.

With Def effect, extending effects is much easier. The runAllEffect in redux-Saga is used to run effects of all type. We copy this code and simply modify it to conform to the effectRunner interface to implement AllEffect in little-Saga. The code for implementing all Effect in Little-Sage is as follows:

// This function is a simplified version of this function, which omits the all-effect cancellation code. Function all([_, effects], CTX, cb, { digestEffect }) { const keys = Object.keys(effects) let completedCount = 0 const results = {} const childCbs = {} Keys. ForEach (key = > {const chCbAtKey = (res, isErr) = > {the if (isErr | | res = = = TASK_CANCEL) {/ / one of the sub - effect when an error occurs, All-effect cb.cancel() cb(res, isErr) } else { results[key] = res completedCount++ if (completedCount === keys.length) { cb(results) } } } childCbs[key] = chCbAtKey }) keys.forEach(key => digestEffect(effects[key], childCbs[key])) }Copy the code

Race and other common effects are implemented in the same way. Little-saga /commonEffects provides seven common type extensions from Redux-Saga, including: All/Race/apply/Call/CPS/getContext/setContext. When we want to use these types in our code, we can use Env to load commonEffects:

Import {Env, IO} from 'little-saga' import commonEffects from 'little-saga/commonEffects' New Env().use(commonEffects).run(function* rootSaga() {yield io.race({foo: io.cps(someFunction), foo: io.call(someAPI), }) })Copy the code

3.2 channelEffects expand

Little-saga /channelEffects offers 5 types of channel-related extensions (take/takeMaybe/Put/actionChannel/Flush), And copied the channel/buffers code from Redux-Saga.

Env.use (channelEffects) not only adds type extension, but also sets a default channel on ctx.channel. When put/take effect is used, ctx.channel is used by default if no channel parameter is specified.

Using channels in little-Saga, you can communicate between any two tasks. However, channel is a big topic, so I won’t cover it in detail in this article. The source code for channel is quite readable, welcome to read the source code directly.

3.3 compat expand

The Compat extension allows little-Saga to integrate with Redux and provide an API consistent with Redux-Saga. However, little-Saga is not compatible with other Redux middleware (such as Redux-Thunk) due to normalizedEffect, and is ultimately not fully compatible with the Redux-Saga API.

CreateSagaMiddleware of Little-Saga is also an interesting function. Its implementation ideas are as follows: Firstly, channel effects are used to add channel-related extensions; Then replace ctx.channel.put with store.dispatch, so that the put effect is converted to a call to the dispatch function; CreateSagaMiddleware, on the other hand, returns a REdux middleware that puts all the actions (recall that in Redux actions can only come from dispatches) back into their original channel, Then all actions can be taken again; Of course, middleware also uses getState to implement Select Effect. The code is as follows:

function createSagaMiddleware(cont) { function middleware({ dispatch, GetState}) {let channelPut const env = new env (cont).use(commonEffects).use(channelEffects).use(CTX => {// record "true ChannelPut = ctx.channel.put // Replace the put method ctx.channel.put = action => { Action [SAGA_ACTION] = true dispatch(action)} // def def(CTX, 'select', ([_effectType, selector = identity, ...args], _ctx, cb) => cb(selector(getState(), ... Args)),)}) // When the middleware function is executed, store is being created. args) => env.run(... Args) return next => action => {const result = next(action) // Hit reducers See 3.4 IF (action[SAGA_ACTION]) {// SAGA_ACTION true indicates that the action is from saga. ChannelPut (action)} else {// indicates that the action is from store.dispatch // for example, React Asap (() => channelPut(action))} return result}} Middleware. Run = (... Args) => {throw new Error(' Before running Saga, Saga middleware must be loaded into Store with applyMiddleware ')} return middleware}Copy the code

3.4 the scheduler

Asap/suspend/Flush is a method derived from Scheduler.js. Asap is used for put effect, while the latter two functions are used for fork/spawn effect.

Scheduler deals primarily with “nested PUT” issues. Consider the following code: rootSaga forks genA and genB, genA puts -a and then takes -B, and genB takes -a and then puts -b.

function* rootSaga() {
  yield fork(genA) // LINE-1
  yield fork(genB) // LINE-2
}

function* genA() {
  yield put({ type: 'A' })
  yield take('B')
}

function* genB() {
  yield take('A')
  yield put({ type: 'B' })
}
Copy the code

In the case that scheduler is used, both takes can be successful, i.e. genA can take to B and genB can take to A, which is what we expect.

Suppose that, without using scheduler, PUT-a wakes up Take-A. Since the put/take execution is synchronous, the next sentence to be executed after take-a is waked up is put -b in genB, while genA is still in put -a state, genA will lose B. That is, when scheduler is not used, nested PUTS are likely to cause some action to be lost.

Wrapping the PUT process with the function ASAP ensures that the “inner PUT” is delayed until the “outer PUT” is finished, thus avoiding nested PUTS. Asap is short for as soon as possible. Asap (FN) can be understood as “Execute FN as soon as possible after all the outer ASAP tasks are completed”.

Consider line-1 and line-2 in the code above. Without scheduler, the order of the two lines affects the result: Because the default channel uses multicastChannel, multicastChannel has no buffer, so in order to succeed in taking -a, the take-a must be executed before the put -a.

Wrapping the fork/spawn process with the function suspend/flush ensures that “synchronous put in fork/spawn” is deferred until “fork/spawn execution ends.” In this way, take-a is always executed before PUT -a, and the sequence of line-1 and line-2 does not affect the running result.

3.5 Other details

This section documents some of the details that still exist in Redux-Saga/little-Saga, but these issues are rare and have little impact in everyday programming.

The Cancel and complete tasks are mutually exclusive. When the Task is cancelled, the code jumps directly to the finally block, but it is still possible to get an error, i.e., “error while performing cancel logic”. At this point, the Task status is Cancelled. You cannot change the Task status to Completed. Little-saga simply prints these errors with console.error and has no elegant way of handling them. So when we write code using redux-saga/little-saga, we try to avoid the overly complicated cancel logic in case there is an error in cancel logic.

What happens to a channel that is taking an END when it puts an END to the channel? The handling in Redux-Saga is a bit strange, I asked the author about it and he said it’s a hack for server-side rendering. Little-saga simplifies this by treating END as TASK_CANCEL if a take gets an END.

There are a lot of things not covered in this article, such as “code execution order when calling iterator throw/return methods”, “exception catching and handling”, etc. In addition, the redux-Saga source code is marked with TODO in many places, so there are still many issues to be resolved.

3.6 summarize

Fork Model is an excellent asynchronous logic processing model, and I learned a lot while reading the Redux-Saga source code and testing to implement little-Saga. If you have any questions or suggestions, please discuss them together.