私はゼロからなにかを作ったり、全体がどのように動作するのかを理解したりするのが好きな開発者の一人です。このようなことに熱中するのは仕事としては不要と認識していますが、特定のフレームワーク、ライブラリー、モジュールの裏側の認識や理解を助けてくれることは間違いありません。
最近また同じような機会があり、Reduxと素のJavaScript以外はなにも使わずにWebアプリケーションの開発に着手しました。本記事では、採用した解決法やこれまでに学んだことについて触れる前に、アプリを開発した方法や初期の最終的に失敗したバージョンを分析した概要について説明します。
セットアップ
React.jsとReduxの組み合わせによって、最新のフロントエンド技術を用いた高速で高性能なWebアプリケーションが開発できるということを耳にしたことはないでしょうか。
Facebookによって開発されたReactは、ユーザーインターフェイス開発用のコンポーネントベースのオープンソースライブラリーです。Reactは単なるビューレイヤー、つまりAngularやEmberのようなフルフレームワークではありませんが、Reduxはアプリケーションのステートを管理します。Reduxは予測可能なステートコンテナー(predictable state container)として動作し、ステート全体が単一のオブジェクトツリーで保存され、いわゆるアクションをエミットすることによってのみ変更されます。
このトピックになじみがない人は、『Understanding Redux (or, How I Fell in Love with a JavaScript State Container)』 をチェックすることをおすすめします。記事を読んでReduxのエキスパートになる必要はありませんが、少なくともReduxの概念の基礎知識が得られることは間違いありません。
Reactを使わないRedux — スクラッチによるアプリケーション開発
Reduxがすばらしいのは、先のことを考え、アプリケーション設計の初期イメージをつかめることです。なにを実際に保存すべきか、どのデータを変更可能にすべきか、どのコンポーネントがstoreにアクセスできるようにするかの定義に着手します。
しかし、Reduxはステートのみに関与するため、その他のアプリケーションをどのように構築し、どのように接続したらよいか、少し混乱してしまいました。Reactはあらゆることをうまく導いてくれますが、誘導されない場合になにがもっとも効果的なのかを考え出すのは自分自身です。
問題になっているアプリケーションはモバイルファーストのテトリスのクローンであり、数個の異なるビューがあります。実際のゲームロジックはReduxで構築されていますが、オフライン機能はlocalStorageで提供されていて、カスタムビュー処理されます。アプリケーションはまだ開発中ですがGitHubにリポジトリ―を用意されていて、本記事は開発に取り組めるように書いています。
アプリケーションアーキテクチャの定義
ReduxやReactのプロジェクトでよく見られるファイル構造を採用することにしました。ファイル構造は論理構造であり、さまざまなセットアップに適用可能です。テーマにはさまざまなバリエーションがあり、ほとんどのプロジェクトは少し異なる動作をしますが、全体的な構造は同じです。
actions/
├── game.js
├── score.js
└── ...
components/
├── router.js
├── pageControls.js
├── canvas.js
└── ...
constants/
├── game.js
├── score.js
└── ...
reducers/
├── game.js
├── score.js
└── ...
store/
├── configureStore.js
├── connect.js
└── index.js
utils/
├── serviceWorker.js
├── localStorage.js
├── dom.js
└── ...
index.js
worker.js
マークアップは別のディレクトリに分けられていて、単一のindex.htmlファイルで最終的にレンダリングされます。コードベース全体を通じて一貫したアーキテクチャが維持できるように、構造をscripts/と同様にしています。
layouts/
└── default.html
partials/
├── back-button.html
└── meta.html
pages/
├── about.html
├── settings.html
└── ...
index.html
Storeの管理およびアクセス
storeにアクセスするには、一度アプリケーションを作成してすべてのインスタンスに渡されなければなりません。ほとんどのフレームワークはなんらかの依存性の注入(dependency injection)コンテナーを利用しているため、フレームワークのユーザーは自分で解決策を考え出す必要はありません。しかし、自分の解決策に取り組む際に、どのようにすべてのコンポーネントにアクセスできるようにすればよいのでしょうか。
最初のバージョンはどうもうまくいきませんでした。なぜこれが良いアイデアだと思ったのか分かりませんが、storeを自身のモジュール(scripts/store/index.js)に配置し、アプリケーションのほかの箇所からインポートできるようにしました。しかし、これは後悔する結果となり、すぐに循環依存(circular dependency)に取りかかりました。問題点は、コンポーネントがアクセスしようとしたときにstoreがプロパティを初期化しないということでした。以下に、私が取り組んでいた依存のフローを示す図をまとめました。
アプリケーションの開始点でコンポーネントは初期化され、直接またはヘルパー関数(ここではconnectと呼ばれる)を介してstoreを内部で利用します。しかし、storeは明確には生成されず、自身のモジュール内のフィードバックとしてのみ存在するため、結局コンポーネントは自身のモジュール内のフィードバックが生成される前にstoreを使用するということになってしまいます。コンポーネントやヘルパー関数が初回にstoreを呼び出すことを制御する方法は存在しません。訳が分かりませんでした。
storeモジュールは以下のようになります。
scripts/store/index.js(☓ bad)
import { createStore } from 'redux'
import reducers from '../reducers'
const store = createStore(reducers)
export default store
export { getItemList } from './connect'
すでに述べたようにstoreはフィードバックとして生成され、エクスポートされました。また、ヘルパー関数もstoreを要求しました。
scripts/store/connect.js(☓ bad)
import store from './'
export function getItemList () {
return store.getState().items.all
}
これはコンポーネントが最終的に相互再帰になったまさにその瞬間です。ヘルパー関数は動作するためにstoreを必要とし、同時にアプリケーションのほかの箇所からアクセスできるようにストアの初期化ファイルの中からエクスポートされます。先ほどの内容がどのようなに面倒だったか、分かっていただけましたか。
解決方法
いまはっきりしていることは、理解するのにしばらく時間がかかるということです。初期化処理をアプリケーションの開始点(scripts/index.js)に移動し、代わりに必要なコンポーネントすべてに渡すことで問題を解決しました。
繰り返しになりますが、Reactが実際にstoreを利用可能にする方法にとてもよく似ています(ソースコードを参照してください)。うまく一緒に動作するのには理由があるので、その概念から学んでいきましょう。
アプリケーションの開始点では最初にstoreが生成され、それからすべてのコンポーネントに渡されます。そして、コンポーネントはstoreに接続されてアクションをディスパッチし、変更に同意するか特定のデータを取得します。
それでは変更点を詳しく見ていきます。
scripts/store/configureStore.js(✓ good)
import { createStore } from 'redux'
import reducers from '../reducers'
export default function configureStore () {
return createStore(reducers)
}
モジュールを保持していますが、代わりにコードベース内のどこかほかの場所にstoreを生成するconfigureStoreという名前の関数をエクスポートします。ただし、これは単なる基本的な概念であるということに注意してください。また、Redux DevTools extensionを使用してLocalStorage経由でpersistedステートを読み込みます。
scripts/store/connect.js(✓ good)
export function getItemList (store) {
return store.getState().items.all
}
connectヘルパー関数は基本的には手つかずですが、現在storeは引数として渡される必要があります。「それではヘルパー関数はなんのためにあるのだろう?」と思い、最初はこの解決方法を用いるのをためらっていました。しかしいまでは、ヘルパー関数は十分に有効でハイレベルなもので、使うことですべてが理解しやすくなると考えています。
import configureStore from './store'
import { PageControls, TetrisGame } from './components'
const store = configureStore()
const pageControls = new PageControls(store)
const tetrisGame = new TetrisGame(store)
// Further initialization logic.
これがアプリケーションの開始点です。storeが生成され、すべてのコンポーネントに渡されます。PageControlsは特定のアクションボタンにグローバルイベントリスナーを追加します。また、TetrisGameは実際のゲームコンポーネントです。storeを移動する前は、個別にすべてのモジュールにstoreを渡すことを除けば基本的に同じに見えました。前に書いたとおり、コンポーネントは失敗したconnectのアプローチを介してアクセスできました。
コンポーネント
2種類のコンポーネントを使って作業することにしました。プレゼンテーショナルおよびコンテナーコンポーネントです。
プレゼンテーショナルコンポーネントは純粋なDOM以外はなにも処理しません。つまり、このコンポーネントはストアを意識しません。一方、コンテナーコンポーネントはアクションをディスパッチしたり、変更を受け取ったりできます。
Dan AbramovはReactに関するすばらしい記事を書きましたが、その手法はほかのコンポーネントアーキテクチャにも同様に適用可能です。
しかし私の場合は例外もありました。コンポーネントがごく最小限で、1つのことしかしない場合があります。それらを前に書いたパターンのいずれかに分割したくなかったので、混ぜ合わせることにしました。コンポーネントが大きくなり、ロジックがもっと増えれば分離します。
scripts/components/pageControls.js
import { $$ } from '../utils'
import { startGame, endGame, addScore, openSettings } from '../actions'
export default class PageControls {
constructor ({ selector, store } = {}) {
this.$buttons = [...$$('button, [role=button]')]
this.store = store
}
onClick ({ target }) {
switch (target.getAttribute('data-action')) {
case 'endGame':
this.store.dispatch(endGame())
this.store.dispatch(addScore())
break
case 'startGame':
this.store.dispatch(startGame())
break
case 'openSettings':
this.store.dispatch(openSettings())
break
default:
break
}
target.blur()
}
addEvents () {
this.$buttons.forEach(
$btn => $btn.addEventListener('click', this.onClick.bind(this))
)
}
}
上の例は説明したようなコンポーネントの1つです。要素のリスト(この場合はdata-action属性を持ったすべての要素)を保持しており、属性の内容に応じて、クリックでアクションをディスパッチします。ほかにはなにもしません。その後、ほかのモジュールがストア内の変更点を確認し、それに応じて自身を更新する場合があります。先ほども述べましたが、コンポ―ネントがDOMを更新した場合は分離することになります。
それでは、2つのコンポーネントタイプが明確に分離している場合について説明します。
DOMの更新
プロジェクトを開始したときの大きな疑問点の1つは、実際のDOMの更新方法についてでした。ReactはVirtual DOMと呼ばれるDOMの高速なメモリー内表記法を用いてDOMの更新を最小限におさえます。
実は私も同じことを考えていたので、アプリケーションがもっと大きくなり、DOMがもっと巨大になった場合はVirtual DOMに切り替える可能性が高いです。現在はまだ従来のDOM操作を使用していますが、Reduxと一緒にうまく動作しています。
基本的な流れは以下のとおりです。
- コンテナーコンポーネントの新しいインスタンスが初期化され、内部で使用するためにstoreに渡される
- コンポーネントはストア内の変更を受け取る
- さらに、ほかのプレゼンテーショナルコンポーネントを使用してDOMの更新をレンダリングする
注記:私はJavaScriptに関連するDOMの$シンボルプレフィックスのファンです。気づいているかもしれませんが、jQueryの$ではありません。そのため、純粋なプレゼンテーショナルコンポーネントのファイル名は頭にドル記号がついています。
import configureStore from './store'
import { ScoreObserver } from './components'
const store = configureStore()
const scoreObserver = new ScoreObserver(store)
scoreObserver.init()
ここでは手の込んだことはなにも起こっていません。コンテナーコンポーネントScoreObserverがインポート、生成、および初期化されます。コンポーネントは実際なにをしているのでしょうか。ビュー要素に関連するすべてのスコア、つまりゲーム中のハイスコアリストおよび現在のスコア情報を更新します。
scripts/components/scoreObserver/index.js
import { isRunning, getScoreList, getCurrentScore } from '../../store'
import ScoreBoard from './$board'
import ScoreLabel from './$label'
export default class ScoreObserver {
constructor (store) {
this.store = store
this.$board = new ScoreBoard()
this.$label = new ScoreLabel()
}
updateScore () {
if (!isRunning(this.store)) {
return
}
this.$label.updateLabel(getCurrentScore(this.store))
}
// Used in a different place.
updateScoreBoard () {
this.$board.updateBoard(getScoreList(this.store))
}
init () {
this.store.subscribe(this.updateScore.bind(this))
}
}
これがシンプルなコンポーネントであるということを念頭においてください。つまり、ほかのコンポーネントはもっと複雑なロジックで、もっとたくさん処理すべきことがある場合があります。ここではなにが起こっているのでしょうか。ScoreObserverコンポーネントはstoreへの内部参照を保存し、あとで使用するために双方のプレゼンテーショナルコンポーネントの新しいインスタンスを生成します。initメソッドはストアの更新を受け取り、ストアの変更のたびに$labelコンポーネントを更新します。ただし、ゲームが実際に動作している場合のみです。
updateScoreBoardメソッドはほかの場所で使用されます。ビューがアクティブではないときに、変更が発生するたびに毎回リストを更新するのは意味がありません。また、ビューの変更ごとにほかのコンポーネントを更新または非アクティブ化するルーティングコンポーネントもあります。そのAPIの概略は以下のとおりです。
// scripts/index.js
route.onRouteChange((leave, enter) => {
if (enter === 'scoreboard') {
scoreObserver.updateScoreBoard()
}
// more logic...
})
注記:$と$$はjQueryの参照ではありません。document.querySelectorへの便利で実用的なショートカットです。
scripts/components/scoreObserver/$board.js
import { $ } from '../../utils'
export default class ScoreBoard {
constructor () {
this.$board = $('.tetrys-scoreboard')
}
emptyBoard () {
this.$board.innerHTML = ''
}
createListItem (txt) {
const $li = document.createElement('li')
const $span = document.createElement('span')
$span.appendChild(document.createTextNode(txt))
$li.appendChild($span)
return $li
}
updateBoard (list = []) {
const fragment = document.createDocumentFragment()
list.forEach((score) => fragment.appendChild(this.createListItem(score)))
this.emptyBoard()
this.$board.appendChild(fragment)
}
}
ふたたび基本的な例および基本的なコンポーネントを示します。updateBoard()メソッドは配列を取得し、繰り返し処理し、スコアリストにその内容を挿入します。
scripts/components/scoreObserver/$label.js
import { $ } from '../../utils'
export default class ScoreLabel {
constructor () {
this.$label = $('.game-current-score')
this.$labelCount = this.$label.querySelector('span')
this.initScore = 0
}
updateLabel (score = this.initScore) {
this.$labelCount.innerText = score
}
}
このコンポーネントは先述のScoreBoardとほぼ同じですが、単一の要素を更新するのみです。
その他の失敗およびアドバイス
もう1つの重要な点は、ユースケース駆動のstoreを実装することです。私の考えでは、アプリケーションに必要不可欠なものだけを保存することが重要です。最初の頃は、現在のアクティブビュー、ゲーム設定、スコア、ホバーエフェクト、ユーザーの呼吸パターンなど、ほとんどすべてを保存していました。
これはあるアプリケーションの開発に向いている場合がありますが、ほかのアプリケーションにも向いているかというとそうではありません。現在のビューを保存したり、再読み込み時にまったく同じ場所で続けたりするのには良いですが、私にとってこれはユーザーエクスペリエンスが低く、便利というよりうっとうしく感じられました。みなさんはメニューやモーダルの切り替えを保存したくないのではないでしょうか。なぜユーザーがその特定の状態に戻る必要があるのでしょうか。もっと大きなWebアプリケーションでは意味があるかもしれません。しかし、私のモバイル専用の小さなゲームでは、そこでやめるためだけに設定画面に戻るのはとても面倒です。
最後に
Reactを使ったReduxプロジェクトにもReactを使わないReduxプロジェクトにも取り組んできましたが、もっとも大事な点はアプリケーション設計に大きな違いは必要ないということです。Reactで使用される多くの手段は実際にほかのビュー操作セットアップに適用可能です。違うことをしなければならないと考えてスタートを切ったため理解するのにしばらく時間がかかりましたが、最終的に必要ないと感じました。
しかし、違うことは、モジュールやstoreの初期化の仕方やコンポーネントはアプリケーション全体のステートを保持することができるという認識の度合いです。概念は同じですが、実装やコード量はちょうどニーズに合致しています。
Reduxは優れたツールであり、より緻密な方法でアプリケーションを構築する手助けをしてくれます。ほかのビューライブラリーを使わずに単独で使用した場合、最初はとてもややこしいかもしれませんが、最初の難しいところを乗り越えればあとはスムーズです。
※本記事はVildan Softicが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Redux without React — State Management in Vanilla JavaScript)
[翻訳:市川千枝/編集:Livit]