redux-saga

一.目標定位express

redux-saga is a library that aims to make side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) in React/Redux applications easier and better.

做爲一個Redux中間件,想讓Redux應用中的反作用(即依賴/影響外部環境的不純的部分)處理起來更優雅redux

二.設計理念
Saga像個獨立線程同樣,專門負責處理反作用,多個Saga能夠串行/並行組合起來,redux-saga負責調度管理api

Saga來頭不小(1W star不是浪得的),是某篇論文中提出的一種分佈式事務機制,用來管理長期運行的業務進程promise

P.S.關於Saga背景的更多信息,請查看Background on the Saga concept併發

三.核心實現
利用generator,讓異步流程控制易讀、優雅、易測試app

In redux-saga, Sagas are implemented using Generator functions. To express the Saga logic we yield plain JavaScript Objects from the Generator.

實現上,關鍵點是:框架

以generator形式組織邏輯序列(function* + yield),把一系列的串行/並行操做經過yield拆分開異步

利用iterator的可「暫停/恢復」特性(iter.next())分步執行async

經過iterator影響內部狀態(iter.next(result)),注入異步操做結果分佈式

利用iterator的錯誤捕獲特性(iter.throw(error)),注入異步操做異常

用generator/iterator實現是由於它很是適合流程控制的場景,體如今:

yield讓描述串行/並行的異步操做變得很優雅

以同步形式獲取異步操做結果,更符合順序執行的直覺

以同步形式捕獲異步錯誤,優雅地捕獲異步錯誤

P.S.關於generator與iterator的關係及generator基礎用法,能夠參考generator(生成器)_ES6筆記2

例如:

const ts = Date.now();
function asyncFn(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`${id} at ${Date.now() - ts}`);
            resolve(id);
        }, 1000);
    });
}

function* gen() {
    // 串行異步
    let A = yield asyncFn('A');
    console.log(A);
    let B = yield asyncFn('B');
    console.log(B);
    // 並行異步
    let C = yield Promise.all([asyncFn('C1'), asyncFn('C2')]);
    console.log(C);
    // 串行/並行組合異步
    let D = yield Promise.all([
        asyncFn('D1-1').then(() => {
            return asyncFn('D1-2');
        }),
        asyncFn('D2')
    ]);
    console.log(D);
}

// test
let iter = gen();
// 尾觸發順序執行iter.next
let next = function(prevResult) {
    let {value: result, done} = iter.next(prevResult);
    if (result instanceof Promise) {
        result.then((res) => {
            if (!done) next(res);
        }, (err) => {
            iter.throw(err);
        });
    }
    else {
        if (!done) next(result);
    }
};
next();

實際結果符合預期:

A at 1002
A
B at 2012
B
C1 at 3015
C2 at 3015
["C1", "C2"]
D1-1 at 4019
D2 at 4020
D1-2 at 5022
["D1-2", "D2"]

執行順序爲:A -> B -> C1,C2 -> D1-1 -> D2 -> D1-2

redux-saga的核心控制部分與上面示例相似(沒錯,就是這麼像co),從實現上看,其異步控制的關鍵是尾觸發順序執行iter.next。示例沒添Effect這一層描述對象,從功能上講Effect並不重要(Effect的做用見下面術語概念部分)

Effect層要實現的東西包括2部分:

業務操做 -> Effect

以Effect creator API形式提供,提供各類語義的用來生成Effect的工具函數,例如把dispatch action包裝成put、把方法調用包裝成call/apply

Effect -> 業務操做

在執行時內部進行轉換,例如把[Effect1, Effect2]轉換爲並行調用

相似於裝箱(把業務操做用Effect包起來)拆箱(執行Effect裏的業務操做),此外,完整的redux-saga還要實現:

做爲middleware接入到Redux

提供讀/寫Redux state的接口(select/put)

提供監聽action的接口(take/takeEvery/takeLatest)

Sagas組合、通訊

task順序控制、取消

action併發控制

差很少是一個大而全的異步流程控制庫了,從實現上看,至關於一個加強版的co

四.術語概念
Effect
Effect指的是描述對象,至關於redux-saga中間件可識別的操做指令,例如調用指定的業務方法(call(myFn))、dispatch指定action(put(action))

An Effect is simply an object which contains some information to be interpreted by the middleware.

Effect層存在的主要意義是爲了易測試性,因此用簡單的描述對象來表示操做,多這樣一層指令

雖然能夠直接yield Promise(好比上面核心實現裏的示例),但測試case中沒法比較兩個promise是否等價。因此添一層描述對象來解決這個問題,測試case中能夠簡單比較描述對象,實際起做用的Promise由redux-saga內部生成

