React、Redux、D3を用いたアニメーション

これは小さな粒を生成するものです。あなたがクリックした場所から、小さな円が生まれて飛び出していくのです。マウスを持って、動かしてみましょう。粒はカーソルから生み出され続けます。

モバイル機器や、マウスではなく指で動かすタイプのコンピュータだったらどうでしょうか。同じように動きます。

私はオタクなので、これが楽しいと思います。皆さんの見解は様々かもしれません。埋め込み画像をクリックして、円が飛ぶのを見てください。クールじゃないですか?

仕組み

これは全てReact、Redux、D3を使って作られています。アニメーションのトリックはありません。少しの賢さが必要なだけです。

一般的な方法を、以下で説明してみます。

私たちは、ページ、SVGエレメント、内部の粒といった全てをレンダリングするためにReactを使います。この全ては、propを使ってDOMを返す、Reactコンポーネントを使って作られています。これによって私たちは、どのノードを更新すべきか、いつ古いノードから不要なデータを回収するべきかを決定する、Reactのアルゴリズムを利用できます。

そして私たちはD3の計算とイベント検出を利用します。D3は優れたランダムジェネレータを持っているので、それを利用することができます。D3のマウスとタッチイベントハンドラがSVGと関係のある座標を計算します。私たちにはこれが必要で、Reactはこれを行えません。ReactのクリックハンドラはDOMのノードに基づいていて、(x, y)座標とは対応していません。D3は画面上の実際のカーソルの位置を見ているのです。

全ての粒子の座標は、Reduxのストアにあります。各粒子は動きのベクトルも持っています。ストアには役立つフラグや一般的なパラメータもあります。これによってアニメーションをデータの変換として扱えるのです。少し、ご説明しましょう。

私たちは、粒子を作る、アニメーションを始める、マウスの位置を変えるといったユーザのイベントとコミュニケーションをとるために、アクションを使います。各requestAnimationFrameで、“1歩進んだアニメーション”アクションをディスパッチします。

それぞれのアクションで、reducerはアプリ全体の新しい状態を計算します。これには、アニメーションの各段階での新しい粒子の位置も含まれます。

ストアが更新されると、Reactはpropを経由してそれを反映します。座標は状態ですから、粒子は動くのです。

その結果は、スムーズなアニメーションになります。

詳細を学ぶために、読み進めてください。コードはGitHubにもあります

この記事は、近々出版される「React+d3js ES6」という私の著書の1つの章になる予定です。

3つのプレゼンテーションのコンポーネント

まずはプレゼンテーションのコンポーネントから始めます。これが一番簡単だからです。粒子の一団をレンダリングするためには、次のことが必要になります。

  • 処理状態を把握しないParticle
  • 処理状態を把握しないParticles
  • 適切なApp

このうちのどれも、状態を持っていません。しかしAppcomponentDidMountを使うための適切なコンポーネントを持っています。これをD3イベントリスナーにくっつける必要があります。

Particleコンポーネントは円です。それは次のようになります。

// src/components/Particles/Particle.jsx
import React, { PropTypes } from 'react';
 
const Particle = ({ x, y }) => (
    <circle cx={x} cy={y} r="1.8" />
);
 
Particle.propTypes = {
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
};
 
export default Particle;

これはxyの座標を取り、SVGの円を返します。

Particlesコンポーネントは、それほど賢くありません。次に挙げるように、グループ化のエレメントに覆われた円のリストを返します。

// src/components/Particles/index.jsx
import React, { PropTypes } from 'react';
 
import Particle from './Particle';
 
const Particles = ({ particles }) => (
    <g>{particles.map(particle =>
        <Particle key={particle.id}
                  {...particle} />
        )}
    </g>
);
 
Particles.propTypes = {
    particles: PropTypes.arrayOf(PropTypes.shape({
        id: PropTypes.number.isRequired,
        x: PropTypes.number.isRequired,
        y: PropTypes.number.isRequired
    }).isRequired).isRequired
};
 
export default Particles;

key={particle.id}の部分に注目してください。これがないと、Reactは際限なくコンプレインし続けます。おそらく、似ているコンポーネントを区別するためのものだと思います。これですてきなアルゴリズムが機能するようになります。

すばらしい。{id, x, y}オブジェクトの配列を与えられれば、SVGの円をレンダリングすることができるのです。そこで出てくるのが、最初の楽しいコンポーネント、Appです。

AppはシーンのレンダリングやD3イベントリスナーの結合を担当します。レンダリングの部分は、次のようになります。


// src/components/index.jsx
 
