2016年4月14日
React、Redux、D3を用いたアニメーション
(2016-03-24)by Swizec Teller
本記事は、原著者の許諾のもとに翻訳・掲載しております。
これは小さな粒を生成するものです。あなたがクリックした場所から、小さな円が生まれて飛び出していくのです。マウスを持って、動かしてみましょう。粒はカーソルから生み出され続けます。
モバイル機器や、マウスではなく指で動かすタイプのコンピュータだったらどうでしょうか。同じように動きます。
私はオタクなので、これが楽しいと思います。皆さんの見解は様々かもしれません。埋め込み画像をクリックして、円が飛ぶのを見てください。クールじゃないですか?
仕組み
これは全て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
このうちのどれも、状態を持っていません。しかし App
は componentDidMount
を使うための適切なコンポーネントを持っています。これを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;
これは x
と y
の座標を取り、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>
を Header
や Footer
、 <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);
}
いくつか考えなければならないイベントがあります。
mousedown
とtouchstart
は、粒子の生成を開始します。mousemove
とtouchmove
は、マウスの位置を更新します。mouseup
、touchend
、mouseleave
は、粒子の生成を終了します。
そして、 updateMousePos
と updateTouchPos
は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を使用します。
その他のアクション は、 tickTime
、 tickerStarted
、 startParticles
、 stopParticles
、 updateMousePos
です。どういう意味か分かるでしょう。
コンテナコンポーネント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
を本来のReact Component
として定義します。ライフサイクルメソッドを使う必要がありますが、これは処理状態を把握しないコンポーネントでは使えません。
このコードには、重要な部分が3つあります。
-
componentDidMount
とcomponentWillUnmount
において、ストアにアクセスします。マウント時にデータ変更を通知するようにし、アンマウント時はしないようにします。 -
レンダリングする際、ストアは私たちのコンテクストであると想定して
getState()
を使い、ラップしているコンポーネントをレンダリングします。
この場合、App
コンポーネントをレンダリングします。 -
ストアを私たちのコンテクストとして得るために、
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();
}
}
すぐに”ゲット”できなくても心配しないでください。私も作成に数時間かかりましたから。
これがアニメーションループを動かします。ゲームループとも呼ばれます。
あらゆる requestAnimationFrame
に tickTime
アクションをディスパッチします。ブラウザがレンダリング可能になるたび、Reduxデータストアを更新する機会が得られます。理論的には、1秒に60回ですが、多くの要因によって異なります。調べてみましょう。
startTicker
はストアを2段階で更新します。
tickerStarted
フラグをチェックし、まだ開始されていないティッカーがあれば開始します。この方法であれば、レンダーフレームごとに複数のアニメーションフレームを試行する必要がありません。結果として、特に何も考えず、onMouseDown
にstartTicker
をバインドすることになります。ticker
関数を作ります。これは粒子を生成し、tickTime
アクションをディスパッチし、全てのrequestAnimationFrame
ごとに再帰的に呼び出しを行います。毎回tickerStarted
フラグをチェックし、必要に応じて時間を停止することもできるようにします。
そうです、つまり、reduxアクションを非同期的にディスパッチするということです。問題はありません。「とりあえず動く」のは、不変データの大きな利点です。
maybeCreateParticles
関数そのものは、大して面白くありません。 store.mousePos
から (x, y)
座標を取得し、 generateParticles
フラグをチェックし、 createParticles
アクションをディスパッチします。
ここまでがコンテナで、 83行のコード です。
Reducer 1つ
いいですね。発射と描画のアクションができたら、粒子ジェネレータのロジック全体を見てみましょう。たった33行のコードと少しの変更でできてしまいます。
いや、正直に言いましょう。多くの変更が必要です。しかし、 CREATE_PARTICLES
と TIME_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ができました。粒子ジェネレータが機能します。アニメーションもスムーズですね。
重要な発見
この粒子ジェネレータをReactとReduxでビルドしたことで、3つの重要な発見がありました。
-
Reduxは考えていたよりもずっと速い。 各アニメーションループにステートツリーの新規コピーを作成するなんて、とんでもないと思うでしょう。しかしうまく動くのです。コピーするとしても、多くの部分で皮相だけをコピーしているのでしょう。速いわけです。
-
JavaScript配列に追加すると遅い。 300あまりの粒子を作り出すと、その後の新規追加は明らかに遅くなります。粒子の追加を止めると、スムーズなアニメーションを取得できます。このことが示すのは、粒子の作成に関わる何か、配列への追加、Reactコンポーネントのインタンスの作成、SVG DOM ノードの作成のいずれかが遅いということです。
-
SVGも遅い。 上記の仮説をテストするため、ジェネレータが、最初のクリックで3000の粒子を吐き出すようにしてみました。アニメーション速度は初めは最悪で、粒子を1000個程度にするとなんとか許容範囲になります。大きな配列の皮相をコピーし、既存のSVG ノードを動かすと、新規追加をするよりも速いことが予測できます。それを表すgif画像です。
粒子3000個は多過ぎる。
終わりに
どうでしょうか。React、Redux、それからD3を使ったアニメーションができました。新しいスーパーパワー? そうかもしれません。
まとめです。
- Reactはレンダリングを扱う。
- D3は計算をする。
- Reduxは状態を扱う。
- エレメントの位置座標は状態である。
- 全ての
requestAnimationFrame
ごとに座標を変える。 - 魔法
忘れないでください。これらのクールなワザは、今月出る私の著書の新版、 React+d3 でも紹介しています。
Primoz Cigler、Sigurt Bladt Dinesen、Will Fanguy、この記事のドラフトを読んでくれて ありがとう。
本記事の著者によるReact+d3jsの著書については こちら
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa