preface

I recently read the redux-Saga website and found that the Advanced Concepts content is very sophisticated. Learning the use of the Advanced apis in the redux-Saga website can help you deal with many complex scenarios. Therefore, while reading, I summarize what I have learned into this article. The advanced features are introduced one by one.

Be aware of this before you readredux-sagaBasic usage and understanding ofredux-sagaThe inner workings of the. If you don’t know, you can read my previous articles in advanceRedux-saga: Apply ~ principle analysis ~ understand design purpose.

By reading this article, you will learnredux-sagaThe official website recommends many advanced gameplay.

1. Channels

1.1 the channel

Here’s a classic example of using fork and take:

import { take, fork } from 'redux-saga/effects'

function* watchRequests() {
    while (true) {
        const {payload} = yield take('REQUEST')
        yield fork(handleRequest, payload)
    }
}

function* handleRequest(payload) {... }Copy the code

The abovewatchRequestsThere is a catch:forkIt’s a non-blockingAPI. So if there’s a lot of correspondence in a short period of timeactionIs capturedhandleRequestIt’s going to be called over and over again ifhandleRequestWith network request logic, a large number of network requests will be executed at the same time.

Suppose our solution to the above shortcomings is to have up to three handleRequest executing at a time, and if there are additional actions being dispatched, wait until one of the three executing handleRequest has finished before a new handleRequest is fork Ed.

But what about the above solution? Redux-saga provides channels for us to do this very easily. Look directly at the following code:

import { channel } from 'redux-saga'
import { take, fork, call } from 'redux-saga/effects'

function* watchRequests() {
  // create a channel to queue incoming requests
  // Create a channel to store incoming information
  const chan = yield call(channel)

  // create 3 worker 'threads'
  // Create 3 'worker threads', not really threads, just because' fork 'is a non-blocking API for calling fn without blocking.
  for (var i = 0; i < 3; i++) {
    yield fork(handleRequest, chan)
  }

  while (true) {
    const {payload} = yield take('REQUEST')
    // When an action is sent, store the action.payload in the chan
    yield put(chan, payload)
  }
}

