はてなブックマーク検索を作りながらFlux Utilsについて学ぶ
facebook/flux 2.1.0からFlux UtilsというStoreなどの実装が含まれるようになりました。
今回Flux Utilsを使って、指定したアカウントのはてなブックマークを検索するウェブアプリを書いてみました。
- azu/hatebu-mydata-search
- azu.github.io/hatebu-mydata-search/
- mydataのAPIがCORS対応してないのでJSONProxyを挟んでます。(なのでブックマークデータが多いアカウント名は避けたほうが…)
これを作ってみてFlux Utilsについて思ったことを書いていきます。
Flux Utils
Flux Utilsの紹介ページに、Flux Utilsの解説が書かれています。
簡単にまとめると以下の4つのクラスがFlux Utilsとして提供されています。
- Store
- ベースとなるクラス
- ReduceStore
- Storeを継承
state
を保持していて、actionsに対してreduce
することでstate
更新する
- MapStore
- ReduceStoreを継承
- Immutable.jsに依存している
- Container
- mixinsの代わり
- React ComponentラップするComponentを作るコンテナ
- Storeを登録しておいて、Storeの変更を元にComponentに通知する
Storeはおそらく直接使わない、MapStore
はImmutable.jsに依存しているので、使うとしたらReduceStore
とContainer
がメインとなると思います。
azu/hatebu-mydata-searchでもReduceStore
とContainer
の2つを利用しました。
Flux UtilsはImmutable.jsを一部使っているのからも分かりますが(使わなくても問題ない)、Immutableなオブジェクトをstate
として使うのが前提となった作りになっています。
Immutable.jsはFlowやTypeScriptなどの型付き言語で使いやすくなってたり、Immutableな実装でのパフォーマンスにおけるメリットはありますが、癖があって普通に扱うのが難しいのです。
そのため、今回はImmutableなstateオブジェクトとして、もう少し扱いやすいchristianalfoni/immutable-storeを利用しました。
追記: immutable-storeはDEPRECATEDなので、omniscientjs/immstructやimmutable-jsを使いましょう
Flux Utilsを利用する場合、以下のようにflux/utils
とパスを指定して読み込むことで利用できます。
(Facebookはこういうのが多いですが、browserifyした時とかに使ってないものが勝手に含まれないから?)
import {ReduceStore} from 'flux/utils';
余談:ファイルサイズ
Flux Utilsを含む前後のfacebook/fluxのファイルサイズは以下のような感じです。
Before:
After:
facebook/fluxのdist
にはデフォルトでFlux Utilsが含まれていないため、Flux Utilsを明示的にimportしない限りファイルサイズは増えたりはしないと思います。
また、immutable.jsに直接依存してるのはMapStoreだけで、他のReduceStoreなどはimportしてもimmutable.js含まれないため、そこまでファイルサイズ気にしなくても良いと思います。
MapStoreを使う場合はImmutable.jsを使うことになるので、結構大きくなります。
import {MapStore} from 'flux/utils';
Flux + Reactなアプリ作成の流れ
ここからはazu/hatebu-mydata-searchがどういう順番で作成したかに沿った流れで書いていきます。
最初はビルド環境やindex.html
を置いてbuild.js
を読み込めるようにします。
babel+babelify+browserifyな感じでJavaScriptを書けるようにしてます。 CSSはcssnextとPureを使いました。
ステートレスコンポーネント
コミットをそこまでキレイに分けてないので上記のコミットに含まれてますが、まずはステートレスなReact Componentを作成していくようにしています。
(React Componentでthis.state
を使わないと考えれば書きやすいと思います)
具体的には以下のユーザ名の入力するinput要素をInputUserName.js
という名前で最初に作っています。
import React from "react"
export default class InputUserName extends React.Component {
onSubmit(event) {
event.preventDefault();
var name = React.findDOMNode(this.refs.userName).value;
this.props.onSubmit({
name
});
}
render() {
return <div className="InputUserName">
<form className="InputUserName-form pure-form" onSubmit={this.onSubmit.bind(this)}>
<input type="text" ref="userName"></input>
<input className="pure-button" type="submit" value="変更"></input>
</form>
</div>
}
}
この作ったComponentを確認する意味も含めて、App.js
というエントリポイントとなるファイルを作って、単純に読み込んでdocument.body
へ表示しています。
import React from "react"
import InputUserName from "./components/InputUserName"
function onSubmit({name}) {
console.log(name);
}
React.render(<InputUserName onSubmit={onSubmit}/>, document.body);
こうすることでsubmit
イベントがちゃんと動いてるのかをstateがなくても確認できたりするので、HTMLでまず構造を書いてみるのに近い作業で入り口とするのに向いています。
同じ要領でブックマークのデータを表示するList要素としてBookmarkList
を作って
import React from "react"
export class BookmarkListItem extends React.Component {
render() {
var {title, url, comment} = this.props.bookmark;
return <li className="BookmarkListItem">
<a href={url}>{title}</a>
<p>{comment}</p>
</li>
}
}
export default class BookmarkList extends React.Component {
render() {
var items = this.props.bookmarks.map(bookmark => {
return <BookmarkListItem key={bookmark.url} bookmark={bookmark}/>;
});
return <ul className="BookmarkList">
{items}
</ul>
}
}
App.js
にダミーの配列データ(bookmarks
)を置いて表示しています。
+import BookmarkList from "./components/BookmarkList"
function onSubmit({name}) {
console.log(name);
}
-React.render(<InputUserName onSubmit={onSubmit}/>, document.body);
+var bookmarks = [
+ {
+ title: "タイトル",
+ url: "http://localhost:3000/",
+ comment: "[test] メッセージ"
+ },
+ {
+ title: "NW.jsでのバイナリリリース",
+ url: "https://efcl.info/2014/09/05/node-webkit-binary-release/",
+ comment: "[test] ビルドが楽になりたい"
+ }
+];
+React.render(<div>
+ <InputUserName onSubmit={onSubmit}/>
+ <BookmarkList bookmarks={bookmarks}/>
+</div>, document.body);
後は同じようにブックマークをフィルターするキーワードを入れるSearchBox
を作ったりしています。
ここまでで、画面に必要な要素をステートレスコンポーネントとして用意してとりあえず表示できるようになりました。
もちろんまだCSSが存在してないので、デザインとか配置については何も設定されていないですが、上記のコンポーネントを見て分かるようにInputUserName
というコンポーネントには.InputUserName
というCSSクラスが適応されています。
これはSUIT CSSという命名ルールと殆ど同じで、MyComponentというコンポーネントには.MyComponentというクラス名をつけるという命名ルールです。
MyComponent以下にある要素のクラスなら、.MyComponent-part
という感じでReact Componentみたいなものとは相性がいいと思うのでよく使ってます。
Flux Utilsの導入
コンポーネントが揃ったらボタンを押したらデータを読み込むみたいな動きをつけていくので、ここでやっとFluxを導入します。
このコミットだとImmutable.jsを使ってますが、最終的にはimmutable-storeを使っています。(値を取得するときにget("key")
みたいな事をしなくて良いので自然に使える)
Fluxをどこから実装するかですが、Flux Utilsを使った場合はStoreかActionCreatorあたりからが自然になると思います。 (ContainerはStoreがないとそもそも始まらない)
ActionCreator
自分はActionにkeys
を置きたいので、まずはActionCreatorから書きました。
(一般的にはconstantsみたいな別のファイルにtype
だけを定義したりする)
ただ、Flux Utilsのサンプルを見てみると分かるようにそもそもActionCreatorがなくなっていて、FlowTypeで書かれたActionの型だけが定義されています。
- flux/examples/flux-utils-todomvc at master · facebook/flux
- flux/TodoActions.js at master · facebook/flux
自分の中ではそれぞれ以下のような認識で単語を使っていますが、正直あんまり深く考えてないです。
- Action: ペイロードオブジェクト(typeとデータを持ってる)
- ActionCreator: ActionをDispatcherに渡してdispatchする関数をまとめたヘルパークラス
Flux: Actions and the Dispatcher | React
SearchAction
というクラスは上記の定義だとActionCreator
に当たるものですが長いので。。
簡単に書くと以下のように特定のtype
を持ったActionオブジェクトをdispatchするだけのメソッドを持ったクラスです。
import {getMyData} from "./SearchUtils"
import Dispatcher from "../Dispatcher";
export var keys = {
reset: Symbol("reset"),
inputText: Symbol("inputText"),
loadItems: Symbol("loadItems")
};
export default class SearchAction {
static reset() {
Dispatcher.dispatch({
type: keys.reset
});
}
static inputText(text) {
Dispatcher.dispatch({
type: keys.inputText,
text
});
}
static loadItems(userName, fromDate) {
return getMyData(userName, fromDate).then(items => {
Dispatcher.dispatch({
type: keys.loadItems,
items
});
});
}
}
ここに出てくるDispatcher.jsはシングルトンなDispatcherで、Flux Utils的にもDispatcherはシングルトンを前提としたものとなっているようです。
Store
次にReduceStore
を使ってフィルターとなる単語と表示してるブックマークのstateを管理するStoreを作ります。
ReduceStoreはsetState
のようなメソッドは持っていません。
代わりreduce(state, action)
というメソッドを実装して、ここでreturnした値が次のstateとなるような仕組みになっています。
Actionのkeys
を使って、keys.loadItems
というtype
のActionが来たならば(ここでいうならSearchAction.loadItems()
が呼ばれたならば)、現在のstateのitemsをaction.items
に置き換えたものを新しいstateにするといった感じです。
import { ReduceStore } from 'flux/utils';
import SearchDispatcher from "./SearchDispatcher";
import { keys } from "./SearchAction"
import Immutable from "immutable-store"
class SearchStore extends ReduceStore {
getInitialState() {
return Immutable({
"text": "",
"items": []
});
}
reduce(state, action) {
switch (action.type) {
case keys.inputText:
return state.set("text", text);
case keys.loadItems:
return state.set("items", action.items);
default:
return state;
}
}
}
// ここもシングルトン
// Export a singleton instance of the store, could do this some other way if
// you want to avoid singletons.
// と書いてあるぐらいなので
const instance = new SearchStore(SearchDispatcher);
export default instance;
Container
Flux Utilsで書いてて結構気に入ってるのはContainerという仕組みです。
これはstatic getStores()
で並べたStoreから”Change”イベントがemitされたら、static calculateState(prevState)
でReact Componentのstateとなるものを返して、React ComponentにsetState(calculateState()))
されるというイメージです。
Containerで包むべきReact ComponentはRootとなるComponentとするべきです。 これはReactを書くときのパターンとしてよくある、stateを中央の一箇所に集めるパターン(Centralize State)で書いているとわかりやすく適応できます。 (子となるReact Component内でstateを持つこともありますが、そのstateは外から不要なstateであるべきです)
最初にステートレスコンポーネントで書いていたのも、App.js
からstate
を渡すことで、それぞれ子となるコンポーネントがstateを持たなくてもよくなるからです。
import InputUserName from "./components/InputUserName"
import BookmarkList from "./components/BookmarkList"
import SearchBox from "./components/SearchBox"
+import SearchStore from "./Search/SearchStore"
+import SearchAction from "./Search/SearchAction"
+import {Container} from 'flux/utils';
function onSubmit({name}) {
console.log(name);
}
function onChange(text) {
+ SearchAction.inputText(text);
}
var bookmarks = [
@@ -22,8 +26,27 @@ var bookmarks = [
comment: "[test] ビルドが楽になりたい"
}
];
-React.render(<div>
- <InputUserName onSubmit={onSubmit}/>
- <SearchBox onChange={onChange}/>
- <BookmarkList bookmarks={bookmarks}/>
-</div>, document.body);
+export default class App extends React.Component {
+ static getStores() { // 変更を監視したいStore一覧
+ return [SearchStore];
+ }
+ // returnされたものが`setState`される
+ static calculateState(prevState) {
+ return {
+ search: SearchStore.getState()
+ }
+ }
+
+ render() {
+ return <div>
+ <InputUserName onSubmit={onSubmit}/>
+ <SearchBox onChange={onChange}/>
+ <BookmarkList bookmarks={this.state.search.get("items")}/>
+ </div>
+ }
+}
+
+SearchAction.loadItems();
// Container.createで`<App />`をラップしてる
+const AppContainer = Container.create(App);
+React.render(<AppContainer />, document.body);
これにより、ContainerがStoreの変更を監視してViewに変更を通知できるようになります。
ここで問題になるのが、Storeの変更とは何かです。
Flux Utilsでは他のFlux実装のようにsetState
やeventEmitter.emit("change")
のような部分は隠蔽されています。
Flux UtilsのReduceStoreにおけるStoreの変更とはreduce(state, action)
の結果が現在とは異なるstateオブジェクトである時をいいます。
現在とは異なるstateオブジェクトになった ==> stateが変更された ==> Storeの変更イベントが発火
ということになっています。
異なるstateオブジェクトかどうかという判定はareEqual(one: TState, two: TState): booleanというメソッドで行われていて、以下のような判定となっています。
areEqual(one: TState, two: TState): boolean {
return one === two;
}
StateがImmutable?
ここで最初の方にも書いてたFlux UtilsがなぜImmutable前提と言えるのかという話をしておきます。
先ほどのareEqual
は===
でstateを比較しているので、以下のようなstateオブジェクトのプロパティを変更した場合でも、同じstateとみなされます。
var state = { key : "value" };
state.key = "!!!";
var newState = state;
areEqual(state, newState);// true
そのため、Storeのstateを変更したという状態にするにはreduce
で現在のstateオブジェクトをcloneしてから変更するなどが必要になります。
そのため、Flux UtilsではImmutable.jsやimmutable-storeといったものを使ってstateを扱わないとかなり面倒になると思います。
immutable-storeだと以下のように===
で異なるオブジェクトが作れます。
var state = Store{
foo: 'bar'
};
var newState = state.set('foo', 'bar2');
areEqual(state, newState);// false
このstateはImmutableであるというのは、stateが汚れたりしないのでいいところもありますが、逆にsetState
のような無理やりstateを変えたいという時に結構面倒な事があります。
ローカルストレージからの復元
azu/hatebu-mydata-searchでも一つ遭遇して、このアプリでは取得済みのブックマークやユーザー名などはIndexedDBに保存しています。
画面を表示した後、非同期で取得したデータをstateを設定し直すのにsetState
みたいな単純な方法がないので以下のような作りにしました。
restoreType
というtypeを追加して、ストレージから取得したオブジェクトをstateにするというものをreduce
に追加しています。
要はActionCreatorからrestoreType
というActionとストレージにあるstateをdispatchするという、他のActionと全く同じ流れを踏むようになっています。
import {ReduceStore} from 'flux/utils';
import Dispatcher from "../Dispatcher";
import { keys } from "./HatebuAction"
import Immutable from "immutable-store"
import {getStorage, setStorage} from "../LocalStorageContainer"
const restoreType = Symbol("restore");
class HatebuStore extends ReduceStore {
constructor(...args) {
super(...args);
getStorage(this.constructor.name).then(state => {
Dispatcher.dispatch({
type: restoreType,
state
});
});
this.addListener(() => {
var storeName = this.constructor.name;
var state = this.getState();
setStorage(storeName, state).catch(error => {
console.warn(error, " on " + storeName);
});
});
}
getInitialState() {
return Immutable({
"userName": ""
});
}
reduce(state, action) {
switch (action.type) {
case keys.inputUser:
return state.set("userName", action.userName);
case restoreType:
return state.import(action.state);
default:
return state;
}
}
}
最初はReduceStoreをハックしてどうにかしようとしていますが結構難しい事がわかったので、restoreType
というActionでやる形にしています。
やってみるとFlux Utilsはこういった感じで結構ルール外なことはやりにくいようになっている気がするので、ギブス的にもなってるような気がします。
ストレージから復元もActionでやったほうが例外的なルールもなくなるので真っ当な設計だと思います。
まとめ
flux-utilsについてでも書いていましたが、特にMapStore
などはFlowやTypeScriptといった型付き言語だと使いやすいような形となっています。
そういったものと一緒にFlux Utilsを使うといろんな人がいても書き方がかなり統一されるような感じがします。
またFlux Utilsのページの最初にも書いてあるように、別にFluxアーキテクチャをやる際にFlux Utilsを絶対使うべきというものでもないと思います。
Flux Utils is a set of basic utility classes to help get you started with Flux. These base classes are a solid foundation for a simple Flux application, but they are not a feature-complete framework that will handle all use cases. There are many other great Flux frameworks out there if these utilities do not fulfill your needs.
Flowで書きやすいようにするというモチベーションがありそうですが、immutable state、pure functionといったあたりはrackt/reduxなどにも近いような話も出てきますが、その前に一度Flux Utilsを触ってみると面白いかもしれません。
以前のDispatcherしかなかったflux
モジュールに比べて、コード量も少なくなり、Storeなどの形も一定になって見通しが良くなったり、Containerが結構いい感じなので普通に使えるといった印象です。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。