import React, { PropTypes, Component } from 'react';
import ReactDOM from 'react-dom';
import d3 from 'd3';
 
import Particles from './Particles';
import Footer from './Footer';
import Header from './Header';
 
class App extends Component {
    render() {
        return (
            <div onMouseDown={e => this.props.startTicker()} style={{overflow: 'hidden'}}>
                 <Header />
                 <svg width={this.props.svgWidth}
                      height={this.props.svgHeight}
                      ref="svg"
                      style={{background: 'rgba(124, 224, 249, .3)'}}>
                     <Particles particles={this.props.particles} />
                 </svg>
                 <Footer N={this.props.particles.length} />
             </div>
        );
    }
}
 
App.propTypes = {
    svgWidth: PropTypes.number.isRequired,
    svgHeight: PropTypes.number.isRequired,
    startTicker: PropTypes.func.isRequired,
    startParticles: PropTypes.func.isRequired,
    stopParticles: PropTypes.func.isRequired,
    updateMousePos: PropTypes.func.isRequired
};
 
export default App;

まだ続きがありますが、要点としては<div>HeaderFooter<svg>と一緒に返すということです。<svg>の内部で、私たちはたくさんの円をレンダリングするためにParticlesを使います。HeaderやFooterは、心配しなくて大丈夫です。ただのテキストです。

注意してほしいのは、レンダリング関数の核は、“全てのParticlesをここに集めてください”と言っているだけだということです。何が動かされるか、何が新しいか、何がもう必要ないかについては何も言っていません。それについては心配しなくていいのです。

座標のリストを入手して、単純に円を描きます。後は、Reactがしてくれます。私に言わせれば、それがここでの本当の魔法です。

そうそう、ユーザがこの領域をクリックすると、startTicker()が呼び出されます。粒子が現われる前にクロックを動かす必要はありません。

D3イベントリスナー

ユーザが粒子を生成するには、propTypesで述べた関数を使わなければなりません。それは、このような感じです。

// src/components/index.jsx

class App extends Component {
    componentDidMount() {
        let svg = d3.select(this.refs.svg);

        svg.on('mousedown', () => {
            this.updateMousePos();
            this.props.startParticles();
        });
        svg.on('touchstart', () => {
            this.updateTouchPos();
            this.props.startParticles();
        });
        svg.on('mousemove', () => {
            this.updateMousePos();
        });
        svg.on('touchmove', () => {
            this.updateTouchPos();
        });
        svg.on('mouseup', () => {
            this.props.stopParticles();
        });
        svg.on('touchend', () => {
            this.props.stopParticles();
        });
        svg.on('mouseleave', () => {
            this.props.stopParticles();
        });

    }

    updateMousePos() {
        let [x, y] = d3.mouse(this.refs.svg);
        this.props.updateMousePos(x, y);
    }

    updateTouchPos() {
        let [x, y] = d3.touches(this.refs.svg)[0];
        this.props.updateMousePos(x, y);
    }

いくつか考えなければならないイベントがあります。

  • mousedowntouchstartは、粒子の生成を開始します。
  • mousemovetouchmoveは、マウスの位置を更新します。
  • mouseuptouchendmouseleaveは、粒子の生成を終了します。

そして、updateMousePosupdateTouchPosはD3の魔法を使って、SVGエレメントと関係する新しい(x, y)座標を計算します。粒子生成段階では、このデータを各粒子の最初の位置として使用します。

はい、複雑なのです。

注意したいのは、Reactが、マウスの位置を描画領域に対して相対的に決定する程賢くはないということです。Reactは、DOMノードがクリックされたことは認識しています。D3が、魔法を使って正確な座標を見つけるのです。

タッチイベントについては、最初のタッチの位置だけを考慮します。一度に複数の指で粒子を描くようなことも可能ではありますが、今の最初の位置の考慮だけで十分でしょう。

レンダリングとユーザイベントについては以上です。コードは、107行あります。

6つのアクション

Reduxのアクションは、「おい、何か起こったぜ」の凝った言い方です。これらは、最新情報についての構造化メタデータを得るために呼び出す関数です。

6つのアクションがあり、最も複雑なものは、このような感じです。

// src/actions/index.jsx
export const CREATE_PARTICLES = 'CREATE_PARTICLES';

export function createParticles(N, x, y) {
    return {
        type: CREATE_PARTICLES,
        x: x,
        y: y,
        N: N
    };
}

これは、(x, y)座標にN個の粒子を生成するようシステムに命令しています。Reducerを見ると、どのような動きをするかが分かり、コンテナを見ると、そのトリガーが分かります。

アクションは、あるtypeを持っていなければなりません。Reducerは、すべきことを決定するためにそのtypeを使用します。

その他のアクションは、tickTimetickerStartedstartParticlesstopParticlesupdateMousePosです。どういう意味か分かるでしょう。:)

