Reactを導入して半年近くが経ちました
Wantedlyでは、今年の初めからReact(+Redux)の導入に取り組み始めたので、気付けば半年近く立っていることになります。今自分がこの記事を書いているエディタから、Wantedly Adminのチケット画面まで、ある程度大きなアプリケーションを開発してきました。
そこで今回は、チームで継続的に開発していく過程で遭遇した問題と、それを解決するために導入したImmutable.jsについて紹介します。
増え続けるCallbackとAction、肥大化するStore
Reactとセットで語られることが多いFluxアーキテクチャ。ここでは詳しい説明は省略しますが、とてもシンプルな考え方なので、チュートリアルなどで簡単に学ぶことができます。しかし、実際にチームで開発していくと、たしかに動いてはいるけど、綺麗とは言い難いコードが増えてしまいました。
ActionとStoreの責務の混在
その原因に、Action(ActionCreator)とStore(Reducer)の責務が、きちんと分けられていないことがありました。Fluxでは「ActionはViewから発行されるイベントを管理し、Storeがアプリケーションの状態を保持する」と言われますが、これはデータの処理には言及しておらず、どこでデータを操作するかは開発者に委ねられています。そのため、データの処理が、ActionやStore、さらにComponentにまで点在してしまい、見通しの悪いコードが生まれやすくなります。
例えば、ブログ投稿エディタみたいものを考えてみましょう。この例では、FluxライブラリとしてReduxを想定します。
まず、タイトルと本文を編集するフォームを持つ、以下のようなPostEditorコンポーネントがあるとします。
<PostEditor
post={this.props.post}
onChangeTitle={this.props.onChangeTitle}
onChangeContent={this.props.onChangeContent}
/>
ここに、投稿をタグ付けできる機能が開発されるとしたら、PostEditorコンポーネントには次のようにCallbackが追加されるでしょう。
<PostEditor
post={this.props.post}
onChangeTitle={this.props.onChangeTitle}
onChangeContent={this.props.onChangeContent}
onAddTag={this.props.onAddTag}
onRemoveTag={this.props.onRemoveTag}
/>
それに伴って、ActionCreatorも増えることになります。どのようにActionCreatorを実装するかは自由ですが、例えば、こんな風に2つのActionCreatorを実装したとします。
addTag(tag) {
return {
type: ADD_TAG,
tag: tag
}
}
removeTag(tag) {
return {
type: REMOVE_TAG,
tag: tag
}
}
ここで増えた2つのアクション`ADD_TAG`と`REMOVE_TAG`に対応するために、Store(Reducer)にも変更が必要になります。`state.currentPost`にタグを追加・削除するコードを書きます。
if (action.type === ADD_TAG) {
let newTags = state.currentPost.tags.concat([tag])
newPost = Object.assign({}, state.post, { tags: newTags }))
return Object.assign({}, state, { currentPost: newPost })
} else if (action.type === REMOVE_TAG) {
let newTags = state.currentPost.tags.filter(t => t !== tag)
newPost = Object.assign({}, state.post, { tags: newTags })
return Object.assign({}, state, { currentPost: newPost })
}
これでやっと、PostEditorがタグを扱うことができるようになりました。このように、機能を少し追加したい場合にも、Callback, Action, Reducerに渡って変更が行われることがあります。PostEditorコンポーネントが深い階層にあった場合、親のコンポーネントもCallbackを受け渡す必要があるので、さらに変更箇所は多くなるでしょう。
この例はちょっと大げさかもしれないですが、このような肥大化は実際に開発している中で、普通に起こります。これがReact+Reduxの普通なのだと思えてきたりもします。
Storeでエンティティの操作はしない
いくつかルールを決めることで、もう少しコードを減らすことが可能です。よくあるルールとして、「Store(Reducer)はエンティティの操作はしない」というものがあります。エンティティとは、ひとまとまりの独立したデータ(オブジェクト)のことで、この場合`post`を指します。この辺りは、pixivさんの記事が参考になると思います。
このルールを適用すると、Storeにあった`post.tags`を操作するコードが、ActionCreatorに移動します。
addTag(post, tag) {
let newTags = post.tags.concat([tag])
return {
type: UPDATE_POST,
post: Object.assign({}, post, { tags: newTags }))
}
}
removeTag(post, tag) {
let newTags = post.tags.filter(t => t !== tag)
return {
type: UPDATE_POST,
post: Object.assign({}, post, { tags: newTags })
}
}
Storeでは、`UPDATE_POST`イベントを受けて、`state.currentPost`を変更するコードを書くだけになるので、Storeが大きくなることは防げました。しかし、ActionCreatorとCallbackが増えることは避けられていないです。
Fluxはスケールする?
「複雑なアプリケーションでは、FluxがMVCよりスケールする」と言われることがありますが、果たして、このように少し機能を追加するだけで、多くの箇所でコードの変更が必要になることが、スケールに強いと言えるでしょうか?
Fluxは、データの流れを一方通行にすることで状態管理やViewの更新を非常に楽にしたと思いますが、Componentが入れ子になり、CallbackやActionが増え、Reducerが肥大化するにつれて、そのデータが流れるルートは加速度的に長く・多くなり、その結果、保守性が低いコードになっていく危険性があると思っています。
Immutable.jsによるモデルの導入
先ほどの例を、ActionCreatorとCallbackを増やさないようにする方法として、postエンティティに関する操作をPostEditorコンポーネントの中に閉じ込めてしまうことを考えます。PostEditorコンポーネントを使う側では、Callbackが一つになり、かなりシンプルになりました。
<PostEditor
post={this.props.post}
onChange={this.props.onChange}
/>
PostEditorコンポーネントの中身はこんな感じになるでしょう。
addTag(tag) {
let newTags = this.props.post.tags.concat([tag])
let newPost = Object.assign({}, this.props.post, { tags: newTags }))
this.props.onChange(newPost)
}
removeTag(tag) {
let newTags = this.props.post.tags.filter(t => t !== tag)
let newPost = Object.assign({}, this.props.post, { tags: newTags }))
this.props.onChange(newPost)
}
これで、ActionCreatorとCallbackが増えることは防げています。しかし、Componentの中にデータ変更のロジックが入ることは、ビジネスロジックが分散し、他の場所から再利用することも難しいため、あまりいいコードとは言えません。
ビジネスロジックはどこに?
一般的なMVCアーキテクチャでは、ビジネスロジックはModelが担当します。では、React(+Flux)アプリケーションではどこが担当すべきでしょうか。
FluxならActionCreatorやContainerコンポーネントにあるべきだという意見もありますが、バリデーションやちょっとしたヘルパーメソッド(firstNameとlastNameからfullNameを取るメソッド)などは、ここには置きにくいです。
そこで、FluxにもModelが存在すれば、そこにビジネスロジックを集めることができると仮説を立てました。
そこでどうやってモデルを作るのかという点で、Reactと同じくFacebookが開発しいてるライブラリImmutable.jsに白羽の矢が立ちました。
Immutable.jsとは
Immutable.jsとは、Facebookが開発している、不変データ構造を扱うJavaScriptのライブラリです。
Immutableに聞き馴染みがない方のためにWikipediaで調べると、
オブジェクト指向プログラミングにおいて、イミュータブル(immutable)なオブジェクトとは、作成後にその状態を変えることのできないオブジェクトのことである。対義語はミュータブル(mutable)なオブジェクトで、作成後も状態を変えることができる。
という意味です。簡単な例を示しましょう。
import { Map, fromJS } from 'immutable'
let map1 = fromJS({a: 1, b: { c: 2 }})
let map2 = map1.set("a", 2) // { "a": 3, "b": { "c": 2 } }
let map3 = map1.setIn(["b", "c"], 4) // { "a": 1, "b": { "c": 4 } }
assert(map1 !== map2)
assert(map1 !== map3)
このように、`map1`のプロパティを変更しているだけに見えるけど、実は新しいオブジェクトを返していて、`map1`自体は変更されていない、という挙動が実現されています。
Immutable.jsを使うメリット
Reactの公式ドキュメントでも、Immutable.jsの活用法が書かれていて、特にReactのパフォーマンス向上に有効だと記述されています。
https://facebook.github.io/react/docs/advanced-performance.html#immutable-js-to-the-rescue
Immutability makes tracking changes cheap; a change will always result in a new object so we only need to check if the reference to the object has changed. For example, in this regular JavaScript code:
このあたりの詳しい情報は、この記事の最後に記載した、自分が過去に発表した資料の中にあるので、パッと目を通していただければと思います。
パフォーマンス以外に、記述が綺麗になることもメリットとして挙げられます。Reactでは、StateやPropsなどのオブジェクトは直接更新してはいけないため、`Object.assign`などを使用した冗長なコードが増えがちです。Immutable.jsを使えば、直感的に理解しやすいコードになります。
Object.assign({}, post, {title: "Title") // Obeject.assign
{...post, title: 'Title'} // ES2015
post.set('title', 'Title') // Immutable.js
モデルの実装
では、実際にImmutable.jsでモデルを作るとどうなるか見てみましょう。
Modelの定義
まず、モデルの定義には、`Immutable.Record`型を使います。これは単純なMapに、プロパティの定義やプロパティアクセッサがついて少し便利になったものです。
import { Record, List } from 'immutable'
const PostRecord = Record({title: '', content: '', tags: List() })
これで型定義ができ、これに独自のメソッドを定義するために、このRecordクラスを継承したクラスを作ります。
class Post extends PostRecord {
}
これで、独自のImmutableなモデルが出来上がります。
タグの追加
では、この`Post`クラスにタグの操作を追加しましょう。
class Post extends PostRecord {
addTag(tag) {
return this.set('tags', this.tags.push(tag))
}
removeTag(tag) {
return this.set('tags', this.tags.filter(t => t !== tag))
}
}
これで、`post.addTag('React')` などを実行しても、もとのpostオブジェクトには変更なく、タグが追加された新しいPostオブジェクトを作成されるようになります。
これで、PostEditorコンポーネントは以下のようにロジックがなくなりシンプルになります。
addTag(tag) {
this.props.onChange(this.props.post.addTag(tag))
}
removeTag(tag) {
this.props.onChange(this.props.post.removeTag(tag))
}
以上のステップで、投稿へのタグ付けという機能を、コンポーネントとモデルを一つづつ変更するだけで、実装することが出来ました。
実際のアプリケーションは、もっと複雑なコードになるかと思いますが、基本的な方針は同じです。エンティティの操作をモデルに用意することで、一つのエンティティに対してCallbackは一つで十分になり、無秩序なCallbackの増加を防ぐことができます。
知見まとめ
Wantedlyでは実際に、上記のような方法でImmutable.jsを使ったモデルを導入しています。その開発の中で気付いた、良かった点や注意点をまとめます。
保守性の向上
ビジネスロジックをImmutable.jsで作ったモデルに置くことで、ActionやCallbackの増加、Storeの肥大化を防ぐことができました。
また、バリデーションやヘルパーメソッドをモデルにまとめることで、見通しがよくなりました。テストを書く際も、役に立つはずです。
開発効率上がった
上と被るかもしれませんが、Action、Storeのコード変更が減ることで、変更箇所が小さくなり、開発速度が上がりました。どこに何を置くべきかという議論も減り、レビューにかかるコストも小さくなりました。
また、StateやPropsを変更するときの`Object.assign`などの冗長な記述が、`post.set`という簡潔な記述で統一されるようになったことも、開発速度の向上に大きな効果があったと思います。
注意点
Immutable.jsを導入するにあたって、どこがImmutableなモデルで、どこがpalin objectか分からなくなると混乱してしまうので、統一することが大事です。まずは、一つのStoreの配下をImmutable.jsにしてみることをオススメします。
あと、ドキュメントがちょっと読みにくいです。が、Immutableのインターフェース自体はES2015などの最新の仕様に従っているため、直感的で、学習コストはあまり高くないです。複数の型が存在しますが、`Map`と`List`くらいしか使わないです(RecordはMapとほぼ同じ) 。必要になったときに他の型の使い型を学べれば十分だと思います。
ファイルサイズに関しては、gzipして16KBで、React本体(43KB)と比較して、37% 増量されます。気になる場合もあるかもしれないですが、Angular2などの100KB超えているものと比較して、あまりサイズが問題になることはないんじゃないかと思います。
最後に
いかがでしたでしょうか?これからReact(+Redux)を導入しようとしている人や、すでに導入済みで同じような経験をしている人たちの参考になれれば嬉しいです。
資料
Meguro.es#3で発表した資料:
WantedlyがReactを選んだ理由:
その他:
http://inside.pixiv.net/entry/2015/12/19/113746
https://medium.com/azendoo-team/immutable-record-react-redux-99f389ed676#.tcl8r5mjv