サーバーサイドプログラマーのためのReact.js 入門 4. react-router、更新系ページの追加
React.jsの仕事の納期におわれ1ヶ月ぶりになってしましたましたが、今回は
- react-router の導入
- 更新系ページの追加
を行い、Todoアプリを完成したいと思います。
react-router
JSで作られたアプリはSPA(Single Page Application)と呼ばれているように物理的には1つのページ(URL)内で動作します。しかしアプリがURLに対応してないとブラウザーのbackボタンに対応出来ないないですし、プログラムの構成を考えるのも面倒です。
そこで、URLとアプリ(JS)を対応づける react-router を使うと Rails の routes のように URLにアプリを対応づけ出来、今ままで Rails 等で開発してきた人にはとても嬉しいことです。
react-router には チュートリアル や充実したドキュメント がありますので、しっかり理解するには チュートリアル を試し、ドキュメント に目を通しておくと良いと思います。
react-router を組み込んでみる
前回作った TODO 一覧に react-router を組み込んで Railsのshow ページと同じものを追加してみましょう。
react-router インストール
$ cd frontend $ npm install --save react-router
index.js ページの作成
前回作ったアプリは全てのコードが src/js/index.js にありましたが、 一覧の機能は src/js/IndexPage.js にしましょう。 src/js/index.js をコピーまたはリネームし src/js/IndexPage.js を作ります(修正は次で行います)
src/js/index.js はルーテイング情報を書きます。
import React from 'react' import { render } from 'react-dom' import { Router, Route, IndexRoute, hashHistory } from 'react-router' import IndexPage from './IndexPage' import ShowPage from './ShowPage' render(( <Router history={hashHistory}> <Route path="/" > <IndexRoute component={IndexPage}/> <Route path="/:id" component={ShowPage}/> </Route> </Router> ), document.getElementById("example") )
ページ繊維の履歴管理にはいくつかの方法が選べます。 ドキュメントのHistories に説明があります。
今回はhashHistoryを使ってみます、ドキュメントに本来は browserHistory を使うべきだと書かれていますが browserHistory を使うと Rails で使っているURLがかぶってしまうので、ここでは /#URL
を使うhashHistoryにしました Router history=
で hashHistory を指定しています。
ルーテイング は /
で一覧(IndexPage.js) /ID番号
で詳細表示(ShowPage.js) が動作するように指定しています。 Rails のルーテイング同様に /:id
の id の部分はパラメーターとして取得できます。
画面へReact.jsの結果を表示する render() はこちらに移動します。
IndexPage.js 変更
前回の index.js をコピーした IndexPage.js は少し変更します。変更点はコメントに ★ を付けたところで
- import の変更、
import ReactDOM from 'react-dom'
は削除されました - IndexPageクラスが外部から参照できるように export default を追加
- Showpage.js へのリンク
<Link>
は<a>
タグが作成されます、Railsのlink_to
と同じようなものですね。<Link>
は単純に<a>
を生成しているのでは無く色々な処理を行っています - 画面へReact.jsの結果を表示する render() は index.js に移行したので削除されました
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' // ★ import axios from 'axios' export default class IndexPage extends Component { // ★ constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } render() { return( <div> <h2>List of Todos</h2> <TodoList todos={this.state.todos}/> </div> ) } } class TodoList extends Component { render() { return( <table> <thead> <tr> <th>Due</th> <th>Task</th> <th colSpan="3"></th> </tr> </thead> <tbody> {this.props.todos.map((todo) => (<TodoListItem key={todo.id} todo={todo} />))} </tbody> </table> ) } } TodoList.propTypes = { todos: PropTypes.array.isRequired } class TodoListItem extends Component { render() { return( <tr> <td> {this.props.todo.due} </td> <td> {this.props.todo.task} </td> <td><Link to={`/${this.props.todo.id}`}>Show</Link></td> // ★ </tr> ) } } TodoListItem.propTypes = { todo: PropTypes.object.isRequired }
ShowPage.js 作成
詳細表示のページ ShowPage.js は以下のようになります。 URLで指定される ID番号は this.props.params.id
で取得できます。その他は特に説明する事はないと思います。
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' import axios from 'axios' export default class ShowPage extends Component { constructor(props) { super(props) this.state = {todo: {}} } componentDidMount() { this.todoFind(this.props.params.id) } componentWillReceiveProps(nextProps) { this.todoFind(nextProps.params.id) } todoFind(todoId) { axios.get(`/todos/${todoId}.json`).then((response) => { this.setState({todo: response.data}) }).catch((response) => { console.log(response) }) } render() { return( <div> <h2>Show</h2> <p> <strong>Due:</strong> {this.state.todo.due} </p> <p> <strong>Task:</strong> {this.state.todo.task} </p> <Link to="/">Back</Link> </div> ) } } ShowPage.propTypes = { params: PropTypes.object }
ここまで出来れば、上の画像のように 一覧の show リンクをクリックすると詳細画面が表示されます
更新系ページの追加
Rails の CSRF対策を無効にする
追加・更新を行うにはRuby on RailsのCSRF対策に対応しないといけませんが、今回は安易にスキップします。 実際のアプリでは行わないで下さいね!
- ../app/controllers/todos_controller.rb
class TodosController < ApplicationController before_action :set_todo, only: [:show, :edit, :update, :destroy] skip_before_action :verify_authenticity_token # ★
新規作成ページのルーティング情報を追加
まずは、index.js にルーティング情報を追加します、コメントに ★ を付けたところ
import { render } from 'react-dom' import { Router, Route, IndexRoute, hashHistory } from 'react-router' import IndexPage from './IndexPage' import ShowPage from './ShowPage' import NewPage from './NewPage' // ★ render(( <Router history={hashHistory}> <Route path="/" > <IndexRoute component={IndexPage}/> <Route path="/new" component={NewPage}/> // ★ <Route path="/:id" component={ShowPage}/> </Route> </Router> ), document.getElementById("example") )
新規作成ページの追加
新規作成ページに対応する NewPage、 TODO情報のフォームに対応する TodoForm に分けました。Railsと同じように編集ページと共通になるフォーム部分を別のコンポーネント(モジュール)にしました。
React.js では state(状態、データ)は上位のコンポーネントに置き、下位のコンポーネントへpropsで渡します。 またボタンを押した等のイベントは下位のコンポーネントでは処理せずに上位のコンポーネントで処理するのが定跡(ベストプラクティス)です。
TODO情報をバックエンドに登録(ポスト)する todoCreateメソッド はNewPageに置いています。 todoCreateメソッド は下位のTodoFormの中にも置けますが、登録した後に state が変化する事は良くあるので、この方が良いと思います。また、通信やモデル(ここでは stateのtodo)変更は上位のコンポーネントに集めるのは下位コンポーネントの汎用性やコードの見通しがよくなります。
todoCreateメソッド内の this.props.history.push()
は指定されたURL(Path)への遷移を起こします。
formChangeメソッドに付いては TodoForm の方で説明します。
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' import axios from 'axios' import TodoForm from './TodoForm' export default class NewPage extends Component { constructor(props) { super(props) this.state = {todo: {}} } todoCreate() { axios.post('/todos.json', {todo: this.state.todo}).then((response) => { this.props.history.push(`/${response.data.id}`) }).catch((response) => { console.log(response) }) } formChange(todo) { this.setState({todo: todo}) } render() { return( <div> <h2>New</h2> <TodoForm todo={this.state.todo} buttonLable="Create" onChange={this.formChange.bind(this)} onSubmit={this.todoCreate.bind(this)} /> <Link to="/">Back</Link> </div> ) } } NewPage.propTypes = { params: PropTypes.object, history: PropTypes.object }
TodoForm は主にを <form>
フォームを扱っています。 React.js でのフォームの扱いは ドキュメントのForms に書かれているます。フォーム内の <input>
のハンドリングは、全ての変更をアプリのコードで扱う Controlled Components と Reactに任せる
Uncontrolled Components があります。ここでは分かりやすい Controlled Components を使っています。
Controlled Componentsでは<input>
に変化がおきるとその変化をアプリのコードで state に反映し、stateの値から <input>
を再表示します(再表示は React.js が行います)。
ここでは state は上位コンポーネントにあるので、上位コンポーネントの formChange メソッドを呼び出しています。その結果、上位コンポーネントから変更された state の値を props で受け取った TodoForm が再表示されます(再表示は React.js が行います)。
React.js では <input>
の値などを参照するには タグに ref
属性を指定し、コードからは this.refs.ref属性値
で取得します。 refに付いては ドキュメントのRefs to Components に書かれています。
submitボタンが押された時の動作はここでは <button>
の onClick
でハンドリングしています。 event.preventDefault()
で他のタグへのイベントの伝搬をキャンセルし、上位コンポーネントのメソッドを呼び出しています。
日付の入力はRailsでは年月日の <select>
になりますが、面倒なので通常の <input type="text">
にしました。たぶんカレンダー入力の React Date Picker などを使えば良いのかなと思います。
import React, { Component, PropTypes } from 'react' export default class TodoForm extends Component { change() { this.props.onChange({due: this.refs.due.value, task: this.refs.task.value}) } submit(event) { event.preventDefault() this.props.onSubmit() } render() { return( <form> <div className="field"> <label htmlFor="todo_due">Due</label><br/> <input type="text" ref="due" id="todo_due" value={this.props.todo.due} onChange={this.change.bind(this)} /> </div> <div className="field"> <label htmlFor="todo_task">Task</label><br/> <input type="text" ref="task" id="todo_task" value={this.props.todo.task} onChange={this.change.bind(this)} /> </div> <div className="actions"> <button onClick={this.submit.bind(this)}>{this.props.buttonLable}</button> </div> </form> ) } } TodoForm.propTypes = { todo: PropTypes.object.isRequired, buttonLable: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired }
IndexPage.js の変更1
新規作成へのリンクを追加します
*** import は変更なし *** export default class IndexPage extends Component { constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { this.todoAll() } componentWillReceiveProps() { this.todoAll() } todoAll() { axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } todoDelete(todoId) { axios.delete(`/todos/${todoId}.json`).then(() => { this.todoAll() }).catch((response) => { console.log(response) }) } render() { return( <div> <h2>List of Todos</h2> <TodoList todos={this.state.todos}/> <br /> <Link to="/new">New Todo</Link> // ★ </div> ) } } *** 以下は同じ ***
IndexPage.js の変更2
新規作成機能を追加し試してみると、新規追加したデータが表示されない時があります。前回書いたように
- 一度表示されたコンポーネントはインスタンスがメモリ上に残っているので、他のコンポーネントから再表示を起こしても componentDidMount は実行されません。その時は componentWillReceiveProps が呼び出されます。
一覧の再表示が正しく行われるように componentWillReceivePropsメソッドでもデータを取得するように変更します。
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' import axios from 'axios' export default class IndexPage extends Component { constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { // ★ this.todoAll() } componentWillReceiveProps() { // ★ this.todoAll() } todoAll() { // ★ axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } todoDelete(todoId) { axios.delete(`/todos/${todoId}.json`).then(() => { this.todoAll() }).catch((response) => { console.log(response) }) } render() { *** 以下は同じ ***
変更ページの追加
新規作成ページと同様なので特に説明する必要はないと思います
- index.js
import React from 'react' import { render } from 'react-dom' import { Router, Route, IndexRoute, hashHistory } from 'react-router' import IndexPage from './IndexPage' import ShowPage from './ShowPage' import EditPage from './EditPage' // ★ import NewPage from './NewPage' render(( <Router history={hashHistory}> <Route path="/" > <IndexRoute component={IndexPage}/> <Route path="/new" component={NewPage}/> <Route path="/:id" component={ShowPage}/> <Route path="/:id/edit" component={EditPage}/> // ★ </Route> </Router> ), document.getElementById("example") )
- EditPage.js
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' import axios from 'axios' import TodoForm from './TodoForm' export default class EditPage extends Component { constructor(props) { super(props) this.state = {todo: {}} } componentDidMount() { this.todoFind(this.props.params.id) } componentWillReceiveProps(nextProps) { this.todoFind(nextProps.params.id) } todoFind(todoId) { axios.get(`/todos/${todoId}.json`).then((response) => { this.setState({todo: response.data}) }).catch((response) => { console.log(response) }) } todoUpdate() { axios.put(`/todos/${this.props.params.id}.json`, {todo: this.state.todo}).then((response) => { this.props.history.push(`/${response.data.id}`) }).catch((response) => { console.log(response) }) } formChange(todo) { this.setState({todo: todo}) } render() { return( <div> <h2>Edit</h2> <TodoForm todo={this.state.todo} buttonLable="Update" onChange={this.formChange.bind(this)} onSubmit={this.todoUpdate.bind(this)} /> <Link to={`/${this.state.todo.id}`}>Show</Link> | <Link to="/">Back</Link> </div> ) } } EditPage.propTypes = { params: PropTypes.object, history: PropTypes.object }
- IndexPage.js
*** 上部は同じ *** class TodoListItem extends Component { render() { return( <tr> <td><Link to={`/${this.props.todo.id}`}>Show</Link></td> <td><Link to={`/${this.props.todo.id}/edit`}>Edit</Link></td> // ★ </tr> ) } } TodoListItem.propTypes = { todo: PropTypes.object.isRequired } *** 以下は同じ ***
削除機能の追加
TodoListItemに 削除用リンクを追加し、クリックした際にバックエンドの削除APIを呼び出す todoDelete メソッドを IndexPageコンポーネントに追加しました。
削除確認のダイアログをどのコンポーネントで出すかは考慮の余地があると思います。今回は確認ダイアログはUIの一部と考え、下位コンポーネントで確認は行うようにしました。 上位コンポーネントで出すという考え方もあると思います。
import React, { Component, PropTypes } from 'react' import { Link } from 'react-router' import axios from 'axios' export default class IndexPage extends Component { constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { this.todoAll() } componentWillReceiveProps() { this.todoAll() } todoAll() { axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } todoDelete(todoId) { // ★ axios.delete(`/todos/${todoId}.json`).then(() => { this.todoAll() }).catch((response) => { console.log(response) }) } render() { return( <div> <h2>List of Todos</h2> <TodoList todos={this.state.todos} onDelete={this.todoDelete.bind(this)} /> </div> ) } } class TodoList extends Component { render() { return( <table> <thead> <tr> <th>Due</th> <th>Task</th> <th colSpan="3"></th> </tr> </thead> <tbody> {this.props.todos.map((todo) => (<TodoListItem key={todo.id} todo={todo} onDelete={this.props.onDelete} />))} // ★ </tbody> </table> ) } } TodoList.propTypes = { todos: PropTypes.array.isRequired, onDelete: PropTypes.func.isRequired // ★ } class TodoListItem extends Component { destroy(event) { // ★ event.preventDefault() if (confirm("Are you sure ?")) { this.props.onDelete(this.props.todo.id) } } render() { return( <tr> <td> {this.props.todo.due} </td> <td> {this.props.todo.task} </td> <td><Link to={`/${this.props.todo.id}`}>Show</Link></td> <td><Link to={`/${this.props.todo.id}/edit`}>Edit</Link></td> <td><Link to='' onClick={this.destroy.bind(this)}>Destroy</Link></td> // ★ </tr> ) } } TodoListItem.propTypes = { todo: PropTypes.object.isRequired, onDelete: PropTypes.func.isRequired // ★ }
まとめ
今回までの知識で React.js でアプリ作成は出来るようになるとおもいます。 React.js以外のライブラリーを組みわせるには、もう少し知識がいりますが大抵のことは ドキュメント に書かれていますし、検索をすると Stackoverflow に答えがみつかると思います。
今回、仕事で1万行以上の React.js を書きましたが、Reactの考え方(設計思想)になれるまでは時間がかかりますが、なれれば動きの分かり易いコードが出来る良いライブラリーだと思います。
サーバーサイドプログラマーのためのReact.js 入門 3. いよいよ React.js を書く
いよいよ React.js 入門
開発環境作成に2回も使いましたが、今回から React.js を始めます。
開発環境というかエディターですが、主なエディターは JSX, ES6 をサポートする拡張パッケージ、マクロ等があると思いますので入れておきましょう。もちん WebStorm のようなIDEも良いかもしれません。
これから作るTodoアプリはRuby on Railsのサーバーとhttpプロトコルで通信する必要があります。そこで今回は通信部分には、Promisベースの今風なHTTP clinet ライブラリー axios を使ってみます、 使い方は極めてシンプルです。
インストールは
$ npm install --save-dev axios
まずは、超簡単なReact.js アプリを動かす
前回書いた index.js を以下のように変更してください。もちろんターミナルでは webpack -d -w
を実行して下さい。
また、別のターミナルでは rails s
でRuby on Rails のサーバーも起動しておいて下さい。
import React, { Component } from 'react' import ReactDOM from 'react-dom' import axios from 'axios' class IndexPage extends Component { constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } render() { return ( <div> <h2>List of Todos</h2> {this.state.todos.map((todo) => <div>{todo.task}</div>)} </div> ) } } ReactDOM.render( <IndexPage />, document.getElementById('example') )
React.js は 画面をコンポーネント(部品)に分割し、そのコンポーネントは独自のHTMLタグになります。
上では <IndexPage>
というコンポーネントを定義しそれを一番下の ReactDOM.render() で index.html にある <div id='example'></div>
の中に埋め込むことでコンポーネントが表示されます。
ブラウザーのConsoleに warnig が表示されています。内容については後で説明しますが、このように React は何かおかしいところがあると warnig を表示してくれます。これは開発・デバックの助けになるのでちゃんと対応して下さい。
解説
state
Reactのコンポーネントは Component クラスを継承して作ります。クラスのコンストラクター(constructor) の中で プロパティー(インスタンス変数)を定義しています。
state は重要なプロパティーで、このコンポーネントが保持する状態やデータなどを保持するオブジェクト(Hashとして使ってます)。ここで todos は サーバーから取得したTodo情報の配列です。適切な(後で説明します)初期値を書いておいて下さい。
render
次に render() メソッドを見てみましょう。これは画面に表示されるHTMLを戻すメソッドです。 JS(JavaScript)の中にHTML タグが書かれていますが、これがJSXで環境設定で入れたBabelがこの部分を通常のJSに変換してくれます。このHTMLタグの部分は JSの式として扱われます。
JSXの中の { 。。。 } にはJSの式が書かれます、この値がタグや属性値として使われます。<div>{todo.task}</div>
の todo.task はタスクの文字列になります。
this.state.todos.map
の部分は state 内の todos の値(配列)からtask文字列を表示するHTMLの列を生成しています。 ES6を使ってコンパクトに書いてますが、ES5 で書くと
{this.state.todos.map(function(todo) { return <div>{todo.task}</div> })}
サーバーサイドのviewと似た感じですね、サーバーサイドのviewが1ページ全体(またはページ内の一部分)をリクエスト毎に一度に作成するように Reactも必要なタイミングで一度にページ全体を作成するようにプログラミングします。
jQueryなどを使ったAjaxではプログラマーが変更すべき部分のみを書き換えるようにプログラミングする必要がありました。なぜならHTML全体を書き換えるのでは遅く使いやすいUIを実現できないからです。その代償としてコードが煩雑になり、メンテナンスしづらいコードになりがちでした。
Reactは HTML全体を書き換えるようなプログラミンをしてもライブラリーが現在のDOM(HTML構造)と新しいDOM(HTML構造)を比較し、必要な部分のみを書き換えてくれます。
この Virtual DOM という仕掛けが、画面全体を書き換えるというシンプルなプログラミンモデルと性能を両立してくれます。
Reactが生成したHTML(下の画像)を見ると、data-reactid という属性が各タグにふられています、これはVirtual DOMと実際のDOMの管理に使われているのだと思われます。
componentDidMount (コンポーネントのライフサイクル)
Reactではコンポーネントの表示を明示的な制御はせず Reactライブラリーに任せます。シンプルなコンポーネントは render メソッドだけでも出来ますが、通常はデータを取得したりいろいろな処理が必要になります。そこでReactでは表示処理のあるタイミングで呼び出されるメソッドが決まっています、それを自分でオーバーライトし処理を組み込みます。 詳細はComponent Specs and Lifecycle を見て下さい。凝ったUIを作ったり、React以外のJSライブラリーと組みせる場合などはこのライフサイクルを熟知する必要があります。
現在、Reactを使いアプリを作っていますが、凝った事はしてないので、ほとんどコンポーネントは
- componentDidMount() 最初にコンポーネントが描画された後に呼び出される
- componentWillReceiveProps(nextProps) コンポーネントが再描画される際に呼び出される(正確には新たなpropsを受け取る時、propsは後で説明します)
の2つで済んでます。 Component Specs and Lifecycle に書かれているように データの取得等はcomponentDidMountメソッドに実装します。
componentDidMountは最初の描画の後に呼び出されます、今回のコードではtodosの初期値は空配列なので、最初は <div><h2>List of Todos</h2></div>
が表示されます(もちろん見えませんが)。
その後 componentDidMount が実行され RailsサーバーからTodo情報を取得してきます。 axios の詳細は axiosのGitHub に書かれていますが、見なくとも想像できると思います。
- get() でGETリクエストをサーバーに送り
- データが取得できると then() に書かれた(無名)関数が実行されます
- エラーが起きた場合は catch() に書かれた(無名)関数が実行されます
データが取得できたら this.setState()
メソッドが呼び出されていますが Reactではstateの更新はプロパティーへの代入ではなく、このメソッドを呼び出す必要があります。
this.setState()
は変更された state を使い再描画を行います。(上のrenderで説明したように Virtual DOM により実際に描かれるのはtodo.task表示のdivタグのみです)
注意点
私が、初めて Reactを使ってハマった点などを書いておきます。
- 一度表示されたコンポーネントはインスタンスがメモリ上に残っているので、他のコンポーネントから再表示を起こしても componentDidMount は実行されません。その時は componentWillReceiveProps が呼び出されます
- 上にも書きましたが、最初はデータが空(初期値)の状態で描画が起きるので、初期値は重要です。適切な値を設定しておくか、renderの方で空データを考慮しないとnull絡みのエラーが発生します
- renderで描かれるコンポーネントは基本的に1つのタグである必要があります、今回のコードではHTML的には最初のdivタグはいらないのですが、 このReactの制限から div で括っています
- List of Todosの画面で発生していた Warning: Each child in an array or iterator should have a unique "key" prop. ... ですが、リストやテーブルのように要素数が可変のタグを正しくReactが認識出るようにkey 属性でユニークな値を設定する必要があります。今回のコードは正しくは以下のように書きます
・・・ 省略 ・・・ render() { return ( <div> <h2>List of Todos</h2> {this.state.todos.map((todo) => <div key={todo.id}>{todo.task}</div>)} </div> ) } } ・・・ 省略 ・・・
Todoリスト表示完成版
Todoリストの表示ページを、
- ページ全体
- Todoリスト表示
- Todo一項目表示
というコンポーネントに分割してみましょう。コンポーネントにはHTMLの属性のように値を渡すことができます。
値を受け取るコンポーネント側では、this.props.属性名
で値をアクセスします。 この props も重要なプロパティーです。また、propsの値をコンポーネント内で変更しないのが React の考え方です。
TodoList.propTypes = {
から始まる部分ですが、propの型を宣言する部分です。無くても動作しますが型宣言をしておくと 違う型が渡された場合 waring が発生するので、開発には役立つと思います。
詳細は Prop Validation を参照して下さい
import React, { Component, PropTypes } from 'react' import ReactDOM from 'react-dom' import axios from 'axios' class IndexPage extends Component { constructor(props) { super(props) this.state = {todos: []} } componentDidMount() { axios.get('/todos.json').then((response) => { this.setState({todos: response.data}) }).catch((response) => { console.log(response) }) } render() { return( <div> <h2>List of Todos</h2> <TodoList todos={this.state.todos}/> </div> ) } } class TodoList extends Component { render() { return( <table> <thead> <tr> <th>Due</th> <th>Task</th> <th colSpan="3"></th> </tr> </thead> <tbody> {this.props.todos.map((todo) => (<TodoListItem key={todo.id} todo={todo} />))} </tbody> </table> ) } } TodoList.propTypes = { todos: PropTypes.array.isRequired } class TodoListItem extends Component { render() { return( <tr> <td> {this.props.todo.due} </td> <td> {this.props.todo.task} </td> </tr> ) } } TodoListItem.propTypes = { todo: PropTypes.object.isRequired } ReactDOM.render( <IndexPage />, document.getElementById('example') )
予告
次回は、Todo情報の変更などに付いて書きます。
サーバーサイドプログラマーのためのReact.js 入門 2. 開発環境の構築の続き
これから作るアプリ
今回と次回で作るアプリですが Ruby on Railsのscaffoldジェネレータが生成する古き良き時代の単純なWebアプリケーションと同じ動きをする、React.js を使った Single page application (SPA)のフロントエンドを作っていきます。
バックエアンド(APIサーバー)は Ruby on Rails を使うことにします。
バックエンドの構築
Ruby on Railsのインストール等はネット上の情報を参考にしてください。 私の会社 EY-OfficeのWikiにもインストール方法を書いています (少し古いのでバージョンは最新のものを入れて下さい)
Railsがインストールできたら、以下のようにプロジェクトを作り scaffold ジェネレータを使い、Railsアプリを作ります。
$ rails new todo_app $ cd todo_app $ rails generate scaffold todo due:date task:string $ rake db:migrate $ rails server
http://localhost:3000/todos をブラウザーからアクセスし、New Todo ページでデータを作成したり、アプリの動きを見てください。
http://localhost:3000/todos.json をアクセスしてみて下さい。 scaffold ジェネレータが作ったコードはデフォルトでJSONを出力できます、これでバックエンドは完成です!
rails server
で起動したサーバーはReact.js でも使いますので、そのまま動かしておいて、以下の作業は別のターミナルで行ってください。
React開発環境の構築
前回は開発環境を構築する前準備でした、今回は具体的な開発環境を作り開発してみます。
Reactに行く前に
Railsの環境ではスタティックなコンテンツ等は public/ に置きます。
そこで JS が入るディレクトリーとJSを表示するための index.html を作成ておきます。
$ mkdir public/js $ vi public/index.html
- index.html
<!DOCTYPE html> <html> <head> <title>ReactTodo</title> <meta charset="utf-8"> </head> <body> <div id="example"></div> <script type="text/javascript" src="js/app.js" charset="utf-8"></script> </body> </html>
React開発環境の構築
今回は Railsプロジェクトの中に frontend ディレクトリーを作りそこで開発することにします。npm init でいろいろとたずねれますが、気にせず return していっても良いです。
$ mkdir frontend $ cd frontend $ mkdir -p src/js src/css $ nam init -y
- React.js、babel、ESLint、webpack のインストール。 XXXX-loader はwebpack用のモジュールです。
本日ためしたところ eslint 2.0.0 をインストールすると babelが動作しませんでしたので @1.10 でバージョン 1.10.X をインストールしています。非互換な変更があったようです
$ npm install --save react react-dom $ npm install --save babel-loader babel-preset-es2015 babel-preset-react $ npm install --save eslint eslint-loader eslint-plugin-react babel $ npm install --save css-loader style-loader $ npm install -g webpack
- babel 設定ファイル .babelrc の作成。ES6(正式にはES2015)とJSX(React)の変換を行います
{ "presets": ["es2015", "react"] }
- ESLint 設定ファイル .eslintlrc の作成、後で説明します。
{ "env": { "browser": true, "node": true, "es6": true }, "parserOptions": { "sourceType": "module", "ecmaFeatures": { "experimentalObjectRestSpread": true, "jsx": true } }, "extends": ["eslint:recommended", "plugin:react/recommended"], "plugins": [ "react" ], "rules": { "strict": [2, "function"], "no-undef": 2, "no-console": 0, "arrow-parens": [2, "always"] } }
- webpack の設定ファイル webpack.config.js の作成
設定ファイルの詳細は webpackのドキュメント や各ローダーのドキュメントを見てください。日本語の記事もいくつかありますので、両方を見ながら覚えて行って下さい。 (いずれ記事を書きたいですが、私もまだまだ苦労しています・・・)
module.exports = { entry: { app: "./src/js/index.js" }, output: { path: '../public/js', filename: "[name].js" }, module: { preLoaders: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: "eslint-loader" }], loaders: [{ test: /\.css$/, loader: "style!css" }, { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' }] }, resolve: { extensions: ['', '.js', '.jsx', '.css'] }, eslint: { configFile: './.eslintrc' } };
試してみよう!
やっと環境ができました、さっそく試してみましょう。 まずは、 Reactの Getting Started のコードを試してみましょう
- src/js/index.js
var React = require('react'); var ReactDOM = require('react-dom'); ReactDOM.render( <h1>Hello, world!</h1>, document.getElementById('example') );
ターミナルで webpack -d -w
を入力し、少し待つと以下のように表示されます
- app.js とマップファイルが生成されました。
- また、index.js では React が定義されているが使われてないというエラーが出ています。
$ pwd /Users/XXXXXX/todo_app/frontend $ webpack -d -w Hash: cf1445dbfe9ce6c676f1 Version: webpack 1.12.13 Time: 2268ms Asset Size Chunks Chunk Names app.js 764 kB 0 [emitted] app app.js.map 846 kB 0 [emitted] app + 180 hidden modules ERROR in ./src/js/index.js /Users/yy/tmp/todo_app/frontend/src/js/index.js 1:8 error "React" is defined but never used no-unused-vars ✖ 1 problem (1 error, 0 warnings)
ここでは、ESLintの出したエラーは一旦無視(他のエラーはちゃんと追求して下さいね)してブラウザーで http://localhost:3000 をアクセスすると 画面に Hello、wold! が表示されます。
上手くいかない場合はブラウザーのコンソール(開発ツール、インスペクター)を見てください。エラーや警告が出ているかもしれません。
上で書いたJSは ES5 ですが ES6 に変えてみましょう。同じエラーは発生しますが Hello, world! が表示されるはずです
import React from 'react' import ReactDOM from 'react-dom' ReactDOM.render( <h1>Hello, world!</h1>, document.getElementById('example') )
Next Steps のコードも試してみてください。
ESLint
JavaScriptは動的な言語です、従って実行してみるまでエラーが発見できません。 テストを書いてこまめに開発していていれば良いのですが、ブラウザーを操作してやっと開発中のページを実行した途端に、つまらない文法エラー発生! などということになりがちです。
そこで大きな助けになるのが ESLint です。文法エラー、未定義エラー、定義されているが未使用などを実行前にチェックしてくれるので開発がはかどります!! 特に Reactでは JS の中に HTML が書かれる JSX は、なれるまで間違いが発生しやすのでお勧めです。
ただし、JSは動的な言語なのでチェックは完璧にはできません。上の Hello,world! 出ていたエラーは Reac変数がプログラムの中で使われていないので発生しました。しかし、この行を消してしまうと React.js が読み込まれないので実行できません。
このような場合は、ソースコードか設定ファイルに指定することで回避できます。ここでは .eslintlrc に React は使われてなくても良いというrule を設定を追加してみましょう。 詳しくは Configuring ESLint をみてください。
{ ... ここまでは同じ... "rules": { "strict": [2, "function"], "no-undef": 2, "no-console": 0, "arrow-parens": [2, "always"], no-unused-vars: [2, {"varsIgnorePattern": "^React$"}] } }
次回は、いよいよ React.js ^^)/
サーバーサイドプログラマーのためのReact.js 入門 1. 開発環境の構築
最近、バリバリと jQueryベースのフロントエンドを React.js に置き換えています。
私も主に Ruby on Rails等の サーバーサイドエンジニアで、最近のフロントエンド開発を本格的に開発するのは初めてです。いろいろとつまずきながら進んできました。
まずは node.js をインストールし npm を知る
環境構築のためのツールは node.js の上で動きます、そこで node をインストールします。 ツールを動かすだけなので node のバージョンはあまり気にしなくて良いと思います。(2016-02-04日現在ではバージョン5.5.0が入ります)
$ brew install node
node をインストールすると npm コマンドもインストールされます。npmは node.js用ライブラリーと、その管理を行うコマンドです。 Rubyの gem に相当するものですが、さらに Ruby の bundle, rake 的な機能も持っていて node の世界を極めるには非常に重要なものです。
node の生態系は ruby と似ていますが、さらに改良されています。良く使うコマンドは
npm init
カレントディレクトリーにnpm用設定ファイル package.json ファイルを作ります。このファイルにはプロジェクト(ライブラリー)の情報、依存ライブラリーなどの情報が入っています。コマンドを起動すると name: version: などを尋ねてきますが後で package.json ファイルを変更できるので全てreturnでも構いません。npm install --save ライブラリー名
ライブラリーカレントディレクトリー下に(node_modulesディレクトリーを作り)インストールします。 --save を指定すると依存ライブラリーとしての情報が package.json に入ります。npm install -g ライブラリー名
ライブラリーを共通な場所(brewでnodeをインストールした場合 /usr/local/lib/node_modules)にインストールします。コマンドを含むものは -g (--global) で入れるとコマンドもしかるべき場所に入り便利です。npm --help
npm にはたくさんの機能があります、知りたい場合はヘルプを
ビルドツールを選ぶ(知る)
フロントエンドの生態系は凄い速さで進化しています。フレームワークも少し前は AngularJS とか言われてましたが、一昨年あたりから React が主役っぽいし、Reactは表示ライブラリーなので、それを使うためのフレームワークは Flux だとか Redux だとか出来きてます・・・
ビルドツールのようなものには grunt, gulp, babel, bower, browserify, webpack ... とたくさんのもので賑わっていて、何を使えば良いのか迷います。近くにフロントエンドのエンジニアがいれば相談するのが良いと思いますが、やみくもにネットを調べても発散するばかりです !
やりたいことを整理しましょう
まず開発環境の要件というか、やりたい事をまとめましょう。今回の私の要件は
- React を使う
- Redux を使うのかな?
- 現在のJS(ES5)はだるいので、ES6 を使いたい
- 完全なシングルページアプリ (Rails等のviewは使わない)
- ブラウザーは最新でけサポートすれば良い
- 環境構築ツールはシンプルなもにしたい、多少の不便があっても良い
- HTML,CSSもプログラマーが書く(既に出来たHTML/CSSがある)
- 過去の開発環境にはこだわらない
- 過去の(デプロイされた)ディレクトリー構成にはこだわらない
一般論としての良いツールではなく自分のやりたい事に最適なツールを探しましょう
ネットで調べてみる
ビルドツール等に関しては qiita を始め多数の日本語の情報がネットから得られので概要を理解するのに役立ちます。しかし最新の正確な情報は本家(英語)のページを読みましょう。
JSX, ES6
ReactではJSXというJSの中に HTML を埋め込んだものを使ういます(使わない方法もあるようですが)。JSXはそのままでは実行できないので通常のJSに変換するツールが必要になります。
var React = require('react'); var ReactDOM = require('react-dom'); ReactDOM.render( <h1>Hello, world!</h1>, document.getElementById('example') );
また、ES6(正確には ES2015, ECMAScript2015)は全ての主要ブラウザーでサポートされているわけではありません。そこでES6からES5に変換する Babel が良く使われています。
JSXやBabelで変換されたコードをブラウザーで動かすのは良いのですが、デバックはどうするのでしょうか? 変換されたコードでデバックするのでしょうか? 安心して下さい、そのために元のコードでデバックするための ソースマップ(.map) が主要ブラウザーではサポートされています。
ビルドツール
現時点では Browserify か Webpack が良いようです。
Browserify
Browserify はもともと node.js 用のモジュール構造をブラウザーのJSで使えるようにする技術ですが、それを行うための変換ツールでもあります。また、babelやJSXの変換ツールを統合できます。
Browserifyとnpmを使った最小限のビルドツールの作り方が Reactをnpmでビルドする方法 browserify (watchify) + babelify編 にまとめられています。
npm のスクリプト実行機能を使い以下のように browserify(watchify), babelify を使い ES6(babel-preset-es2015), JSX(babel-preset-react)の変換を行います。また、ソースマップ作成にexorcistを使っています。
watchify -t babelify ./app.js -o 'exorcist ./build.js.map > ./build.js' -d
Browserifyはいつかのツールを組み合わせボトムアップにビルド環境を作るツールです。各種ツールやgulpと組み合わせると、自分のやりたいことが実現できると思います。
Webpack
Webpack ホームページの絵からもわかるようにフロントエンドの統合開発ツールです。フレームワークのような作りになっていて、そこに種々のツールをプラグインで組み込み設定ファイルで管理します。
新たにトップダウンで作られたツールなのでわかり易いように思います。ただし、自分のやりたいことが完全に実現できないこともあるかもしれません。
試してみた
BrowserifyとWebpackのどちらが良いのか情報でけでは決めかねたので ReactのGetting Started を両方の環境で試してみました。
その結果、今回の要件にはWebpackが良さそうなので使うことにしました。
予告
次回はReactを使った簡単なアプリ作成を Webpack の環境作りから始めます・・・・
Network Link ConditionerのVery Bad Networkは役立つ!
先日、あるサービスでトラブルが !! お客様のところで、ファイルのアップロードが出来ないと連絡がありました。
こちらで試してみると正常にアップロードできます。詳しく聞くと、お客様の自宅からアップロード出来ないとのこと・・・
Macにはネットワークの速度を制限するツールがある事を思い出し、Network Link Conditioner をインストール ( 詳細はこのページでも参考にして下さい ) し通信速度 3G, Edgeなどにを落としてもアップロードできます。
Profileを見ると Very Bad Network というのがあります、Packets Dropped(パケットロス)やDelay(遅延)に値が設定されています、通信品質が極めて悪いネットワークの設定ですね。
これで、アップロードを試すと確かに全然終わりません !! アクティビティ モニターでネットワークをモニターすると、超低速でアップロードが実行されているようです(転送レートから計算すると10分くらいすれば完了しそうでしたが・・・・)。
ということで、今日の結論 「Network Link ConditionerのVery Bad Networkは役立つ」
Turnip, Cucumber などを使った end-to-endテストのテストデータに付いて
Turnip, Cucumber, RSpec feature などを使った end-to-end テスト のテストデータをどう作るかについて、今までの経験を書いてみます。
RSpecなどを使ったモデルのテストであれば、テストデータは factory_girl 等を使い、テストに必要なデータを it, describe単位でテストで作成する事が多いと思います。
しかし、end-to-endテストでは人がそのアプリを使う手順、シナリオに沿ってのテストにり、モデルのテストのように個々のテストで個別のデータを必要になることはあまりなく、いくつかのデータを作っておけば済みます。また、モデルのテストでは必要になるモデル(テーブル)は少数ですがend-to-endテストではほとんどのモデル(テーブル)にデータが必要になります。
そこで、今まで作ったアプリのend-to-endテスト用のデータをどのように準備したかを、まとめてみました。またテストデータではありませんが、開発時に使う db_seed についてもふれています。
1. Cucumber + fixture + db_seedなし
Rails 2の頃に作ったアプリで標準の fixture を使っています。なんとモデルも fixture を使っているので fixture はかなりカオスな状況になっています ^^;
また、db_seed については知らなかったので、 rake db:fixtures:load
を使っていました。 fixture は人手でデータを作るので テストや開発に必要な意味のあるデータを書くのには向いていると思います。
ただし、テスト用にデータを100件準備するとかには向きません(ERBが書けるので出来ないわけではないですが)。
2. RSpec feature + factory_girl + db_seed
この開発では end-to-endテストは RSpec feature を使い、テスト用のデータは factory_girl を使って作っていますが、helperメソッド内で全モデル用のデータを作り全end-to-endテストで同じものを使っています。 db_seed はActiveRecordのメソッドだけで別に作りました。
3. RSpec feature + fixture + db_seedなし
これは、1. のシステムをRails4にバージョンアップする際に管理画面の end-to-endテストを追加した際に作りました。従ってテストデータは 1. と同じです。
4. RSpec feature + factory_girl + factory_girl を使ったdb_seed
db_seed と end-to-endテストのデータは同じもで良いのでは? または db_seed は end-to-endテストのデータ + アルファ なのでは? という仮定のもとに db_seedもfactory_girl を使って作りました。 同じようなものを2つ作らずに済んだので良いアプローチだったと思います。
また、end-to-endテストを作る際には実際にブラウザーを操作し、画面や DOM を見ながらテストを作るのでdb_seed と end-to-endテストのデータが同じだと作業がはかどりました。
5. Turnip + factory_girl + db_seed
この案件は既存のアプリの改善だったので、既に db_seed はあるが end-to-endテストは無い状態だったので end-to-endテストのデータは 2. と同じように作りました。
ただし、 4. に書いたようにテストを書く際にテスト用データでアプリを動かせると便利なので、それを行うツールを作りテストアプリ作成に役立てました。
ツールは以下のようなコードで、load_turnip_seed で Turnip 用に書いたテストデータ作成メソッド turnip_seed_data() を呼び出していま。turnip_seed_data() は通常のfactory_girlを使ったコードです。 テストデータを消すための truncate というメソッドも作っておきました。
# Usage: # rails runner Tasks::DbTool.truncate # rails runner Tasks::DbTool.load_turnip_seed $LOAD_PATH << "#{Rails.root}/spec" # TODO: もっと良いやり方 require 'turnip_seed_data' class Tasks::DbTool extend FactoryGirl::Syntax::Methods def self.load_turnip_seed self.truncate turnip_seed_data() end def self.truncate ActiveRecord::Base.connection.tables.reject{|t| t == 'schema_migrations'}.each do |table| # MySQL only ActiveRecord::Base.connection.execute("TRUNCATE TABLE `#{table}`") end end end
Turnip を使ったテスト作成のノウハウ
現在、バリバリと Tunip + Capyabara + PhantomJS (poltergeist) で end-to-end テストを書いてます。 そこで知ったノウハウを書きます、今回はテストの構成分けなどの上流側ではなく、ツールや steps 側になります。
Turnipに限らず Cucmber, RSpec Feature など Capybara を使った、テストの大部分は
の繰り返しです。
ツール
Chrome等のDevTools
まずDOM上のエレメントを特定するための正しく、かつメンテンス生の高いCSSセレクターを決めないと行けません。これは ブラウザーの開発ツール (Cmd+Opt+I)のElements画面で、エレメント選択(Cmd+Shift+C)で画面上の目的の場所をクリックして目的の CSSセレクター を探していけると思います。
また開発ツールの Console を使うと、document.querySelector() や jQuery でCSSセレクターが 正しいかを確認できます。 よくあるのは CSSセレクターがユニークでは無く違う要素が選択されてしまう事があるで、単純なCSSセレクター以外は試してみるのが良いです。
正し、jQueryのCSSセレクターには拡張があります、これはCapybaraでは動作しないので注意して下さい。
Snapshot
テストが上手く行かない時は表示要素の特定用CSSセレクターの間違だけではなく、対象のページまで来てなかったりJavaScriptが正しく動作してないなどの事もよくあります。 Capybara-WebkitやPoltergeistには現在のDOM(画面)を画像やHTMLとして保存できます。
これを使い以下のようなメソッドやstepsを定義すると、stepsの中や feature の中から呼び出す事で画面を確認できます。
- helper
def take_screenshot(save_type = :image, path = nil) path = Time.now.strftime("/tmp/%y%m%d-%H%M%S-%L.png") unless path if save_type == :image page.save_screenshot(path) else File.write(path, page.html) end end
- steps
step 'HTML保存' do take_screenshot(:html) end step '画面画像保存' do take_screenshot end
失敗時の Snapshot
以下のような設定を書くと、テストが失敗した時に自動的に Snapshot をとる事ができ失敗の原因追求にとても役立ちます。
RSpec.configure do |config| ・・・ config.after(type: :feature) do |example| DatabaseCleaner.clean take_screenshot if example.exception.present? end
ログ出力
steps は Ruby のコードなので、 p 等でデバック情報をコンソールに出力出来ます。
また、テスト対象の画面にあるJavaScript中に console.log() を書くとやはりコンソールに出力されるのでAjaxからみの複雑な機能のテスト作成に役立ちます。
steps ノウハウ
findの match オプション
page.find()メソッドはデフォルトでは多数がマッチするとエラーになります。CSSセレクターを工夫することでも対処しなくとも、絶対に最初にマッチしたものが正解の場合は以下のように match オプションを指定して逃げる事も出来ます。
page.find("form input[name='email']", match: :first)
findの visible オプション
page.find()メソッドはデフォルトでは表示されている要素しかマッチしませんが、非表示の要素の値を変更したい場合がありますが、そのような場合は以下のように visible オプションを指定することで可能になります。
page.find("form input[name='email']", visible: :all)
他のステップの利用
step はrubyのメソッドでは無いので、単純には呼び出せませんが、 Calling steps from other steps に書いてあるように、
の2つを適宜、使い分けると良いと思います。
step 'メール :email パスワード :password ででログイン' do |email, password| ... end step '社員Aのログイン' do step "メール 'a@kaisya.com' パスワード 'PDB4oq4X' ででログイン" end step 'ある社員のログイン' do send 'メール :email パスワード :password ででログイン', "e#{rand(1000)}@kaisya.com", "PDB4oq4X" end
その他
画面サイズ
アプリによってはCapyabara-Webkit画面サイズ(1024 x 768)では一部表示されない場合があるかもしれません。画面サイズは以下のようにして指定できます。
Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, window_size: [1280, 1024]) end
ここに書いたノウハウは、検索すると stackoverflow, qiita, ブログ等に書かれています。それらの情報を書いて頂いた方に感謝いたします。