新人プログラマーが「React」を使ってリアルタイムコメント機能を作ってみた(後編)

  • このエントリーをはてなブックマークに追加
  • Evernoteに保存

さて、前回はコメントをjson形式で取得するところまで行いました。
今回はコメントの投稿と投稿したコメントを表示させるところまで実装して、リアルタイムコメント機能を完成させたいと思います。

前回記事: 新人プログラマーが「React」を使ってリアルタイムコメント機能を作ってみた(前編)

サーバからのデータ取得

前回まではコメントデータをコード上に用意してそこから取得していましたが、サーバから取得できるようにします。

ReactDOM.render(
    <CommentBox url="/api/comments" />
    document.getElementById('content')
)

前回はdata={data}としていた部分をurl="/api/comments"に書き換えました。
data={data}は固定のjsonデータを取得していましたが、url="/api/comments"とすることでajaxを使い、データを動的に処理できるようにします。今回コメントデータはcomments.jsonというファイルに保存しています。(なぜ/api/commentsでcomments.jsonに保存できるのか疑問だったのですが、これはサーバ側の処理に書かれていたので次の項目で少し触れたいと思います。)この時点ではまだコメント機能は動きません。

Reactive state

前回までデータの参照にはpropsを使用していました。
チュートリアルに「propsはイミュータブル」と書いてありますが、つまりはpropsは状態を変更できないのです。(参考: React.jsのProp

今回作るコメント機能はcommentFormに入力したデータをcommentListに表示させるというものなので、常にやり取りするデータの状態が変わります。イミュータブルなpropsでは実現が難しいです。

そこで使うのがstateです。stateはミュータブルなのでいつでも状態を変更することができます。
値の参照方法はpropsと同じで、this.stateを使います。
propsとの違いはthis.setState()を呼び出すことでstateを更新することができる、というところです。

ひとまず、stateを表すコメントデータの配列をCommentBoxコンポーネントに加えます。

var CommentBox = React.createClass({
    getInitialState: function() {
        return {data: []};
    },
    render: function() {
        return (
            <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm />
            </div>
        );
    }
});

getInitialStateはコンポーネントのstateにおける初期値を設定するメソッドです。このメソッドは1度だけ実行されます。
初期化されているのでdata配列は現時点では空です。

Stateの更新

コンポーネント作成と同時にサーバからコメントデータを取得し、stateを更新して最新のデータを反映させます。
ここからはWebサーバ側の処理が必要になります。ソースコードはGithubに既に用意されているので自分の環境に合わせて設定してみてください。私はPHPで行いました。
https://github.com/reactjs/react-tutorial

var CommentBox = React.createClass({
    loadCommentsFromServer: function() {
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            cache: false,
            success: function(data) {
                this.setState({data: data});
            }.bind(this),
            error: function(xhr, status, err) {
               console.error(this.props.url, status, err.toString());
           }.bind(this)
       });
    },
    getInitialState: function() {
        return {data: []};
    },
    componentDidMount: function() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer, this.props.pollInterval);
    },
    render: function() {
        return (
            <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm />
            </div>
        );
    }
});

ReactDOM.render(
    <CommentBox url="/api/comments" pollInterval={2000} />,
    document.getElementById('content')
);

さて、componentDidMountメソッドとloadCommentsFromServerメソッドを作成しました。ここでお馴染みのajaxを使用します。

componentDidMountメソッドはコンポーネントがレンダリングされた後にReactが自動的に呼び出すメソッドです。

loadCommentsFromServerメソッドはajaxを使い、コメントデータをサーバから取得し、取得したデータをthis.setStateにセットしています。

このコンポーネントの処理の流れとしては、まずgetInitialStateが実行されてthis.Stateが初期化され、次にrenderが実行されます。
この状態ではCommentListコンポーネントには初期化された空のthis.state.dataが渡されます。
次にcomponentDidMountが実行されます。ここでloadCommentsFromServerが呼ばれ、this.setState()を使って取得したデータをコンポーネントにセットし、this.setState()によって再度renderが実行され、CommentListコンポーネントにサーバから取得したデータが渡されます。

