[Web フロントエンド] 状態更新ロジックをフレームワークから独立させる
はじめに
はじめまして。 null です。最近の悩みは 2 歳半の息子が言うことを聞いてくださらないことです。
2 歳児もそうなのですが、
ソフトウェアフレームワークに振り回されてるって感じること、稀によくありませんか??
ある程度の学習コストがかかるのは当然としても、冗長な書き方を要求されるわ、かゆいところに手が届かないわ、実データ投入したらパフォーマンス問題だらけだわ、バージョンアップしたら動かなくなるわで、あぁ私いま振り回されてるなぁ、みたいな。
「じゃあ使わなければいい」「じゃあ自分で作ればいい」という話ではなく、「必要以上には依存しない方が無難だよね」「フレームワークの外へ切り出せるところは切り出してみよう」という話をします。
続きを 3 行で
- 状態更新ロジックってフレームワーク非依存にできそうだよね
- でもフレームワークによって状態をイミュータブル(状態更新=状態オブジェクト生成)にしたかったりミュータブル(状態更新=状態オブジェクト変更)にしたかったりするから難しいわ
- 状態オブジェクトの変更として実装しておけば、必要に応じて immer でラップするだけでイミュータブルな状態オブジェクトの生成処理に変えられるよ
【安定依存の原則】と
【変わりやすいフレームワークに依存するジレンマ】
安定依存の原則(Stable Dependencies Principle; SDP)
安定度の高い方向に依存すること。
クリーンアーキテクチャに出てくるシンプルな原則です。
(カブク社内で書籍『クリーンアーキテクチャ』の輪読会をやったのでちょっと使ってみたくなりました。)
パッと見、分かりやすい原則ですが、だからといって守りやすいとは限りません。まずフレームワークが不安定だからです。
(フレームワークなしで戦うひとには関係のない話ですが。)
たとえば Angular は 6 か月ごとにメジャーリリースすると公式に謳っています。
そして、 Angular 4 から Angular 6 への移行は一筋縄ではいかなかったそうです。
Angular に限らず、次のような会話って経験ありませんか?
「フレームワーク XXX の現在利用しているバージョンのサポートがあと 1 年で切れます。
最新バージョンへの対応には、コンパイルエラーと自動テストエラーをつぶすだけでなく、手で動かしておかしな挙動を修正する必要もあります。
2 週間で終わるかもしれませんし、 2 ヶ月かかるかもしれません。
新機能開発を止めて対応してよろしいでしょうか…?」「え? 2 週間もかかるの??」
(え? 「2 ヶ月かかるかもしれない」ってとこ聞こえなかった??)
※ この会話は架空のものです。決して私が Spring Boot 1.x から 2.x に上げたかったときの相談ではありません。
(【安定依存の原則】にしたがうなら、)
フレームワークを、サポートが切れないよう最新化しつつ利用するには、そのフレームワークに依存しないようにする必要があります。
なんと綺麗なジレンマでしょう。
そんなわけで私は Angular アプリ開発にも Angular の HttpClient
サービス ではなく fetch()
を普通に使います。
(ところで、巷ではよく Angular が React や Vue.js に比べて「大規模アプリケーション向き」だと言われますが、 Angular のリリースサイクルの速さを考慮に入れても本当に大規模アプリケーションに向いていると言えるでしょうか…?)
状態更新ロジックをフレームワークから独立させたい
さて、
JavaScript で状態を更新するロジックを書くとき、
- Redux や NgRx の Reducer として、
- MobX のアクションとして、
- rxjs の BehaviorSubject として、
など、(状態管理の)フレームワークと絡めがちです。
が、
フレームワークに依存しない方がフレームワークを更新しやすいのは前述のとおりです。
「状態の持ち方」と「状態更新ロジック」とを分けて考えれば、少なくとも「状態更新ロジック」はフレームワークから独立させられるのではないでしょうか。
ところがこれが思いのほか難しい。
Angular とか Redux とか素の React とかの文脈では基本的に状態をイミュータブルにします(よね)。
すなわち状態の更新は新しい状態オブジェクトの生成という形で実現します。
一方 Vue.js とか Mobx とかの文脈では状態オブジェクトを直接変更します。
状態更新処理が状態オブジェクトを生成すべきか変更すべきか、利用するフレームワークによって異なるんです。
これではフレームワークが決まらないとロジックを書けません。
「状態オブジェクトの生成にも変更にも使えるコードは書けないものか…」
私は悩みました。
そして immer に至ります。
immer
immer は「現在の状態の変更によって次のイミュータブルな状態を生成する」ライブラリです。
くわしくは公式のコード例が語ります。
import produce from "immer"
const baseState = [
{
todo: "Learn typescript",
done: true
},
{
todo: "Try immer",
done: false
}
]
const nextState = produce(baseState, draftState => {
draftState.push({todo: "Tweet about it"})
draftState[1].done = true
})
// the new item is only added to the next state,
// base state is unmodified
expect(baseState.length).toBe(2)
expect(nextState.length).toBe(3)
// same for the changed 'done' prop
expect(baseState[1].done).toBe(false)
expect(nextState[1].done).toBe(true)
// unchanged data is structurally shared
expect(nextState[0]).toBe(baseState[0])
// changed data not (dûh)
expect(nextState[1]).not.toBe(baseState[1])
オブジェクトを変更するように記述したコード
draftState.push({todo: "Tweet about it"})
draftState[1].done = true
が
immer でくるむだけで
新しいオブジェクトの生成処理に変わってしまいます。
しかも状態ツリーの中で変更があったオブジェクト(とその先祖)だけ生成されます。
これですよ。これ。
これで、夢にまで見た状態オブジェクトの生成にも変更にも使えるコードが書けます。
状態オブジェクトを変更するコードを書いとけばいいんです。
これほど簡単なことはありません。
状態をイミュータブルにしたいときは、ロジックを利用する側が immer でくるむだけです。
(これが利用側であることは重要です、状態更新ロジック自体は immer に依存しません。)
そして私はやった
状態オブジェクトの変更として記述したロジックを、 Angular と React と Vue.js とで使い回すことを試みました。
Angular と React では immer を介して、 Vue.js ではそのまま、ロジックを利用します。
やってみるものです。きちんと落とし穴に嵌りましたから。
immer の落とし穴
- 状態ツリー内に循環参照があると immer はエラーを投げる
- 状態ツリー内に同じオブジェクトへの参照があっても immer は別のオブジェクトとして扱う(一方を変更しても他方は更新されない)
はい。README に書いてあります。(読む前に踏みました。両方踏みました。)
これはロジック以前にデータ構造への制約なので、かなり強い制限のように感じます。
ある程度実装が進んだプロジェクトにおいて、あとから「やっぱり immer つかおうかな」はツライかもしれません。
しかたがないので
データ構造を変えた上で
ユニットテストは immer 版と非 immer 版で走らせるようにしました。
Vue.js の落とし穴
- Vue.js 2.x は配列要素の差し替え
array[index] = value
に対してリアクティブでない
はい。もちろん公式に書いてあります。
ですが、 Vue.js だけのために array[index] = value
を array.splice(index, 1, value)
に書き換えるのはちょっと我慢できません。
immer の Patches 機能を使えば変更内容を配列でもらって自分でほげほげできるので、 Patches を使って Vue.set()
を呼ぶような実装をしてみたのですが、うまくいきませんでした。
そもそも immer は Vue.js からの利用を想定していないようです。
- Why can’t drafts have computed properties? · Issue #317 · immerjs/immer
(「なんで Vue.js のオブジェクトに immer 使ってるの?」ってたしかにそりゃそう思うわな。)
代入操作を抽象化することも考えましたが…
(ロジック側が set()
関数を受けとって array[index] = value
の代わりに set(array, index, value)
を呼ぶようにしておけば、 Vue.js のときは Vue.set()
を呼ぶようにできるわけですが…)
そこまでしたくないなぁと…
ということで
この件はあきらめました。
ワケあって今回作った例ではちゃんと動いてしまう、という事情もありますが、
Vue.js 3.x では配列要素の差し替えを検知するようになるはず、というのが主な理由です。
この記事としては本末転倒感が強い話ですが、
本件についてはフレームワークの変化に期待するばかりです。(苦笑)
実際のコードとプチ解説
スプレッドシートを扱うアプリで、アクティブセルを更新する処理を例にします。
まずはこの例で扱う状態オブジェクトの型定義を紹介しておきます。
export interface Worksheet {
readonly maxCellAddress: Readonly<WorksheetCellAddress>
activeCellAddress: WorksheetCellAddress
}
export interface WorksheetCellAddress {
rowIndex: number
columnIndex: number
}
状態変更ロジック
- 状態変更ロジックを記述するクラスは「状態を変更するロジック(関数)を受け取って実際の更新処理を行う関数
update: (mutate: (state: Worksheet) => void) => unknown
」を受け取ります(状態の更新処理を抽象化します)。 - 状態の変更メソッドでは前述の「実際の更新処理を行う関数
update()
」に「オブジェクトの変更ロジック」を渡しますupdate(state => state.xxx.yyy = zzz)
。 - このクラスは状態そのものを保持しません。状態の持ち方を利用側の自由にするためです。
export class WorksheetOperations {
constructor(private readonly update: (mutate: (state: Worksheet) => void) => unknown) {}
setActiveCellAddress(rowIndex?: number, columnIndex?: number): this {
this.update(({ maxCellAddress, activeCellAddress }) => {
if (typeof rowIndex === 'number' && rowIndex >= 0 && rowIndex <= maxCellAddress.rowIndex) {
activeCellAddress.rowIndex = rowIndex
}
if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex <= maxCellAddress.columnIndex) {
activeCellAddress.columnIndex = columnIndex
}
})
return this
}
}
Vue.js で利用する場合
- Vue.js のように、状態オブジェクトがミュータブルな前提で使えるフレームワークでは、
update()
関数として単純にmutate => mutate(state)
を渡します。state.xxx.yyy = zzz
のような普通の変更処理が実行されて、フレームワークが勝手に(Vue.js 2.x なら setter, 3.x なら proxy を介して)反応します。
export default Vue.extend({
data() {
const worksheet = Vue.observable(createWorksheet())
const worksheetOperations = new WorksheetOperations(
mutate => mutate(worksheet)
)
return { worksheet, worksheetOperations }
},
}
React.PureComponent(あるいは React.memo() されたコンポーネント)の props に利用する場合
- 状態オブジェクトがイミュータブルな前提で使う際には、
update()
関数として immer のproduce()
関数を介したmutate => state = produce(state, mutate)
のような関数を渡します。produce(state, state => state.xxx.yyy = zzz)
が新しいオブジェクトを生成してくれるので、返ってきたオブジェクトを保持するだけです。
produce(state, mutate)
はproduce(mutate)(state)
とも記述できるので、 React ではmutate => setState(state => produce(state, mutate))
の代わりにmutate => setState(produce(mutate))
と記述できます。
import produce from 'immer'
const WorksheetTable: React.SFC<{
worksheet: Worksheet
worksheetOperations: WorksheetOperations
}> = /* ... */
const WorksheetTableMemoized = React.memo(WorksheetTable)
export default () => {
const [state, setState] = React.useState(createWorksheet())
const worksheetOperations = new WorksheetOperations(
mutate => setState(produce(mutate))
)
return (
<WorksheetTableMemoized
worksheet={state}
worksheetOperations={worksheetOperations}
/>
)
}
Angular (rxjs) で利用する場合
- 状態を rxjs の
BehaviorSubject
として持つ場合も、状態をイミュータブルとして扱うためにReact.PureComponent
同様 immer を介します。
import produce from 'immer'
import { BehaviorSubject } from 'rxjs'
@Injectable()
export class WorksheetService {
private readonly _worksheet = new BehaviorSubject<Worksheet>(createWorksheet())
readonly worksheetOperations = new WorksheetOperations(
mutate => this._worksheet.next(produce(this._worksheet.value, mutate))
)
}
- 以上いずれのフレームワークでも、
worksheetOperations.setActiveCellAddress(rowIndx, columnIndex)
を呼ぶことで状態が更新(変更または生成)されて画面に反映されます。
できあがったものがこちら
GitHub に置いてあります。
おわりに
状態更新ロジックからフレームワークを抽象化してみました。
最後に、少し冷静になりましょう。
「十分に発達した抽象化は、難読化と見分けがつかない」―@raganwald
この抽象化は本当に必要でしょうか?
率直に言うと、
必ずしも状態更新ロジックからフレームワークを抽象化する必要はないけど、
手軽にできるなら新規プロジェクトでは状態更新ロジックをフレームワークから独立させておくのが無難かな、
ぐらいに私は考えます。
フレームワークは変わります。
Svelte はバージョン 3.0.0 で “Everything” を変えたそうです。
ビジネスも変わります。
デザイントレンドも変わります。
フレームワークのバージョンアップなんかのためにコードベースを修正している場合ではありません。
元号も変わります。
紙幣も変わります。
「immer も大きく変わるかもしれないじゃないか!」
たしかに。
ですが immer は役割が完全に決まっているので、破壊的変更があったとしても影響は大きくないだろうと予想します。
「依存するモジュールが 1 つ増えただけじゃないか!」
まぁそうっちゃそうなんですよね。
でも、フレームワークの外で、オブジェクトの変更として、とても素直なロジックを記述できるのは、大きなメリットだと私は考えます。
依存関係の変更に強いアプリを構築する手段として、 immer に頼ることは現実的な解のひとつではないでしょうか。
※ 私は業務ではまだ immer を利用していません。あしからず。
カブクでは変化に強いエンジニアを募集中です!
迷ったらポチる!
その他の記事
Other Articles
関連職種
Recruit