ReactとD3.jsの組み合わせで変わる!JavaScriptアニメーション開発

2016/09/16

Swizec Teller

0

Articles in this issue reproduced from SitePoint
Copyright © 2016, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2016, KADOKAWA CorporationJapanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

人気のデータビジュアライゼーションライブラリー「D3.js」をもっと使いやすく、便利にするために。Reactを使ったコンポーネント化に取り組み、リアルタイムで描画されるアニメーションを作ります。

D3は最高です。Webのデータビジュアライゼーション分野におけるjQueryのように、考えられるあらゆることが実現できます。

オンラインで見られる最良のWebのデータビジュアライゼーションの多くがD3を使用しています。D3は素晴らしいライブラリーですが、最近のv4のアップデートで従来よりさらに安定性が増しました。

Reactと一緒に使えば、D3はさらに便利になります。

ちょうどjQueryのように、D3は高性能ですが課題もあります。データビジュアライゼーションが大規模になればなるほど、用いるコードは複雑になり、バグを修正したりアイデアを絞り出したりするのにさらに時間がかかるようになります。

しかし、Reactを使えばそれを解決できます。

私の著書『React+d3js ES6』を読めば理解が深まり、またReactとD3の最良の統合方法に関する情報も書いてあります。実例を用いて、宣言型トランザクションベースのアニメーションを実装する方法を説明します。

本記事は『bayd3: React & D3 - Better Together - 06.08.16 』としてYouTubeにも掲載されています。

Reactは有用か?

たしかにReactは大規模です。Reactを使用するとコード量が大きく増加し、さらにそれに伴う負荷が増大します。また、アップデートし続けなければならないライブラリーが1つ増えるということにもなります。

効果的にReactを使用したいなら、ビルド手順が必要です。なんらかの手段でJSXコードを純粋なJavaScriptに変換します。

現在では、WebpackとBabelのセットアップは簡単です。単にcreate-react-appを実行するだけです。セットアップが終わればJSXのコンパイル、最新のJavaScriptの機能、リンティング、ホットローディング、製品ビルドのためのコード縮小などが使用できます。素晴らしいですね。

サイズが大きくツールも複雑ですが、データビジュアライゼーションに取り組んでいる場合は特に、Reactを導入する価値があります。メンテナンスやデバッグ、拡張をしなくてもよい単発のプログラムを開発している場合は、純粋なD3にこだわってください。実用向けのアプリを開発している場合は、Reactの追加を勧めます。

一番のメリットは、Reactがコードのコンポーネント化を強く促す点です。また、コンポーネント化によって間接的・直接的にさまざまなメリットがでてきます。

D3コードと一緒にReactを用いる主なメリットを以下に示します。

  • コンポーネント化
  • テストとデバッグを容易にする
  • スマートなDOMの再描画
  • ホットローディング

コンポーネント化をすると、一連の論理ユニット、すなわちコンポーネントとしてコードがビルドされます。JSXを用いると、<Histogram /><Piechart /><MyFancyThingThatIMade /> などのように、HTML要素であるかのように使用できます。次のセクションで深く掘り下げます。

一連のコンポーネントとしてデータビジュアライゼーションをビルドすれば、テストやデバッグが容易になります。つまり、1度に1つの論理ユニットに集中できるようになります。また、ここでコンポーネントが動作すれば、同様にほかの場所でもそのコンポーネントが動作するということです。問題なくテストをパスするのであれば、レンダリングの頻度、設置場所、誰に呼び出されても、やはり問題なくテストをパスするはずです。

Reactはコードの構造を理解しており、変更のあったコンポーネントだけを再描画する方法を知っています。なにを再レンダリングして、なにをそのままにしておくかを決める面倒な作業はもう必要ありません。ただ変更して忘れてしまえば良いのです。Reactは自身でそれを把握します。そして、プロファイリングツールを見れば、変更のあった箇所のみが再レンダリングされていることが分かります。

alphabet-redraws

