手寫Redux-Saga原始碼

蔣鵬飛發表於2020-10-19

上一篇文章我們分析了Redux-Thunk的原始碼,可以看到他的程式碼非常簡單,只是讓dispatch可以處理函式型別的action,其作者也承認對於複雜場景,Redux-Thunk並不適用,還推薦了Redux-Saga來處理複雜副作用。本文要講的就是Redux-Saga,這個也是我在實際工作中使用最多的Redux非同步解決方案。Redux-SagaRedux-Thunk複雜得多,而且他整個非同步流程都使用Generator來處理,Generator也是我們這篇文章的前置知識,如果你對Generator還不熟悉,可以看看這篇文章

本文仍然是老套路,先來一個Redux-Saga的簡單例子,然後我們自己寫一個Redux-Saga來替代他,也就是原始碼分析。

本文可執行的程式碼已經上傳到GitHub,可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga

簡單例子

網路請求是我們經常需要處理的非同步操作,假設我們現在的一個簡單需求就是點選一個按鈕去請求使用者的資訊,大概長這樣:

Sep-11-2020 16-31-55

這個需求使用Redux實現起來也很簡單,點選按鈕的時候dispatch出一個action。這個action會觸發一個請求,請求返回的資料拿來顯示在頁面上就行:

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);

上面這種寫法都是我們之前講Redux就介紹過的Redux-Saga介入的地方是dispatch({ type: 'FETCH_USER_INFO' })之後。按照Redux一般的流程,FETCH_USER_INFO被髮出後應該進入reducer處理,但是reducer都是同步程式碼,並不適合發起網路請求,所以我們可以使用Redux-Saga來捕獲FETCH_USER_INFO並處理。

Redux-Saga是一個Redux中介軟體,所以我們在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));

// 注意這裡,sagaMiddleware作為中介軟體放入Redux後
// 還需要手動啟動他來執行rootSaga
sagaMiddleware.run(rootSaga);

export default store;

注意上面程式碼裡的這一行:

sagaMiddleware.run(rootSaga);

sagaMiddleware.run是用來手動啟動rootSaga的,我們來看看rootSaga是怎麼寫的:

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;

上面的程式碼我們從export開始看吧,export的東西是rootSaga這個Generator函式,這裡面就一行:

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

這一行程式碼用到了Redux-Saga的一個effect,也就是takeEvery,他的作用是監聽每個FETCH_USER_INFO,當FETCH_USER_INFO出現的時候,就呼叫fetchUserInfo函式,注意這裡是每個FETCH_USER_INFO。也就是說如果同時發出多個FETCH_USER_INFO,我們每個都會響應併發起請求。類似的還有takeLatesttakeLatest從名字都可以看出來,是響應最後一個請求,具體使用哪一個,要看具體的需求。

然後看看fetchUserInfo函式,這個函式也不復雜,就是呼叫一個API函式fetchUserInfoAPI去獲取資料,注意我們這裡函式呼叫並不是直接的fetchUserInfoAPI(),而是使用了Redux-Sagacall這個effect,這樣做可以讓我們寫單元測試變得更簡單,為什麼會這樣,我們後面講原始碼的時候再來仔細看看。獲取資料後,我們呼叫了put去發出FETCH_USER_SUCCEEDED這個action,這裡的put類似於Redux裡面的dispatch,也是用來發出action的。這樣我們的reducer就可以拿到FETCH_USER_SUCCEEDED進行處理了,跟以前的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:
      return state;
  }
}

export default reducer;

通過這個例子的程式碼結構我們可以看出:

  1. action被分為了兩種,一種是觸發非同步處理的,一種是普通的同步action
  2. 非同步action使用Redux-Saga來監聽,監聽的時候可以使用takeLatest或者takeEvery來處理併發的請求。
  3. 具體的saga實現可以使用Redux-Saga提供的方法,比如callput之類的,可以讓單元測試更好寫。
  4. 一個action可以被Redux-SagaReducer同時響應,比如上面的FETCH_USER_INFO發出後我還想讓頁面轉個圈,可以直接在reducer裡面加一個就行:

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

