React 開発環境構築

React を使ってきちんと SPA を作るのに、毎回ブラウザ表示時にコンパイルしていては、さすがに効率悪い。 モダンなフロントエンド開発環境にしたいので、調べながら構築することにした。

react や react-dom などのライブラリは npm でインストール。 そういえば Bower ってオワコン化してしまったな。

# プロジェクトフォルダを作る(名前は適当なやつで)
mkdir react-project
cd react-project

# package.json を作る
npm init

# React をインストール
npm install --save react react-dom

npm でインストールしたライブラリを使って書いたアプリは、 browserify でビルドして 1 つの .js ファイルにする。

npm install --save-dev browserify

JSX のコンパイルは、Babel がデファクトスタンダードになっているので、Babel で。 browserify と一緒に使うから babelify をインストールする。

Babel で JSX と ES2015 をコンパイルするためには、 別途パッケージのインストールが必要になっていて嵌った。

npm install --save-dev babelify
npm install --save-dev babel-preset-react
npm install --save-dev babel-preset-es2015 # Windows だと失敗した

.babelrc を作成して、別途インストールしたプリセットを使うように指定。

{
  "presets": ["react", "es2015"]
}

React で書いたアプリのビルドは当面、npm run で行うので、package.json に次を追加。 grunt や glup は必要になったら導入する。

"script": {
  "build": "browserify src/app.js --transform babelify --outfile build/bundle.js"
}

これで React 使って書いたアプリが

npm run build

でビルドできるようになった。めでたしめでたし。

React コンポーネントは Redux に依存しないほうがいい

先日 Redux を試したとき

末端のコンポーネントからコンテナコンポーネントまでイベント(というかコールバック)を伝播して、 コンテナコンポーネントで dispatch を呼び出してみたけど、 これ末端のコンポーネントで dispatch 呼び出したらダメなんだろうか。

って疑問に思ったけど、よくよく考えると、これやっちゃったら React コンポーネントが Redux に依存してしまうな。React コンポーネントが再利用しづらくなる。

React Bootstrap みたいな立派なコンポーネントライブラリを作るわけじゃなくても、アプリ内でコンポーネントを再利用したい場面がきっとある。そんなとき、dispatch を渡すやり方だとアクションも固定されるから、再利用しづらいだろうな。

コンテナコンポーネントから末端のコンポーネントまでコールバックを渡すのが面倒なのは変わりないけど、そこは、うん、理性捨てて props 渡すとかしか思いつかないね。

React Meets Redux