create-react-appを使用してツールを構成すれば、Reactはホットローディングを利用できます。ここで、3万ものデータポイントを持つデータビジュアライゼーションをビルドするとします。純粋なD3を使用した場合、コードの変更があるたびにページを更新しなくてはなりません。データセットを読み込み、データセットを解析し、データセットをレンダリングし、テストの状態になるようにあちこちクリックして……あくびが出そうです。

Reactを使えば、再読み込みしたり待機したりする必要がありません。ページ上で即座に変更されます。はじめて動作中のReactを見たときはとても衝撃的でした。

コンポーネント化のメリット

どうしてあれもこれもコンポーネント化するのでしょうか? すでにデータビジュアライゼーションのコードは動作しています。それをビルドしてリリースすれば、人びとは幸せになります。

しかし、そのコードで自分自身が幸せになれますか? コンポーネントを使用すれば、それが可能です。コンポーネントは以下のようなコードを生成してもっと楽になります。

  • 宣言型
  • 再利用可能
  • 理解しやすい
  • 整理されている

流行りの言葉の羅列に見えるかもしれませんが問題ありません。説明していきましょう。

たとえば宣言型コードとは、どうしたいのかではなく、なにをしたいのかを示すコードのことです。HTMLやCSSを書いたことがありますか? もしあれば、すでに宣言型コードの書き方が分かっています。おめでとうございます!

ReactはJSXを使って、JavaScriptをHTMLのような見た目にします。しかし心配はいりません。それは裏側で純粋なJavaScriptにコンパイルされます。

次のコードがなにをするのか予想してみてください。

render() {
  // ...
  return (
      <g transform={translate}>
          <Histogram data={this.props.data}
                     value={(d) => d.base_salary}
                     x={0}
                     y={0}
                     width={400}
                     height={200}
                     title="All" />
          <Histogram data={engineerData}
                     value={(d) => d.base_salary}
                     x={450}
                     y={0}
                     width={400}
                     height={200}
                     title="Engineer" />
          <Histogram data={programmerData}
                     value={(d) => d.base_salary}
                     x={0}
                     y={220}
                     width={400}
                     height={200}
                     title="Programmer"/>
          <Histogram data={developerData}
                     value={(d) => d.base_salary}
                     x={450}
                     y={220}
                     width={400}
                     height={200}
                     title="Developer" />
      </g>
  )
}

「4つのヒストグラムをレンダリングする」と予想していたら、正解です。おめでとうございます。

ヒストグラムコンポーネントを生成したあとは、通常のHTML要素と同様に使用できます。ヒストグラムは適切なパラメーターを指定した<Histogram />タグを挿入した場所に表示されます。

この場合のパラメーターはxおよびy座標、widthheightのサイズ、title、いくつかのdata、アクセサのvalueとなります。それぞれのコンポーネントに必要な値を指定可能です。

パラメーターはHTML属性のように見えますが、関数を含む、どんなJavaScriptオブジェクトでも指定できます。まるでパワーアップしたHTMLのようです。

いくつかのひな型と適切なデータセットを使用すると、先ほどのコードでこのような画像が表示されます。ソフトウェア制作に携わる人のカテゴリ別の給料分布を比較しています。

4 histograms of salary distributions

もう一度コードを見てください。どのようにコンポーネントが再利用されているか分かりましたか? まるで<Histogram />がヒストグラムを作成する関数であるかのようです。裏側で関数呼び出し(new Histogram()).render()、または同様のなにかにコンパイルされます。Histogramはクラスになり、<Histogram />を使用するたびに、インスタンスのrender関数を呼び出します。

Reactコンポーネントは関数プログラミングの原則に従う必要があります。ルールを破りたくなければ、副作用がなく、ステートレスで、何度実行しても結果が同じで、同等性を持つようにしなければなりません。

JavaScriptの関数では、これらの原則を守るのに相応の努力が必要ですが、Reactでは原則を守らないようにコーディングすること自体が困難です。それこそがチームで作業する際のメリットです。

宣言型かつ再利用可能なので、コードははじめから理解しやすくなります。また、HTMLを使用したことがあれば、コードがなにをするのか理解できます。詳細が分からないこともあるかもしれませんが、HTMLとJavaScriptをある程度知っていれば、JSXの読み方が分かるはずです。

