In the last article we looked at the source code for Redux-Thunk and saw that its code is very simple, allowing Dispatch to handle function-type actions. The authors acknowledge that Redux-Thunk is not suitable for complex scenarios and recommend Redux-Saga to handle complex side effects. This article focuses on Redux-Saga, which is the Redux asynchronous solution I use most in my work. Redux-saga is much more complex than Redux-thunk, and its entire asynchronous process is handled using a Generator, which is also a precursor to this article if you are not familiar with generators.

In this article, we will continue to do the same. We will start with a simple example of Redux-Saga, and then we will write our own redux-Saga to replace it, the source code analysis.

The working code for this article has been uploaded to GitHub for fun:Github.com/dennis-jian…

A simple example

Web requests are asynchronous operations that we often need to deal with. Suppose a simple request we have now is to click a button to request user information, something like this:

This requirement is also easy to implement using Redux, which dispatches an action when a button is clicked. This action triggers a request for the data to be displayed on the page:

import React from 'react';
import { connect } from 'react-redux';

function App(props) {
  const { dispatch, userInfo } = props;

  const getUserInfo = () = > {
    dispatch({ type: 'FETCH_USER_INFO'})}return (
    <div className="App">
      <button onClick={getUserInfo}>Get User Info</button>
      <br></br>
      {userInfo && JSON.stringify(userInfo)}
    </div>
  );
}

const matStateToProps = (state) = > ({
  userInfo: state.userInfo
})

export default connect(matStateToProps)(App);
Copy the code

Redux-saga is used after dispatch({type: ‘FETCH_USER_INFO’}). Redux-saga is used after dispatch({type: ‘FETCH_USER_INFO’}). According to the general process of Redux, FETCH_USER_INFO is sent and should be processed by reducer. However, Reducer is not suitable for making network requests because it is synchronous code. Therefore, we can use redux-saga to capture FETCH_USER_INFO and process it.

Redux-saga is a Redux middleware, so we introduced it in createStore:

// store.js

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import reducer from './reducer';
import rootSaga from './saga';

const sagaMiddleware = createSagaMiddleware()

let store = createStore(reducer, applyMiddleware(sagaMiddleware));

// Notice here that sagaMiddleware is placed after Redux as middleware
// You also need to start it manually to run rootSaga
sagaMiddleware.run(rootSaga);

export default store;
Copy the code

Notice this line in the code above:

sagaMiddleware.run(rootSaga);
Copy the code

Sagamiddleware. run is used to start rootSaga manually.

import { call, put, takeLatest } from 'redux-saga/effects';
import { fetchUserInfoAPI } from './api';

function* fetchUserInfo() {
  try {
    const user = yield call(fetchUserInfoAPI);
    yield put({ type: "FETCH_USER_SUCCEEDED".payload: user });
  } catch (e) {
    yield put({ type: "FETCH_USER_FAILED".payload: e.message }); }}function* rootSaga() {
  yield takeEvery("FETCH_USER_INFO", fetchUserInfo);
}

export default rootSaga;
Copy the code

Export is a Generator for rootSaga, which has a single line:

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);
Copy the code

This line of code uses a redux-saga effect called takeEvery, which listens for each FETCH_USER_INFO and calls the fetchUserInfo function when the FETCH_USER_INFO occurs. Notice that this is each FETCH_USER_INFO. That is, if we issue multiple FETCH_USER_INFO at the same time, each of us will respond and initiate the request. Similarly, takeLatest, as the name suggests, responds to the last request, depending on the specific requirement.

And then fetchUserInfo, which is not too complicated, just calls an API called fetchUserInfoAPI to get the data, and notice that the function call is not directly fetchUserInfoAPI(), Instead, we use the Redux-Saga call effect, which makes it much easier to write unit tests. We’ll look at this later when we talk about the source code. After we get the data, we call put to issue FETCH_USER_SUCCEEDED, which is similar to the DISPATCH in Redux and is also used to issue the action. This way our reducer can get the FETCH_USER_SUCCEEDED and process it, which is not much different from the previous reducer.

// reducer.js

const initState = {
  userInfo: null.error: ' '
};

