Redux と仮想 DOM ライブラリの Snabbdom を組み合わせる



記事を書いた動機
--------------
 * jQuery や Backbone.js を使っており、仮想 DOM への対応を少しずつ進めたい。
 * これまで使ってきたツールとの比較をしたい。
 * 仮想 DOM を導入することでどんなことが期待できるのか、自分が納得できる説明をできるようになりたい。

Redux を使う理由
---------------

アプリケーションの状態管理のために Redux を使う理由は Event Dispatcher (Event Bus もしくは Event Emitter) に相当する機能が組み込まれていることです。

`subscribe` メソッドによって、Model の更新を監視して、View を自動的に更新することができます。View の自動更新機能は仮想 DOM ライブラリを使う上で必要不可欠です。もし自動更新機能がなければ、イベントごとに View 更新のメソッドを呼び出さなければなりません。仮想 DOM のおかげで、View を更新する際に Model の個別の値を監視しなくてすみます。

Redux および仮想 DOM を採用する共通の目的は Model と View の責務を明確にして分離することです。

Event Dispatcher と比較すると、イベントはアクションという名前に置き換わっています。アクションの実行を実行するには `dispatch` メソッドを使います。`dispatch` メソッドの引数はオブジェクトなので、アクション以外の情報を渡すこともできます。

アクションの規約として非公式の Flux Standard Action ([翻訳](http://qiita.com/yasuhiro-okada-aktsk/items/a14f7f37262fb6cf0bf8)) があります。`redux-actions` はアクションを生成するためのユーティリティメソッドを提供します。

Event Dispatcher をもとに Model を自分でつくる場合と比べて、配布のためのパッケージ管理をしたり、マニュアルをつくらなくてすみます。

比較の対象として Backbone.js の Model を挙げることができます。Redux の場合、View や Router のライブラリが含まれないので、さまざまなライブラリと組み合わせることができます。

ほかの違いとして、ユーザーの入力と Model の関係式を管理するためのしくみが Model に内包されていることが挙げられます。

ユーザーの入力と Model の関係をあらわすのに reducer と呼ばれる関数を使います。Model を生成するには、ファクトリメソッドである `createStore` で reducer を指定します。reducer のロジックが大きくなって複数の reducer に分割するために、複数の reducer を合成する `combineReducers` が用意されています。

非同期処理への対応には `redux-thunk` や `redux-saga` といったミドルウェアが開発されています。

ほかに Redux のアーキテクチャの参考になった Elm アーキテクチャを理想とする開発ができるようにする取り組みとして、`redux-loop`、`redux-effects`、`redux-elm` などが挙げられます。

React ではなく Snabbdom を使う理由
-------------------------------


React は仮想 DOM とコンポーネント API を統合したライブラリですが、アプリの状態管理を Redux に任せ、コンポーネントをステートレスに保ち、ストアの状態をそのまま反映させる方針をとる場合、React が提供するさまざまなコンポーネント API が不要になります。

仮想 DOM ライブラリに関して、処理速度の改善や API のありかたをめぐり、さまざまな開発者が取り組んでいます。Snabbdom を選ぶ理由は処理速度の速さと API のシンプルさです。くわしくは [Backbone.js で書かれたアプリに仮想 DOM ライブラリの Snabbdom を導入する](http://qiita.com/masakielastic/items/b4b18c9175fd66e969ba) の記事をご参照ください。


コードの例
----------

ES5 + `const` で書きました。ES2015 (ES6) で導入された `const` は主要なブラウザーでサポートされています。

### Hello World

```js
const { createStore } = require('redux');
const snabbdom = require('snabbdom');
const patch = snabbdom.init([
  require('snabbdom/modules/class'),
  require('snabbdom/modules/props'),
  require('snabbdom/modules/style'),
  require('snabbdom/modules/eventlisteners'),
]);
const h = require('snabbdom/h');
const { div, p } = require('hyperscript-helpers')(h);

function createNode(Model) {
  return div([
    p(Model.text)
  ]);
}

function render(oldVnode, Model) {
  var newVnode = createNode(Model);
  return patch(oldVnode, newVnode);
}

// http://stackoverflow.com/a/14947838/531320
const fromId = document.getElementById.bind(document);
var oldVnode = fromId('container');

var store = createStore(function(state, action) {
  return action.hasOwnProperty('type') ? action.text : '';
});

store.subscribe(function() {
  oldVnode = render(oldVnode, store.getState());
});

store.dispatch({
  type: 'CONTENT',
  text: 'Hello World'
});
```


### クリックカウンター


```js
const { createStore } = require('redux');
const snabbdom = require('snabbdom');
const h = require('snabbdom/h');
const { div, p, button } = require('hyperscript-helpers')(h);

const patch = snabbdom.init([
  require('snabbdom/modules/class'),
  require('snabbdom/modules/props'),
  require('snabbdom/modules/style'),
  require('snabbdom/modules/eventlisteners'),
]);

function createNode(count) {
  return div([
    p([String(count)]),
    button('#plus', 'プラス'),
    p([' ']),
    button('#minus', 'マイナス'),
  ]);
}

function render(oldVnode, count) {
  var newVnode = createNode(count);
  return patch(oldVnode, newVnode);
}

function counter(state = 0, action) {
  switch (action.type) {
  case 'INCREMENT':
    return state + 1;
  case 'DECREMENT':
    return state - 1;
  default:
    return state;
  }
}

const store = createStore(counter);

// http://stackoverflow.com/a/14947838/531320
const fromId = document.getElementById.bind(document);

var oldVnode = fromId('container');
var count = 0;

oldVnode = render(oldVnode, count);

store.subscribe(function() {
  oldVnode = render(oldVnode, count);
});

fromId('plus').addEventListener('click', function() {
  store.dispatch({ type: 'INCREMENT' });
});
fromId('minus').addEventListener('click', function() {
  store.dispatch({ type: 'DECREMENT' });
});
```