function* handleRequest(chan) {
  while (true) {
    // Check to see if any information is stored in chan, and if any is retrieved, then run the following logic
    const payload = yield take(chan)
    // process the request}}Copy the code

Very brief, and compared to writing your own limiting function. Using channels allows us to test better.

Another nice feature of the channel API is that the channel generated by default stores any input information in an unlimited number of channels, but if you want to limit the number of channels, you can call a redux-saga buffer, such as:

import { buffers,channel } from 'redux-saga'

function* watchRequests() {
  // Accept a maximum of 5 incoming messages.
  // buffers. Sliding means that if a new action is sent, the earliest action will be discarded.
  // The new is the old.
  const chan = yield call(channel, buffers.sliding(5))... }Copy the code

Buffers have several modes in addition to sliding:

  • Buffers. None (): Any stored input information is discarded, not cached.
  • buffers.fixed(limit): The input informationIs cached until the number exceeds the upper limit, and an error is thrown. herelimitThe default value is 10.
  • Buffers. Expanding (initialSize): The input information will be cached until the number of buffers reaches the maximum and dynamically expanded.
  • buffers.dropping(limit): actionWill be cached until the number exceeds the upper limit, that is, no errors are thrown, and no new ones are cachedThe input information.
  • Buffers. Sliding (limit): Input information is cached until the number of buffers reaches the upper limit. The first cached input information is removed, and the latest input information is cached.

Redux-saga: Channel source code redux-Saga: Redux-Saga: Channel source code

1.2 actionChannel

Let’s look again at the watchRequests at the beginning:

import { take, fork } from 'redux-saga/effects'

function* watchRequests() {
    while (true) {
        const {payload} = yield take('REQUEST')
        yield fork(handleRequest, payload)
    }
}

function* handleRequest(payload) {... }Copy the code

In the 1.1 Channel we talked about a catch in watchRequests: if matching actions are dispatched at a very high rate in a short period of time, there are many handleRequest tasks running at the same time. In the 1.1 Channel section, the solution is to limit the number of handleRequest executions at any one time.

If we wanted a different solution: execute handleRequest serially. The idea is to limit one handleRequest to running at any one time. The number of created “worker threads” should be set to 1, as shown below:

import { channel } from 'redux-saga'
import { take, fork, call } from 'redux-saga/effects'

function* watchRequests() {
  // create a channel to queue incoming requests
  // Create a channel to store incoming information
  const chan = yield call(channel)

  // Create a worker thread
  yield fork(handleRequest, chan)

  while (true) {
    const {payload} = yield take('REQUEST')
    // When an action is sent, store the action.payload in the chan
    yield put(chan, payload)
  }
}

function* handleRequest(chan) {
  while (true) {
    // Check to see if any information is stored in chan, and if any is retrieved, then run the following logic
    const payload = yield take(chan)
    // process the request}}Copy the code

But actually, for serial processing. Redux-saga provides a more useful API: actionChannel. Let’s take a look at what happens if the above requirement is implemented using actionChannel:

import { take, actionChannel, call } from 'redux-saga/effects'

function* watchRequests() {
    // 1- Emit ActionChannel Effect to create a pipe for storing actions that cannot be processed
    const requestChan = yield actionChannel('REQUEST')
    while (true) {
        // 2- Remove the action from the pipe
        const {payload} = yield take(requestChan)
        // 3- Call handleRequest to handle the action
        // Notice that the call blocking API is called. If the fork non-blocking API is called, the effect of serial processing is not achieved
        yield call(handleRequest, payload)
    }
}

function* handleRequest(payload) {... }Copy the code

How about, comparing the code of the two implementation methods, using actionChannel is much simpler. As with channel, we can limit the mode in which the generated channel stores the action by passing a buffer, as in the second actionChannel argument, as follows:

import { buffers } from 'redux-saga'
import { actionChannel } from 'redux-saga/effects'

function* watchRequests() {
    const requestChan = yield actionChannel('REQUEST', buffers.sliding(5))... }Copy the code

ActionChannel source code analysis is available in Redux-Saga.

1.3 the eventChannel

EventChannel is a factory function (unlike actionChannel, eventChannel is not an EffectCreator) that creates a channel whose event source is separate from the Redux Store. Use an example to briefly illustrate the following:

import { eventChannel, END } from 'redux-saga'
import { take, put, call } from 'redux-saga/effects'

function countdown(secs) {
  / * * * the eventChannel parameter for subscription function, the paradigm of emitter = > (() = > {} | | void) * each call emitter is triggered capture the channel (namely take (chan)) of the saga continues executing * /
  return eventChannel(emitter= > {
      const iv = setInterval(() = > {
        secs -= 1
        if (secs > 0) {
          /** * Data passed into an emitter can be obtained by yield take(chan) ** Officially recommended that the data structure for incoming data be pure functions, i.e., * Compared to an emitter(number), an emitter({number}) is better */
          emitter(secs)
        } else {
          /** * This operation will close the channel. END is an action defined by Redux-saga to close the channel. After closing the channel, no information will be passed to the channel
          emitter(END)
        }
      }, 1000);
      /** * if the result is a function, this function is used to unsubscribe the subscription, * is used when the channel is closed, */ is called inside redux-saga
      return () = > {
        clearInterval(iv)
      }
    }
  )
}

export function* saga() {
  const chan = yield call(countdown, 5)
  try {    
    while (true) {
      // Channel closure causes saga to jump directly to the finally block
      let seconds = yield take(chan)
      console.log(`countdown: ${seconds}`)}}finally {
    console.log('countdown terminated')}}Copy the code

After the above saga is sagamiddleware.run (saga), the page console will have the following effect:

Similarly, eventChannel supports a caching mode that uses buffers to control incoming information. The second parameter in eventChannel can be passed as a buffer parameter.

In this way, eventChannel puts the event source outside the Redux Saga. In fact, this is very extensible to the logic we normally use to write Redux Saga. Take a look at my article implementing lazy-load in Redux, which allows you to write fewer dispatch statements, and Lazy loading of data in Redux Stata via eventChannel.

The official website provides an eventchannel-based socket handling saga example, you can check it out.

ActionChannel source code analysis is available in Redux-Saga.

2. Composing Saga (Composing Sagas)

In general, we use yield statements to construct the logic for writing a saga. But here are two things to keep in mind when writing saga:

  1. Separate parts of logic into one, based on the single responsibility principlesaga. Avoid putting too much logic, too muchyieldIt’s all in onesagaSo that when testing, you have to write a bunch of repetitive code execution untilsagaRun to the section of code you want to test.
  2. Some combinations can be usedAPITo perform multiple tasks, thereby reducingsagatheyieldThe number of times.

In fact, the first point above is easy to understand, for example:

function* fetchPosts() {
  yield put(actions.requestPosts())
  const products = yield call(fetchApi, '/products')
  yield put(actions.receivePosts(products))
}

function* watchFetch() {
  while (yield take('FETCH_POSTS')) {
    yield call(fetchPosts) // waits for the fetchPosts task to terminate}}Copy the code

In the example above, the logic in watchFetch’s while is separated into a saga (fetchPosts), and instead fetchPosts are called with a call from the while. This reduces the complexity of watchFetch and improves the ease of testing.

The second point requires us to use all and race flexibly, and then learn how to use both in turn.

2.1 all

There are two parameter transmission modes for all. The following are introduced one by one:

  • all([...effects])

    Effects created in this way instruct sagaMiddleware to sequentially process the effects passed into its parameters, and then wait for all effects to finish and store the results in an array to return to Saga. The parameter is passed and returns the same result as promise.all. Examples are as follows:

    import { fetchCustomers, fetchProducts } from './path/to/api'
    import { all, call } from `redux-saga/effects`
    
    function* mySaga() {
      const [customers, products] = yield all([
        call(fetchCustomers),
        call(fetchProducts)
      ])
    }
    Copy the code

    When the Effect of Call (fetchCustomers) is processed, the result is placed on the first element in the array, customers in our example. And the same goes for everything else.

  • all(effects)

    Here the parameter Effects is an object, as shown in the following example:

    import { fetchCustomers, fetchProducts } from './path/to/api'
    import { all, call } from `redux-saga/effects`
    
    function* mySaga() {
      const { customers, products } = yield all({
        customers: call(fetchCustomers),
        products: call(fetchProducts)
      })
    }
    Copy the code

    Note that Effect in Effects is also executed sequentially. When the Effect of Call (fetchCustomers) is processed, the result is placed on the Customers property of the pure object. And the same goes for everything else.

    Pay attention toallIn the incomingEffectMust be usedtakeorcallThis kind of blockAPIGenerate, if passedforkThe generatedEffect, which may lead tosagaAfter the execution,EffectStill executing, as shown in the following example:

    function timeout(sec) {
      return new Promise((resolve) = > {
        setTimeout(() = > {
          console.log(`${sec}s pass`);
          resolve();
        }, sec * 1000);
      });
    }
    
    function* allSaga() {
      yield all([fork(timeout, 2), fork(timeout, 3)]);
      console.log("allSaga finishs");
    }
    Copy the code

    The following output is displayed after the allSaga command is executed:

When all is called, saga blocks until all effects are processed in the parameter. But if an Effect throws an error during processing, the saga exits the blocking state and stops execution. To keep a saga going, you can wrap a try-catch statement around its statement. As follows:

import { fetchCustomers, fetchProducts } from './path/to/api'
import { all, call } from `redux-saga/effects`

function* mySaga() {
  try{
    const { customers, products } = yield all({
      customers: call(fetchCustomers),
      products: call(fetchProducts)
    })
  }catch(error){
    // Processing error}}Copy the code

2.2 race

Race and All pass parameters in the same way, but effects in RACE are processed in parallel. Race has the same Effect as promise. race, that is, as soon as one of the parameters completes processing or throws an error, the blocking state ends:

  • race([...effects])

    As with all, we pass an array and return an array.

    import { take, call, race } from `redux-saga/effects`
    import fetchUsers from './path/to/fetchUsers'
    
    function* fetchUsersSaga() {
      const [response, cancel] = yield race([
        call(fetchUsers),
        take(CANCEL_FETCH)
      ])
    }
    Copy the code

    In the example above, the two effects generated by call(fetchUsers) and take(CANCEL_FETCH) are racing. Call (fetchUsers) is used to request back-end data. Take (CANCEL_FETCH) Is used to interrupt the call(fetchUsers) data request when the corresponding action is dispatched.

    When Effect corresponding to call(fetchUsers) completes first, race returns an array [response, cancel] in which response is the result returned by fetchUsers and cancel is undefined. If the Effect corresponding to take(CANCEL_FETCH) finishes processing first, that is, when the corresponding action is dispatched, cancel in [response, cancel] is the action to be dispatched and response is undefined.

    andallThe same,raceIn the incomingEffectMust be usedtakeorcallThis kind of blockAPIGenerate, if passedforkThe generatedEffect, which may lead tosagaAfter the execution,EffectStill executing, as shown in the following example:

    function* raceSaga() {
     yield race([fork(timeout, 2), fork(timeout, 3)]);
     console.log("raceSaga finishs");
    }
    Copy the code

    The output result of raceSaga is as follows:

  • race(effects)

    Pass an object like all and return an object.

    import { take, call, race } from `redux-saga/effects`
    import fetchUsers from './path/to/fetchUsers'
    
    function* fetchUsersSaga() {
      const { response, cancel } = yield race({
        response: call(fetchUsers),
        cancel: take(CANCEL_FETCH)
      })
    }
    Copy the code

    When Effect corresponding to call(fetchUsers) completes processing first, race returns {response, cancel} in which response is the result returned by fetchUsers and cancel is undefined. If the Effect corresponding to take(CANCEL_FETCH) completes first, cancel in {response, cancel} is the action to be dispatched and response is undefined.

Race applies to a number of scenarios, and the fetchUsersSaga example above shows an asynchronous request that can be terminated manually. Here is another example using race:

function* game(getState) {
  let finished
  while(! finished) {// has to finish in 60 seconds
    const {score, timeout} = yield race({
      score: call(play, getState),
      timeout: delay(60000)})if(! timeout) { finished =true
      yield put(showScore(score))
    }
  }
}
Copy the code

The example game above shows an action that needs to be completed within a limited time: Call (Play, getState) races with Delay (60000). If play is not completed within 60 seconds, delay(60000) will be returned after 60 seconds. In {score, timeout}, timeout is true (if you want to define the return value of delay, you can define the second parameter of delay. For example, delay(60000,’timeout’) returns ‘timeout’ after a timeout, and score is undefined. Use the following if statement to determine whether to timeout processing.

3. Concurrency

The takeEvery and takeLatest apis are often used to capture actions. The difference between the two is the treatment of the concurrency of Effect. TakeEvery allows multiple saga executions that handle the same action. The takeLatest value allows the saga execution of an action. If the same action is triggered more than once, only the latest saga will be retained and the previous saga will be cancelled.

Here’s how to implement the two apis using basic apis like take and fork:

takeEvery

import {fork, take} from "redux-saga/effects"

const takeEvery = (pattern, saga, ... args) = > fork(function* () {
  while (true) {
    const action = yield take(pattern)
    yieldfork(saga, ... args.concat(action)) } })Copy the code

takeLatest

import {cancel, fork, take} from "redux-saga/effects"

const takeLatest = (pattern, saga, ... args) = > fork(function* () {
  let lastTask
  while (true) {
    const action = yield take(pattern)
    if (lastTask) {
      // The yield fork returns a Task. If a previous Task exists, cancel the subtask
      // Cancel is an empty function if the subtask has completed or terminated
      yield cancel(lastTask) 
    }
    lastTask = yieldfork(saga, ... args.concat(action)) } })Copy the code

4. Fork Model

In Saga, we can schedule subtasks (which can be generators and functions) in the background through fork and spawn. But there is one difference between the two apis:

  • forkUsed to generateSubsidiary dispatch (attached forks)
  • spawnUsed to generateIndependent scheduling (detached forks)

Attached forks we refer to attached forks as attached forks and detached forks as detached forks. So what’s the difference between the two? Let’s begin one by one:

4.1 Attached forksforkCreate)

Attached forks = attached forks = attached to In this case, the attached forks are attached to the parent, the saga that initiated the dispatch (we call it the parent saga). Attached Forks execution cycles interact with parent Saga execution cycles, which is the difference between Attached Forks and detached forks in that the execution cycles of attached Forks are independent of any external factors. Next, we will talk about how attached Forks and parent Saga interact with each other in several cases.

4.1.1 Normal Execution

Normal execution refers to a saga that does not have a cancel and an internal throw error during execution. During normal execution, it terminates with the following two actions:

  1. The saga statement is complete

  2. Attached Forks initiated by Saga has been executed

Here’s an example:

function* delayTimeout(sec, err = false) {
  yield delay(sec * 1000);
  console.log(`${sec}s pass`);
  if (err) throw new Error(a); }function* fetchAllWithDelay() {
    yield fork(delayTimeout, 2);
    yield fork(delayTimeout, 3);
    console.log("fetchAllWithDelay finishs");
}

function* rootSaga() {
  /** Note that call is used here, not fork, because it is a blocking API, and the next print is not performed until the fetchAllWithDelay ends, so that it knows when the fetchAllWithDelay ends. * /
  yield call(fetchAllWithDelay);
  console.log('root finish');
}
Copy the code

After sagamiddleware.run (rootSaga), rootSaga executes fetch challwithdelay via call. In fetchAllWithDelay, two delayTimeout attached forks are initiated in turn. And then print “fetchAllWithDelay finishs”. At this point, the execution of the two attached forks is not complete, so fetchAllWithDelay waits until the execution of the two attached forks is complete. So, when rootSaga terminates, the console outputs the following:

fetchAllWithDelay finishs
2s pass
3s pass
root finish
Copy the code

4.1.2 Error Outgoing

Error passing is when an error is thrown internally by either attached fork or Parant Saga or a promise.reject is manually executed during execution.

To give you a direct example:

function* delayTimeout(sec, err = false) {
  yield delay(sec * 1000);
  console.log(`${sec}s pass`);
  if (err) throw new Error(a); }function* fetchAllWithDelay() {
    yield fork(delayTimeout, 2.true);
    yield fork(delayTimeout, 3);
    yield delay(4000)
    console.log("fetchAllWithDelay finishs");
}

function* rootSaga() {
  try {
    yield call(fetchAllWithDelay);
  } catch (error) {
    console.log('error from root',error);
  }
  console.log('root finish');
}
Copy the code

DelayTimeout is known to throw an error when the second parameter is true. FetchAllWithDelay is a parent saga, where attach fork:delayTimeout(2, true) throws an error.

As long asparent sagaError occurs,parent sagaWill do two things:

  1. To cancel what is attached to oneselfattached forks(not affected if it has already been executed)

  2. Terminates its execution and throws a message fromattach forkOr their own mistakes

According to the above rules, we can deduce the following process: After attach fork:delayTimeout(2, true) throws an error, fetchAllWithDelay terminates the other attached fork:delayTimeout(3, true) and then terminates its execution. Including the processing of delay(4000).

The final output in the console is as follows:

There are two things worth noting about what happens when errors get out:

  1. parent sagaWill cancel the attachment to itselfattached forkThe execution, but in fact she is terminated onattached forkThe output ofEffectAnd the surrender of enforcement authority is equivalent toattached forkInternally calledcancel. But if theattached forkIf there is no internal yield statementattached forkWill continue to execute, as shown below:

    function* delayTimeout(sec, err = false) {
      // yield delay(sec * 1000);
      // console.log(`${sec}s pass`);
      // if (err) throw new Error();
      // If we change the logic to the following, the attached fork generated based on that generator cannot be terminated
      return new Promise((resolve, reject) = > {
        setTimeout(() = > {
          console.log(`${sec}s pass`);
          if (err) {
            return reject(new Error());
          }
          resolve();
        }, sec * 1000);
      });
    }
    Copy the code

    After changing the above function, the final console output looks like this:

  2. About Error Handling

    Notice that we caught the error that the try-catch block was wrapped around the yield call(fetchAllWithDelay) instead of inside the fetchAllWithDelay. This is a rule of thumb: You cannot catch attached fork errors from fork statements, because errors from attached fork will cause Parent Saga to terminate its execution.

4.1.3 Cancel Behavior

Cancellation behavior refers to calling Cancel () inside the saga to cancel itself and calling Cancel (Task) inside the Parent Saga to cancel attached fork.

whenparent sagaCalled externallycancelOr inside itselfcancelWill result inparent sagaAnd what it is executingattached forkTermination.

Here’s an example:

function* fetchAllWithCancel() {
  yield fork(delayTimeout, 2);
  yield fork(delayTimeout, 3);
  yield delay(4 * 1000);
  console.log("fetchAllWithCancel finish");
}

function* cancelSaga() {
  const task = yield fork(fetchAllWithCancel);
  yield cancel(task);
}

function* rootSaga() {
  yield call(cancelSaga);
  console.log('root finish');
}
Copy the code

The final console output is as follows:

root finish
Copy the code

Detached forks 4.detached forksspawnCreate)

The implementation of the detached Fork and the implementation of the parent Saga do not affect each other:

  1. Parent Saga does not wait for the detached fork to finish executing before terminating

  2. An error thrown in the detached fork will not bubble up to the parent saga

  3. The detached fork of parent Saga will not terminate whether it is executing or finished executing when cancel is performed on the parent Saga

In short, the detached fork behaves like a saga executed directly by middleware.run.

5. Control Flow

Up to now, many people use takeEvery more than take in saga, because takeEvery requires fewer while blocks than take if the corresponding scenario only schedules subtasks on matched actions. But using Take gives you more flexibility with the trigger Flow in Sage, known on the website as Control Flow. Here’s an example where we need to design a Control Flow:

Suppose you now want to implement a requirement in a temperature monitoring system that triggers an alarm if the temperature exceeds a threshold four times within 10 seconds.

Redux-saga is a very clever way to do this by analyzing how to dispatch an action ({type:’EXCEED’}) every time the temperature exceeds a threshold. We can capture this action in Saga with take. The question is how do we count four consecutive times that the action is exceeded by 10 seconds? Then, when a new record is created, we compare the current timestamp with the timestamp in the header of the array. If the two are less than 10s, an alarm is triggered, and the timestamp in the header is removed and the current timestamp is inserted into the end. The relevant saga code is shown below:

function* watchSaga() {
  const timeRecord = [];
  let i = 0;
  while (i < 3) {
    yield take("EXCEED");
    timeRecord.push(new Date().getTime());
    i++;
  }
  while (true) {
    yield take("EXCEED");
    const currentTime = new Date().getTime();
    const previousTime = timeRecord.shift();
    if (currentTime - previousTime > 10 * 1000) {
      // send {type: "SHOW_ALERT"} display alarm
      yield put({ type: "SHOW_ALERT"}); } timeRecord.push(currentTime); }}Copy the code

By taking advantage of the fact that Saga is a generator, we can achieve this very cleverly. Imagine that a take is like a debugger breakpoint, controlling where the program is going and waiting until we want it to run again. This is the beauty of Control Flow.

A typical Control Flow-based example is also provided in the NonBlockingCalls section of the Redux-Saga website:

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

Usually a site is logged in and logged out, so you can cram the entire process onto a saga and then write the corresponding control flow logic. The official website provides a lot of details for this example, and you can read the link above for more details.

5. RootSaga Patterns

Start by explaining what RootSaga is. When sagamiddleware. run is called, the saga passed in as a parameter is RootSaga. Within RootSaga, other sagas are scheduled via apis such as fork. In this section, we will explore how best to start a saga subquest in RootSaga. The following describes and compares several commonly used scheduling writing methods:

  1. Mode 1: Non-blockingfork Effectscheduling

    export default function* rootSaga() {
      yield fork(saga1)
      yield fork(saga2)
      yield fork(saga3)
      // code after fork-effect
    }
    Copy the code

    This is a common pattern because fork is a non-blocking API and because the three saga schedules in the example above are executed in parallel. And the fork dispatcher returns a task descriptor, which we can operate on through apis like Cancel and Join.

    In addition, there is a more simplified version of the above logic:

    const [task1, task2, task3] = yield all([ fork(saga1), fork(saga2), fork(saga3) ])
    Copy the code

    However, there is a catch: if one of all the saga scheduled by rootSaga has an error thrown within it, the entire rootSaga and all the other saga being executed by rootSaga will terminate (as detailed in section 4. Fork Model **). And rootSaga cannot handle errors from scheduled saga by try-catch **.

  2. Pattern two: LetRoot SagaMaintain normal operation

    export default function* rootSaga() {
      yield spawn(saga1)
      yield spawn(saga2)
      yield spawn(saga3)
    }
    Copy the code

    The hidden problems in mode 1 can be solved in this mode, because spawn is a separate schedule whose saga is decouped from Root saga. Thus, errors from one saga will not bubble up to the Root saga and cause it to terminate.

  3. Pattern three: Keep everything running

    The writing method in Mode 2 basically solves the basic problems in mode 1, but there are still two small disadvantages:

    • One of the scheduled saga will stop running if an error is thrown

    • The scheduled saga throws an error without processing its error

    Here’s a way to make up for it:

    function* rootSaga () {
      const sagas = [saga1, saga2, saga3];
    
      yield all(sagas.map(saga= >
        spawn(function* () {
          while (true) {
            try {
              // A saga error exits the call block, and the while loop calls call again to re-block the saga execution
              yield call(saga)
              break
            } catch (e) {
              // Error handling can be customized, such as reporting runtime errors
              console.log(e)
            }
          }
        }))
      );
    }
    Copy the code

6. Task Cancellation

Task refers to the Task created by call and fork scheduling saga, which is called Task. Once a Task has been created, we can cancel the Task in two ways:

  • External cancel: Cancel with yield Cancel (Task) in parent saga.

  • Internal cancel: Call yield Cancel () internally to cancel.

This section focuses on the details of when cancel is called.

For the sake of understanding, let’s assume a scenario where there is a switch component in the front end page. When the component is on, the front end periodically synchronizes some data from the back end, and when the component is off, the synchronization process stops. For this scenario, we can set the corresponding action to be delivered on and off. Here we assume that action ({type:’START_SYNC’}) is delivered on and action ({type:’STOP_SYNC’}) is delivered on and off. This can be achieved through the following saga:

import { take, put, call, fork, cancel, cancelled, delay } from 'redux-saga/effects'
import { someApi, actions } from 'somewhere'

function* syncSaga() {
  try {
    while (true) {
      // The synchronization is in progress
      yield put({type: 'SHOW_SYNC_PENDING'})
      // Call an asynchronous method to fetch back-end data and store it in the store
      const result = yield call(someApi)
      yield put({type: 'SAVE_SYNC_DATA'.payload: {data: result}})
      // The synchronization is complete
      yield put({type: 'SHOW_SYNC_SUCCESS'})
      // Synchronize data again after an interval of five seconds
      yield delay(5000)}/ * * * when syncTask is cancel, will lead to the Generator. The prototype. The execution of return * making syncTask internal running jump straight to the finally block * /
  } finally {
    /** * checks whether the Task itself has been cancelled by cancelled. If it has not been cancelled, * blocks until cancelled. * /
    if (yield cancelled())
      // The synchronization has stopped
      yield put({type: 'SHOW_SYNC_STOP'}}})function* main() {
  // When the switch component is enabled, action ({type:'START_SYNC'}) is dispatched and 'main' begins execution in the while block
  while ( yield take('START_SYNC')) {// Schedule syncSaga to generate syncTask
    const syncTask = yield fork(syncSaga)
    // Wait for action:({type:'STOP_SYNC'}) to be dispatched
    yield take('STOP_SYNC')
    // Cancel syncTask execution when the switch component is turned off
    yield cancel(syncTask)
  }
}
Copy the code

Cancelling an executing Task not only causes it to jump to a finally block (note: some sagas may not have a finally block written), but also cancelling effects that are generated by Task and are being handled by sagaMiddleware. Here’s an example:

function* main() {
  const task = yield fork(subtask)
  ...
  yield cancel(task)
}

function* subtask() {
  yield call(subtask2)
}

function* subtask2() {
  yield call(someApi)
}
Copy the code

When the yield Cancel (task) is performed in main, the subtask and the call Effect that subtask2 is being handled are cancelled. The execution of its cancellation behavior forms a chain reaction from subtask to Subtask2 to someApi. We can see that the reaction process propagates downward (for example, error throws and bubbling events propagate upward).

Caller is the caller of an asynchronous operation, and Callee is the callee of an asynchronous operation. As an example, caller is the caller of an asynchronous operation, and Callee is the called.

  • subtaskandsubtask2iscallerandcalleeThe relationship between
  • subtask2andsomeApiiscallerandcalleeThe relationship between

When the caller cancells the callee in progress, it triggers a cascade of downward-propagating responses. When a callee is cancelled, if the callee is also a caller (as in subtask2 above), the cancellation will also be performed on the corresponding Callee.

In addition to the downward propagation mentioned above, cancellation also has another propagation direction. As mentioned in section 4.1.3 Cancellation, Cancel cancels Saga and its attacked fork. Therefore, not only Callee will be cancelled, but also Fork will be cancelled. As caller, Callee under Fork will also be cancelled.

7. Testing

Reading this section requires that you have code experience in unit testing Saga. If you haven’t already, check out the section I wrote earlier about testing Saga with Jest.

Redux-saga officially provides a library for testing, @redux-saga/testing-utils, but there are only two methods in this library: CloneableGenerator and createMockTask, with the help of these two methods, we can basically complete all simple or complex saga unit tests. Let’s talk about what these two methods can do in turn.

** Note: @redux-saga/testing-utils should be installed independently via NPM I @redux-saga/testing-utils -d **.

7.1 cloneableGenerator

Suppose the following is the saga we want to test:

export function* setColorWhenModeChange() {
  const action = yield take("CHANGE_MODE");
  switch (action.payload.mode) {
    case 0:
      yield put({ type: "SET_COLOR".payload: { color: "white"}});break;
    case 1:
      yield put({ type: "SET_COLOR".payload: { color: "black"}});break;
    default:
      break; }}Copy the code

When mode is set in UI interaction, action ({type:”CHANGE_MODE”}) is distributed. If mode is set to 0, the color is set to white. If the mode value is set to 1, set the color to black.

For saga above, there are conditional judgments (if or switch). In unit testing, without external methods, we need to initialize a corresponding number of iterators to determine how many branches there are in a block of statements, which increases the code’s complexity. At this point, we can solve this problem with cloneableGenerator, as shown in the following code:

import { cloneableGenerator } from "@redux-saga/testing-utils";
import { setColorWhenModeChange } from "./saga";

describe("setColorWhenModeChange testing".() = > {
  // Call cloneableGenerator to generate gen
  const gen = cloneableGenerator(setColorWhenModeChange)();
  
  // The first statement is yield take('CHANGE_MODE')"
  test("test: yield take('CHANGE_MODE')".() = > {
    expect(gen.next().value).toEqual(take("CHANGE_MODE"));
  });

  // Test Effect in the switch block
  // Yield PUT ({type:'SET_COLOR',payload:{color:'white'}})
  test("test: yield put({type:'SET_COLOR',payload:{color:'white'}})".() = > {
    /** * Clone can be called to generate a copy of gen. * The execution position of the copy remains the same as that of gen when Clone was called. We don't need to initialize a new iterator and walk through the common process */ each time we test the branch
    const clone = gen.clone();
    expect(
      clone.next({ type: "CHANGE_MODE".payload: { mode: 0 } }).value
    ).toEqual(put({ type: "SET_COLOR".payload: { color: "white"}})); });// Yield PUT ({type:'SET_COLOR',payload:{color:'black'}})
  test("test: yield put({type:'SET_COLOR',payload:{color:'white'}})".() = > {
    const clone = gen.clone();
    expect(
      clone.next({ type: "CHANGE_MODE".payload: { mode: 1 } }).value
    ).toEqual(put({ type: "SET_COLOR".payload: { color: "black"}})); }); });Copy the code

7.2 createMockTask

Let’s unit test the main method in the Task cancellation example. Let’s look back at the code in the previous example:

// In the unit test of main, we don't need to worry about the logic in syncSaga
export function* syncSaga() {}

export function* main() {
  // When the switch component is enabled, action ({type:'START_SYNC'}) is dispatched and 'main' begins execution in the while block
  while (yield take("START_SYNC")) {
    // Schedule syncSaga to generate syncTask
    const syncTask = yield fork(syncSaga);
    // Wait for action:({type:'STOP_SYNC'}) to be dispatched
    yield take("STOP_SYNC");
    // Cancel syncTask execution when the switch component is turned off
    yieldcancel(syncTask); }}Copy the code

In the abovemainIn,forkGenerated tasksTask. How do you simulate this in a unit testTask?@redux-saga/testing-utilsProvided in thecreateMockTaskMethod solves this problem for us. Now let’s look at themainUnit test code for:

import { createMockTask } from "@redux-saga/testing-utils";
import { main, syncSaga } from "./saga";

describe("main testing".() = > {
  // Generate iterators
  const gen = main();

  // Test while (yield take("START_SYNC")) without explaining the basic operation
  test('test: yield take("START_SYNC")'.() = > {
    expect(gen.next().value).toEqual(take("START_SYNC"));
  });

  // Test yield fork(syncSaga)
  test("test: yield fork(syncSaga)".() = > {
    /** * action:{type: * If the yield take("START_SYNC") does not return a true value, * if the yield take("START_SYNC") does not return a true value, * the value in the while parenthesis is false, and you cannot walk into the while block */
    const mockAction = { type: "START_SYNC" };
    expect(gen.next(mockAction).value).toEqual(fork(syncSaga));
  });

  test('test: yield take("STOP_SYNC") and yield cancel(syncTask)'.() = > {
    /** * We need not pay attention to the internal logic of the task, * we only need to know its running state, because the internal logic of the task is not affected by the outside world, but its running state can be changed by the parent saga * therefore, We can call createMockTask to generate a mockTask and place it in Gen.next to test the cancel logic */ later
    const mockTask = createMockTask();
    // Test yield take("STOP_SYNC") here, basic operation, but be careful to put the generated mockTask into gen.next
    expect(gen.next(mockTask).value).toEqual(take("STOP_SYNC"));
    /** * Test yield Cancel (syncTask) * syncTask is the mockTask you just put in, * so call Cancel to generate an Effect comparison */
    expect(gen.next().value).toEqual(cancel(mockTask));
  });
});
Copy the code

7.3 Two Test Modes

There are two ways to test saga:

  1. Step by step test generator functions: This is the same idea as all the previous test cases, which compare the effects produced by saga one by one.

  2. Run the entire middleware and assert its boundary effects: This pattern is to call a Mock sagaMiddleware to run saga, and then dispatch corresponding actions through the Mock sagaMiddleware to capture internal reactions.

There are third-party libraries for both of the above patterns. Redux-sag-testing is recommended for the first pattern and Redux-Sag-Tester for the second. You can read for yourself.

8. Apis for Your Taste (Recipes)

Here are three common apis built into Redux-Saga: Throttle, Debounce, and Retry.

8.1 Throttle to throttling functions

Throttling, as we all know, means that no matter how many times a method is called, it will only be executed once in a given period of time. With that in mind, let’s go straight to the example of throttle built into Redux-Saga:

import { throttle } from 'redux-saga/effects'

function* handleInput(input) {
  // ...
}

function* watchInput() {
  yield throttle(500.'INPUT_CHANGED', handleInput)
}
Copy the code

When watchInput is executed, the action ({type:’INPUT_CHANGED’}) within 500ms will only be executed once, even if it is issued multiple times.

8.2 debounce~ Anti – shake function

It is known that shaking prevention means that each time a method is called, the method will be delayed for a specified time before execution. If the method is called again within the delay time, the last call will be canceled and the execution will be delayed after a specified time on the basis of the latest call. With that in mind, let’s go straight to the example of using debounce built into Redux-Saga:

import { debounce } from `redux-saga/effects`

function* handleInput(action) {
  / /...
}

function* watchInput() {
  yield debounce(500.'INPUT_CHANGED', fetchAutocomplete)
}
Copy the code

When debounceAutocomplete is executed, fetchAutocomplete is executed 1000ms after action ({type:’FETCH_AUTOCOMPLETE’}) is dispatched. We can implement the above logic with some basic apis, as follows:

import { call, cancel, fork, take, delay } from 'redux-saga/effects'

function* handleInput(input) {
  // debounce by 500ms
  yield delay(500)... }function* watchInput() {
  let task
  while (true) {
    const { input } = yield take('INPUT_CHANGED')
    if (task) {
      yield cancel(task)
    }
    task = yield fork(handleInput, input)
  }
}
Copy the code

8.3 retry to try again

This API is similar to call calling asynchronous methods, but with a retry mechanism that specifies how many times to call again and how long to execute each call. It should be wrapped in a try~catch statement. Examples are as follows:

import { put, retry } from 'redux-saga/effects'
import { request } from 'some-api';

function* retrySaga(data) {
  try {
    const response = yield retry(3.10 * 1000, request, data)
    yield put({ type: 'REQUEST_SUCCESS'.payload: response })
  } catch(error) {
    yield put({ type: 'REQUEST_FAIL'.payload: { error } })
  }
}
Copy the code

In retrySaga above, the asynchronous method request can be called three times, and each call will fail if it takes more than 10 seconds to execute. If the first call to Request fails due to 10 seconds or network error, request will be called again until the call succeeds or the number of calls exceeds 3.

Afterword.

This article has been written for a long time. If you think it is useful, please give a thumbs-up. If you have any questions, please leave a message at any time.