function reducer(state = initState, action) {
  switch (action.type) {
    case 'FETCH_USER_SUCCEEDED':
      return { ...state, userInfo: action.payload };
    case 'FETCH_USER_FAILED':
      return { ...state, error: action.payload };
    default:
      returnstate; }}export default reducer;
Copy the code

From the code structure of this example we can see:

  1. Actions are divided into two types, those that trigger asynchronous processing and those that are normal synchronous actions.

  2. Asynchronous actions use redux-saga to listen, and can use takeLatest or takeEvery to handle concurrent requests.

  3. Specific saga implementations can use the methods provided by Redux-Saga, such as Call, PUT, etc., to make unit tests easier to write.

  4. An action can be responded to by redux-saga and Reducer at the same time. For example, FETCH_USER_INFO was sent and I wanted to turn the page around.

    .case 'FETCH_USER_INFO':
          return { ...state, isLoading: true}; .Copy the code

Handwritten source

From the above example, we can see that redux-saga runs with this line of code:

sagaMiddleware.run(rootSaga);
Copy the code

The entire redux-Saga operation does not conflict with the original Redux, Redux does not even know it exists, there is very little coupling between them, and they only communicate with each other through put and action when needed. So I guess, he must have implemented a completely independent asynchronous task processing mechanism, let’s start with a sense of API, step by step to explore the mystery of his source code. All the code in this article refers to the official source code, function name and variable name as far as possible to keep consistent, write specific methods when I will post the corresponding code address, the main code is here :github.com/redux-saga/…

Let’s take a look at some of the apis we use, which are the targets of today’s handwriting:

  1. createSagaMiddlewareThis method returns an instance of middlewaresagaMiddleware
  2. sagaMiddleware.run: This method is really running what we wrotesagaThe entrance of the
  3. TakeEvery: This method is used to control concurrent processes
  4. Call: Used to call other methods
  5. putFrom:actionTo andReduxcommunication

Start with middleware

When we talked about the source code of Redux, we analyzed the principle and paradigm of Redux middleware in detail. A middleware might look like this:

function logger(store) {
  return function(next) {
    return function(action) {
      console.group(action.type);
      console.info('dispatching', action);
      let result = next(action);
      console.log('next state', store.getState());
      console.groupEnd();
      return result
    }
  }
}
Copy the code

This is essentially a paradigm for Redux middleware:

  1. A middleware receiverstoreAs an argument, a function is returned
  2. The function returned accepts the olddispatchFunction as arguments (i.e., abovenext), returns a new function
  3. The new function returned is newdispatchFunction, the inside of this function can be passed in two layersstoreAnd the olddispatchfunction

Following this paradigm and the previous use of createSagaMiddleware, we can first write the skeleton of this function:

// sagaMiddlewareFactory is the createSagaMiddleware we use outside
function sagaMiddlewareFactory() {
  // Returns a Redux middleware
  // Needs to fit his paradigm
  const sagaMiddleware = function (store) {
    return function (next) {
      return function (action) {
        // Write the content empty
        let result = next(action);
        returnresult; }}}// sagaMiddleware also has a run method
  // Is used to start saga
  // Let's leave it blank
  sagaMiddleware.run = () = >{}return sagaMiddleware;
}

export default sagaMiddlewareFactory;
Copy the code

Combing architecture

Now that we have an empty skeleton, what do we do next? As we said earlier, Redux-Saga probably implemented a completely independent asynchronous event handling mechanism on its own. This asynchronous event processing mechanism requires a processing center to store events and handlers, and a method to trigger the execution of events in the queue. Looking back at the apis we used earlier, we found two apis with similar functionality:

  1. takeEvery(action, callback): The parameter he receives isactionandcallbackAnd we are at the rootsagaIt may be called multiple times to register differencesactionThis is essentially stuffing events into the processing center.
  2. put(action):putThe parameter isaction, its only function is to trigger the corresponding event callback run.

As you can see, the Redux-Saga mechanism uses takeEvery to register the callback and then uses PUT to send a message to trigger the callback execution, which is very similar to the publisk-subscribe model we’ve talked about many times in other articles.

Handwritten channel

Channel is where redux-saga saves the callback and triggers the callback.

export function multicastChannel() {
  const currentTakers = [];     // A variable stores all of our registered events and callbacks

  // A function that holds events and callbacks
  // take accepts callback cb and matcher in redux-saga
  // In fact, the event names taken are encapsulated in matcher
  function take(cb, matcher) {
    cb['MATCH'] = matcher;
    currentTakers.push(cb);
  }

  function put(input) {
    const takers = currentTakers;

    for (let i = 0, len = takers.length; i < len; i++) {
      const taker = takers[i]

      // Where 'MATCH' is the MATCH method inserted by take above
      // If a match is found, the callback is executed
      if (taker['MATCH'](input)) { taker(input); }}}return {
    take,
    put
  }
}
Copy the code

One of the oddities in the code above is that the matcher is placed as a property on the callback function. I think the reason for this is to allow external customization of matching methods, rather than simply event name matching. In fact, Redux-Saga already supports several matching modes, including strings, symbols, arrays, and so on.

Built-in support for matching methods can be found here: github.com/redux-saga/… .

The source code for channel can be seen here: github.com/redux-saga/…

With a channel, there is only one more thing we need to do in our middleware: call channel.put to send the received action to the channel to execute the callback, so we add a line of code:

/ /... Omit the previous code

const result = next(action);

channel.put(action);     // Send the received action to Redux-Saga as well

return result;

/ /... Omit the following code
Copy the code

sagaMiddleware.run

The previous put is the event that executes the callback, but our callback is not registered yet, so where should the registered callback be? There seems to be only one place left, sagamiddleware.run. In simple terms, sagamiddleware. run takes a Generator as an argument, executes the Generator, and registers it with a channel when a take is encountered. Here we first implement take, take every is implemented on this basis. This code in Redux-Saga extracts a single file, so let’s do the same.

Firstly, the getState and Dispatch parameters of Redux need to be passed into the middleware. Redux-saga uses bind function, so the middleware method is modified as follows:

function sagaMiddleware({ getState, dispatch }) {
  // 将getState, dispatch通过bind传给runSaga
  boundRunSaga = runSaga.bind(null, {
    channel,
    dispatch,
    getState,
  })

  return function (next) {
    return function (action) {
      const result = next(action);

      channel.put(action);

      returnresult; }}}Copy the code

Then sagamiddleware. run directly runs boundRunSaga:

sagaMiddleware.run = (. args) = >{ boundRunSaga(... args) }Copy the code

Notice the… Args, this is actually the rootSaga that we passed in. Here in fact, the middleware part has been completed, the following code is the specific implementation process.

Middleware corresponding source can see here: github.com/redux-saga/…

runSaga

RunSaga is actually the real sagamiddleware. run. From the previous analysis, we know that its function is to receive the Generator and execute it. If it encounters a take, it will register it with a channel. But Redux-Saga breaks this process down into several layers, one at a time. The runSaga argument is passed to context-dependent variables like getState and Dispatch via bind, and then to rootSaga at runtime, so it should look like this:

import proc from './proc';

export function runSaga({ channel, dispatch, getState }, saga, ... args) {
  // saga is a Generator. When run, it gets an iterator
  constiterator = saga(... args);const env = {
    channel,
    dispatch,
    getState,
  };

  proc(env, iterator);
}
Copy the code

As you can see runSaga simply runs the Generator, gets the iterator object and calls proc to handle it.

RunSaga can be found at github.com/redux-saga/…

proc

Proc is the process of executing this iterator. Generator execution is described in detail in another article. In short, we can write another method next to execute Generator. Then the outer layer calls Next to start the process.

export default function proc(env, iterator) {
  // Call next to start iterator execution
  next();

  // The next function is not complicated
  // Execute iterator
  function next(arg, isErr) {
    let result;
    if (isErr) {
      result = iterator.throw(arg);
    } else {
      result = iterator.next(arg);
    }

    // If he doesn't finish, proceed to next
    // digestEffect is a function that processes the value returned by the current step
    // Next is called by him
    if(! result.done) { digestEffect(result.value, next) } } }Copy the code

digestEffect