pollIntervalでコンポーネントが最初に呼び出されてから2秒ごとにloadCommentsFromServerが呼び出されるように設定しました。
これでcomments.jsonに変更が加わると2秒以内に表示が変更されるようになります。

ここでちょっとWebサーバ側の処理を見てみましょう。今回はPHPを使っているのでPHPのコードを見てみます。

$scriptInvokedFromCli =
    isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] === 'server.php';

if($scriptInvokedFromCli) {
    $port = getenv('PORT');
    if (empty($port)) {
        $port = "3000";
    }

    echo 'starting server on port '. $port . PHP_EOL;
    exec('php -S localhost:'. $port . ' -t public server.php');
} else {
    return routeRequest();
}

function routeRequest()
{
    $comments = file_get_contents('comments.json');
    $uri = $_SERVER['REQUEST_URI'];
    if ($uri == '/') {
        echo file_get_contents('./public/index.html');
    } elseif (preg_match('//api/comments(?.*)?/', $uri)) {
        if($_SERVER['REQUEST_METHOD'] === 'POST') {
            $commentsDecoded = json_decode($comments, true);
            $commentsDecoded[] = [
                'id'      => round(microtime(true) * 1000),
                'author'  => $_POST['author'],
                'text'    => $_POST['text']
            ];

            $comments = json_encode($commentsDecoded, JSON_PRETTY_PRINT);
            file_put_contents('comments.json', $comments);
        }
        header('Content-Type: application/json');
        header('Cache-Control: no-cache');
        header('Access-Control-Allow-Origin: *');
        echo $comments;
    } else {
        return false;
    }
}

routeRequest()の中身を見てみると、/api/commentsにアクセスするとcomment.jsonの中身を返し、POSTされるとcomment.jsonファイルにデータを追記して保存するようになっています。