複雑なコンポーネントはより単純なコンポーネントで構成され、そのコンポーネントはさらに単純なコンポーネントで構成され、そのコンポーネントは最終的に純粋なHTML要素で構成されます。こうすることで、コードは整理された状態で保たれます。

半年経ってからコードを見ても「ああ、これは4つのヒストグラムだな。これを調整するには、ヒストグラムコンポーネントを開いて手を加えれば良いはずだ」と理解できます。

Reactは、私のお気に入りの関数型プログラミングの原則を採用し、それらを実用的にしてくれます。私はそこが気に入っています。

それでは、アニメ化されたアルファベットの例を見てください。

アニメ化されたアルファベット例

Animated alphabet

アニメ化されたアルファベットのビルドです。ReactとD3を一緒に使用するもっとも単純な例だからというわけではなく、かっこよく見えるからです。講演でこれを見せるときはいつも、特に変更があったDOM要素のみが再描画されることを証明すると観客からどよめきが起こります。

この記事は、数か月前にブログに投稿した『Using d3js transitions in React』の要約版です。短くまとめるために、記事ではいくつかの詳細にはあまりきちんと触れません。GitHubレポジトリでコードベース全体を見られます。コードはReact 15とD3 4.0.0をベースにしています。クラスのプロパティなど、使用している構文のいくつかはまだ安定版のES6に存在しませんが、ツールのセットアップにcreate-react-appを使用すれば動作するはずです。

————

アニメ化されたアルファベットを作成するには、以下の2つのコンポーネントが必要です。

  • Alphabet - 1.5秒ごとにランダムな文字リストを生成し、それらをマッピングしてLetterコンポーネントをレンダリングする
  • Letter - SVGテキスト要素をレンダリングし、自身のトランザクションの開始、更新、終了を管理する

SVG要素のレンダリングにReactを使用し、トランザクションやインターバル、そして数学関連の処理のためにD3を使用します。

Alphabetコンポーネント

Alphabetコンポーネントは現在の文字リストを保持しており、ループ内でLetterコンポーネントの集合をレンダリングします。

以下のような骨組みから始めます。

// src/components/Alphabet/index.jsx
import React, { Component } from 'react';
import ReactTransitionGroup from 'react-addons-transition-group';
import * as d3 from 'd3';

require('./style.css');

import Letter from './Letter';

class Alphabet extends Component {
    static letters = "abcdefghijklmnopqrstuvwxyz".split('');
    state = {alphabet: []}

    componentWillMount() {
        // starts an interval to update alphabet
    }

    render() {
        // spits out svg elements
    }
}

export default Alphabet;

関連するものをインポートし、少し整形し、Alphabetコンポーネントを定義します。そこで、静的なlettersプロパティでは利用可能な文字リストが保持され、コンポーネントのstateでは空のalphabetが保持されます。また、componentWillMountrenderメソッドも必要です。

1.5秒ごとに新しいアルファベットを生成するのに最適な場所はcomponentWillMountの中です。

// src/components/Alphabet/index.jsx
    componentWillMount() {
        d3.interval(() => this.setState({
           alphabet: d3.shuffle(Alphabet.letters)
                       .slice(0, Math.floor(Math.random() * Alphabet.letters.length))
                       .sort()
        }), 1500);
    }

