React + Redux- Animation -
engineering

React + Redux
- Animation -

SPA はアニメーションと相性が悪い?

デザイン戦略室フロントエンドエンジニアの吉井です。
昨今の React 実装は、StatelessFunctionalComponent(SFC)を用いて Flux や Redux に状態管理を委譲するデザインパターンが定着していますね。アプリケーションの UX に欠かせないアニメーションですが「SPA とは相性が悪い」という噂を聞いたことがあります。従来の html アニメーションといえば jQuery.animate によるものが定番ですが、jQuery.animate は以下の理由で React と相性が悪いです。

  • jQuery を使用しないことがほとんどで、ほかライブラリに頼る必要がある。
  • DOM 参照(ref 参照)のために SFC を諦めないといけない。
  • Animation 実行中に component を unmount すると参照エラーになる。
  • Animation の状態を Store で共有出来ないため、不整合が生じる。

React Redux での Animation 実装方法

React の導入事例は日を追うごとに増えてきており、React といえば Redux と言えるぐらいにその組み合わせは定番化している様に感じます。小さなコンポーネントのちょっとしたアニメーションであれば、Store で管理する必要も無いかも知れません。しかし、画面遷移アニメーションの状態に応じて action を dispatch したい場合など、演出を盛り込んだ Redux アプリケーションではアニメーションを一連の Redux フローから切り離すことは出来ない、ということにすぐに気がつきます。

アニメーションは非同期処理ということ

アニメーションは Ajax 等と同様に、処理を開始してから終了するまでに時間がかかる非同期処理です。Ajax リクエストを投げて、その結果に応じて内容をレンダリングする様に、全ての UI がアニメーション完了を待つ必要が出てくることもあります。アニメーションに必要なアセットのロード、実行のタイミングなど、要件のスケール・可変に応じて、柔軟に処理出来る体系が望ましいです。それでは何処にアニメーション処理(非同期処理)を記述していくべきか、考えていきたいと思います。

Redux ではどこで非同期処理をするのか

React Redux では、状態を保持するひとつの Store の状態を各コンポーネントが props として参照することで、コンポーネントを React.Component の Class インスタンスとして生成しなくても良い環境を提供してくれます。

Redux アプリケーションの一連の処理のなかで、ActionType、ActionCreator、Store、Reducer、Container、Component は各レイヤーにおいて処理が明確です。しかしながら、非同期処理は middleware レイヤーに頼ることとなります。この middleware で非同期処理の「定番」と呼べるものが今現在、過渡期にある様に感じています。ReactRedux において XHR リクエストなどの非同期処理は、redux-thunk が有名ですが、もうひとつの選択肢として redux-saga が登場しています。今回はこの redux-saga を用いた場合の実装方法について考察したいと思います。

【参考】
redux-saga で非同期処理と戦う
redux-saga

redux-saga の利点は一つの記事では語りきれず、上記の参考記事やリポジトリに素晴らしい解説がありますので、投稿では割愛します。redux-saga では、独立したスレッドとも呼べる機構(タスク)内で、自由に組み合わせながら非同期処理を追加していくことが出来ます。

このタスクに非同期処理であるアニメーションを作りこんでいこう、というのが本稿の主旨です。早速アニメーションタスクをどの様に実装するか、見て行きましょう。

Redux Saga - Promise & Genarator -

Redux Saga を用いた場合、非同期処理は Generator 関数の恩恵により、同期的に記述することが出来ます。非同期処理は redux-saga/effects の call 関数で Promise.resolve を待ちます。

root.saga.js
import AnimationSaga from "path/to/sagas/animations.saga";

export default function* rootSaga() {
  yield fork(AnimationSaga);
}
animations.saga.js
import { call, put, fork, select } from "redux-saga/effects";
import * as actions from "path/to/actions/creators.js";
import * as types from "path/to/actions/types.js";
import * as animations from "path/to/sagas/animations.saga.functions.js";