コンテナコンポーネント1つ

コンテナは、ちょうどこのプレゼンテーション部分のようなReactコンポーネントです。プレゼンテーションコンポーネントと異なり、コンテナは、Reduxデータストアにアクセスします。

この区別が厳密に必要かどうかよく分かりませんが、これによってコードの見栄えが良くなります。その区別とは、プロパティをエレメントに変換する、関数的にきれいなプレゼンテーションコンポーネントと、外部にアクセスする汚い汚いコンテナです。

これらをデータストアのモナドと考えても良いでしょう。

AppContainerの概要は、このような感じです。

// src/containers/AppContainer.jsx
import { connect } from 'react-redux';
import React, { Component } from 'react';

import App from '../components';
import { tickTime, tickerStarted, startParticles, stopParticles, updateMousePos, createParticles } from '../actions';

class AppContainer extends Component {
    componentDidMount() {
        const { store } = this.context;
        this.unsubscribe = store.subscribe(() =>
            this.forceUpdate()
        );
    }

    componentWillUnmount() {
        this.unsubscribe();
    }

   // ...

    render() {
        const { store } = this.context;
        const state = store.getState();

        return (
            <App {...state}
                 startTicker={::this.startTicker}
                 startParticles={::this.startParticles}
                 stopParticles={::this.stopParticles}
                 updateMousePos={::this.updateMousePos}
            />
        );
    }
};

AppContainer.contextTypes = {
    store: React.PropTypes.object
};

export default AppContainer;

必要なものをインポートして、AppContainerを本来のReactComponentとして定義します。ライフサイクルメソッドを使う必要がありますが、これは処理状態を把握しないコンポーネントでは使えません。

このコードには、重要な部分が3つあります。

  1. componentDidMountcomponentWillUnmountにおいて、ストアにアクセスします。マウント時にデータ変更を通知するようにし、アンマウント時はしないようにします。

  2. レンダリングする際、ストアは私たちのコンテクストであると想定してgetState()を使い、ラップしているコンポーネントをレンダリングします。
    この場合、Appコンポーネントをレンダリングします。

  3. ストアを私たちのコンテクストとして得るために、contextTypesを定義しなければなりません。そうでないと、有効になりません。これは謎のReactマジックです。

コンテクストは、素晴らしいです。なぜなら、暗黙的にプロパティを渡すからです。コンテクストは、何でも良いのですが、Reduxは、ストアであることを好みます。アプリケーションの処理状態の全てを含んでいます。UIもビジネスデータもです。

そこからモナド比較が始まるのです。私は、Haskellに手を出したために、壊れてしまったかもしれません。モナドは至る所で見られました。

理解できない人のために言っておきますが、その{::this.startTicker}というシンタックスは、ES2016に見られます。{this.startTicker.bind(this)}と同等です。これを使うには、Babelのコンフィギュレーションのstage-0を有効にしてください。

AppContainerがストアにアクセス

素晴らしい。あなたはもう基本が理解できています。では、これらのコールバックを定義して、Appにアクションを始動させるようにしましょう。大体は、決まった指示のようなアクションラッパーです。このような感じです。

// src/containers/AppContainer.jsx
    startParticles() {
        const { store } = this.context;
        store.dispatch(startParticles());
    }

    stopParticles() {
        const { store } = this.context;
        store.dispatch(stopParticles());
    }

    updateMousePos(x, y) {
        const { store } = this.context;
        store.dispatch(updateMousePos(x, y));
    }

これがテンプレートです。アクション関数が{type: ..}オブジェクトを提供するので、ストア上にそれをディスパッチします。

そのプロセスが起こると、Reduxは、reducerを使ってステートツリーの新規インスタンスを作成します。次の章でもっと詳しく説明しましょう。

まず、startTickerコールバックを見る必要があります。これが魔法の始まりです。

startTicker() {
        const { store } = this.context;

        let ticker = () => {
            if (store.getState().tickerStarted) {
                this.maybeCreateParticles();
                store.dispatch(tickTime());

                window.requestAnimationFrame(ticker);
            }
        };

        if (!store.getState().tickerStarted) {
            console.log("Starting ticker");
            store.dispatch(tickerStarted());
            ticker();
        }
    }

すぐに”ゲット”できなくても心配しないでください。私も作成に数時間かかりましたから。

これがアニメーションループを動かします。ゲームループとも呼ばれます。

あらゆるrequestAnimationFrametickTimeアクションをディスパッチします。ブラウザがレンダリング可能になるたび、Reduxデータストアを更新する機会が得られます。理論的には、1秒に60回ですが、多くの要因によって異なります。調べてみましょう。

startTickerはストアを2段階で更新します。

  1. tickerStartedフラグをチェックし、まだ開始されていないティッカーがあれば開始します。この方法であれば、レンダーフレームごとに複数のアニメーションフレームを試行する必要がありません。結果として、特に何も考えず、onMouseDownstartTickerをバインドすることになります。
  2. ticker関数を作ります。これは粒子を生成し、tickTimeアクションをディスパッチし、全てのrequestAnimationFrameごとに再帰的に呼び出しを行います。毎回tickerStartedフラグをチェックし、必要に応じて時間を停止することもできるようにします。

そうです、つまり、reduxアクションを非同期的にディスパッチするということです。問題はありません。「とりあえず動く」のは、不変データの大きな利点です。

maybeCreateParticles関数そのものは、大して面白くありません。store.mousePosから(x, y)座標を取得し、generateParticlesフラグをチェックし、createParticlesアクションをディスパッチします。

ここまでがコンテナで、83行のコードです。

Reducer 1つ

いいですね。発射と描画のアクションができたら、粒子ジェネレータのロジック全体を見てみましょう。たった33行のコードと少しの変更でできてしまいます。

いや、正直に言いましょう。多くの変更が必要です。しかし、CREATE_PARTICLESTIME_TICKの変化を作り上げる33行は最も興味深いところです。

全てのロジックはreducerに集約されます。Dan Abramovは、reducerは.reduce()に置く関数として考えるべき、と言っています。状態と一連の変化を指示されたら、新規状態はどのようにして作ればよいでしょうか。

最も簡単に書くとすれば、次のようになるでしょう。

let sum = [1,2,3,4].reduce((sum, i) => sum+i, 0);

全ての数字は、その前の合計を取って数字を足します。

ここで使う粒子ジェネレータはそれをもう少し複雑にしたものです。現在のアプリケーションの状態を取り、アクションを組み入れ、新規のアプリケーションの状態を返します。なるべくシンプルにするため、全てを同じreducerに置き、大きなswitch宣言を使ってaction.typeを基盤に動きを決めます。

より大きなアプリケーションでは複数のreducerに分けることになりますが、基本の原則は変わりません。

基礎から始めましょう。

// src/reducers/index.js
const Gravity = 0.5,
      randNormal = d3.random.normal(0.3, 2),
      randNormal2 = d3.random.normal(0.5, 1.8);

const initialState = {
    particles: [],
    particleIndex: 0,
    particlesPerTick: 5,
    svgWidth: 800,
    svgHeight: 600,
    tickerStarted: false,
    generateParticles: false,
    mousePos: [null, null]
};

function particlesApp(state = initialState, action) {
    switch (action.type) {
    default:
            return state;
    }
}

export default particlesApp;

これがreducerです。

重力定数と2つのランダムジェネレータから始めましょう。それから初期状態を定義します。

  • 粒子の空のリスト
  • 粒子インデックス 後で追記します。
  • ティッカーが動くたびに生成したい粒子の数
  • svgの初期サイズ設定
  • ジェネレータ用の2つのフラグとmousePos

これだけではreducerは何も変えません。常に最低1つの不変状態を返すことが重要です。

アニメーションのための状態更新

以下に示すように、多くのアクションでreducerは単一値を更新します。