先日 React で作ったタスクリストのサンプルを、 React と Redux で書き直してみた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Redux Sample</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react-dom.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.0.4/redux.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.0.0/react-redux.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
  </head>
  <body>
    <div id="content"></div>
    <script type="text/babel">
      // アクションの種類を定義
      const ADD_TASK = "ADD_TASK";
      const SAVE_TASK = "SAVE_TASK";
      const DELETE_TASK = "DELETE_TASK";

      // Action Creator を定義
      function addTask(name) {
        return { type: ADD_TASK, name };
      }
      function saveTask(task, values) {
        return { type: SAVE_TASK, task, values };
      }
      function deleteTask(task) {
        return { type: DELETE_TASK, task };
      }

      // 最初に表示するタスク
      const initialState = [
        { name: "foo", completed: false, editing: false },
        { name: "bar", completed: false, editing: false },
        { name: "hoge", completed: false, editing: false },
        { name: "fuga", completed: false, editing: false }
      ];

      // Reducer を定義
      function taskReducer(state = initialState, action) {
        switch (action.type) {
          case ADD_TASK:
            return state.concat({
              name: action.name,
              completed: false,
              editing: false
            });
          case SAVE_TASK:
            return state.map(t => {
              if (t === action.task) {
                return $.extend(action.task, action.values);
              } else {
                return t;
              }
            });
          case DELETE_TASK:
            return state.filter(t => {
              return t !== action.task;
            });
          default:
            // この Reducer で処理しないアクションのときは
            // state をそのまま返さないといけない
            return state;
        }
      }
      const rootReducer = Redux.combineReducers({
        tasks: taskReducer
      });

      // タスク表示用コンポーネントを定義
      var Task = React.createClass({
        handleEdit(e) {
          this.props.dispatch(saveTask(this.props.task, {
            editing: true
          }));
        },

        handleDelete(e) {
          this.props.dispatch(deleteTask(this.props.task));
        },

        handleChangeCheckbox(e) {
          this.props.dispatch(saveTask(this.props.task, {
            completed: !this.props.task.completed
          }));
        },

        render() {
          return (
            <div>
              <input type="checkbox"
                     checked={this.props.task.completed}
                     onChange={this.handleChangeCheckbox} />
              {(() => {
                if (this.props.task.completed) {
                  return <del>{this.props.task.name}</del>
                } else {
                  return <span>{this.props.task.name}</span>
                }
              }())}
              <a href="#" onClick={this.handleEdit}>[edit]</a>
              <a href="#" onClick={this.handleDelete}>[del]</a>
            </div>
          );
        }
      });

      // タスク編集用コンポーネントを定義
      var EditTaskForm = React.createClass({
        getInitialState() {
          return {
            name: ""
          };
        },

        handleCancel(e) {
          this.props.dispatch(saveTask(this.props.task, {
            editing: false
          }));
        },

        handleSave(e) {
          e.preventDefault();

          if (!this.state.name) {
            return;
          }

          this.props.dispatch(saveTask(this.props.task, {
            name: this.state.name,
            editing: false
          }));

          this.setState({ name: "" });
        },

        handleChangeText(e) {
          this.setState({ name: e.target.value });
        },

        render() {
          return (
            <div>
              <input type="text"
                     value={this.state.name || this.props.task.name}
                     onChange={this.handleChangeText}
                     ref="name"/>
              <button onClick={this.handleSave}>save</button>
              <button onClick={this.handleCancel}>cancel</button>
            </div>
          );
        }
      });

      // タスクリスト表示用コンポーネントを定義
      var TaskList = React.createClass({
        render() {
          var rows = this.props.tasks.map(t => {
            if (t.editing) {
              return (
                <EditTaskForm
                  task={t}
                  dispatch={this.props.dispatch} />
              );
            } else {
              return (
                <Task
                  task={t}
                  dispatch={this.props.dispatch} />
              );
            }
          });

          return (
            <div>
              {rows}
            </div>
          );
        }
      });

      // タスク登録用コンポーネントを定義
      var AddTaskForm = React.createClass({
        handleAdd(e) {
          e.preventDefault();

          var newTask = ReactDOM.findDOMNode(this.refs.name).value.trim();
          if (!newTask) {
            return;
          }

          this.props.dispatch(addTask(newTask));

          ReactDOM.findDOMNode(this.refs.name).value = "";
        },

        render() {
          return (
            <div>
              <input type="text" placeholder="newTask" ref="name"/>
              <button onClick={this.handleAdd}>add</button>
            </div>
          );
        }
      });

      // ルートコンポーネントを定義
      var _TaskApp = React.createClass({
        handleTaskSave(task, values) {
          this.props.dispatch(saveTask(task,values));
        },

        handleTaskDelete(task) {
          this.props.dispatch(deleteTask(task));
        },

        render() {
          return (
            <div>
              <AddTaskForm
                dispatch={this.props.dispatch} />
              <TaskList
                tasks={this.props.tasks}
                dispatch={this.props.dispatch} />
            </div>
          );
        }
      });

      // ルートコンポーネントをデコレート
      var TaskApp = ReactRedux.connect(state => {
        return {
          // state には Reducer を実行した結果が格納されているっぽい
          // combineReducers に Reducer を渡すときのキーと同名みたい
          tasks: state.tasks
        };
      })(_TaskApp);

      // ストアを作成
      var store = Redux.createStore(rootReducer);

      ReactDOM.render(
        <ReactRedux.Provider store={store}>
          <TaskApp />
        </ReactRedux.Provider>,
        document.getElementById("content")
      );
    </script>
  </body>
</html>

React も Redux も Bebel も全部 script タグで CDN から読み込んでいる。 表示に時間はかかるけど、コピペで試せてお手軽。 cdnjs 便利だ。

Redux を使っていて思ったことをざっと書いておこう。

  • state は Redux のストアが一元管理する
  • Redux のストアが state を管理するから React コンポーネントはほとんど state を使わないな
  • アクションは単なるオブジェクト
  • Action Creator はそのアクションを作るだけの関数
  • state を変更するのは Reducer の役割
  • Reducer は state とアクションを受け取って、新しい state を返す関数
  • React コンポーネントの自体のコード量は state を使わないぶん少し減った程度

末端のコンポーネントからコンテナコンポーネントまでイベント(というかコールバック)を伝播して、 コンテナコンポーネントで dispatch を呼び出してみたけど、 これ末端のコンポーネントで dispatch 呼び出したらダメなんだろうか。

Middleware はまだ試していないし、Web API 呼び出しをどこに書けばいいかもまだ掴めていない。 ルーティングは react-router を使えばよさそうかな。

React + Redux なかなか良い感じ。

Hello React World

Single Page Application を Angular で実装しようと思っていたんだけど、 Angular2 でコンポーネント指向にガラリと変わるみたいなので、 Angular の採用は見送って React を選択。 同じコンポーネント指向なら React でいいかな、と。

React 入門ということで TODO リストのサンプルを書いてみる。 毎度のこだわりとして、その場編集機能を実装している。

コピペするだけで試せるようにしたかったので、あえて事前にコンパイルする方法は取ってない。 おかげで画面が表示されるまで結構待たされるけど、手軽さ優先で我慢我慢。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>React Tutorial</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react-dom.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
  </head>
  <body>
    <div id="content"></div>
    <script type="text/babel">
      // タスクを表示するコンポーネント
      var Task = React.createClass({
        handleEdit(e) {
          this.props.onTaskSave(this.props.task, {
            editing: true
          });
        },

        handleDelete(e) {
          this.props.onTaskDelete(this.props.task);
        },

        handleChangeCheckbox(e) {
          this.props.onTaskSave(this.props.task, {
            completed: !this.props.task.completed
          });
        },

        render() {
          // JSX の中で分岐する場合は即時関数を使う
          return (
            <div>
              <input type="checkbox"
                     checked={this.props.task.completed}
                     onChange={this.handleChangeCheckbox} />
              {(() => {
                if (this.props.task.completed) {
                  return <del>{this.props.task.name}</del>
                } else {
                  return <span>{this.props.task.name}</span>
                }
              }())}
              <a href="#" onClick={this.handleEdit}>[edit]</a>
              <a href="#" onClick={this.handleDelete}>[del]</a>
            </div>
          );
        }
      });

      // タスクをその場編集するためのコンポーネント
      var EditTaskForm = React.createClass({
        getInitialState() {
          return {
            name: ""
          };
        },

        handleCancel(e) {
          this.props.onTaskSave(this.props.task, {
            editing: false
          });
        },

        handleSave(e) {
          e.preventDefault();

          if (!this.state.name) {
            return;
          }

          this.props.onTaskSave(this.props.task, {
            name: this.state.name,
            editing: false
          });

          this.setState({ name: "" });
        },

        handleChangeText(e) {
          this.setState({ name: e.target.value });
        },

        render() {
          return (
            <div>
              <input type="text"
                     value={this.state.name || this.props.task.name}
                     onChange={this.handleChangeText}
                     ref="name"/>
              <button onClick={this.handleSave}>save</button>
              <button onClick={this.handleCancel}>cancel</button>
            </div>
          );
        }
      });

      // タスク一覧を表示するためのコンポーネント
      var TaskList = React.createClass({
        render() {
          var rows = this.props.tasks.map(t => {
            if (t.editing) {
              return (
                <EditTaskForm task={t}
                              onTaskSave={this.props.onTaskSave} />
              );
            } else {
              return (
                <Task task={t}
                      onTaskSave={this.props.onTaskSave}
                      onTaskDelete={this.props.onTaskDelete} />
              );
            }
          });

          return (
            <div>
              {rows}
            </div>
          );
        }
      });

      // タスク登録フォームを表示するためのコンポーネント
      var AddTaskForm = React.createClass({
        handleAdd(e) {
          e.preventDefault();

          var newTask = ReactDOM.findDOMNode(this.refs.name).value.trim();
          if (!newTask) {
            return;
          }

          this.props.onTaskAdd({ name: newTask });

          ReactDOM.findDOMNode(this.refs.name).value = "";
        },

        render() {
          return (
            <div>
              <input type="text" placeholder="newTask" ref="name"/>
              <button onClick={this.handleAdd}>add</button>
            </div>
          );
        }
      });

      // ルートコンポーネント
      // 状態をまとめて管理する
      var TaskApp = React.createClass({
        getInitialState() {
          return {
            tasks: [
              { name: "foo", completed: false, editing: false },
              { name: "bar", completed: false, editing: false },
              { name: "hoge", completed: false, editing: false },
              { name: "fuga", completed: false, editing: false }
            ]
          };
        },

        handleTaskAdd(newTask) {
          var newTasks = this.state.tasks.concat(newTask);
          this.setState({ tasks: newTasks });
        },

        handleTaskDelete(task) {
          var newTasks = this.state.tasks.filter(t => {
            return t !== task;
          });
          this.setState({ tasks: newTasks });
        },

        handleTaskSave(task, newValues) {
          var newTasks = this.state.tasks.map(t => {
            if (t === task) {
              return $.extend(task, newValues);
            } else {
              return t;
            }
          });
          this.setState({ tasks: newTasks });
        },

        render() {
          return (
            <div>
              <AddTaskForm onTaskAdd={this.handleTaskAdd} />
              <TaskList tasks={this.state.tasks}
                        onTaskSave={this.handleTaskSave}
                        onTaskDelete={this.handleTaskDelete} />
            </div>
          );
        }
      });
      

      ReactDOM.render(
        <TaskApp />,
        document.getElementById("content")
      );
    </script>
  </body>
