Vue.jsにreduxを載せた話

この記事はVue.js Advent Calendar 2015の第三日目の記事です。

概要

先日とある案件でVue.jsを用いたアプリケーションを開発することとなりました。
一般に同種のフレームワークと比較してVue.jsは、学習コストが低く気軽にはじめやすいということがメリットとして語られています。
ただ、今回の案件のようにMVC系フレームワークを用いて開発する際にネックとなっていたのが、コンポーネント間のデータ共有についての最適解が見つけられていないと感じていたことでした。
もちろん、適切にモデルを設計すればコンポーネント間のやり取りに責任を持つ、親コンポーネントとでもいうべき層を実装することでこの問題は解決できると思います。
しかし実案件では往々にして親コンポーネント層の実装が個々人の裁量に陥りやすく、結果的にコンポーネント間データ共有の方式が統一出来ていないケースも出てきてしまいました。
コンポーネント間で共有したいデータ=アプリケーションの本質となるデータは、もっとはっきりとコンポーネントと区別して設計した方が、結果的にはシンプルな構造を保ち続け易いのではないか?と考えたのです。

React.jsでは、Fluxを踏襲した設計にすることでモデルは必然的にコンポーネントから分離され、別のレイヤーとして実装されます。
同じことをVue.jsにも適用できれば、モデルから分離されたコンポーネントによる、シンプルな構造を保ち続けやすいアプリケーションに出来そうです。

Vue.jsの公式ドキュメントでも大規模アプリケーションを構築する際のプラクティスとして、Fluxによる設計に行き着き得ることが書かれています。

If we enforce a convention where components are never allowed to directly mutate state that belongs to a store, but should instead dispatch events that notify the store to perform actions, we’ve essentially arrived at the Flux architecture.

コンポーネントが(コンポーネント間で共有したいような)storeに属する状態を直接変更せず、その代わりstoreへ発生したactionを通知するだけに留めようとするなら、Fluxアーキテクチャの概念に辿り着いたと言えます、みたいに解釈しました。

The Flux architecture is commonly used in React applications. Turns out the core idea behind Flux can be quite simply achieved in Vue.js, thanks to the unobtrusive reactivity system.

一般にFluxアーキテクチャはReactによるアプリケーションで使用されています(が、)控えめなリアクティビティ機構のおかげで、とても簡単にFluxの中心的なアイディアをVue.jsに適用できることがわかります、みたいに解釈しました。

Building Large-Scale Apps/State Management
邦訳版

公式ドキュメントにあるように、Vue.jsFlux系ライブラリは、互いに対して疎結合な設計を保っているので、非常に簡単に適用することが出来ました。
本稿ではFluxのより洗練された形であるReduxライブラリを採用し、Vue.jsコンポーネントとの連携について書きたいと思います。

実装

およそ以下のような手順で実装しました。

  1. React.jsのアプリケーションと同様にconstant,actionCreator,Reducerを作成します
  2. エントリーポイントとなるRoot VueModeldatastoreを作成します
  3. 1の子コンポーネントにpropsとしてstoreを渡します
  4. 子コンポーネントは必要に応じて、自身のライフサイクルの適したタイミングでstoreを購読します
  5. 子コンポーネントは(主にユーザーの)入力に応じて、適宜actionを発行します

以下に簡単なコード例を載せつつ実装手順を説明します。

1.React.jsのアプリケーションと同様にconstant,actionCreator,Reducerを作成します

// constant.js
const CHANGE_STATE = 'CHANGE_STATE';

// actionCreator.js
export function changeMyState(nextState) {
  return {
    type: CHANGE_STATE,
    nextState: nextState,
  };
}

// reducer.js
const initialState = {
  currentState: '最初の状態',
};

function myState(state = initialState, action) {
  switch (action.type) {

  case CHANGE_STATE:
    return {
      currentState: state.nextState,
    };

  default:
    return state;
  }
}

const reducers = combineReducers({
  myState: myState,
});

export default reducers;

2.エントリーポイントとなるRoot VueModeldatastoreを作成します

import { createStore } from 'redux';
import reducers from './reducers';

new Vue({
  el: '#root',
  data: {
    stores: createStore(reducers),
  },
  components: {
    'my-component': myComponent,
  },
});

3.1の子コンポーネントにpropsとしてstoreを渡します

<!--index.html-->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>title</title>
</head>
<body>
  <div id="root">
    <my-component :store="store" ></my-component>
  </div>
  <script src="./bundle.js"></script>
</body>
</html>