手寫原始碼

通過上面這個例子,我們可以看出,Redux-Saga的執行是通過這一行程式碼來實現的:

sagaMiddleware.run(rootSaga);

整個Redux-Saga的執行和原本的Redux並不衝突,Redux甚至都不知道他的存在,他們之間耦合很小,只在需要的時候通過put發出action來進行通訊。所以我猜測,他應該是自己實現了一套完全獨立的非同步任務處理機制,下面我們從能感知到的API入手,一步一步來探尋下他原始碼的奧祕吧。本文全部程式碼參照官方原始碼寫成,函式名字和變數名字儘量保持一致,寫到具體的方法的時候我也會貼出對應的程式碼地址,主要程式碼都在這裡:https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

先來看看我們用到了哪些API,這些API就是我們今天手寫的目標:

  1. createSagaMiddleware:這個方法會返回一箇中介軟體例項sagaMiddleware
  2. sagaMiddleware.run: 這個方法是真正執行我們寫的saga的入口
  3. takeEvery:這個方法是用來控制併發流程的
  4. call:用來呼叫其他方法
  5. put:發出action,用來和Redux通訊

從中介軟體入手

之前我們講Redux原始碼的時候詳細分析了Redux中介軟體的原理和正規化,一箇中介軟體大概就長這個樣子:

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
    }
  }
}

這其實就相當於一個Redux中介軟體的正規化了:

  1. 一箇中介軟體接收store作為引數,會返回一個函式
  2. 返回的這個函式接收老的dispatch函式作為引數(也就是上面的next),會返回一個新的函式
  3. 返回的新函式就是新的dispatch函式,這個函式裡面可以拿到外面兩層傳進來的store和老dispatch函式

依照這個正規化以及前面對createSagaMiddleware的使用,我們可以先寫出這個函式的骨架:

// sagaMiddlewareFactory其實就是我們外面使用的createSagaMiddleware
function sagaMiddlewareFactory() {
  // 返回的是一個Redux中介軟體
  // 需要符合他的正規化
  const sagaMiddleware = function (store) {
    return function (next) {
      return function (action) {
        // 內容先寫個空的
        let result = next(action);
        return result;
      }
    }
  }
  
  // sagaMiddleware上還有個run方法
  // 是用來啟動saga的
  // 我們先留空吧
  sagaMiddleware.run = () => { }

  return sagaMiddleware;
}

export default sagaMiddlewareFactory;

梳理架構

現在我們有了一個空的骨架,接下來該幹啥呢?前面我們說過了,Redux-Saga很可能是自己實現了一套完全獨立的非同步事件處理機制。這種非同步事件處理機制需要一個處理中心來儲存事件和處理函式,還需要一個方法來觸發佇列中的事件的執行,再回看前面的使用的API,我們發現了兩個類似功能的API:

  1. takeEvery(action, callback):他接收的引數就是actioncallback,而且我們在根saga裡面可能會多次呼叫它來註冊不同action的處理函式,這其實就相當於往處理中心裡面塞入事件了。
  2. put(action)put的引數是action,他唯一的作用就是觸發對應事件的回撥執行。

可以看到Redux-Saga這種機制也是用takeEvery先註冊回撥,然後使用put發出訊息來觸發回撥執行,這其實跟我們其他文章多次提到的釋出訂閱模式很像。

手寫channel

channelRedux-Saga儲存回撥和觸發回撥的地方,類似於釋出訂閱模式,我們先來寫個:

export function multicastChannel() {
  const currentTakers = [];     // 一個變數儲存我們所有註冊的事件和回撥

  // 儲存事件和回撥的函式
  // Redux-Saga裡面take接收回撥cb和匹配方法matcher兩個引數
  // 事實上take到的事件名稱也被封裝到了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]

      // 這裡的'MATCH'是上面take塞進來的匹配方法
      // 如果匹配上了就將回撥拿出來執行
      if (taker['MATCH'](input)) {
        taker(input);
      }
    }
  }
  
  return {
    take,
    put
  }
}

上述程式碼中有一個奇怪的點,就是將matcher作為屬性放到了回撥函式上,這麼做的原因我想是為了讓外部可以自定義匹配方法,而不是簡單的事件名稱匹配,事實上Redux-Saga本身就支援好幾種匹配模式,包括字串,Symbol,陣列等等。

內建支援的匹配方法可以看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/matcher.js

channel對應的原始碼可以看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/channel.js#L153

有了channel之後,我們的中介軟體裡面其實只要再幹一件事情就行了,就是呼叫channel.put將接收的action再發給channel去執行回撥就行,所以我們加一行程式碼:

// ... 省略前面程式碼

const result = next(action);

channel.put(action);     // 將收到的action也發給Redux-Saga

return result;

// ... 省略後面程式碼

sagaMiddleware.run

前面的put是發出事件,執行回撥,可是我們的回撥還沒註冊呢,那註冊回撥應該在什麼地方呢?看起來只有一個地方了,那就是sagaMiddleware.run。簡單來說,sagaMiddleware.run接收一個Generator作為引數,然後執行這個Generator,當遇到take的時候就將它註冊到channel上面去。這裡我們先實現taketakeEvery是在這個基礎上實現的。Redux-Saga中這塊程式碼是單獨抽取了一個檔案,我們仿照這種做法吧。

首先需要在中介軟體裡面將ReduxgetStatedispatch等引數傳遞進去,Redux-Saga使用的是bind函式,所以中介軟體方法改造如下:

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);

      return result;
    }
  }
}

然後sagaMiddleware.run就直接將boundRunSaga拿來執行就行了:

sagaMiddleware.run = (...args) => {
  boundRunSaga(...args)
}

注意這裡的...args,這個其實就是我們傳進去的rootSaga。到這裡其實中介軟體部分就已經完成了,後面的程式碼就是具體的執行過程了。

中介軟體對應的原始碼可以看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/middleware.js

runSaga

runSaga其實才是真正的sagaMiddleware.run,通過前面的分析,我們已經知道他的作用是接收Generator並執行,如果遇到take就將它註冊到channel上去,如果遇到put就將對應的回撥拿出來執行,但是Redux-Saga又將這個過程分為了好幾層,我們一層一層來看吧。runSaga的引數先是通過bind傳入了一些上下文相關的變數,比如getState, dispatch,然後又在執行的時候傳入了rootSaga,所以他應該是長這個樣子的:

import proc from './proc';

export function runSaga(
  { channel, dispatch, getState },
  saga,
  ...args
) {
  // saga是一個Generator,執行後得到一個迭代器
  const iterator = saga(...args);

  const env = {
    channel,
    dispatch,
    getState,
  };

  proc(env, iterator);
}

可以看到runSaga僅僅是將Generator執行下,得到迭代器物件後又呼叫了proc來處理。

runSaga對應的原始碼看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/runSaga.js

proc

proc就是具體執行這個迭代器的過程,Generator的執行方式我們之前在另一篇文章詳細講過,簡單來說就是可以另外寫一個方法next來執行Generatornext裡面檢測到如果Generator沒有執行完,就繼續執行next,然後外層呼叫一下next啟動這個流程就行。

export default function proc(env, iterator) {
  // 呼叫next啟動迭代器執行
  next();

  // next函式也不復雜
  // 就是執行iterator
  function next(arg, isErr) {
    let result;
    if (isErr) {
      result = iterator.throw(arg);
    } else {
      result = iterator.next(arg);
    }

    // 如果他沒結束,就繼續next
    // digestEffect是處理當前步驟返回值的函式
    // 繼續執行的next也由他來呼叫
    if (!result.done) {
      digestEffect(result.value, next)
    }
  }
}

digestEffect

上面如果迭代器沒有執行完,我們會將它的值傳給digestEffect處理,那麼這裡的result.value的值是什麼的呢?回想下我們前面rootSaga裡面的用法

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

result.value的值應該是yield後面的值,也就是takeEvery("FETCH_USER_INFO", fetchUserInfo)的返回值,takeEvery是再次包裝過的effect,他包裝了take,fork這些簡單的effect。其實對於像take這種簡單的effect來說,比如:

take("FETCH_USER_INFO", fetchUserInfo);

這行程式碼的返回值直接就是一個物件,類似於這樣:

{
  IO: true,
  type: 'TAKE',
  payload: {},
}

所以我們這裡digestEffect拿到的result.value也是這樣的一個物件,這個物件就代表了我們的一個effect,所以我們的digestEffect就長這樣:

function digestEffect(effect, cb) {    // 這個cb其實就是前面傳進來的next
    // 這個變數是用來解決競爭問題的
    let effectSettled;
    function currCb(res, isErr) {
      // 如果已經執行過了,直接return
      if (effectSettled) {
        return
      }

      effectSettled = true;

      cb(res, isErr);
    }

    runEffect(effect, currCb);
  }

runEffect

可以看到digestEffect又呼叫了一個函式runEffect,這個函式會處理具體的effect:

// runEffect就只是獲取對應type的處理函式,然後拿來處理當前effect
function runEffect(effect, currCb) {
  if (effect && effect.IO) {
    const effectRunner = effectRunnerMap[effect.type]
    effectRunner(env, effect.payload, currCb);
  } else {
    currCb();
  }
}

這點程式碼可以看出,runEffect也只是對effect進行了檢測,通過他的型別獲取對應的處理函式,然後進行處理,我這裡程式碼簡化了,只支援IO這種effect,官方原始碼中還支援promiseiterator,具體的可以看看他的原始碼:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/proc.js

effectRunner

effectRunner是通過effect.type匹配出來的具體的effect的處理函式,我們先來看兩個:takefork

runTakeEffect

take的處理其實很簡單,就是將它註冊到我們的channel裡面就行,所以我們建一個effectRunnerMap.js檔案,在裡面新增take的處理函式runTakeEffect:

// effectRunnerMap.js

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

  // 注意channel.take的第二個引數是matcher
  // 我們直接寫一個簡單的matcher,就是輸入型別必須跟pattern一樣才行
  // 這裡的pattern就是我們經常用的action名字,比如FETCH_USER_INFO
  // Redux-Saga不僅僅支援這種字串,還支援多種形式,也可以自定義matcher來解析
  channel.take(cb, matcher);
}

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

export default effectRunnerMap;

注意上面程式碼channel.take(cb, matcher);裡面的cb,這個cb其實就是我們迭代器的next,也就是說take的回撥是迭代器繼續執行,也就是繼續執行下面的程式碼。也就是說,當你這樣寫時:

yield take("SOME_ACTION");
yield fork(saga);

當執行到yield take("SOME_ACTION");這行程式碼時,整個迭代器都阻塞了,不會再往下執行。除非你觸發了SOME_ACTION,這時候會把SOME_ACTION的回撥拿出來執行,這個回撥就是迭代器的next,所以就可以繼續執行下面這行程式碼了yield fork(saga)

runForkEffect

我們前面的示例程式碼其實沒有直接用到fork這個API,但是用到了takeEverytakeEvery其實是組合takefork來實現的,所以我們先來看看forkfork的使用跟call很像,也是可以直接呼叫傳進來的方法,只是call會等待結果回來才進行下一步,fork不會阻塞這個過程,而是當前結果沒回來也會直接執行下一步:

fork(fn, ...args);

所以當我們拿到fork的時候,處理起來也很簡單,直接呼叫proc處理fn就行了,fn應該是一個Generator函式。

function runForkEffect(env, { fn }, cb) {
  const taskIterator = fn();    // 執行fn得到一個迭代器

  proc(env, taskIterator);      // 直接將taskIterator給proc處理

  cb();      // 直接呼叫cb,不需要等待proc的結果
}