</html>

ルートにだけ状態を持たせる作りに挑戦したけど、イベントの伝播が面倒だった。 この程度のサンプルで面倒に感じるんだから、もっと本格的なアプリになったら、 やっぱりフレームワークは必要だな。 Redux あたりを試してみようかな。

Rails Assets を使って Rails プロジェクトに AngularJS をインストール

Rails で AngularJS を使おうと思っていたけど、

  • ファイルをダウンロードして vendor/assets に直接配置するのは管理が面倒
  • AngularJS をラップした gem は AngularJS 本体がバージョンアップしたとき対応するまでタイムラグがある
    • そもそも angular-gem や angular-rails や angularjs-rails-resource など乱立してるし
  • Bower で AngularJS を管理するのがいいかも
    • Bower を動かすために Node が必須なので、Heroku デプロイ時に AngularJS をインストールできない
    • カスタム Buildpack を作れば可能だけど、アプリの本質ではないので労力をかけたくない。

という紆余曲折を経て、『Rails Assets』を使ってみることにした。

Rails Assets は、Bower のパッケージを gem にコンバートし、ホストしているサービス。 Gemfile に

source "https://rails-assets.org"

を追加し、

gem "rails-assets-<Bower パッケージ名>"

という形式で指定することで、Bundler で JavaScript ライブラリをインストールできる。

Rails で AngularJS を使いたい場合、

source "https://rails-assets.org"
gem "rails-assets-angular"
gem "rails-assets-angular-route"

と書いて bundle install でインストール。 このとき依存するライブラリもインストールしてくれる。

あとは app/assets/javascript/application.js に

//= require angular
//= require angular-route

と書けば、Asset Pipeline に AngularJS が組み込まれる。

Rails Assets は gem のインストールなので Bower 不要。Node も不要。 カスタム Buildpack を使わなくても Heroku で動かせる。

AngularJS で Underscore.js を使ってみた

AngularJS にユーティリティ関数あるにはあるけど、配列やオブジェクトを操作する関数が足りないので、 Underscore.js を使うことにした。

AngularJS らしく依存性注入したいから、Underscope.js を AngularJS モジュール化している。

<!DOCTYPE html>
<html ng-app="UnderscoreSample">
  <head>
    <meta charset="utf-8">
    <title>UnderscoreSample</title>
  </head>
  <body ng-controller="MainCtrl">
    <ul>
      <li ng-repeat="value in values" ng-bind="value"></li>
    </ul>

    <script src="underscore.js"></script>
    <script src="angular.js"></script>
    <script>
      // underscore をモジュール化
      angular.module("underscore", []).factory("_", function() {
          return _;
      });

      var app = angular.module("UnderscoreSample", ["underscore"]);

      // モジュール化した Underscore を使っていることがわかるように、$_ にしておく。
      // 実際は _ で使っている。
      app.controller("MainCtrl", ["$scope", "_", function($scope, $_) {
          var src = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

          // 偶数だけを表示する
          $scope.values = $_.filter(src, function(n) {
            return n % 2 === 0;
          });
      }]);
    </script>
  </body>
