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: .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を探ることが出来ます。

方法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が現時点で最も良い選択肢だと考えています。