1.5秒ごとに関数を呼び出すにはd3.interval( //.., 1500)を使います。ピリオドごとに利用可能な文字をシャッフルし、ランダムな数だけ取り出して並べ替え、setState()を使ってコンポーネントのstateを更新します。

これでランダム順とアルファベット順の両方が実現できます。setState()をきっかけに再レンダリングします。

宣言型コードの魔法はrenderメソッドで体験できます。

// src/components/Alphabet/index.jsx
    render() {
        let transform = `translate(${this.props.x}, ${this.props.y})`;

        return (
            <g transform={transform}>
                <ReactTransitionGroup component="g">
                    {this.state.alphabet.map((d, i) => (
                        <Letter d={d} i={i} key={`letter-${d}`} />
                     ))}
                </ReactTransitionGroup>
            </g>
        );
    }

SVG transformationを使って指定された(x, y)の位置へとアルファベットを移動し、それからReactTransitionGroupを定義してthis.state.alphabetにマッピングして、 Letterコンポーネントの集合をレンダリングします。

Letterは現在のテキストdと、インデックスiを取得します。key属性を使用してReactにどのコンポーネントがどれを指すのか認識させます。ReactTransitionGroupを使うと、特別なコンポーネントライフサイクルメソッドを使って滑らかなトランジションを実現できます。

ReactTransitionGroup

いつコンポーネントがマウントされて、更新されて、アンマウントされるかを通知する通常のライフサイクルフックに加えて、ReactTransitionGroupを使うとcomponentWillEntercomponentWillLeaveなどにアクセスできます。なにか見覚えのあるものがありませんでしたか?

componentWillEnterはD3の.enter()と同じで、componentWillLeaveはD3の.exit()と同じ。そしてcomponentWillUpdateはD3の.update()と同じです。

「同じ」は強い概念であり、類似しています。D3のフックは、Reactのフックが各コンポーネントで個別に動作している間に、セレクション全体、すなわちコンポーネントのグループで動作します。D3ではなにが起こるかオーバーロードで指示しますが、Reactでは各コンポーネントがなにをするのかを知っています。

これにより、Reactのコードがより理解しやすくなると考えられます。

ReactTransitionGroupさらにたくさんのフックを使用できますが、これら3つすべてが必要です。良い点は、componentWillEntercomponentWillLeaveの両方で、コールバックを使用して「トランザクションが終了して、Reactが返ってきた」とはっきり言えることです。

StackOverflowReactTransitionGroupについて書いてくれたMichelle Tilleyに感謝します。

Letterコンポーネント

さあ、すてきなもの、宣言型データビジュアライゼーション、あるいはデータビジュアライゼーション自身を遷移できるコンポーネントを作る準備が整いました。

Letterコンポーネントのための基本的な骨組みは以下のようになります。

// src/components/Alphabet/Letter.jsx

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import * as d3 from 'd3';

class Letter extends Component {
    state = {
        y: -60,
        x: 0,
        className: 'enter',
        fillOpacity: 1e-6
    }
    transition = d3.transition()
                   .duration(750)
                   .ease(d3.easeCubicInOut);

    componentWillEnter(callback) {
        // start enter transition, then callback()
    }

    componentWillLeave(callback) {
        // start exit transition, then callback()
    }

    componentWillReceiveProps(nextProps) {
        if (this.props.i != nextProps.i) {
           // start update transition
        }
    }

    render() {
       // spit out a <text> element
    }
};

export default Letter;

はじめに関連するものをいくつか記載し、Letterコンポーネントをデフォルトのstateおよびデフォルトのトランザクションで定義します。たいていの場合は、連携や一時プロパティのためにstateの使用を避けたいと考えるでしょう。そのためにpropsがあります。transitionと一緒にstateを使用すると、D3の実動作とReactの実動作の同期が保たれます。

そうした魔法のデフォルト値はデフォルトのpropsになります。これによってより柔軟にAlphabetを生成できます。

componentWillEnter

トランザクションの開始をcomponentWillEnterに格納します。

// src/components/Alphabet/Letter.jsx
    componentWillEnter(callback) {
        let node = d3.select(ReactDOM.findDOMNode(this));

        this.setState({x: this.props.i*32});

        node.transition(this.transition)
            .attr('y', 0)
            .style('fill-opacity', 1)
            .on('end', () => {
                this.setState({y: 0, fillOpacity: 1});
                callback()
            });
    }

reactDOM.findDOMNode()を使用してDOMノードを取得し、d3.select()を使用してd3 selectionへ変換します。いまはD3でできることすべてがコンポーネントで実現できるのです。やりましたね!

そして現在のインデックスと文字の幅を用いてthis.state.xを更新します。幅は既知の値です。xstateに入れると、コマ落ちを防げます。iプロパティは更新のたびに変化しますが、Letterの移動を遅延させたいのです。

Letterは初回のレンダリング時、非表示であり、ベースラインから60ピクセル上部に存在します。それを下に移動させて表示させるには、D3トランザクションを使います。

node.transition(this.transition)を使用して、以前のデフォルト設定で新しいトランザクションを開始します。すべての.attr.styleは時間の経過とともに生じた内容をDOM要素自身で直接変更します。

こうするとReactに混乱が生じます。なぜなら、それがDOMをコントロールしているからです。従って、コールバック.on('end', ...)を使ってReactの動作を実際の動きと同期させなければなりません。setState()を使用して、コンポーネントのstateを更新し、メインのcallbackを呼び出します。Reactはこのとき、この文字が表示されているということを認識しています。

componentWillLeave

トランザクションの終了はcomponentWillLeave()です。先ほどの内容とはちょうど逆になります。

// src/components/Alphabet/
    componentWillLeave(callback) {
        let node = d3.select(ReactDOM.findDOMNode(this));

        this.setState({className: 'exit'});

        node.transition(this.transition)
            .attr('y', 60)
            .style('fill-opacity', 1e-6)
            .on('end', () => {
                callback()
            });
    }

このとき、statexの代わりにclassNameに変更します。これはxが変化しないためです。

トランザクション自身の終了とは、開始の逆を意味します。つまり、文字が下に移動して非表示になるということです。トランザクションの終了後に、コンポーネントを削除して良いことをReactに伝えます。

componentWillReceiveProps

トランザクションの更新はcomponentWillReceiveProps()です。

// src/components/Alphabet/Letter.jsx
    componentWillReceiveProps(nextProps) {
        if (this.props.i != nextProps.i) {
            let node = d3.select(ReactDOM.findDOMNode(this));

            this.setState({className: 'update'});

            node.transition(this.transition)
                .attr('x', nextProps.i*32)
                .on('end', () => this.setState({x: nextProps.i*32}));
        }
    }

もうパターンが分かりますね? stateを更新しトランザクションを実行して、stateをトランザクションのあとの実動作と同期させます。

この場合classNameを変更して、それから文字を新しい水平位置に移動します。

render

トランザクションの動作がすべて終わったあとに、「どうやってこれをレンダリングするんだろう?」と思うでしょう。気持ちは分かります!

しかし、面倒な作業はすべて終わりました。レンダリングは以下のとおり簡単です。

// src/components/Alphabet/Letter.jsx
    render() {
        return (
            <text dy=".35em"
                  y={this.state.y}
                  x={this.state.x}
                  className={this.state.className}
                  style={{fillOpacity: this.state.fillOpacity}}>
                {this.props.d}
            </text>
        );
    }

classNamefillOpacityを使って、位置(x, y)でレンダリングされたSVG<text>要素を返します。そうすると、dプロパティによって得られた単一の文字が表示されます。

すでに述べたように、xyclassNamefillOpacitystateを使用するのは理論的には誤っています。通常はそれらに対してpropを使用します。しかし、stateはrenderとライフサイクルメソッドの間のでやりとりをするためのもっともシンプルな方法だと考えられます。

基礎を終えて

流行だといえばそのとおりです。アニメ化された宣言型ビジュアライゼーションのビルド方法が分かりました。そう言ってもらえると、とてもうれしいです。

以下が動作の中でどのように見えるかは次のとおりです。

215

すてきなトランザクションなど、実行すべきものはすべて配列内でループし、いくつかの<Letter>コンポーネントをレンダリングします。とてもクールですね。

最後に

これで、技術的判断を下すために必要なReactに関して十分理解できたはずです。プロジェクトを見て、「たしかにこれは使い捨てのおもちゃよりも良い。コンポーネントは便利だし、デバッグが簡単で助かる」と判断できます。

さらにうれしいことに、ReactとD3を一緒に使って宣言型アニメーションをビルドする方法も理解できたはずです。従来は至難の業でした。

ReactとD3の適切な統合についてもっと理解を深めるには、私の著書『React+d3js ES6』を読んでください。

(原文:Building Animated Components, or How React Makes D3 Better

[翻訳:市川千枝/編集:Livit
Image:simone mescolini / Shutterstock.com

Copyright © 2016, Swizec Teller All Rights Reserved.

Swizec Teller

Swizec Teller

Loading...