</html>

余談だけど、angular-underscore というライブラリがある。

こいつはフィルタも提供してくれるんだけど、フィルタは今のところ必要としていないのと、 $rootScope に Underscore の関数を生やすのが個人的に気に入らないので、導入は見送った。

AngularJS のテンプレートを入れ子にしたかったから angular-ui-router を使ってみた

AngularJS でテンプレートを入れ子にしたかったけど、ng-view は入れ子にできない。 つまり AngularJS の機能だけでは困難。 おまけに ng-view は複数置いて、別々のテンプレートを表示することもできないときた。

そんな ng-view や ngRoute に置き換わるのを目指したモジュールで、angular-ui-router がある。

こいつを使えばできそう。論よりコード。

<!DOCTYPE html>
<html ng-app="UIRouterSample">
  <head>
    <meta charset="utf-8">
    <title>UIRouterSample</title>
  </head>
  <body ng-controller="MainCtrl">
    <ul>
      <li ng-repeat="label in labels">
        <a ui-sref="label({ labelName: label.name })" ng-bind="label.name"></a>
      </li>
    </ul>
    <div ui-view></div>

    <script id="mails_template" type="text/ng-template">
      <table class="table">
        <thead>
          <tr>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          <tr ng-repeat="mail in mails">
            <td><a ui-sref=".thread({mailId:mail.id})" ng-bind="mail.title"></a></td>
          </tr>
        </tbody>
      </table>
      <div ui-view></div>
    </script>

    <script id="thread_template" type="text/ng-template">
      <table class="table">
        <thead>
          <tr>
            <th>Title</th>
            <th>Message</th>
          </tr>
        </thead>
        <tbody>
          <tr ng-repeat="mail in mails">
            <td ng-bind="mail.title"></td>
            <td ng-bind="mail.message"></td>
          </tr>
        </tbody>
      </table>
    </script>

    <script src="angular.js"></script>
    <script src="angular-ui-router.js"></script>
    <script>
      // ui.router を使う
      window.App = angular.module("UIRouterSample", ["ui.router"]);

      App.controller("MainCtrl", ["$scope", function($scope) {
        $scope.labels = [
          { id: 1, name: "today" },
          { id: 2, name: "someday" },
          { id: 3, name: "scheduled" }
        ];
      }]);

      // $stateParams からパラメータを取り出せる
      App.controller("MailsCtrl", ["$scope", "$stateParams", function($scope, $stateParams) {
        var prefix = $stateParams.labelName + "_";
        $scope.mails = [
          { id: 1, title: prefix + "aaaaaa" },
          { id: 2, title: prefix + "bbbbbb" },
          { id: 3, title: prefix + "cccccc" }
        ];
      }]);
      App.controller("ThreadCtrl", ["$scope", "$stateParams", function($scope, $stateParams) {
          var prefix = "mail" + $stateParams.mailId + "_";
          $scope.mails = [
            { id: 1, title: "aaaaaa", message: prefix + "hogehoge" },
            { id: 2, title: "bbbbbb", message: prefix + "fugafuga" },
            { id: 3, title: "cccccc", message: prefix + "foobar" }
          ];
      }]);

      // ステートを登録
      // URL・コントローラー・テンプレートのセットに名前を付ける
      App.config(["$stateProvider", "$urlRouterProvider", function($stateProvider, $urlRouterProvider) {
        $stateProvider.
          state("label", {
            url: "/labels/{labelName:[a-zA-Z]+}",
            controller: "MailsCtrl",
            templateUrl: "mails_template"
          }).
          state("label.thread", {
            url: "/thread/{mailId:[0-9]+}",
            controller: "ThreadCtrl",
            templateUrl: "thread_template"
          });
      }]);
    </script>
  </body>
</html>

URL とコントローラーとテンプレートをセットにして「ステート」として扱っている。 ステートというより、Django のエンドポイントに近い気がする。 名前から URL を逆引きできるし。

angular-ui-router を AngularJS 本体に組み込む、なんて動きもあるみたいだけど、 早く進めて欲しいところだ。