跳到主要內容
黯羽輕揚每天積累一點點

redux-saga

免費2017-10-13#JS#redux saga effect#redux saga join#redux saga教程#redux saga入门

完全理解 redux-saga

一。目標定位

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 應用中的副作用(即依賴/影響外部環境的不純的部分)處理起來更優雅

二。設計理念

Saga 像個獨立執行緒一樣,專門負責處理副作用,多個 Saga 可以串列/並行組合起來,redux-saga 負責排程管理

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

P.S. 關於 Saga 背景的更多資訊,請檢視 Background on the Saga concept

三。核心實現

利用 generator,讓非同步流程控制易讀、優雅、易測試

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())分步執行

  • 通過 iterator 影響內部狀態(iter.next(result)),注入非同步操作結果

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

generator/iterator 實現是因為它非常適合流程控制的場景,體現在:

  • yield 讓描述串列/並行的非同步操作變得很優雅

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

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

P.S. 關於 generator 與 iterator 的關係及 generator 基礎用法,可以參考 [generator(生成器)_ES6 筆記 2](/articles/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 如下:

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

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

其中 forkspawn 都是非阻塞型方法呼叫,二者的區別是:

  • 通過 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

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論