function* someAnimationSaga() {
  while (true) {
    const { onEndAnimation } = actions;
    const { someAnimation } = animations;
    const action = yield take(types.SOME_ACTION); // SOME_ACTION が dispatch されるのを待つ
    const { duration } = action; // ActionCreator から渡された duration を参照
    yield call(someAnimation(duration)); // Promiseにラップされたアニメーション関数をcall
    yield put(onEndAnimation()); // onEndAnimation action を dispatch する
  }
}

export default function* () {
  yield fork(someAnimationSaga);
}
animations.saga.functions.js
export function someAnimation(duration) {
  return new Promise((resolve) => {
    // ここでAnimation処理を実行する
    // setTimeout(() => {
    //   resolve()
    // }, duration)
  });
}

上記の様なコードの場合、以下の様な挙動となります

  • rootSaga が animationSaga を起動する
  • animationSaga が someAnimationSaga を起動する
  • someAnimationSaga が SOME_ACTION を待つ
  • 誰かが SOME_ACTION を dispatch する
  • someAnimation を引数間隔(duration)で実行する
  • onEndAnimation を dispatch する
  • someAnimationSaga が再び SOME_ACTION を待つ

この様に、特定のイベントを待ち、Store の状態を参照し、任意の action を dispatch することが出来ます。この redux-saga タスクにおける、アニメーションの実装方法を二つの紹介します。

方法 1.CSS Animation による実装

一つはコンポーネントのマルチクラス付与による CSS Animation トリガーです。バケツリレーされた props からコンポーネントにマルチクラスを付与し、CSS を併用することでアニメーションを作りこむことが出来ます。jQuery.animate 等による DOMProperty を fps 毎に書き換えるものでは無いため、CSSAnimation による実装はレスポンシブデザインでも威力を発揮し、描画も高速です。

Promise を利用しない手取り早い方法としては、クラスを付与するための state を変更した後に、CSS の transition-duration に指定した時間分を redux.delay で待つ方法があります。ただし、この方法だと実行環境・変更内容によっては負荷によるズレが生じる場合があります。先ほどのコードを書き換えてみます。

animations.saga.js
import { delay } from "redux-saga";
import { call, put, fork, select } from "redux-saga/effects";
import * as actions from "path/to/actions/creators.js";
import * as types from "path/to/actions/types.js";
import * as animations from "path/to/sagas/animations.saga.functions.js";

function* someAnimationSaga() {
  while (true) {
    const { onEndAnimation, addClass } = actions;
    const action = yield take(types.SOME_ACTION); // SOME_ACTION が dispatch されるのを待つ
    yield put(addClass("animating")); // onEndAnimation action を dispatch する
    yield call(delay, 600); // PromiseにラップされたsetTimeout関数をcall
    yield put(onEndAnimation()); // onEndAnimation action を dispatch する
  }
}

export default function* () {
  yield fork(someAnimationSaga);
}
creators.js
import * as types from "path/to/actions/types.js";

export function addClass(className) {
  return { type: types.ADD_CLASS, className };
}
reducer.js
import * as types from "path/to/actions/types.js";

export const initialState = {
  componentClassNames: {},
};
export default (state = initialState, action) => {
  switch (action.type) {
    case types.ADD_CLASS: {
      const classNames = Object.assign({}, state.componentClassNames, {
        [action.className]: true,
      });
      return Object.assign({}, state, { componentClassNames });
    }
  }
};
some.component.js
import classNames from "classnames";

export default function SomeComponent(props) {
  const { componentClassNames } = props;
  // 下記の様にSpread展開すると自由にclassを付与することが出来る
  const componentClassName = classNames("some-compoent", {
    ...componentClassNames,
  });
  return <div className={componentClassName}>// ...</div>;
}
style.scss
.some-compoent {
  opacity: 0;
  &.animating {
    opacity: 1;
    transition-duration: 0.6s;
  }
}

React v15.0 より DOM の CSSAnimationEvent をトリガー出来る様になっていますので、onTransitionEnd に終了したことを通知する Action を dispatch させるのも良いかと思います。ただし、CSSAnimationEvent は一部ブラウザで未サポートなため、サポートブラウザ全てで動作するか確認が必要です。
React v15.0 Release Candidate
caniuse.com

上記のサンプルを修正すると次の様になります