runPutEffect

我們前面的例子還用到了put這個effect,他就更簡單了,只是發出一個action,事實上他也是呼叫的Reduxdispatch來發出action

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

  cb(result);
}

注意我們這裡的程式碼只需要dispatch(action)就行了,不需要再手動調channel.put了,因為我們前面的中介軟體裡面已經改造了dispatch方法了,每次dispatch的時候都會自動呼叫channel.put

runCallEffect

前面我們發起API請求還用到了call,一般我們使用axios這種庫返回的都是一個promise,所以我們這裡寫一種支援promise的情況,當然普通同步函式肯定也是支援的:

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);
}

這些effect具體處理的方法對應的原始碼都在這個檔案裡面:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/effectRunnerMap.js

effects

上面我們講了幾個effect具體處理的方法,但是這些都不是對外暴露的effect API。真正對外暴露的effect API還需要單獨寫,他們其實都很簡單,都是返回一個帶有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 })
}

可以看到當我們使用effect時,他的返回值就僅僅是一個描述當前任務的物件,這就讓我們的單元測試好寫很多。因為我們的程式碼在不同的環境下執行可能會產生不同的結果,特別是這些非同步請求,我們寫單元測試時來造這些資料也會很麻煩。但是如果你使用Redux-Sagaeffect,每次你程式碼執行的時候得到的都是一個任務描述物件,這個物件是穩定的,不受執行結果影響,也就不需要針對這個造測試資料了,大大減少了工作量。

effects對應的原始碼檔案看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/io.js

takeEvery

我們前面還用到了takeEvery來處理同時發起的多個請求,這個API是一個高階API,是封裝前面的takefork來實現的,官方原始碼又構造了一個新的迭代器來組合他們,不是很直觀。官方文件中的這種寫法反而很好理解,我這裡採用文件中的這種寫法:

export function takeEvery(pattern, saga) {
  function* takeEveryHelper() {
    while (true) {
      yield take(pattern);
      yield fork(saga);
    }
  }

  return fork(takeEveryHelper);
}

上面這段程式碼就很好理解了,我們一個死迴圈不停的監聽pattern,即目標事件,當目標事件過來的時候,就執行對應的saga,然後又進入下一次迴圈繼續監聽pattern

總結

到這裡我們例子中用到的API已經全部自己實現了,我們可以用自己的這個Redux-Saga來替換官方的了,只是我們只實現了他的一部分功能,還有很多功能沒有實現,不過這已經不妨礙我們理解他的基本原理了。再來回顧下他的主要要點:

  1. Redux-Saga其實也是一個釋出訂閱模式,管理事件的地方是channel,兩個重點APItakeput
  2. take是註冊一個事件到channel上,當事件過來時觸發回撥,需要注意的是,這裡的回撥僅僅是迭代器的next,並不是具體響應事件的函式。也就是說take的意思就是:我在等某某事件,這個事件來之前不許往下走,來了後就可以往下走了。
  3. put是發出事件,他是使用Redux dispatch發出事件的,也就是說put的事件會被ReduxRedux-Saga同時響應。
  4. Redux-Saga增強了Reduxdispatch函式,在dispatch的同時會觸發channel.put,也就是讓Redux-Saga也響應回撥。
  5. 我們呼叫的effects和真正實現功能的函式是分開的,表層呼叫的effects只會返回一個簡單的物件,這個物件描述了當前任務,他是穩定的,所以基於effects的單元測試很好寫。
  6. 當拿到effects返回的物件後,我們再根據他的type去找對應的處理函式來進行處理。
  7. 整個Redux-Saga都是基於Generator的,每往下走一步都需要手動呼叫next,這樣當他執行到中途的時候我們可以根據情況不再繼續呼叫next,這其實就相當於將當前任務cancel了。

本文可執行的程式碼已經上傳到GitHub,可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga

參考資料

Redux-Saga官方文件:https://redux-saga.js.org/

Redux-Saga原始碼地址: https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

相關文章