Backbone.jsにReduxを導入してみる

  • 36
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Reduxを導入する契機

ここ何年間かSPAアプリの構築にはBackbone.jsを使っています。
小さくて自由度が高いので、スマホのハイブリッドアプリによくマッチングするからです。

ただアプリが大きくなるにつれて、モデルのデータ管理が難しくなってきました。
SPAで作るにあたって、他の画面へモデルを引き継いだり、処理が完了すればモデルを初期化する必要が出てきます。
メニュー等で別の画面に直接飛んだり、入力をし直すために画面を戻った際にモデルのデータが意図せぬ状況になるパターンが増えてきました。
また、扱うモデルも増えてきたため、モデル内のデータがどのように変化をしているのかの把握が困難になりつつあります。

そこで目をつけたのがReduxです。
Reduxとは、アプリ全体で唯一のStoreを持ち、Reducerと呼ばれる副作用のない関数がAction(依頼種別とデータが組みになったObject)と現在のstete(Storeのデータ)から新しいstateを返すことで、アプリの全体のデータを一辺に更新するというフレームワークです。
この単純な仕組みの利点は、ChromeにRedux DevToolをインストールすればたちどころに理解できます。
ReduxはReactで使われることが圧倒的に多いとは思いますが、Reduxの公式ページにあるように、AngularやVanilla JSのアプリにも活用でき、今回のBackboneのアプリにも問題なく導入できています。
Redux導入によりデータ更新に関して圧倒的に簡単に把握できるようになります。

Redux DevToolについて

コードに1行Middlewareにwindow.devToolsExtension()の記述(npm install不要)とChromeにRedux DevToolをインストールすれば使えるようになります。
下記に主だった機能をあげてみます。

Actionの内容 & 履歴

どういうActionが呼ばれたか
スクリーンショット 2016-07-09 13.18.29.png

Action発行後のstate

Actionごとのstateの状態
state.png

Action発行前と後のstateの変化

Diffを表示
スクリーンショット 2016-07-09 13.47.03.png

stateの変化をオブジェクトグラフのアニメーションで表示

図と動きで違いを表示
chart.png

Testの自動生成

前回のstate+Actionが今回のstateとなるTestのコードを作成してくれる
test.png

Actionの再生

Reducerの実装を変えた上でActionを最初から再生させることが可能(任意のActionをSkip可能)
play.png

BackboneとReduxとの連携について

ユーザの操作をviewで検知すると、今まではBackboneのモデルを直接更新していましたが、そのかわりにActionCreatorを呼び出し、Action(種別とデータが組みになったオブジェクト)を作成し、StoreにそのActionをDispatchします。Storeは現状のstateとActionを各Reducerに渡し、Resucerがそれを元に新たなstateを作成して返します。
Reduxで新たなstateが作られると通知されるので、通知を受けたらstateをBackboneのModelやCollectionにMappingします。
Backbone側のModelやCollectionは前回の状態が保持されているので、stateの値を使って、ModelやCollectionを更新することで、ModelやCollectionを使っているViewにaddやchangeイベントが通知され、viewが更新されます。
js構成図.png

BackboneとReduxとの連携部分の実装

基本的にModelやCollectionに変更を行っていた箇所がReduxのActionの呼び出しに変更になっています。
実装はtodo-backbone-es2015にあります。

Redux対応による変更箇所

package.json

  • Reduxの追加
  • backbone.localstorageの削除(Backboneのcrudの処理をServerとの通信ではなく、localstorage経由にするために使っていた) package.png

app.js

  • ReduxのStoreの作成処理
  • Steteの変更時にstateからBackboneのモデルやコレクションにMappingする処理の登録。
  • Backboneのsyncを空メソッド(デフォルトの機能のRESTを使ったAjaxの呼び出しをなくしcreateやdestroyやsaveを実行するとイベントの発行のみとすることができる)
  • viewやrouterでdispatchを行えるようStoreをRouterの引数に追加。 app.png

router.js

  • viewにdispatchを行えるようStoreの引数の追加
  • 絞り込み(全部、未完了のみ、完了のみ)によるURLの変更時に、filter変更のActionをdispatch router.png

app_view.js

  • ModelやCollectionを直接操作していた箇所すべてをReduxのActionに変更
  • 子ViewにDispatchが行えるようStoreの引数を追加しています。
  • 直接モデルを操作しないようになったため、syncイベントがなくなったので、allで各モデルの変更やCollection状態の変更を拾う app_view.png

todo_view.js

  • ModelやCollectionを直接操作していた箇所すべてをReduxのActionに変更 todo_view.png

Redux対応による追加箇所

変更箇所は思いの他少ないのですが、Redux実装部分は結構多いです。

map_state_to_backbone.js

  • localStorageにstateをまるっと保存する。(ActionのfetchAllでstateを復元することで最後の状態が保ち続けられる)
  • 削除されたModelを検出し、destroyメソッドを呼び出す(Collectionに新たなModelの集合値を渡しても、Collection内に該当モデルはいなくなるものの、Modelのdestroyイベントが発行されないため)
  • collection,modelにstateをそのままset (Backboneが変更があったもののみchangeイベントを発行してくれる)