some.component.js
import classNames from "classnames";
import * as actions from "path/to/actions/creators.js";

export default function SomeComponent(props) {
  const { componentClassNames } = props;
  const componentClassName = classNames("some-compoent", {
    ...componentClassNames,
  });
  return (
    <div className={componentClassName} onTransitionEnd={actions.transitionEnd}>
      // ...
    </div>
  );
}
creators.js
import * as types from "path/to/actions/types.js";

export function addClass(className) {
  return { type: types.ADD_CLASS, className };
}
export function transitionEnd() {
  return { type: types.TRANSITION_END };
}
animations.saga.js
import { delay } from "redux-saga";
import { call, put, fork, select } from "redux-saga/effects";
import * as actions from "path/to/actions/creators.js";
import * as types from "path/to/actions/types.js";
import * as animations from "path/to/sagas/animations.saga.functions.js";

function* someAnimationSaga() {
  while (true) {
    const { onEndAnimation, addClass } = actions;
    const action = yield take(types.SOME_ACTION);
    yield put(addClass("animation-start"));
    yield take(types.TRANSITION_END); // ここが変更
    yield put(addClass("animating-progress"));
    yield take(types.TRANSITION_END); // 必要に応じてどんどんclassを追加していく
    yield put(onEndAnimation());
  }
}

export default function* () {
  yield fork(someAnimationSaga);
}

CSS 側にどれくらい duration を指定したのか把握しなくても良くなり、実行環境パフォーマンスによるズレが生じないため、こちらの方がスマートです。ただし、アニメーションさせるコンポーネントの数だけ ActionType を指定しなくてはいけないため、複雑なものになってくると辛いのは確かです。

CSSAnimation によるアニメーションの作り込みは、Redux DevTools Extensionによるデバッグがし易くなり、細かな動きを調整しながら最適な UX を探ることが出来ます。

Redux DevTools Extension

方法 2.Animation ライブラリによる実装

アニメーションさせるためのライブラリを紹介するにあたり、TweenMaxを選定しました。設計方針としては、DOM と紐付かないプロパティをアニメーションさせ、必要なコンポーネントから参照する手法をとります。冒頭で紹介したコードのまま、以下の関数を書き換えていきます。

animations.saga.functions.js
export function someAnimation(duration) {
  return new Promise((resolve) => {
    // ここでAnimation処理を実行する
    // setTimeout(() => {
    //   resolve()
    // }, duration)
  });
}
animations.saga.functions.js
import { updateStyle } from "~/actions/creators/animate";
import { dispatch } from "~/store";

function someAnimate(duration) {
  return new Promise((resolve) => {
    const currentStyle = { opacity: 0 };
    const completeStyle = { opacity: 1 };
    TweenLite.to(currentStyle, duration / 1000, {
      ...completeStyle,
      onUpdate: () => {
        dispatch(updateStyle(currentStyle));
      },
      onComplete: resolve,
      ease: Circ.easeOutBounce,
    });
  });
}

アニメーション実行対象が style プロパティとなり、onUpdate 毎に action を dispatch します。そしてアニメーション対象の component は、props を参照して inline-style を生成します。このため、redux-devtools の TimeTravel 等を使ったデバッグには不向きですが、CSS と切り離すことが出来るため、複雑なアニメーションを作りこみ易くなります。

実装についての詳細記事は先日 Qiita::Redux Advent Calendar 2016 24 日目 に投稿しましたので、興味のあるかたはそちらをご覧いただければと思います。

redux-saga がもたらす利点

アニメーションの状態を Redux で共有出来る様になったことから、従来よりも複雑な演出が可能になり、スケールに併せた処理の追加も柔軟に出来る様になりました。

Component インスタンスが mount されていることに依存しないため、unmount 時の参照エラーに遭遇することが無くなり、コンポーネントライフサイクルに処理を挟む必要が無くなります。何より、SFC を維持出来るという点はとても重要です。

redux-saga は、ユーザの行動を想起し易いシナリオを、同期的に記述することが出来ます。React Redux で非同期処理をするには、redux-saga が現時点で最も良い選択肢だと考えています。