React.jsのように、細かくコンポーネントを作りこんでいくような設計だと、子々孫々までstoreを適用するのが非常に面倒になるのでreact-reduxの提供するconnectのような仕組みが必要になってきますが、Vue.jsでは機能ごとくらいの粒度でコンポーネントを作成するのでそのような問題が発生しませんでした。
そのため親から子へ直接storeを手渡ししていますが、もっとコンポーネントの階層が深くなるようであれば、react-redux/connectに類する機構が必要になってくるかも知れません。

4.子コンポーネントは、必要に応じて自身のライフサイクルの適したタイミングでstoreを購読します

// my-component.js
import Vue from 'vue';
import template from './template';

export default Vue.extend({
  data() {
    return {
      currentState: this.store.getState().myState.currentState,
    };
  },
  props: ['store'],
  created() {
    this.store.subscribe(()=> {
      this.currentState = this.store.getState().myState.currentState;
    });
  },
  template: `
  <div>
    {{currentState}}
    <button @click="onClickAndDispatch">状態を更新する</button>
  </div>
  `,
});

この例ではcreatedのタイミングでstoreの購読を開始しています。
サーバーサイドでレンダリングしたHTMLにVue.jsアプリケーションを適用している場合に、jQueryなんかのDOMを直接触るような必要が
あれば、DOM生成後となるreadyのタイミングに購読を開始してもいいかも知れません。

5.子コンポーネントは(主にユーザーの)入力に応じて、適宜actionを発行します。

export default Vue.extend({
  ... // my-component.jsの続き
  methods: {
    onClickAndDispatch() {
      const nextState = '新しい状態';
      this.store.dispatch(changeMyState(nextState));
    },
  }
});

上述の通り、storeの伝播方法と購読のタイミング以外はほとんどReact.jsの時と同じように使う事ができます。
Reduxのミドルウェアも特別に工夫することなく使えますので、必要に応じて拡張できて便利です。

使ってみての雑感

良い点

モデルが半強制的にコンポーネントから分離される

概要に書いたようにモデルが半強制的にコンポーネントから追い出されるため、設計方針を共有しやすくなりました。
どちらかと言うと設計からズレた実装を作る方が面倒なので、設計方針を担保し易いところが良いと思います。
また、コンポーネントの責任を描画内容に集中させやすくなりました。

テストしやすい

actionCreatorsレイヤーが非同期処理を含めた最新の状態の取得にまつわる処理を単なる関数として実装しているおかげで、とてもテストがし易い設計になりました。
一部のコードはテストファーストで書くことが出来たので、もうライオンも怖くありません。

Reduxの良さをそのまま活かせる

ミドルウェアの仕組みがそのまま使えるので、開発効率の向上用ツールをReact.jsのプロジェクトから流用できました。

悪い点

Redux(Flux)自体の学習コスト

もともとVue.jsを採用した背景に、JavaScript専業のエンジニアとの協業のための学習コストの低さがありましたが、Fluxを導入したことで確実に学習コストが底上げされてしまいました。

Fluxを構成しているデザイン自体は特段珍しいものではないと思いますが、クライアントサイドアプリケーションがFluxにたどり着いた経緯や、メリットを感じるための背景を事前に簡単に説明しておいた方がスムーズに行くかも知れません。

積極的にVue.jsを使うメリットは

これReact+Reduxじゃダメなのか…? みたいな心の声が常に聞こえました。
ただし、Vue.jsは他のフレームワークとの差別ポイントに協業のし易さを挙げているので、JavaScript専業のエンジニアが何名も確保できるのであったり、デザイナーがJSX構文を問題なく使いこなすような環境でなければ、Vue.jsを採用するメリットはあると思います。

コード量が増える

アプリケーションの状態があるべき姿を1つずつ宣言していく手前、モデルをコンポーネントに記述する場合に比べてコード量は確実に増えます。
共通して使う宣言はクラスにまとめるなど、なるべく単純なタイプ数が増えすぎないように気を使う必要があるかも知れません。

まとめ

思いの外すんなり導入できて驚きました。
Reduxによる見通しの良い設計は気に入っているので、機会があれば他のフレームワークにも組み込んでみたいと思っています(Angularとか)

2015.12.5更新
このポストで触れたような実装を、ライブラリとして切り出しているレポジトリを見つけました。
(このポストと前後する時期に開発が始まったようです)

revue

やっていることはほとんど同じですが、こちらのライブラリを使ったほうが楽そうです。

Vue.jsやReact、Fluxを採用したモダンなフロントエンドアプリケーション開発をご検討の企業様は、是非MMMにご相談下さいませ!