ちなみにgetInitialStatecomponentDidMountはReactが用意しているメソッドです。Reactはコンポーネントの状態の変化に合わせて色々メソッドを呼んでくれます。他にも様々なメソッドが用意されています。(参考:Component Specs and Lifecycle

新しいコメントの追加

いよいよフォームを作ります。
用意するフォームは、投稿者名、コメント、送信ボタンです。

var CommentForm = React.createClass({
    render: function() {
        return (
            <form className="commentForm">
                <input type="text" placeholder="Your name" />
                <input type="text" placeholder="Say something..." />
                <input type="submit" value="Post" />
            </form>
        );
    }
});

非常に見覚えのあるタグです。HTMLタグそっくりなので非常にわかりやすいです。
ちなみにそれぞれのタグの最後についている/は必ず付けてください。これがないとエラーになります。
ついでのちなみにReactで作成するコンポーネントはルート階層の要素を一つにする必要があるため、return()の中でのルート階層には一つしか要素を作ることができません。

return (
    <div>first</div>
    <div>second</div>
);

上記ような書き方はできないので注意してください。もし2つの要素を並べたい場合divなどで囲って1つの要素にまとめてしまうといいです。

return (
    <div>
        <div>first</div>
        <div>second</div>
    </div>
)

(参考:[Sy] Reactで「Adjacent JSX elements must be wrapped in an enclosing tag」となる場合の対処

制御コンポーネント

制御コンポーネントというのはvalueが設定されているinputのことを指しています。
HTMLのinputはvalueに初期値を設定するとその状態でレンダリングされますが、何か入力があってもビューの状態としてvalueの値が変わるということはないです。
ReactはHTMLとは違い、初期化の時だけではなく、ある時点のビューの状態を表示している必要があります。そのため、フォームの入力をリアルタイムでvalueに反映するようにします。

var CommentForm = React.createClass({
    getInitialState: function() {
        return {author: '', text: ''};
    },
    handleAuthorChange: function(e) {
        this.setState({author: e.target.value});
    },
    handleTextChange: function(e) {
        this.setState({text: e.target.value});
    },
    render: function() {
        return (
            <form className="commentForm">
                <input type="text"
                    placeholder="Your name..."
                    value={this.state.author}
                    onChange={this.handleAuthorChange}
                />
                <input type="text" 
                    placeholder="Say something..." 
                    value={this.state.text} 
                    onChange={this.handleAuthorText} 
                />
                <input type="submit" value="Post" />
            </form>
        );
    }
});

getInitialStatethis.stateを初期化します。
handleAuthorChangehandleTextChangeはフォームに入力があった時にstateを更新するためのメソッドです。onChangeイベントを使うことによってフォームに入力された値を反映することができます。
ぜひデベロッパーツールなど見ながらフォームに入力してみてください。入力と同時にinputタグのvalueの値が変化することがわかると思います。
制御コンポーネント

フォーム送信

いよいよコメントを送信してリスト反映させます。
コメントを送信し、リストに反映して表示させる、そしてコメント送信後はフォームをクリアしなければなりませんのでその実装も行います。

var CommentForm = React.createClass({
    getInitialState: function() {
        return {author: '', text: ''};
    },
    handleAuthorChange: function(e) {
        this.setState({author: e.target.value});
    },
    handleTextChange: function(e) {
        this.setState({text: e.target.value});
    },
    handleSubmit: function(e) {
        e.preventDefault();
        var author = this.state.author.trim();
        var text = this.state.text.trim();
        if(!text || !author) {
            return;
        }
        this.setState({author: '', text: ''});
    },
    render: function() {
        return (
            <form className="commentForm" onSubmit={this.handleSubmit}>
                <input type="text"
                    placeholder="Your name..."
                    value={this.state.author}
                    onChange={this.handleAuthorChange}
                />
                <input type="text" 
                    placeholder="Say something..." 
                    value={this.state.text} 
                    onChange={this.handleAuthorText} 
                />
                <input type="submit" value="Post" />
            </form>
        );
    }
});

handleSubmitメソッドはsubmitが押された時にonSubmitハンドラから呼び出されます。
preventDefault()はフォームを送信するイベントでブラウザのデフォルトのアクションが実行されるのを防ぐためのメソッドです。(参考: preventDefault()について
submitが押された時、textフォーム、authorフォームの両方に入力があった場合にthis.Stateをクリアする処理を入れます。

Propsとしてのコールバック

まずはCommentBoxとCommentListのコードを記載します。

CommentBox

var CommentBox = React.createClass({
    loadCommentsFromServer: function() {
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            cache: false,
            success: function(data) {
                this.setState({data: data});
            }.bind(this),
            error: function(xhr, status, err) {
               console.error(this.props.url, status, err.toString());
           }.bind(this)
       });
    },
    handleCommentSubmit: function(comment) {
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            type: 'POST',
            data: comment,
            success: function(data) {
                this.setState({data: data});
            }.bind(this),
            error: function(xhr, status, err) {
                console.error(this.props.url, status, err.toString());
            }.bind(this)
        });
    },
    getInitialState: function() {
        return {data: []};
    },
    componentDidMount: function() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer, this.props.pollInterval);
    },
    render: function() {
        return (
            <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm onCommentSubmit={this.handleCommentSubmit} />
            </div>
        );
    }
});

CommentForm

var CommentForm = React.createClass({
    getInitialState: function() {
        return {author: '', text: ''};
    },
    handleAuthorChange: function(e) {
        this.setState({author: e.target.value});
    },
    handleTextChange: function(e) {
        this.setState({text: e.target.value});
    },
    handleSubmit: function(e) {
        e.preventDefault();
        var author = this.state.author.trim();
        var text = this.state.text.trim();
        if(!text || !author) {
            return;
        }
        this.props.onCommentSubmit({author: author, text: text});
        this.setState({author: '', text: ''});
    },
    render: function() {
        return (
            <form className="commentForm" onSubmit={this.handleSubmit}>
                <input type="text"
                    placeholder="Your name..."
                    value={this.state.author}
                    onChange={this.handleAuthorChange}
                />
                <input type="text" 
                    placeholder="Say something..." 
                    value={this.state.text} 
                    onChange={this.handleAuthorText} 
                />
                <input type="submit" value="Post" />
            </form>
        );
    }
});

ここはちょっと私も理解するのに苦労したのですが、フォームの入力をsubmitするとhandleSubmitメソッドが呼びだされます。両方のフォーム入力があった場合はonCommentSubmit({author:author, text:text})が参照されます。
この参照されたonCommentSubmit({author:author, text:text})はCommentBoxにイベントハンドラとして設定されています。
このonCommentSubmitハンドラはhandleCommentSubmitメソッドを呼び出しています。
handleCommentSubmitメソッドは送信したコメントをサーバに送信してコメントリストを更新します。
つまりコメントをSubmitするたびにonCommentSubmitイベントが発生するようになっているのです。

こうしてコメントを投稿するごとにデータの登録と更新が行われるようになりました。

ここまででコメント機能として動くようになっているはずです!さて、実際に動かしてみましょう!

GitHubのREADME.mdにそれぞれの実行方法が記載されています。
https://github.com/reactjs/react-tutorial
今回はPHPで動かすので、コマンドラインに以下を入力します。
php server.php
すると以下の様に返ってきます。
3000
3000番ポートを使っている、ということなので、http://localhost:3000という感じでアクセスします。localhostの部分は環境によって違う可能性があるので、自分の環境にあわせてアクセスしてください。すると…
page
このように表示されたら成功です!予めコメントを2つほど用意しておいています。
ちなみにcomments.jsonはこんな感じになっています。
json
comments.jsonの内容が表示されているのがわかると思います。
せっかくなので何かコメントを送信してみましょう。
Markdownの変換ツールを入れているのでMarkdownの記法できちんと反映されるか試してみます。
post
PHPのコードをバッククオートでくくってみました。
tester
先ほど入力したコードがMarkdown記法で表示されていますね。
json2
comments.jsonはこんな感じです。Postしたコメントが追記されていますね。

最適化:先読み更新

この項目はパフォーマンスアップのためのおまけ項目のようなものです。
サーバからレスポンスが返ってくるまで投稿したコメントは表示されないので送信したコメントを先に表示させてしまってリアルタイム感をだそう、というものです。

var CommentBox = React.createClass({
    loadCommentsFromServer: function() {
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            cache: false,
            success: function(data) {
                this.setState({data: data});
            }.bind(this),
            error: function(xhr, status, err) {
               console.error(this.props.url, status, err.toString());
           }.bind(this)
       });
    },
    handleCommentSubmit: function(comment) {
        var comments = this.state.data;
        comment.id = Date.now();
        var newComments = comments.concat([comment]);
        this.setState({data: newComments});
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            type: 'POST',
            data: comment,
            success: function(data) {
                this.setState({data: data});
            }.bind(this),
            error: function(xhr, status, err) {
                this.setState({data: comments});
                console.error(this.props.url, status, err.toString());
            }.bind(this)
        });
    },
    getInitialState: function() {
        return {data: []};
    },
    componentDidMount: function() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer, this.props.pollInterval);
    },
    render: function() {
        return (
            <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm onCommentSubmit={this.handleCommentSubmit} />
            </div>
        );
    }
});

サーバにPOSTする前に取得済みのデータの配列(this.state.data)にsubmitされたデータを入れてthis.setStateしています。こうすることでサーバにデータを送信する前に投稿した内容がthis.stateによって画面上に表示されるようになります。これで投稿した後すぐに投稿内容が反映されているような感じにできますね。

まとめ

さて、いかがでしたでしょうか。

はじめてReactを使って機能を実装してみましたが、表示部分は殆どHTMLと変わらないため非常にわかりやすく、コンポーネントごとに処理をまとめることができるので、コードの変更も非常に楽な印象を持ちました。データの流れも一方通行で処理の流れを追うのも簡単ですし、それぞれのコンポーネントをコンパクトに纏めることで保守性も高まりそうです。

今回はReactがどんなものなのか理解するためにチュートリアルをなぞってみましたが今度はReactを使って何か機能を作ってみたいと思います。

みなさんもぜひReactを使って快適なJavaScriptライフを送ってみてはいかがでしょうか。

参考

チュートリアル – React
フォーム – React
5分で理解する React.js

  • このエントリーをはてなブックマークに追加
  • Evernoteに保存