淺析redux-saga實現原理

螞蟻金服資料體驗技術發表於2017-10-13

作者簡介 joey 螞蟻金服·資料體驗技術團隊

專案中一直使用redux-saga來處理非同步action的流程。對於effect的實現原理感到很好奇。抽空去研究了一下他的實現。本文不會描述redux-saga的基礎API和優點,單純聊實現原理,歡迎大家在評論區留言討論。

前言

redux-saga監聽action的程式碼如下:

import { takeEvery } from 'redux-saga';

function* mainSaga() {
  yield takeEvery('action_name', function* (action) {
    console.log(action);
  });
}
複製程式碼

用generator究竟是怎麼實現takeEvery的呢?我們先來看稍微簡單一點的take的實現原理:

take實現原理

我們嘗試寫一個demo,用saga的方式實現用generator監聽action。

$btn.addEventListener('click', () => {
  const action =`action data${i++}`;
  // trigger action
}, false);

function* mainSaga() {
  const action = yield take();
  console.log(action);
}
複製程式碼

要在$btn點選時候,能夠讀到action的值。

channel

這裡我們需要引入一個概念——channel

channel是對事件源的抽象,作用是先註冊一個take方法,當put觸發時,執行一次take方法,然後銷燬他。

channel的簡單實現如下:

function channel() {
  let taker;

  function take(cb) {
    taker = cb;
  }

  function put(input) {
    if (taker) {
      const tempTaker = taker;
      taker = null;
      tempTaker(input);
    }
  }

  return {
    put,
    take,
  };
}

const chan = channel();
複製程式碼

我們利用channel做generator和dom事件的連線,將dom事件改寫如下:

$btn.addEventListener('click', () => {
  const action =`action data${i++}`;
  chan.put(action);
}, false);
複製程式碼

當put觸發時,如果channel裡已經有註冊了的taker,taker就會執行。

我們需要在put觸發之前,先呼叫channel的take方法,註冊實際要執行的方法。

我們繼續看mainSaga裡的實現。

function* mainSaga() {
  const action = yield take();
  console.log(action);
}
複製程式碼

這個take是saga裡的一種effect型別。

先看effecttake()的實現。

function take() {
  return {
    type: 'take'
  };
}

複製程式碼

出乎意料,僅僅返回了一個帶型別的object。

其實redux-saga裡所有effect返回的值,都是一個帶型別的純object物件。

那究竟是什麼時候觸發channel的take方法的呢?還需要從呼叫mainSaga的程式碼上找原因。

generator的特點是執行到某一步時,可以把控制權交給外部程式碼,由外部程式碼拿到返回結果後,決定該怎麼做。

task

這裡我們又要引入一個新的概念task

task是generator方法的執行環境,所有saga的generator方法都跑在task裡。

task的簡易實現如下:

function task(iterator) {
  const iter = iterator();
  function next(args) {
    const result = iter.next(args);
    if (!result.done) {
      const effect = result.value;
      if (effect.type === 'take) {
        runTakeEffect(result.value, next);
      }
    }
  }
  next();
}

task(mainSaga);
複製程式碼

yield take()執行時,將take()返回的結果交給外層的task,此時程式碼的控制權就已經從gennerator方法中轉到了task裡了。

result.value的值就是take()返回的結果{ type: 'take' }

再看runTakeEffect的實現:

function runTakeEffect(effect, cb) {
  chan.take(input => {
    cb(input);
  });
}
複製程式碼

到這裡,我們終於看到呼叫channel的take方法的地方了。

完整程式碼如下:

function channel() {
  let taker;

  function take(cb) {
    taker = cb;
  }

  function put(input) {
    if (taker) {
      const tempTaker = taker;
      taker = null;
      tempTaker(input);
    }
  }

  return {
    put,
    take,
  };
}

const chan = channel();

function take() {
  return {
    type: 'take'
  };
}

function* mainSaga() {
  const action = yield take();
  console.log(action);
}

function runTakeEffect(effect, cb) {
  chan.take(input => {
    cb(input);
  });
}

