React の Component 間通信について色々実験してみる
React さんのこといまいちよく理解していなかったので、コンポーネントのやり取りを題材に色々試してみました。 コード例は載っていますが思考実験的なのでかなり雑な点ご了承ください。
対象課題
たとえばモーダル表示切り替えを考える。 DOM構造は下記のようなイメージ。
main main-content modal-open-button modal modal-content modal-close-button
modal は全体に覆いかぶさる要素なので、CSS的に main 要素と同列の構造で扱いたい。 (ルートスタックコンテキストを適用したい)
modal-open-button により modal が開き、 modal-close-button により modal が閉じる。 main コンポーネント(もしくは modal-open-button コンポーネント)と modal コンポーネントの間での相互作用が必要になるが、これらは親子関係にないので props の受け渡しでは単純に表現できない。
SPA (Single Page Application) ではなく、サーバサイドのテンプレートエンジンで render した結果に対してJS制御を付与する。 つまりサーバーサイドのテンプレートエンジンの実行と局所的な React コンポーネントが共存する環境を想定する。
親コンポーネントを作成
大本の親(コンテナ)コンポーネントを作り、その子コンポーネントとして含めるアイデア。
container main modal-open-button modal modal-close-button
container に modal の状態を持たせ、 main や modal コンポーネント内でその状態を変えるコールバックを実行し伝播する形になる。
おそらく最もポピュラーな方法だが、コンポーネントツリーが深くなってくると問題になる。 何をするにしてもルートコンポーネントを辿って反映しないといけない。
また今回の例では他の問題もある。
これらは、SPAのような全体をReactコンポーネントを組み合わせたアプリケーションなら問題なさそう。
Flux パターン
有名なやつ。 実装としては Flux や Redux などがある。 公式ドキュメントやサンプルが異様にしっかりしているのと、情報もたくさんあるのでここでは省略。
Flux と Redux 両方試してみた感触としては、Redux を react-redux なしで使い始めるのが最初はわかりやすさの面で良いかも?と思った。 (react-redux はパフォーマンス最適化されてるので必要性が増えてきたら積極的に導入すると良さそう)
Observer パターン
Flux パターンに乗っておけばとりあえずは大丈夫そうだけど、シンプルな用途には大げさ感がある。 そういうときは最小限のエッセンスだけを真似してみても良い。
Flux は Observer パターンと、 PubSub パターンのようなメッセージパッシングを組み合わせて、単方向データフローとなるように整えた感じのやつと考えられそう。 状態を保持する Store とコンポーネントの View を分け、その間を Observer パターンで通信することだけを真似してみる。
下記がその実装例。 各 Component が Observer で、 Store が Observable (Subject) にあたる。
import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; // Observable class ModalStore { constructor() { this.state = { active: false }; this.observers = []; } activate() { this.state.active = true; this.notify(this.state); } deactivate() { this.state.active = false; this.notify(this.state); } registerObserver(observer) { this.observers.push(observer); } notify(state) { // TODO: register でコールバックを登録するように実装すると依存が減らせる this.observers.forEach(l => { l.setState({ active: state.active }) }) } } class Modal extends React.Component { constructor(props) { super(props); this.handleClose = this.handleClose.bind(this); this.state = { active: props.active } } componentDidMount() { this.props.store.registerObserver(this); } render() { const modalClass = classNames( 'modal', { 'modal_active': this.state.active, } ); return ( <div> <div className={ modalClass }> <div className='modal__text'> <div>active: { `${this.state.active}` }</div> </div> <div className='modal__close' onClick={this.handleClose}> [x] close </div> </div> </div> ) } handleClose(e) { this.props.store.deactivate(); } static get defaultProps() { return { active: false } } } const ModalActivator = (props) => { const handleOpen = (e) => { props.store.activate(); } return ( <div className='modalFull__open' onClick={handleOpen}> [x] open </div> ) }; document.addEventListener('DOMContentLoaded', () => { const modalStore = new ModalStore(); ReactDOM.render( <Modal store={modalStore} />, document.body.appendChild(document.createElement('div')) ); ReactDOM.render( <ModalActivator store={modalStore} />, document.getElementById('main') ); });
EventEmitter を使った Observer パターン
Observer パターンを自前で用意するよりは EventEmitter みたいなのを使っても良い。
https://www.npmjs.com/package/wolfy87-eventemitter
import EventEmitter from 'wolfy87-eventemitter'; class ModalStore extends EventEmitter { constructor() { super(); this.state = { active: false }; } activate() { this.state.active = true; this.emitChange(); } deactivate() { this.state.active = false; this.emitChange(); } registerObserver(observer) { this.on('change', () => { observer.setState({ active: this.state.active }); }); } emitChange() { this.emit('change'); } }
表現力が上がったほか、手段が統制された。
なお、 registerObserver(observer)
のかわりに addChangeListener(callback)
のようにすると、呼び出し側への依存を減らせる。
Mediator パターン
これまでの例には Flux と違ってメッセージ配送を制御する Action や Dispatcher がないので、 Store - Component 間が複雑(多対多とか)になるとコードの治安が悪くなるかもしれない。 そのときは Mediator パターンを使ってもよさそう。というわけで実験。
class ModalStore { constructor() { this.state = { active: false }; } activate() { this.state.active = true; } deactivate() { this.state.active = false; } } class ModalMediator { constructor(store) { this.stores = [store]; // 雑に多対多を想定してみる this.listeners = []; } activate() { this.stores.forEach(s => { s.activate(); }); this.notify({ active: true }); } deactivate() { this.stores.forEach(s => { s.deactivate(); }); this.notify({ active: false }); } register(listener) { this.listeners.push(listener); } notify(state) { this.listeners.forEach(l => { l.setState({ active: state.active }) }) } } class Modal extends React.Component { componentDidMount() { this.props.mediator.register(this); } handleClose(e) { this.props.mediator.deactivate(); } // ... } const ModalActivator = (props) => { const handleOpen = (e) => { props.mediator.activate(); } // ... }; document.addEventListener('DOMContentLoaded', () => { const modalStore = new ModalStore(); const modalMediator = new ModalMediator(modalStore); ReactDOM.render( <Modal mediator={modalMediator} />, document.body.appendChild(document.createElement('div')) ); ReactDOM.render( <ModalActivator mediator={modalMediator} />, document.getElementById('main') ); });
うーん、この例ではあまり必要性が実感できない…。 非同期処理とか複数 Store の順序依存とかが出てくるとメリットがわかりやすそう。 そうなる前に Flux, Redux に移れという話になるのかな…。
感想
上記はいづれも粗い実装ではありますが、最低限PDSは守られているので Flux に乗せるのも簡単のはずです。 というか複雑性に対処しようとしていろいろやっていくと Flux に近づいていく気がします。
では最初から Flux でいいのかというとそうでもなくて、状況(複雑性、リソース、スケジュール、コードの寿命)に合わせて引き出しをいくつか持っておくと良さそうです。
そしていろいろ試した結果、こういう一部だけコンポーネントを使うようなケースには、 React じゃなくて Vue 等のほうが向いているな、と思ったのでした…。 次は Vuex を触ってみよう。