// src/reducers/index.js
    switch (action.type) {
        case 'TICKER_STARTED':
            return Object.assign({}, state, {
                tickerStarted: true
            });
        case 'START_PARTICLES':
            return Object.assign({}, state, {
                generateParticles: true
            });
        case 'STOP_PARTICLES':
            return Object.assign({}, state, {
                generateParticles: false
            });
        case 'UPDATE_MOUSE_POS':
            return Object.assign({}, state, {
                mousePos: [action.x, action.y]
            });

ここではブーリアンフラグの値と2桁の配列を変えるだけですが、新しい状態を作らなければなりません。常に新規状態を作るのです。Reduxはアプリケーションの状態が不変であることに依存します。

そのため、毎回Object.assign({}, …を使います。新しい空のオブジェクトを作り、現在の状態を入力し、特定の値を新規の値で上書きします。

それを毎回行うのでなければ、不変データ構造用のライブラリを使ってもいいでしょう。後者はおそらくうまく機能しますが、私はまだテストしていません。

2つの重要な状態の更新 – アニメーションのティックと粒子の作成 – は次のようになります。

case 'CREATE_PARTICLES':
            let newParticles = state.particles.slice(0),
                i;

            for (i = 0; i < action.N; i++) {
                let particle = {id: state.particleIndex+i,
                                x: action.x,
                                y: action.y};

                particle.vector = [particle.id%2 ? -randNormal() : randNormal(),
                                   -randNormal2()*3.3];

                newParticles.unshift(particle);
            }

            return Object.assign({}, state, {
                particles: newParticles,
                particleIndex: state.particleIndex+i+1
            });
        case 'TIME_TICK':
            let {svgWidth, svgHeight} = state,
                movedParticles = state.particles
                                      .filter((p) =>
                                          !(p.y > svgHeight || p.x < 0 || p.x > svgWidth))
                                      .map((p) => {
                                          let [vx, vy] = p.vector;
                                          p.x += vx;
                                          p.y += vy;
                                          p.vector[1] += Gravity;
                                          return p;
                                      });

            return Object.assign({}, state, {
                particles: movedParticles
            });

大量のコードがあるように見えます。確かに、非常に長くなっています。

最初の部分 – CREATE_PARTICLES – は、全ての現状の粒子を新規配列にコピーし、action.Nの新規粒子を先頭に追加します。私がテストしたところ、粒子を末尾に追加するよりもそのほうがスムーズに動きました。各粒子は(action.x, action.y)で動き出し、ランダムな動きのベクトルを取得します。

Reduxの観点からすると、これは悪い例です。reducerは純粋な関数であるべきところ、ランダムな動きは本来、純粋ではないからです。しかし今回のケースでは問題ありません。

他に考えられる方法としては、このロジックをアクションに落とし込むことです。これには幾つか利点がありますが、1箇所で全てのロジックを網羅するのが困難になります。それはよいとして…

2つ目の部分 – TIME_TICK-は粒子をコピーしません(そうすべきかもしれませんが)。参照すると配列が渡されるので、既存データを何らかの方法で変異させることになります。これも良くない方法ですが、絶対悪いというわけでもありません。何よりこうすると動きが速いです。:)

可視エリア外に出た粒子は全て取り除きましょう。その他については、動きのベクトルをそれらの位置に追加します。それからGravity定数を使っているベクトルのy部分を変更します。

以上が簡単に加速を実装する方法です。

これで終わりです。reducerができました。粒子ジェネレータが機能します。アニメーションもスムーズですね。

Particle generator gif

重要な発見

この粒子ジェネレータをReactとReduxでビルドしたことで、3つの重要な発見がありました。

  1. Reduxは考えていたよりもずっと速い。各アニメーションループにステートツリーの新規コピーを作成するなんて、とんでもないと思うでしょう。しかしうまく動くのです。コピーするとしても、多くの部分で皮相だけをコピーしているのでしょう。速いわけです。

  2. JavaScript配列に追加すると遅い。300あまりの粒子を作り出すと、その後の新規追加は明らかに遅くなります。粒子の追加を止めると、スムーズなアニメーションを取得できます。このことが示すのは、粒子の作成に関わる何か、配列への追加、Reactコンポーネントのインタンスの作成、SVG DOM ノードの作成のいずれかが遅いということです。

  3. SVGも遅い。上記の仮説をテストするため、ジェネレータが、最初のクリックで3000の粒子を吐き出すようにしてみました。アニメーション速度は初めは最悪で、粒子を1000個程度にするとなんとか許容範囲になります。大きな配列の皮相をコピーし、既存のSVG ノードを動かすと、新規追加をするよりも速いことが予測できます。それを表すgif画像です。


粒子3000個は多過ぎる。

終わりに

どうでしょうか。React、Redux、それからD3を使ったアニメーションができました。新しいスーパーパワー? そうかもしれません。

まとめです。

  • Reactはレンダリングを扱う。
  • D3は計算をする。
  • Reduxは状態を扱う。
  • エレメントの位置座標は状態である。
  • 全てのrequestAnimationFrameごとに座標を変える。
  • 魔法

忘れないでください。これらのクールなワザは、今月出る私の著書の新版、React+d3でも紹介しています。
:)
Primoz Cigler、Sigurt Bladt Dinesen、Will Fanguy、この記事のドラフトを読んでくれてありがとう。


本記事の著者によるReact+d3jsの著書についてはこちら
cover