function task(iterator) {
  const iter = iterator();
  function next(args) {
    const result = iter.next(args);
    if (!result.done) {
      const effect = result.value;
      if (effect.type === 'take') {
        runTakeEffect(result.value, next);
      }
    }
  }
  next();
}

task(mainSaga);

let i = 0;
$btn.addEventListener('click', () => {
  const action =`action data${i++}`;
  chan.put(action);
}, false);
複製程式碼

整體流程就是,先通過mainSaga往channel裡註冊了一個taker,一旦dom點選發生,就觸發channel的put,put會消耗掉已經註冊的taker,這樣就完成了一次點選事件的監聽過程。

檢視線上demo

takeEvery實現原理

在上一節中,我們已經模仿saga實現了一次事件監聽,但是還是有問題,我們只能監聽一次點選,怎麼能做到監聽每次點選事件呢?redux-saga提供了一個helper方法——takeEvery。我們嘗試在我們的簡易版saga中實現一下takeEvery

function* takeEvery(worker) {
  yield fork(function* () {
    while(true) {
      const action = yield take();
      worker(action);
    }
  });
}

function* mainSaga() {
  yield takeEvery(action => {
    $result.innerHTML = action;
  });
}
複製程式碼

這裡用到了一個新的effect方法fork

fork

fork的作用是啟動一個新的task,不阻塞原task執行。程式碼修改如下:

function fork(cb) {
  return {
    type: 'fork',
    fn: cb,
  };
}

function runForkEffect(effect, cb) {
  task(effect.fn || effect);
  cb();
}

function task(iterator) {
  const iter = typeof iterator === 'function' ? iterator() : iterator;
  function next(args) {
    const result = iter.next(args);
    if (!result.done) {
      const effect = result.value;

      // 判斷effect是否是iterator
      if (typeof effect[Symbol.iterator] === 'function') {
        runForkEffect(effect, next);
      } else if (effect.type) {
        switch (effect.type) {
        case 'take':
          runTakeEffect(effect, next);
          break;
        case 'fork':
          runForkEffect(effect, next);
          break;
        default:
        }
      }
    }
  }
  next();
}
複製程式碼

我們通過新增了一種新的effectfork,啟動了一個新的task takeEvery。

takeEvery的作用就是當channel的put發生後,自動往channel裡放進一個新的taker。

我們實現的channel裡同時只能有一個taker,while(true)的作用就是每當一個put觸發消耗掉了taker後,就自動觸發runTakeEffect中傳入的task的next方法,再次往channel裡放進一個taker,從而做到源源不斷地監聽事件。

線上demo

effect的本質

通過上文的實現,我們發現所有的yield後返回的effect,都是一個純object,用來給generator外層的執行容器task傳送一個訊號,告訴task該做什麼。

基於這種思路,如果我們要新增一個effect,來cancel task,也可以很容易實現。

首先我們先定義一個cancel方法,用來傳送cancel的訊號。

function cancel() {
  return {
    type: 'cancel'
  };
}
複製程式碼

然後修改task的程式碼,讓他能真正執行cancel的邏輯。

function task(iterator) {
  const iter = typeof iterator === 'function' ? iterator() : iterator;
  ...

  function runCancelEffect() {
    // do some cancel logic
  }

  function next(args) {
    const result = iter.next(args);
    if (!result.done) {
      const effect = result.value;

      if (typeof effect[Symbol.iterator] === 'function') {
        runForkEffect(effect, next);
      } else if (effect.type) {
        switch (effect.type) {
        case 'cancel':
          runCancelEffect();
        case 'take':
          runTakeEffect(result.value, next);
          break;
        case 'fork':
          runForkEffect(result.value, next);
          break;
        default:
        }
      }
    }
  }
  next();
}
複製程式碼

小結

本文通過簡單實現了幾個effect方法來地介紹了redux-saga的原理,要真正做到redux-saga的所有功能,只需要再新增一些細節就可以了。大概如下圖所示:

淺析redux-saga實現原理

對generator使用有興趣的同學推薦學習一下redux-saga原始碼。在此推薦一篇使用generator實現dom事件監聽的文章 繼續探索JS中的Iterator,兼談與Observable的對比

感興趣的同學可以關注專欄或者傳送簡歷至'chaofeng.lcf####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章