這樣作的好處是單測中不用mock異步方法(通常單測中會把全部異步方法替換掉,只比較傳入參數是否相同,而不作實際操做),能夠簡單比較操做指令(Effect)是否等價。從單元測試的角度來看,Effect至關於把參數提出去了,讓「比較傳入參數是否相同」這一步能夠在外面統一進行,而不用逐個mock替換

P.S.關於易測試性的更多信息,請查看Testing Sagas

另外,mock測試不但比較麻煩,還不可靠,畢竟與真實場景/流程有差別。經過框架約束,多一層描述對象來避免mock

這樣作並不十分完美,還存在2個問題:

業務代碼稍顯麻煩(不直接yield promise/dispatch action,而都要用框架提供的creator(call, put)包起來)

有額外的學習成本(理解各個creator的語義,適應先包一層的玩法)

例如:

// 直接
const userInfo = yield API.fetch('user/info', userId);

// 包一層creator
const userInfo = yield call(API.fetch, 'user/info', userId);
// 並指定context,默認是null
const userInfo = yield call([myContext, API.fetch], 'user/info', userId);

形式上與fn.call相似(實際上也提供了一個apply creator,形式與fn.apply相似),內部處理也是相似的:

// call返回的描述對象(Effect)
{
    @@redux-saga/IO: true,
    CALL: {
        args: ["user/info", userId],
        context: myContext,
        fn: fetch
    }
}

// 實際執行
result = fn.apply(context, args)

寫起來不那麼直接,但比起易測試性帶來的好處(不用mock異步函數),這不很過度

注意,不須要mock異步函數只是簡化了單元測試的一個環節,即使使用這種對比描述對象的方式,仍然須要提供預期的數據,例如:

// 測試場景直接執行
const iterator = fetchProducts()

// expects a call instruction
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products'),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

// 預期接口返回數據
const products = {}

// expects a dispatch instruction
assert.deepEqual(
  iterator.next(products).value,
  put({ type: 'PRODUCTS_RECEIVED', products }),
  "fetchProducts should yield an Effect put({ type: 'PRODUCTS_RECEIVED', products })"
)

P.S.這種描述對象的套路,和Flux/Redux的action一模一樣:Effect至關於Action,Effect creator至關於Action Creator。區別是Flux用action描述消息(發生了什麼),而redux-saga用Effect描述操做指令(要作什麼)

Effect creator
redux-saga/effects提供了不少用來生成Effect的工具方法。經常使用的Effect creator以下:

阻塞型方法調用:call/apply 詳見Declarative Effects

非阻塞型方法調用:fork/spawn 詳見redux-saga’s fork model

並行執行task:all/race 詳見Running Tasks In Parallel,Starting a race between multiple Effects

讀寫state:select/put 詳見Pulling future actions

task控制:join/cancel/cancelled 詳見Task cancellation

大多creator語義都很直白,只有一個須要額外說明下:

join用來獲取非阻塞的task的返回結果

其中fork與spawn都是非阻塞型方法調用,兩者的區別是:

經過spawn執行的task徹底獨立,與當前saga無關

當前saga無論它執行完了沒,發生cancel/error也不會影響當前saga

效果至關於讓指定task獨立在頂層執行,與middleware.run(rootSaga)相似

經過fork執行的task與當前saga有關

fork所在的saga會等待forked task,只有在全部forked task都執行結束後,當前saga纔會結束

fork的執行機制與all徹底一致,包括cancel和error的傳遞方式,因此若是任一task有未捕獲的error,當前saga也會結束

另外,cancel機制比較有意思:

對於執行中的task序列,全部task天然完成時,把結果向上傳遞到隊首,做爲上層某個yield的返回值。若是task序列在處理過程當中被cancel掉了,會把cancel信號向下傳遞,取消執行全部pending task。另外,還會把cancel信號沿着join鏈向上傳遞,取消執行全部依賴該task的task

簡言之:complete信號沿調用鏈反向傳遞,而cancel信號沿task鏈正向傳遞,沿join鏈反向傳遞

注意:yield cancel(task)也是非阻塞的(與fork相似),而被cancel掉的任務在完成善後邏輯後會當即返回

P.S.經過join創建依賴關係(取task結果),例如:

function* rootSaga() {
  // Returns immediately with a Task object
  const task = yield spawn(serverHello, 'world');

  // Perform an effect in the meantime
  yield call(console.log, "waiting on server result...");

  // Block on the result of serverHello
  const result = yield join(task);
}

Saga
術語Saga指的是一系列操做的集合,是個運行時的抽象概念

redux-saga裏的Saga形式上是generator,用來描述一組操做,而generator是個具體的靜態概念

