Reactで画像タイムラインを作ってみる

こんにちは、システムデザイン部のウザワです。
今回はfacebook製のUI生成用JavaScriptライブラリ「React」の紹介と、Reactを使って簡単なアプリケーションを作って見ようと思います。

Reactとは

UIを作るためのfacebook製のJavaScriptライブラリです。
Viewの状態管理を容易にしてくれる様々な特徴を持っています。
以下の様な特徴を持っています。

一方向のデータフロー

Reactのコンポーネントは、状態とプロパティという値を持っており、コンポーネントをこれらの関数として画面に表示します。
状態は親コンポーネントのみに指定するのが好ましく、親以下の子コンポーネントでは親から渡された値をプロパティとして呼び出します。
これにより親コンポーネントの状態のみでコンポーネント全体を生成でき、画面状態の管理を容易にしてくれます。

VirtualDOM

コンポーネントを毎回再描画していてはパフォーマンスが悪いのでは、と思うかもしれません。
Reactで生成したコンポーネントに何らかの変更があった際に、コンポーネント全体を再描画せず、変更があった部分だけを検知し更新してくれる仕組みがあり、それがVirtualDOMです。

また、ReactがカバーするのはViewのみであるため、既存のWebアプリケーションにReactを導入することも用意です。

以上がReactの簡単な紹介になります。
それではReactを使って簡単なアプリケーションを作っていきましょう。

アプリケーション作成

準備

今回はNode.js+Express+Reactという構成で作成していきます。
その前にNode.jsとExpressの簡単な紹介を行います。

Node.js

サーバサイドで動作するJavaScriptのフレームワークです。
スケーラブルなアプリケーションを構築するために設計された、非同期なイベント駆動のフレームワークです。
npmというパッケージマネージャーが同梱されていて、多くのパッケージをすぐに利用することができます。

Express

最小限かつ柔軟なNode.jsのWebアプリケーションフレームワークです。
記述が簡潔で、日本語記事が多いことも特徴です。
特定の関数をミドルウェアと呼び、ミドルウェアを追加することで簡単に機能追加できる仕組みになっています。

環境設定

まずはNode.jsの環境設定を行います。

私の環境ではhomebrewを使ったので、

$ brew install node

とターミナルで実行します。 しばらく待つとインストールされます。
(バージョン管理したい場合はnodebrewというものもあります。)

WindowsではNode.jsの公式サイトからインストーラーをダウンロードしてました。

インストールが完了したら、
nodeコマンドが使えるようになっているはずです、

$ node --version

Node.jsがインストールされたら、npmコマンドが使用できるかと思います。

$ npm --version

これから作成するアプリの設定ファイルを作成します。

$ npm init

対話環境が起動するのでアプリの情報を入力していきます。
内容は後から変更可能なので、とりあえず決まっている部分を書いていきましょう。

Hello Express!

対話環境 が終わると、package.jsonという設定ファイルが作成されます。
このファイルはアプリの説明や製作者の情報など記述でき、npmコマンドが見に行ってくれたり書き込んでくれたりしてパッケージの設定ファイルとしても使われます。

{
  "name": "test",
  "version": "1.0.0",
  "description": "ReactとExpressの勉強用アプリケーション",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "React",
    "Express",
    "Multer",
    "SuperAgent"
  ],
  "author": "uzawa",
  "license": "ISC"
}

早速Expressをインストールします。

$ npm install express --save

ここで –save はアプリをExpressに依存させるオプションです。
実行後のpackage.jsonを見ると確かに書き込まれていますね。

{
  ...
  "dependencies": {
    "express": "^4.13.3"
  }
}

Expressを使って文字列を表示してみましょう。
app.jsを作成し、以下を記入していきます。

var express = require('express');
var app = express();

app.get('/', function(req, res) {
  res.send('Hello Express!');
});

app.listen(3000);

準備ができました。サーバを起動してみましょう。Expressでは、

$ node app

というコマンドをapp.jsが存在するディレクトリで実行するとサーバが起動します。サーバの停止はCtrl+Cです。

ブラウザから ローカルの3000版ポート にアクセスしてみると「Hello Express!」の文字列が表示されると思います。

次に文字列ではなくhtmlファイルを返してみます。
publicディレクトリを作成して、その下にindex.htmlを作成します。

$ mkdir public
$ vi public/index.html

 

<!DOCTYPE html>
<html>
  <head>
    <title>React Sample</title>
  </head>
  <body>
    
<div id="content">Hello Express!</div>

  </body>
</html>

静的ファイルを返すように設定を記述します。

var express = require('express');
var app = express();

app.use(express.static('./public'));

app.get('/', function(req, res) {
  res.send('index.html');
});

app.listen(3000);

サーバを再起動し、ブラウザからlocalhost:3000/にアクセスしてみましょう。

helloexpress-c

facebookの提供するtutorial等ではReact使うためにブラウザから呼び出す方法で書かれていますが、
今回はサーバサイドで.jsファイルにコンパイルして出力します。

まずはreactreact-domパッケージをインストールします。

$ npm install react react-dom --save

ReactはJavaScriptとして書くこともできますが、
jsxというXML的な記法で書くと判り易いため、そのように記述していきます。
jsxをコンパイルするためにbrowserifyreactifyパッケージをインストールします。

$ npm install browserify reactify --save

Reactを使う準備が完了しました。
index.jsxを次のように記述します。

var React = require('react');
var ReactDOM = require('react-dom');
var Index = React.createClass({
  render: function() {
    return (
      
<div className="index">
        
<h1>Hello React!</h1>

      </div>

    );
  }
});

ReactDOM.render(<Index />, document.getElementById('content'));

1,2行目のrequirereactreact-domをインポートしています。
React.createClassにより、Indexコンポーネントを作成しました。
Reactコンポーネントではrendarメソッドの戻り値として、描画したいDOMを返します。
jsx記法を使うことで、上記のようにhtmlを記述するように書くことができています。
最終行のReactDOM.render(…は、呼び出し元のhtml内の要素にIndexコンポーネントを描画する処理です。

完成したjsxをコンパイルします。

$ ./node_modules/.bin/browserify -t reactify index.jsx  > public/index.js

index.htmlから生成されたjsを呼び出します。
先ほどのHello Express!の下に書いておきます。

  ...
  
<div id="content">Hello Express!</div>

  <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20src%3D'index.js'%3E%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />

アクセスしてみましょう。

helloreact-c

表示されました!

アプリケーション作成

タイムラインその1

必要な環境を準備出来たので、
アプリケーションを作成していきましょう。

今回は画像タイムラインを作る予定ですが、一旦画像のことは忘れて、
テキストを投稿しタイムラインに表示させる部分を作ります。

各コンポーネントは以下のようにしようと思います

親コンポーネント
 記事一覧
  記事1
  記事2
  ...
 フォーム

フォームから記事の登録を行います。
記事一覧は記事を表示するコンポーネントで、
受け取った記事情報から、記事の一覧を表示します。

それらを踏まえ、以下のように書いてみました。

var React = require('react');
var ReactDOM = require('react-dom');

var Index = React.createClass({
  getInitialState: function() {
    return {articles: [
      {"title": "Node", "comment": "As an asynchronous event driven framework, Node.js is designed to build scalable network applications."},
      {"title": "Express", "comment": "Fast, unopinionated, minimalist web framework for Node.js"},
      {"title": "React", "comment": "A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES"}
    ]}
  },
  render: function() {
    return (
      
<div className="index">
        <ArticleList articles={this.state.articles} />
        <ArticleForm />
      </div>

    );
  }
});

var ArticleList = React.createClass({
  render: function() {
    return (
      
<div className="articleList">
        {this.props.articles.map(function(article, i) {
          return 
<Article key={i} title={article.title} comment={article.comment} />
        })}
      </div>

    );
  }
});

var Article = React.createClass({
  render: function() {
    return (
      
<div className="article">
        
<h3 className="title">
          {this.props.title}
        </h3>

        

{this.props.comment}

      </div>

    );
  }
});

var ArticleForm = React.createClass({
render: function() {
  return (
    
<form className="articleForm" >
      <input type="text" placeholder="タイトル" ref="title"/>
      <input type="textarea" placeholder="コメント..." ref="comment" />
      <button>Add</button>
    </form>

    );
  }
});

ReactDOM.render(<Index />, document.getElementById('content'));

子コンポーネントにプロパティを渡すには、rendarする際にプロパティ名と値を指定してあげましょう。
Indexコンポーネントに記述してあるgetInitialStateはコンポーネント生成時に一度だけ呼ばれるメソッドです。
これをつかってstateに記事の値を設定し一覧に渡します。

ブラウザで見てみましょう。

reactapl1-c

記事が取得されています!

次にデータをサーバから取得するようにします。
/articles へのリクエストに対しget時は記事の一覧を、post時は記事を登録し、更新後の記事一覧を返すようにします。
今回は簡素化のためDBは使わずに、jsonファイルに書いた情報を読み取るようにします。
まずはjsonファイルを用意します。

[
  {"title": "Node", "comment": "As an asynchronous event driven framework, Node.js is designed to build scalable network applications."},
  {"title": "Express", "comment": "Fast, unopinionated, minimalist web framework for Node.js"},
  {"title": "React", "comment": "A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES"}
]

jsonファイルの読み書きのために、fsミドルウェアを使用します。

読み書きの処理

var express = require('express');
var fs = require('fs');
var app = express();
var ARTICLES_FILE = './articles.json'

app.use(express.static('./public'));

app.get('/', function(req, res) {
  res.send('index.html');
});

app.get('/articles', function(req, res) {
  var file = fs.readFileSync(ARTICLES_FILE);
  res.json(JSON.parse(file));
});

app.post('/articles', function(req, res) {
  var file = fs.readFileSync(ARTICLES_FILE);
  var articles = JSON.parse(file);
  var article = {
    title: req.body.title,
    comment: req.body.comment
  };
  articles.push(article);
  fs.writeFile(ARTICLES_FILE, JSON.stringify(articles, null, 2));
  res.json(articles);
});

app.listen(3000);

サーバを再起動して、動作確認します。
まだフォームができていないので、ターミナルからPOSTしてみます。

$ curl localhost:3000/articles -X POST -d "title=hoge&comment=fuga"

登録されていることがarticles.jsonファイルから確認できました!

余談ですがExpressではRouterというルーティング用のミドルウェアを使ってapp.jsからarticlesの処理を切りすことができます。
しかし今回は小さなアプリケーションなのでこのままとします。

Viewから記事を登録できるようにFormと通信部分を実装しましょう。
Reactではサーバーとの通信を親のコンポーネントで行うのが、状態を管理する上で適切です。
サーバとの通信はjqueryではなく通信機能だけをもったsuperagentというパッケージを使います。

$ npm install superagent --save

インストール後index.jsxを以下のように変更します。

var React = require('react');
var ReactDOM = require('react-dom');
var request = require('superagent');

var Index = React.createClass({
  loadArticles: function() {
    request
      .get(this.props.url)
      .end(function(err, res) {
        this.setState({articles: res.body});
      }.bind(this));
  },
  handleArticleSubmit: function(title, comment) {
    request
      .post(this.props.url)
      .send({'title': title ,'comment': comment})
      .end(function(err, res){
        this.setState({articles: res.body});
      }.bind(this));
  },
  getInitialState: function() {
    return {articles: []}
  },
  componentDidMount: function() {
    this.loadArticles();
    setInterval(this.loadArticles, this.props.interval);
  },
  render: function() {
    return (
      
<div className="index">
        <ArticleList articles={this.state.articles} />
        <ArticleForm onArticleSubmit={this.handleArticleSubmit}/>
      </div>

    );
  }
});

var ArticleList = React.createClass({
  render: function() {
    return (
      
<div className="articleList">
        {this.props.articles.map(function(article, i) {
          return 
<Article key={i} title={article.title} comment={article.comment} />
        })}
      </div>

    );
  }
});

var Article = React.createClass({
  render: function() {
    return (
      
<div className="article">
        
<h3 className="title">
          {this.props.title}
        </h3>

        

{this.props.comment}

      </div>

    );
  }
});

var ArticleForm = React.createClass({
  handleSubmit: function(e) {
    e.preventDefault();
    var title = ReactDOM.findDOMNode(this.refs.title).value.trim();
    var comment = ReactDOM.findDOMNode(this.refs.comment).value.trim();
    this.props.onArticleSubmit(title, comment);
    return;
  },
  render: function() {
    return (
      
<form className="articleForm" onSubmit={this.handleSubmit} >
        <input type="text" placeholder="タイトル" ref="title"/>
        <input type="textarea" placeholder="コメント..." ref="comment" />
        <button>Add</button>
      </form>

    );
  }
});

ReactDOM.render(<Index url='/articles' interval={2000} />, document.getElementById('content'));

IndexコンポーネントのhandleArticleSubmitでサーバと通信します。これをArticleFormに渡してsubmitされた時に呼び出させるようにしています。
componentDidMountは予約されているメソッドで、コンポーネントが表示された後に実行される処理を書いています。
ここでは記事の一覧を2000ms間隔で更新するように設定しています。

jsxのコンパイルと、サーバの再起動を行います。
アクセスしてみると登録できるようになっているのが確認できるかと思います。

タイムラインその2

画像ファイルのアップロード処理を書いていきます。
画像ファイルのアップロードはmulterパッケージを使います。

$ npm install multer --save

簡単のため、画像の保存先はpublic/uploadにします。

$ mkdir public/upload

Viewでは画像をmultipart/form-dataで送れるように修正し、値をサーバに送ります。

var React = require('react');
var ReactDOM = require('react-dom');
var request = require('superagent');

var Index = React.createClass({
  loadArticles: function() {
    request
      .get(this.props.url)
      .end(function(err, res) {
        this.setState({articles: res.body});
      }.bind(this));
  },
  handleArticleSubmit: function(title, comment, image) {
    request
      .post(this.props.url)
      .field('title', title)
      .field('comment', comment)
      .attach('image', image)
      .end(function(err, res){
        this.setState({articles: res.body});
      }.bind(this));
  },
  getInitialState: function() {
    return {articles: []}
  },
  componentDidMount: function() {
    this.loadArticles();
    setInterval(this.loadArticles, this.props.interval);
  },
  render: function() {
    return (
      
<div className="index">
        <ArticleList articles={this.state.articles} />
        <ArticleForm onArticleSubmit={this.handleArticleSubmit}/>
      </div>

    );
  }
});

var ArticleList = React.createClass({
  render: function() {
    return (
      
<div className="articleList">
        {this.props.articles.map(function(article, i) {
          return 
<Article key={i} title={article.title} comment={article.comment} image={article.image} />
        })}
      </div>

    );
  }
});

var Article = React.createClass({
  render: function() {
    return (
      
<div className="article">
        
<h3 className="title">
          {this.props.title}
        </h3>

        <img src={'/uploads/' + this.props.image} alt={this.props.title} />
        

{this.props.comment}

      </div>

    );
  }
});

var ArticleForm = React.createClass({
  handleSubmit: function(e) {
    e.preventDefault();
    var title = ReactDOM.findDOMNode(this.refs.title).value.trim();
    var comment = ReactDOM.findDOMNode(this.refs.comment).value.trim();
    var image = ReactDOM.findDOMNode(this.refs.image).files[0];
    this.props.onArticleSubmit(title, comment, image);
    return;
  },
  render: function() {
    return (
      
<form className="articleForm" encType="multipart/form-data" onSubmit={this.handleSubmit}>
        <input type="text" placeholder="タイトル" ref="title"/>
        <input type="file" ref="image" />
        <input type="textarea" placeholder="コメント..." ref="comment" />
        <button>Add</button>
      </form>

      );
    }
  });

ReactDOM.render(<Index url='/articles' interval={2000} />, document.getElementById('content'));

 

var express = require('express');
var fs = require('fs');
var multer = require('multer');
var upload = multer({dest: "./public/uploads/"});
var app = express();
var ARTICLES_FILE = './articles.json'

app.use(express.static('./public'));

app.get('/', function(req, res) {
  res.send('index.html');
});

app.get('/articles', function(req, res) {
  var file = fs.readFileSync(ARTICLES_FILE);
  res.json(JSON.parse(file));
});

app.post('/articles', upload.single('image'), function(req, res) {
  var file = fs.readFileSync(ARTICLES_FILE);
  var articles = JSON.parse(file);
  var article = {
    title: req.body.title,
    image: req.file.filename,
    comment: req.body.comment,
  };
  articles.push(article);
  fs.writeFile(ARTICLES_FILE, JSON.stringify(articles, null, 2));
  res.json(articles);
});

app.listen(3000);

jsxをコンパイルして、サーバを再起動します。
アクセスして画像を送信してみましょう。

reactapl2-c

できました!画像がサーバに保存され、表示されるようになってます。

まとめ

いかがだったでしょうか。
Reactを使うことで画面のデータハンドリング等の状態管理が簡潔な記述で行えると言った強みが、少しはお伝え出来たかと思います。
またExpressの豊富なミドルウェアも機能追加を簡単に行えるため魅力的ですね。
Node.jsを初め、少し手を広げすぎて深掘りが出来なかった点が今回の反省です。
ルーティング、jsxのコンパイルの自動化、ディレクトリ構成、データ取得の非同期処理、見た目など課題が残ってしまいました、次回はその辺りを書くことができればと思います。
まだエンジニアとして働き始めたばかりですが、気になった技術を使って、手を動かして何かを作るということを継続してやっていきたいと思います!
読んでくださりありがとうございます。

参考

Node.js
Express
React
reactチュートリアル
multerに関して