If the iterator does not complete, we will pass its value to digestEffect. What is the value of result.value? Think back to rootSaga

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);
Copy the code

The value of result.value should be the yield value, which is the return value of takeEvery(“FETCH_USER_INFO”, fetchUserInfo). TakeEvery is the rewrapped effect that wraps take, Fork these simple effects. For simple effects like take, such as:

take("FETCH_USER_INFO", fetchUserInfo);
Copy the code

The return value of this line of code is simply an object, like this:

{
  IO: true.type: 'TAKE'.payload: {},}Copy the code

Therefore, the result.value received by digestEffect is also such an object, and this object represents our effect, so our digestEffect looks like this:

function digestEffect(effect, cb) {    // Next is next
    // This variable is used to solve competition problems
    let effectSettled;
    function currCb(res, isErr) {
      // If it has already been run, return
      if (effectSettled) {
        return
      }

      effectSettled = true;

      cb(res, isErr);
    }

    runEffect(effect, currCb);
  }
Copy the code

runEffect

As you can see, digestEffect calls another function, runEffect, which handles the specific effect:

// runEffect simply takes the type handler and uses it to process the current effect
function runEffect(effect, currCb) {
  if (effect && effect.IO) {
    const effectRunner = effectRunnerMap[effect.type]
    effectRunner(env, effect.payload, currCb);
  } else{ currCb(); }}Copy the code

As you can see from this code, runEffect only checks the effect, obtains the corresponding handler function from its type, and then processes it. I have simplified the code here, only supports the effect of IO. The official source code also supports promise and iterator. Github.com/redux-saga/…

effectRunner

EffectRunner is a specific effect handler that is matched by effect.type. Let’s look at two first: take and fork.

runTakeEffect

The effecTrunnermap. js file is created and the take handler runTakeEffect is added:

// effectRunnerMap.js

function runTakeEffect(env, { channel = env.channel, pattern }, cb) {
  const matcher = input= > input.type === pattern;

  // Notice that the second argument to channel.take is matcher
  // Let's write a simple matcher where the input type must be the same as pattern
  // The pattern here is the action name we usually use, such as FETCH_USER_INFO
  // Redux-Saga not only supports this string, but also supports various forms and can be parsed by custom matcher
  channel.take(cb, matcher);
}

const effectRunnerMap = {
  'TAKE': runTakeEffect,
};

export default effectRunnerMap;
Copy the code

Take (cb, matcher); Cb inside, cb is actually the next of our iterator, which means that the callback to take is the iterator to continue, which means to continue the following code. That is, when you write like this:

yield take("SOME_ACTION");
yield fork(saga);
Copy the code

When run to yield take(“SOME_ACTION”); This line of code blocks the entire iterator and will not run further. Unless you trigger SOME_ACTION, at which point the SOME_ACTION callback will be executed. This callback is the next iteration of the iterator, so you can continue with the following line of code: Yield fork(saga).

runForkEffect

The previous example code didn’t use the fork API directly, but used takeEvery, which is a combination of take and fork, so let’s look at fork first. The use of fork is similar to that of call. The method passed in can be called directly, except that the call waits for the result to come back before proceeding to the next step. Fork does not block the process, but runs the next step without the result coming back:

fork(fn, ... args);Copy the code

So when we get fork, it’s easy to just call proc to process fn, which should be a Generator function.

function runForkEffect(env, { fn }, cb) {
  const taskIterator = fn();    // Run fn to get an iterator

  proc(env, taskIterator);      // Give the taskIterator directly to the proc

  cb();      // Call cb directly without waiting for the result of the proc
}
Copy the code

runPutEffect

Our previous example also uses the put effect, which makes it even simpler to issue an action, but it also calls Redux’s Dispatch to issue the action:

function runPutEffect(env, { action }, cb) {
  const result = env.dispatch(action);     / / directly dispatch (action)

  cb(result);
}
Copy the code

Note that our code only needs to dispatch(Action) and does not need to manually call channel.put, because our middleware has already modified the dispatch method to automatically call channel.put every time we dispatch.

runCallEffect

We used call to make the API request, and we usually use axios to return a promise, so we’ll write a case that supports promises, as well as normal synchronization functions:

function runCallEffect(env, { fn, args }, cb) {
  const result = fn.apply(null, args);

  if (isPromise(result)) {
    return result
      .then(data= > cb(data))
      .catch(error= > cb(error, true));
  }

  cb(result);
}
Copy the code

The source code for these effects is available in this file: github.com/redux-saga/…

effects

Above we have described several specific methods for handling effects, but these are not exposed Effect apis. The actual exposed Effect apis need to be written separately. They are all very simple and return a simple object with type:

const makeEffect = (type, payload) = > ({
  IO: true,
  type,
  payload
})

export function take(pattern) {
  return makeEffect('TAKE', { pattern })
}

export function fork(fn) {
  return makeEffect('FORK', { fn })
}

export function call(fn, ... args) {
  return makeEffect('CALL', { fn, args })
}

export function put(action) {
  return makeEffect('PUT', { action })
}
Copy the code

You can see that when we use Effect, its return value is just an object describing the current task, which makes our unit tests a lot easier to write. Because our code running in different environments can produce different results, especially for these asynchronous requests, it can be a hassle to build this data when we write unit tests. However, if you use the Redux-Saga effect, every time you run your code you get a task description object, which is stable and not affected by the results of the run, so you don’t need to build test data for it, which greatly reduces the workload.

The source files for Effects can be found here: github.com/redux-saga/…

takeEvery

We also used takeEvery to handle multiple requests initiated at the same time. This API is a high-level API, which encapsulates the previous take and fork to achieve, and the official source code constructs a new iterator to combine them, which is not very intuitive. This is easier to understand in the official documentation, so I’ll use it here:

export function takeEvery(pattern, saga) {
  function* takeEveryHelper() {
    while (true) {
      yield take(pattern);
      yieldfork(saga); }}return fork(takeEveryHelper);
}
Copy the code

The above code is easy to understand, we keep listening for pattern in an endless loop, that is, the target event. When the target event comes, we execute the corresponding saga, and then enter the next loop to continue listening for pattern.

conclusion

At this point, the API used in our example is fully self-implemented. We can replace the official redux-Saga with our own, but we have only implemented some of its features, and there are many other features that are not implemented, but this does not prevent us from understanding its basic principles. To recap his main points:

  1. Redux-SagaIt’s also a publish-subscribe model, where events are managedchannelTwo key pointsAPI:takeandput.
  2. takeIs to register an event tochannelWhen the event comes, the callback is triggered. It is important to note that the callback is only for the iteratornextIs not a function that responds specifically to events. That is to say,takeI am waiting for such and such event, this event is not allowed to go down, after the coming can go down.
  3. putIs the issue of the event that he is usingRedux dispatchOf the event, which meansputThe event will beReduxandRedux-SagaSimultaneous response.
  4. Redux-SagaTo enhance theReduxthedispatchFunction,dispatchAt the same time will triggerchannel.putThat is, letRedux-SagaAlso responds to callbacks.
  5. What we calleffectsIt’s separate from the function that actually implements the function, and it’s called on the surfaceeffectsReturns a simple object that describes the current task, which is stable, so based oneffectsUnit tests are easy to write.
  6. When geteffectsAfter returning the object, we then base it on histypeGo to the corresponding handler to do the processing.
  7. The wholeRedux-SagaAre based onGeneratorEach step down requires a manual callnextSo that when it’s halfway through we can stop calling it, depending on the situationnext“, which is equivalent to the current taskcancel.

The working code for this article has been uploaded to GitHub for fun:Github.com/dennis-jian…

The resources

Redux-saga official documentation: redux-saga.js.org/

Redux-saga source code: github.com/redux-saga/…

At the end of this article, thank you for your precious time to read this article. If this article gives you a little help or inspiration, please do not spare your thumbs up and GitHub stars. Your support is the motivation of the author’s continuous creation.

Welcome to follow my public numberThe big front end of the attackThe first time to obtain high quality original ~

“Front-end Advanced Knowledge” series:Juejin. Cn/post / 684490…

“Front-end advanced knowledge” series article source code GitHub address:Github.com/dennis-jian…