メインコンテンツへ移動

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))、指定 action を dispatch(put(action)

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

Effect 層が存在する主な意義はテストのしやすさ のためで、そのため簡単な説明オブジェクトで操作を表し、この層の指令を追加

直接 Promise を yield することもできます(例えば上記コア実装の示例のように)が、テストケース中で2 つの promise が等価かどうか比較できません。そのためこの問題を解決するために説明オブジェクトの層を追加し、テストケース中で簡単に説明オブジェクトを比較でき、実際に作用する 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 する必要がないのは単体テストの 1 つの环节を簡素化しただけ で、このような説明オブジェクトを対比する方式を使用しても、依然として予想データを提供する必要 があります。例えば:

// 测试场景直接执行
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 の意味は非常に直感的で、1 つだけ追加説明が必要 です:

  • 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 された task は善後ロジックを完成後直ちに戻ります

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* は 1 つの 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 時にまた来たら pending を cancel、最新のみ実行)

takeEvery, takeLatesttake の上での封鎖、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 を参照

参考資料

コメント

コメントはまだありません

コメントを書く