P.S.redux-saga裏所說的Saga大多數狀況下指的都是generator形式的一組操做,而不是指redux-saga自身。簡單理解的話:在redux-saga裏,Saga就是generator,Sagas就是多個generator

Sagas有2種順序組合方式:

yield* saga()

call(saga)

一樣,直接yield iterator運行時展開也面臨不便測試的問題,因此經過call包一層Effect。另外,yield只接受一個iterator,組合起來不很方便,例如:

function* saga1() {
    yield 1;
    yield 2;
}
function* saga2() {
    yield 3;
    yield 4;
}
function* rootSaga() {
    yield 0;
    // 組合多個generator不方便
    yield* (function*() {
        yield* saga1();
        yield* saga2();
    })();
    yield 5;
}

// test
for (let val of rootSaga()) {
    console.log(val);   // 0 1 2 3 4 5
}

注意:實際上,call(saga)返回的Effect與其它類型的Effect沒什麼本質差別,也能夠經過all/race進行組合

Saga Helpers
Saga Helper用來監聽action,API形式是takeXXX,其語義至關於addActionListener:

take:語義至關於once

takeEvery:語義至關於on,容許併發action(上一個沒完成也當即開始下一個)

takeLatest:限制版的on,不容許併發action(pending時又來一個就cancel掉pending的,只作最新的)

takeEvery, takeLatest是在take之上的封裝,take纔是底層API,靈活性最大,能手動知足各類場景

P.S.關於3者關係的更多信息,請查看Concurrency

pull action與push action
從控制方式上講,take是pull的方式,takeEvery, takeLatest是push的方式

pull與push是指:

pull action:要求業務方主動去取action(yeild take()會返回action)

push action:由框架從外部注入action(takeEvery/takeLatest註冊的Saga會被注入action參數)

pull方式的優點在於:

容許更精細的控制

好比能夠手動實現takeN的效果(只關注某幾回action,用完就釋放掉)

以同步形式描述控制流

takeEvery, takeLatest只支持單action,若是是action序列的話要拆開,用take能保留關聯邏輯塊的完整性,好比登陸/註銷

別人更容易理解

控制邏輯在業務代碼裏,而不是藏在框架內部機制裏,必定程度上下降了維護成本

P.S.關於pull/push的更多信息,請查看Pulling future actions

五.場景示例
有幾個印象比較深的場景,充分體現出了redux-saga的優雅

接口訪問

function* fetchProducts() {
  try {
    const products = yield call(Api.fetch, '/products')
    yield put({ type: 'PRODUCTS_RECEIVED', products })
  }
  catch(error) {
    yield put({ type: 'PRODUCTS_REQUEST_FAILED', error })
  }
}

除了須要知道put表示dispatch action外,幾乎不須要什麼註釋,實際狀況就是你想的那樣

登陸/註銷
function* loginFlow() {
  while (true) {
    yield take('LOGIN')
    // ... perform the login logic
    yield take('LOGOUT')
    // ... perform the logout logic
  }
}

pull action能保持關聯action的處理順序,而不須要額外外部狀態控制。這樣保證了LOGOUT老是在執行過LOGIN以後的某個時刻發生的,代碼看起來至關漂亮

特定操做提示
// 在建立第3條todo的時候,給出提示消息
function* watchFirstThreeTodosCreation() {
  for (let i = 0; i < 3; i++) {
    const action = yield take('TODO_CREATED')
  }
  yield put({type: 'SHOW_CONGRATULATION'})
}

// 接口訪問異常重試
function* updateApi(data) {
  for(let i = 0; i < 5; i++) {
    try {
      const apiResponse = yield call(apiRequest, { data });
      return apiResponse;
    } catch(err) {
      if(i < 4) {
        yield call(delay, 2000);
      }
    }
  }
  // attempts failed after 5 attempts
  throw new Error('API request failed');
}

即takeN的示例,這樣就把本應該存在於reducer中的反作用提到了外面,保證了reducer的純度

六.優缺點
優勢:

易測試,提供了各類case的測試方案,包括mock task,分支覆蓋等等

大而全的異步控制庫,從異步流程控制到併發控制應有盡有

完備的錯誤捕獲機制,阻塞型錯誤可try-catch,非阻塞型會通知所屬Saga

優雅的流程控制,可讀性/精煉程度不比async&await差多少,很容易描述並行操做

缺點:

體積略大,1700行,min版24KB,實際上併發控制等功能很難用到

依賴ES6 generator特性,可能須要polyfill

P.S.redux-saga也能夠接入其它環境(不與Redux綁定),詳細見Connecting Sagas to external Input/Output

參考資料
JavaScript Power Tools Part II: Composition Patterns In Redux-Saga

API Reference

Reference 6: A Saga on Sagas

相關文章
相關標籤/搜索