普段React+Reduxを用いてwebアプリを作っていて、Front-End Developer Handbook 2017でMobXってワードが出ていて気になっていたのですが、日本語の記事が少なかったので調べてサンプル作ってみました。序盤はパッケージ選定とかの話しているので飛ばしてもらってもかまわないです。(雑な認識なので詳しい方いたら教えてください。。。)
Front-End Developer Handbook 2017のこのへん
When you have Redux mastered, take a look MobX or consider creating your own small custom Redux like implementation from scratch.
意訳
Reduxをマスターしたら、MobXを見てみたり、独自のカスタムReduxをスクラッチで作ってみてください。
ルーティングとかは一旦入れず単純にReactからstateをMobXに管理してもらうだけのアプリを作成します。具体的にはよくあるカウンターアプリを実装します。(見栄えとかはクソ悪いです)
成果物
demo
コード
環境構築
react-create-appなどを使って雛形を生成してもいいのですが、中身を理解しづらくなるので自前で一つづつpackageを入れて構築していきます。
package選定
devDependencies周り
トランスパイルツールはwebpack+bableを使うことにします。
理由は単純ですが、Ruby on Rails等でも取り込まれてので、今後使用頻度が高くなると感じているためです。合わせてbableを使うことでトランスパイルします。
linterはeslintを使うことにしてます。
理由はかなりしっかりとlintしてくれる、自動fix機能があるためです。
babelについて
周辺ツールの利用用途を雑な認識でまとめておきます。
プリセットは下記を利用します。
- babel-preset-env
- babel-preset-react
bable-preset-envはサポートするブラウザを指定してそれに基づいて必要なプラグインとpolyfillを勝手に入れてくれるプリセットです。
もう一つのbabel-preset-reactはReact周りのプラグインのオール・イン版です。
jsx記法が使えるようにしたりするために入れています。
プラグインは下記を利用します。
- babel-plugin-transform-class-properties
- babel-plugin-transform-decorators-legacy
- babel-plugin-transform-runtime
babel-plugin-transform-class-propertiesはClassPropertiesを使用したいので導入しています。あとで記載するのですが、MobXのinjectを使うとPropTypesが暴れます。wrappedComponent.proptypesとかを利用すると解決するとREADMEに記載されているのでそちらを使うこともできます。
babel-plugin-transform-decorators-legacyは@を使いたいので導入しています。legacyとついていて気持ち悪いのですがbabel-plugin-transform-decoratorsではうまく変換できなかったためlegacyを使用しています。今後もしかしたら変更できるかもしれないです。
babel-plugin-transform-runtimeは古いブラウザ対応するためのpolyfillをいい感じに入れてくれます。envとかぶってる気もするのですが regeneratorRuntime
のエラーが出てトランスパイルできないので入れています。こうした方がいいとかこうなるからとかあれば教えて貰いたいです。
上で書いたことを全部対応した設定ファイルが下記の .babelrc
です。
3つ前のバージョンのブラウザかつSafari10以上をサポートするように設定しています。
{
"presets": [
[
"env",
{
"targets": {
"node": "current",
"browsers": [
"last 3 versions",
"safari >= 10"
],
"modules": false,
"loose": true
}
}
],
"react"
],
"plugins": [
"transform-decorators-legacy",
"transform-class-properties",
"transform-runtime"
]
}
webpackについて
周辺ツールの利用用途を雑な認識でまとめておきます。
- webpack-dev-server
- html-webpack-plugin
- babel-loader
webpack-dev-serverは言わずですが動作確認をするためにwebサーバーを立ててくれます。基本webpackのconfigと同じものが使えるのですごく便利です。
html-webpack-pluginはテンプレートで用意しておいたhtmlを利用できます。トランスパイルしたjsファイルを動的にhtmlに差し込んだりできます。
babel-loaderはbabelと組み合わせて使うために導入しています。
上で書いたことを全部対応した設定ファイルが下記の webpack.config.babel.js
です。
webpack.config.babel.js
という名前にすると設定を読み込むときにbabelを通して変換して読み込んでくれるのでimport等が使えます。
import path from 'path';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const NODE_ENV = process.env.NODE_ENV || 'development';
const outputPath = path.join(__dirname, 'dist');
export default {
devtool: false,
entry: {
bundle: './src/index.jsx',
},
output: {
path: outputPath,
publicPath: '/',
filename: '[name].js',
},
module: {
rules: [
{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' },
],
},
plugins: [
new HtmlWebpackPlugin({
hash: true,
filename: 'index.html',
template: './src/index.template.ejs',
inject: 'body',
}),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(`${NODE_ENV}`),
},
}),
],
resolve: {
extensions: ['.js', '.jsx'],
},
};
eslintについて
周辺ツールの利用用途を雑な認識でまとめておきます。
- babel-eslint
- eslint-config-airbnb
babel-eslintはeslintのパーサーにbableを使用してくれます。
eslint-config-airbnbはeslintの設定をairbnbが決めた仕様で設定してくれます。
上で書いたことを全部対応した設定ファイルが下記の .eslintrc.json
です。
{
"extends": "airbnb",
"plugins": [
"react",
"jsx-a11y",
"import"
],
"parser": "babel-eslint",
"env": {
"browser": true
},
"rules": {
"react/forbid-prop-types": 0
}
}
dependencies周り
ReactとMobXを使うことが決まっているので、その周辺ツールを選定しています。公式のリファレンスみてたら mobx-react
を使いましょうってなっていたので使用します。
Reactについて
- react-dom
- prop-types
react-domはview周り、prop-typesはpropのtypeの型定義するために導入しています。
MobXについて
- mobx-react
- mobx-react-devtools
mobx-react
はstoreをbindするため、mobx-react-devtools
はデバッグ用で導入しています。
フローを眺めておく
先に大枠の流れだけ見ておきます。
雑な認識だとActionにイベントが来てStateを書き換えComputedでStateをもとにした加工を行いViewに反映、Reactionと呼ばれる機能で監視していたStateに変更があった場合にActionにイベントを送るって流れだと思います。
MobXでstoreを作る
カウントアプリを作るので CountStore
クラスを作ります。
先程のフローに当てはめるとStateとComputed、Action周りの実装がここでできます。
storeを定義するときには下記の3つを利用します。
- observable
- computed
- action
最終的に下記のようなstoreになりました。
import { observable, computed, action } from 'mobx';
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
export default class CountStore {
@observable num = 0;
@computed get getDoubleCount() {
return this.num * 2;
}
@action.bound onIncrement() {
this.num = this.num + 1;
}
@action.bound onDecrement() {
this.num = this.num - 1;
}
@action.bound async onAsyncIecrement() {
await sleep(1000);
this.onIncrement();
}
}
observaleについて
observaleとは非同期のデータを扱うオブジェクトみたいです。
CountStoreクラス内でnumは外部から変更を監視したりすることができます。
コンポーネントにstoreをinjectすることで参照できます。(できるけどフロー図を参考にする限りcomputedを用意して上げるべきな気もする)
computedについて
computedとはobservaleとかでstoreに登録されてるデータを加工してアクセスしたい場合に使います。
コンポーネントにstoreをinjectすることで参照できます。
Reduxを使ってる場合はreselectとかを使って実現するような機能だと思います。
actionについて
action.boundを使うことでFunctionを渡すことができます。
コンポーネントにstoreをinjectして実行することができます。
ReduxのActionと似ていますがtypeを持つオブジェクトをディスパッチに送る必要がなく便利です。
サイドエフェクトをどう書くのか考えていたのですが、普通にasync, awaitで良さそうです。1000ms待ってからonIncrementを実行できるようにしています。
ComponentにMobXからStateをbindする
よくあるCountコンポーネントを実装してMobXからStateをbindします。
先に完成形を眺めながらのほうがいいと思うので下記にコンポーネントのコード書きます。
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from 'mobx-react';
import DevTools from 'mobx-react-devtools';
@inject('count')
@observer
export default class Counter extends Component {
static propTypes = {
count: PropTypes.object.isRequired,
};
render() {
const { count } = this.props;
return (
<div>
Count: {count.num} <br />
Double count: {count.getDoubleCount} <br />
<button onClick={count.onIncrement}>+1</button>
<button onClick={count.onDecrement}>-1</button>
<button onClick={count.onAsyncIecrement}>After 1000ms +1</button>
<DevTools />
</div>
);
}
}
ここではMobXの話を主にしたいので、inject
と observer
について書きます。
DevToolsは単にコンソールとかでどんなactionが実行されたかなどを見ることができるだけなのでこんなものもあるんだなってぐらいで見てください。なくても全然問題ないです。
observer
とはReact ComponentをリアクティブComponentに変換してくれます。使う側はMobXのstoreをいい感じに使えるようにしてくれるって覚えておけば基本問題ないと思います。
ingect
はcontextからpropにstateを渡してくれます。実際にはComponentをラップしているようで Counter.propTypes = { count: PropTypes.object.isRequired };
とか書くと Failed prop type: The prop ~
的な感じでエラーが出ます。こことかを見る限り wrappedComponent
を使うことで解決するようです。今回はラップされるクラスに直接プロパティとして宣言するようにしました。
作ったComponentとStoreをつなげて動作するようにする
Providerを利用してstoreを渡して子要素にCounterコンポーネントを入れて動作できるようにします。
全体は下記です。
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'mobx-react';
import Counter from './components/Counter';
import CountStore from './stores/CountStore';
const stores = {
count: new CountStore(),
};
render(
<Provider {...stores}>
<Counter />
</Provider>,
document.getElementById('root'),
);
まとめ
Reduxの冗長な書き方がスッキリしていて良さを感じた。ただ記事には書いてないがコードを読んだ感じはReduxの方がすごくシンプルで何が起きてるのか理解しやすい。僕がリアクティブと言うものを理解していない感が強いが使うだけなら良さそう。冗長な記載はその分何が起きるのか理解し易いと思うのです。デコレータを使ってラップしたり魔法がいたる所に散りばめられていてどう動作するのか混乱することが多々あると思いますがしばらく使ってみようと思います。