React関連の使い方を復習する/(1) create-react-app
React関連の使い方を復習する/(2) prop
React関連の使い方を復習する/(3) stateとイベントハンドラ
React関連の使い方を復習する/(4) prop-typesとreact-dnd
React関連の使い方を復習する/(5) react-redux、redux-actions
前回、reactで作成してきたアプリケーションにreact-reduxとredux-actionsを導入しました。
今回は、オブジェクトで管理していたstateを、immutable.jsを利用したオブジェクトに変更していきます。
Reactとimmutableを組み合わせたときのメリットは、ググれば色々と出てきますので詳細はそちらを参照してください。
ざっとメリットを挙げると、大きく以下3点でしょうか。
- 参照透過性を保つことができる
- (正しく使えば)パフォーマンスが向上する
- モデルクラスを作ることができる
なお、参照透過性とは、以下を指します。
ある式が参照透過であるとは、その式をその式の値に置き換えてもプログラムの振る舞いが変わらない(言い換えれば、同じ入力に対して同じ作用と同じ出力とを持つプログラムになる)ことを言う。(wikipedia)
中身を見ていく前に、immutable.jsを簡単に紹介します。
復習と言いつつ、実はimmutable.jsは今回初めて調べたのでなかなか学びが多かったです。
immutable.jsとは
immutable.jsは、Facebookが開発している、不変オブジェクトを扱うパッケージです。
扱うことができる主なクラスは色々とありますが、本記事では以下3つを紹介します。
- Map
- List
- Record
基本的な挙動として、
- 不変オブジェクトなので元のオブジェクトは変更されない
- setやpushなどでプロパティを操作するメソッドを呼び出したら、操作後の新たなオブジェクトを返却する
となります。それを踏まえて、各クラスを見ていきます。
Map
JavaScriptのオブジェクトを扱うクラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { Map, fromJS } from 'immutable'; let map = Map({ a: 10 }); map.set('b', 20); // 不変オブジェクトなので元のオブジェクトには追加されない console.log(map.toJS()); // { a: 10 } Map型をJSオブジェクトに変換するためにはtoJS()が必要 map = map.set('b', 2); // オブジェクトに値を代入した後に新たなオブジェクトを返す console.log(map.toJS()); // { a: 10, b: 2 } map = map.update('a', (val) => { return { c: val * 3, d: val / 4 } }); console.log(map.toJS()); // { a: { c: 30, d: 2.5 }, b: 2 } try { map = map.setIn(['a', 'c'], 400); // aに格納されたオブジェクトはMap型ではなくJSオブジェクトのためMapの関数を利用できない } catch (e) { console.log(e); // error } // JSオブジェクトをMap型に変換するためにはfromJS()を利用 map = map.update('a', (val) => fromJS({ c: 3, d: 4 })); console.log(map.toJS()); // { a: { c: 3, d: 4 }, b: 2 } map = map.setIn(['a', 'c'], 400); // ネストしたオブジェクトの更新ができる console.log(map.toJS()); // { a: { c: 400, d: 4 }, b: 2 } |
List
配列を扱うクラスです。
Listの中でオブジェクトを扱う場合、updateInなどを利用する場合はやはりfromJSでMap型に変換する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { List, fromJS } from 'immutable'; let list = List([0]); list.push(1); // 不変オブジェクトなので元のオブジェクトには追加されない console.log(list.toJS()); // [0] list = list.push(1); // オブジェクトに値を代入した後に新たなオブジェクトを返す console.log(list.toJS()); // [0, 1] list = list.push(2, fromJS({ a: 3, b: 4})); console.log(list.toJS()); // [0, 1, 2, { a: 3, d: 4 }] list = list.set(1, 100); console.log(list.toJS()); // [0, 100, 2, { a: 3, d: 4 }] list = list.updateIn([3, 'b'], val => val * 10); console.log(list.toJS()); // [0, 100, 2, { a: 3, d: 40 }] |
Record
JavaScriptのオブジェクトを扱うのはMapと同様ですが、初期値や関数を定義することができます。いわゆる型定義ですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { Record, fromJS } from 'immutable'; const Person = class Person extends Record({ // 初期値を定義できる name: '', age: 0 }) { // constructorを定義しない場合は自動的に以下が適応される // constructor(args) { // super(args); // } // 関数を定義できる birthday() { return this.update('age', age => age + 1); } } let person = new Person(); console.log(person.toJS()); // {name: "", age: 0} person = new Person({ name: 'tanaka', age: 20 }) console.log(person.toJS()); // {name: "tanaka", age: 20} person = person.birthday(); console.log(person.toJS()); // {name: "tanaka", age: 21} person = person.set('name', 'suzuki'); console.log(person.toJS()); // {name: "suzuki", age: 21} person = person.set('company', 'ntt'); console.log(person.toJS()); // エラー、未定義のプロパティを操作することはできない |
Reactと組み合わせる
それでは、Storeで管理するstateをimmutable.jsのオブジェクトに修正していきます。
Modelクラスを定義する
前回までに作成したstateと合わせて、アプリケーション全体の状態を管理するTodoModel、カードを表すCardModel、タスクを表すTaskModelの3つを作成します。
Card、Taskを新たに追加するときは、ひとまず簡易的にidはDate.now()で付与することにします。
連続で複数作成すると同じidになるでしょうけど、今回の本質ではないのでとりあえず置いておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { Record, List } from 'immutable'; /** * Todoアプリケーションの状態を管理するモデルです。 */ export default class TodoModel extends Record({ cards: List() }) { // 処理なし } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import { Record, List } from 'immutable'; import TaskModel from './TaskModel'; /** * カンバンのカードを表すモデルです。 */ export default class CardModel extends Record({ id: 0, label: '新しいタスクです。', description: '', status: 1, tasks: List(), orderBy: 0, isDone: false }) { /** * idを生成してCardクラスのインスタンスを生成する。 * 他に引数があれば合わせて設定する。 * @param args 引数 */ constructor(args) { super(Object.assign(args, { id: args.id ? args.id : Date.now(), tasks: args.tasks ? List(args.tasks.map(task => new TaskModel(task))) : List() })); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import { Record } from 'immutable'; /** * カンバンのカードがもつタスクを表すモデルです。 */ export default class TaskModel extends Record({ id: 0, label: '', isDone: false, orderBy: 0, cardId: 0 }) { /** * idを生成してTaskクラスのインスタンスを生成する。 * 他に引数があれば合わせて設定する。 * @param args 引数 */ constructor(args) { super(Object.assign(args, { id: args.id ? args.id : Date.now() })); } } |
TodoReducer
続いて、TodoReducerを修正します。
前回、新たなインスタンスを作るためにObject.assignやArray.concatを使っていましたが、immutable.jsが常に新たなインスタンスを返却してくれるためこれが不要になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import TodoAction from '../actions/TodoAction'; import { handleActions } from 'redux-actions'; import TodoModel from '../models/TodoModel'; import CardModel from '../models/CardModel'; /** * TodoReducer * 初期状態は空のTodoModel */ export default handleActions({ /** * 複数のカードを追加する * @param state 現在の状態 * @param action アクションオブジェクト */ [TodoAction.addCards]: (state, action) => { const { cards: newCards } = action.payload; return state.update('cards', cards => cards.push(...newCards.map(card => new CardModel(card)))); }, /** * カードのレーンを移動する * @param state 現在の状態 * @param action アクションオブジェクト */ [TodoAction.moveCard]: (state, action) => { const { sourceCard, targetLaneId } = action.payload; const updateCardIdx = state.cards.findIndex(card => card.id === sourceCard.id); return state.updateIn(['cards', updateCardIdx], card => card.set('status', targetLaneId)); }, /** * タスクとカードの完了状態を反転させる * @param state 現在の状態 * @param action アクションオブジェクト */ [TodoAction.toggleTask]: (state, action) => { const { cardId, taskId } = action.payload; // 更新するカードの添字を取得する const cardIdx = state.cards.findIndex(card => card.id === cardId); const taskIdx = state.cards.get(cardIdx).tasks.findIndex(task => task.id === taskId); return state.withMutations(s => // タスクの完了状態を更新する s.updateIn(['cards', cardIdx, 'tasks', taskIdx], task => task.set('isDone', !task.isDone)) // タスクが全て完了していたらカードを完了状態にする .updateIn(['cards', cardIdx], card => card.set('isDone', card.tasks.map(task => task.isDone).every(item => item))) ); } }, new TodoModel()); |
L57、TodoReducerが管理するStateの初期状態は、TodoModelのインスタンスに変更しています。各関数で行われる処理も、それぞれimmutable.jsの記法に合わせて修正していますね。
L50-L55でwithMutationsを使っていることが1つのポイントです。
オブジェクトを操作するメソッドを呼び出すと新たなインスタンスが返却されるので、メソッドチェーンで記載することもできます。しかし、普通にメソッドチェーンをするだけだとオブジェクトを操作するたびに新たなインスタンスが生成されるため、性能がよくありません。これらの処理をバッチで行うために"withMutations"を用いることで、それを改善することができます。
1 2 3 4 5 6 7 8 |
// bad state.set('a', 1).set('b', 2); // good state.withMutations( s => { s.set('a', 1) .set('b', 2) }); |
Appコンポーネント
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import React from 'react'; import './App.css'; import Lane from './components/Lane' import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-dnd'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { toJS } from './ImmutableWrapper'; import TodoAction from './actions/TodoAction'; class App extends React.Component { componentWillMount() { const cards = [ (snip) ]; this.props.addCards(cards); } render() { (snip) } } App = DragDropContext(HTML5Backend)(App); export default connect( state => { return { cards: state.TodoReducer.get('cards'), lane: state.LaneReducer }; }, dispatch => { return bindActionCreators(TodoAction, dispatch); } )(toJS(App)) |
ポイントはL36、L8でインポートしたImmutableWrapper#toJSでラップしています。これが何者かは後述します。
Storeで管理するStateはimmutable.jsを用いた型ですが、ReactのコンポーネントでPropsとして利用するときはプレーンなJavaScriptのオブジェクトに変換しないといけません。何も考えずに使おうとすると、以下のようにやりたくなります。
1 2 3 4 5 6 7 8 |
// bad export default connect( state => { return { cards: state.TodoReducer.toJS() }; } )(App) |
しかし、これは公式ドキュメントによると性能が悪いアンチパターンとのことです。
要は、毎回新しいインスタンスが返却されるから必ずshallow equality checkに失敗するので、値が変更されない場合でもreact-reduxによって再レンダリングされてしまうから性能が悪いよ、ということみたいです。
This is a particular issue if you use toJS() in a wrapped component’s mapStateToProps function, as React-Redux shallowly compares each value in the returned props object. For example, the value referenced by the todos prop returned from mapStateToProps below will always be a different object, and so will fail a shallow equality check.
(samle code)
When the shallow check fails, React-Redux will cause the component to re-render. Using toJS() in mapStateToProps in this way, therefore, will always cause the component to re-render, even if the value never changes, impacting heavily on performance.
これを回避するためのベストプラクティスとして、Higher Order Component(HOC)を使うとよいよ!ということが公式ドキュメントに書かれています。何やら難しそうな名称ですが、HOCとは他のコンポーネントをラップするコンポーネントのことです。公式を参考にしつつ作成したクラスが以下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import React from 'react'; import { Iterable } from 'immutable'; /** * PropsをラップするHigher Order component * @param WrappedComponent 描画するコンポーネント * @returns immutableなオブジェクトを変換したコンポーネント */ export const toJS = WrappedComponent => { return class ImmutableWrapper extends React.Component { constructor(props) { super(props); this.updateNewProps = this.updateNewProps.bind(this); this.newProps = this.updateNewProps(this.props); } // immutableなオブジェクトかをチェックし、そうであれば変換する updateNewProps(currentProps) { return Object.entries(currentProps).reduce((newProps, entry) => { newProps[entry[0]] = Iterable.isIterable(entry[1]) ? entry[1].toJS() : entry[1]; return newProps; }, {}); } // 新しいPropsを受け取るたびに新しいPropsを渡す componentWillReceiveProps(nextProps) { this.newProps = this.updateNewProps(nextProps); } render() { return ( <WrappedComponent {...this.newProps} /> ); } }; }; |
公式によると、immutable.jsのオブジェクトをHOCでプレーンなJavaScriptオブジェクトに変換することで、移植性を実現しつつパフォーマンスが低下することはないよ、ということみたいです。
By converting Immutable.JS objects to plain JavaScript values within a HOC, we achieve Dumb Component portability, but without the performance hits of using toJS() in the Smart Component.
知らないと普通にやりますね、これ。
index.js
最後に、redux-loggerが出力するログが、toJSで変換される前のimmutable.jsのオブジェクトになってしまいわかりづらいため、少々変換処理を追加します。該当部分のコードだけ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
ReactDOM.render( <Provider store={createStore( // 複数のReducerを利用する場合 combineReducers({ TodoReducer, LaneReducer }), applyMiddleware(createLogger({ // https://github.com/evgenyrodionov/redux-logger#transform-immutable-with-combinereducers stateTransformer: (state) => { let newState = {}; for (let i of Object.keys(state)) { if (Iterable.isIterable(state[i])) { newState[i] = state[i].toJS(); } else { newState[i] = state[i]; } } return newState; } })) )}> <App/> </Provider>, document.getElementById('root') ); |
combineReducersを使っている場合と使っていない場合で処理が異なりますが、redux-loggerのドキュメントに対処法が書いてあるので、これをes6の記法に合わせて記述してあげればOKです。
まとめ
以上でReactにimmutable.jsを導入することができました。
性能面で気にしなければいけないところが結構あって、思っていたよりずっと大変でしたけど、なかなか勉強になりました。使いこなせれば、より良いコードを書くことができそうですね。
現時点でのソースコードは、例によってGithubに置いておきます。
・・・日に日に記事の文字量が増えて辛くなってきた。冬休みの宿題と言いつつ冬休み中に終わる量ではない。
次回はカンバンのカードが入れ替えられるようになっていたりと急に機能が進化すると思います。
その後react-routerとテスト周りを書いて一段落としたい。