map_state_to_backbone.js
import _ from 'lodash';
export default function mapStateToBackbone(options) {
  const todos = options.todos;
  const todoFilter = options.todoFilter;
  const state = options.store.getState()

  localStorage.setItem("todoApp", JSON.stringify(state));

  const currentIDs = todos.map((todo)=> todo.id);
  const stateIDs = state.todos.map((todo)=> todo.id);
  _.difference(currentIDs, stateIDs).forEach((id) => todos.get(id).destroy());

  todos.set(state.todos);
  todoFilter.set({ status: state.todoFilter })
  return;
}

constants/action_type.js

  • Actionの種別を定義。(打ち間違いをなくし、補完が効くようになる)
constants/action_type.js
export const FETCH_ALL = 'FETCH_ALL';
export const CHANGE_FILTER = 'CHANGE_FILTER';
export const TOGGLE_ALL_COMPLETE = 'TOGGLE_ALL_COMPLETE';
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED';
export const CREATE_TODO = 'CREATE_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const CHANGE_TITLE = 'CHANGE_TITLE';
export const DESTROY_TODO = 'DESTROY_TODO';

actions/index.js

  • ActionCreatorの実装。ActionCreatorはActionを作成して返す

もしServerと通信した結果の値を返したい場合は、redux-thunkを使う。

actions/index.js
import * as ActionType from '../constants/action_type';

export function fetchAll() {
  const str = localStorage.getItem('todoApp');
  let data = {}
  if (str) {
    data = JSON.parse(str)
  }
  return { type: ActionType.FETCH_ALL, data: data };
}

export function changeFilter(filter) {
  return { type: ActionType.CHANGE_FILTER, filter: filter }
}

export function toggleAllComplete(completed) {
  return { type: ActionType.TOGGLE_ALL_COMPLETE, completed: completed };
}

export function clearCompleted() {
  return { type: ActionType.CLEAR_COMPLETED };
}

export function createTodo(todo) {
  return { type: ActionType.CREATE_TODO, todo: todo };
}

export function toggleTodo(id) {
  return { type: ActionType.TOGGLE_TODO, id: id };
}

export function changeTitle(id, title) {
  return { type: ActionType.CHANGE_TITLE, id: id, title: title };
}

export function destroyTodo(id) {
  return { type: ActionType.DESTROY_TODO, id: id };
}

reducers/index.js

  • 各Reducerを結びつける

Storeがもつstateはreducerごとに分割することで管理しやすくする。デフォルトだと、state名とreducer名は同じ。

reducers/index.js
import { combineReducers } from 'redux'
import todos from './todos'
import todoFilter from './todo_filter'
const todoApp = combineReducers({ todos, todoFilter });
export default todoApp;

reducers/todo.js

  • todoデータのリストのstate用の純粋関数郡

imutableにするため、現在のstateは更新しない。
削除の場合だと、新たなリストを作成し、削除対象のtodoは新たなリストに登録しないことで削除を表現

reducers/todo.js
import * as ActionType from '../constants/action_type';

function todos(state = [], action) {
  switch(action.type) {

    case ActionType.FETCH_ALL:
      if(action.data['todos']) {
        return action.data['todos'];
      } else {
        return state;
      }

    case ActionType.TOGGLE_ALL_COMPLETE:
      return state.map((todo)=> Object.assign({}, todo, { completed: action.completed }));

    case ActionType.CLEAR_COMPLETED:
      return state.filter((todo) => !todo.completed )

    case ActionType.CREATE_TODO:
      let id = 1;
      if (state.length != 0) {
        id = state.sort((a, b) => a.id > b.id)[state.length - 1].id + 1
      }
      return [...state, { id: id, order: id, title: action.todo.title, completed: false }];

    case ActionType.TOGGLE_TODO:
      return state.map((todo)=> {
        if (todo.id == action.id) {
          return Object.assign({}, todo, { completed: !todo.completed });
        } else {
          return todo
        }
      });

    case ActionType.CHANGE_TITLE:
      return state.map((todo) => {
        if (todo.id === action.id) {
          return Object.assign({}, todo, { title: action.title })
        } else {
          return todo
        }
      });

    case ActionType.DESTROY_TODO:
      return state.filter((todo) => todo.id != action.id )

    default:
      return state;
  }
}
export default todos;

reducers/todo_filter.js

  • 絞り込みのstate用の純粋関数郡

関係のないAction種別が来た場合は現在のstateを返す

reducers/todo_filter.js
import * as ActionType from '../constants/action_type';

function todoFilter(state = '', action) {
  switch(action.type) {
    case ActionType.FETCH_ALL:
      return action.data['todoFilter'] || state;

    case ActionType.CHANGE_FILTER:
      return action.filter;

    default:
      return state;
  }
}
export default todoFilter;

Reductを導入するにあたって難しい点

  • コード量が増える
    • こればっかりは仕方ないです。
  • Backboneのモデルのバリデーションが使えなくなる
    • state->Backboneモデルの流れなので、stateにセットする前に異常値を弾く必要があります。
  • 監視すべきObjectが増える
    • 今までAjaxを呼んでその結果を元にダイアログを出したり、エラー箇所を表示していた処理をReduxに対応させるには、Actionを投げて結果を受けるのにそれようのObjectを作成し、イベントを拾い処理を行う必要があります。 Reactだともともとそういうものなので問題ないのでしょうが。

まとめ

React使いたくても、下記理由等により導入できない場合があります。

  • 過去のコード資産
  • ネイティブの操作感に近づけるためにtouchイベント等を駆使している
  • デザイナがtemplateを直接編集できる

Reactを使わなくてもReduxをうまく活用できる可能性があるので、検討してみる価値があると思います。