Redux-Saga:ジェネレータとエフェクト

今回は、Redux-Sagaで使用するジェネレータ関数、エフェクトについて紹介します。ジェネレータ関数は協力的で、run-to-completionではなく、かといって非同期でもない、そのような関数です。エフェクトはRedux-Sagaの最も中心となる特徴です。ReduxのActionのように単純で一般的なオブジェクトですが、開発者に大きな魔法を見せてくれます。そしてほとんどのサービスロジックがこのエフェクトで作成されます。

ジェネレータ

Redux-Sagaはジェネレータを美しく使用しています。そして、Redux-SagaのSagaがジェネレータ関数です。
ジェネレータは要約するとジェネレータ関数の返却です。

function* myGeneratorFunction() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = myGeneratorFunction();

console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
console.log(generator.next().value); // 3

私たちがfunction*キーワードで作成する関数は、ジェネレータではなく、ジェネレータ関数です。ジェネレータ関数を呼び出して、返されるオブジェクトがジェネレータです。ジェネレータはイテレータ(Iterator)プロトコルとイテラブル(Iterable)プロトコルに従います。

イテラブルプロトコルは、単純にobj[Symbol.iterator]: Function => Iteratorで表現できます。オブジェクトは、イテレータのシンボルキー値にイテレータを返却するメソッドを持っている場合、イテラブルです。

イテレータプロトコルも単純です。オブジェクトがnextという名前のメソッドを持ち、その結果としてIteratorResultというオブジェクトを返却します。返却されるIteratorResultは{done: boolean, value: any}形態の単純なオブジェクトです。

これらのイテラブル、イテレータの正確な定義は、ECMAScriptの仕様-第25章から確認できます。(参考までにRedux-Sagaでよく使われるtakeEverytakeLatestなどのhelperは、ジェネレータを使用せずイテレータオブジェクトを直接作成して使用します。)

/* ジェネレータはイテレータプロトコルに従う */

// 1. "function"
typeof generator.next;

// 2. {done: boolean, value: any} 返却
generator.next();

//-----//

/* ジェネレータはイテラブルプロトコルに従う */

// 1. "function"
typeof generator[Symbol.iterator];

// 2. イテレータが返却される
const iterator = generator[Symbol.iterator]();
typeof iterator.next(); // {done: boolean, value: any}

興味深いのは、ジェネレータはイテラブルでありながらイテレータであり、イテラブルから返されるイテレータがまさに自分自身であるということです。

/* 
  ジェネレータのイテラブル実装は下記のように簡単にできる
  generator[Symbol.iterator] = () => this;
*/
generator === generator[Symbol.iterator](); // true

ジェネレータ関数 – CallerとCallee

  • ジェネレータ関数はCallee、これを呼び出す関数はCallerである
  • CallerはCalleeが返却したジェネレータを持ちロジックを実行する
  • CallerはCalleeのyield地点から次の進行可否/地点を制御する

CallerはCalleeを呼び出すだけでなく、Callee内部ロジックの実行に対する制御権を持ちます。よくCallerをRunnerと呼びますが、それについては、「Generator in Practice – [1]基本的なプロパティとRunner」を参照してみよう。(ジェネレータ自体に興味があれば「ES6のジェネレータを使った非同期プログラミング」もおすすめします。)
Redux-Sagaの立場からみると、ミドルウェアはCallerであり、私たちが作成したSagaはCalleeです。

Redux-Sagaとジェネレータ

これまでジェネレータ関数、Caller(=Runner)とCalleeについて簡単に調べました。そしてRedux-Sagaで言う”Saga”がまさにジェネレータ関数です。なぜSagaをジェネレータ関数として実装するのでしょうか?これは、Redux-Sagaがエフェクトと呼ばれるものを、どのように使用するかということに関連します。Redux-Sagaを使用するということは、Redux-SagaミドルウェアにSagaを登録して実行させるという意味です。ミドルウェアはSagaを絶えず動作させます。

// Sagaの初期化、開始コードには常に "run" がある
middleware.run(RootSaga);

Sagaはジェネレータ関数で、ミドルウェアはSagaにyield値を使って別のアクションを実行させることができます。Sagaはコマンドを実行する役割のみで、実際の直接的な動作はミドルウェアが処理できるという意味です。 redux-thunkとの最大の違いです。

比較のため、単純なredux-thunk非同期関数を考えてみよう。

function asyncIncrement() {
  return async (dispatch) => {
    await delay(1000);
    dispatch({type: 'INCREMENT'});
  };
}

上記の関数は、自ら非同期的な処理を実行します。関数のテストが必要ならdispatchしてどのように証明するか考えてみよう。問題は関数の内部に非同期的なロジックがそのまま溶け込んでいるということです。

Sagaで次のように表現できます。

function* asyncIncrement() {
  // Sagaは以下のような簡単な形のコマンドのみyieldする
  yield call(delay, 1000); // {CALL: {fn: delay, args: [1000]}}
  yield put({type: 'INCREMENT'}); //  {PUT: {type: 'INCREMENT'}}
}

callであれputであれ、すべて直接的な処理をしません(call、putはエフェクトコンストラクタ(Effect creator)と呼びます)。コマンドを作成するだけで、このコマンドによる直接的な処理はすべてミドルウェアが行います。

// TestCase
// 実際にDelayさせるのではなく、これに対するコマンドだけなのでテストで1秒ずつ待つ必要がない
// どのようなコマンドが実行されるかだけ確認すればよい

const gen = asyncIncrement();
expect(gen.next().value).toEqual(call(delay, 1000));
expect(gen.next().value).toEqual(put({type: 'INCREMENT'}));

Sagaでは非同期処理がいくら複雑でも、大部分はifelseforのように簡単なコードだけで実装できます。スコープが複雑になることもありません。Redux-Sagaではこのような利点のためにジェネレータ関数をSagaとして使用します。

エフェクト

エフェクトは、ミドルウェアによって実行されるコマンドを含むJavaScriptのオブジェクトと考えればよいでしょう。エフェクトコンストラクタは、常に一般的なオブジェクトを作成するだけで、他の動作も実行しません。Sagaはコマンドを含んだエフェクトと呼ばれる純粋なオブジェクトをyieldするもので、ミドルウェアはこれらのコマンドを解析、処理して、その結果を再びSagaに返します。例えば、call(fn, arg1, arg2)エフェクトをSagaからyieldした場合、ミドルウェアはfn(arg1, arg2);を実行し、結果を再びSagaに返却します。

もちろんSagaはエフェクトだけをyieldするわけではありません。一般的なPromiseもyieldすることができ、ミドルウェアは、これも見事にresolveやrejectを持ってきてくれます。しかし、このような非同期ロジックをSaga内部で直接処理すると、テストや複数エフェクトとの相互作用が難しいでしょう。thunkから大きく変わる点はありません。そのため、なるべくエフェクトだけをyieldするSagaの作成をお勧めます。

Sagaのエフェクトは10種類以上あり、私たちが活用する上で何の問題もないほど多様です。ここでは1つ1つのエフェクトを説明していませんが、Effect creators APIを参照すれば、Sagaを活用する際に大いに役立つでしょう。エフェクトは単にテストのためだけに作られたものではありません。pullingや、non-blocking、blocking、parallelなど様々な特徴を持ち、多くの動作を簡単に処理することができます。公式文書のAdvanced Conceptsから、その内容がよく分かるでしょう。

そして最近、1.0 betaリリースにエフェクトミドルウェアが追加されました。これは基本提供されるエフェクトではなく、カスタムしたエフェクトを作成して利用できるいう意味です。

おわりに

Redux-Sagaは比較的、馴染みの薄いジェネレータ基盤のミドルウェアで、参入障壁が高く感じられることが多いでしょう。少なくともredux-thunkと比べると遥かに高いです。しかしジェネレータを理解し、Redux-Sagaをもう少し身近に感じられたら、これほど簡単に使用できるライブラリーもないと思います。

エフェクト以外にもRedux-Sagaを習得、活用する方法は、まだまだたくさん残っています。
redux-thunkで複雑な処理に困っているなら、Redux-Sagaの積極的な活用をお勧めします。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop