要約
- 画面を状態マシンとして表現する
- 画面の全ての状態を単一のUnion型の状態変数として表現する
- 操作による画面表示の変化を状態遷移関数として表現する
導入
私の作ったウェブアプリに以下のようなページがありました。
- カレンダーが表示されている
- 2種類のモーダルダイヤログがある
- カレンダーには範囲選択モードというのがあり、その状態だと表示やできることが変わる
- 同じダイヤログや範囲選択モードであっても複数の操作のために共通で使われている
- 一つのダイヤログを予定の新規作成と既存変更の二つの用途で使っているなど
- つまりダイヤログを閉じたり範囲選択モードで選択した後に行うべき処理が何の操作をしているかによって異なる
- 操作によってはまずはダイヤログを表示して、その後ダイヤログの結果によってはそのままカレンダー範囲選択モードに入るといった流れもある
ともかく一般的なページよりもかなり複雑なことをしないといけないという事は伝わったと思いますがどうでしょう。
これを何も考えずに実装するとだんだんぐちゃぐちゃになって訳わからなくなりそうだったので、どうにかうまく書けないか考えて思いついたのがTypeScriptの型による状態表現と状態遷移関数を使って状態マシンとして記述する方法です。
手法
画面状態の洗い出し
まずはページの画面にどんな状態があるのかを洗い出してリストにします。 ページは常にリスト内のいずれかの状態にあり、かつ同時に複数の状態になっていることはないようにします。 今回のページの場合以下の状態があります。
- 初期状態(通常状態)
- モーダルダイヤログ1を表示している状態
- モーダルダイヤログ2を表示している状態
- カレンダー範囲選択状態
今回のページは常に上記のいずれかの状態にあり、かつ同時に複数の状態になっていることはないです。
操作状態の洗い出し
次にどんな操作があるかを洗い出して、それを画面状態と紐づけます。例えば今回は以下のような操作がありました(実際にはもっとたくさんありました)。
- 操作1. ダイヤログ1で予定の新規作成
- 操作2. ダイヤログ1で既存の予定の変更
- 操作3. 既存の予定をカレンダー範囲選択モードで指定した場所に複製
- 操作4. 既存の予定を付加情報をダイヤログ2で入力した後、カレンダー範囲選択モードで指定した日時に変更
これらの操作を画面状態と紐づけます。
- 初期状態(通常状態)
- モーダルダイヤログ1を表示している状態
- 操作1
- 操作2
- モーダルダイヤログ2を表示している状態
- 操作4
- カレンダー範囲選択状態
- 操作3
- 操作4
状態をコード化
ここが一番重要かもしれません。上の状態のリストを TypeScript を使って表現し、単一の変数の型とします。
const state: (
{ type: 'normal' } |
{ type: 'dialog1', operation: (
{ type: 'operation1' } |
{ type: 'operation2', event: Event }
) } |
{ type: 'dialog2', operation: (
{ type: 'operation4', event: Event }
) } |
{ type: 'calendarRangeSelection', operation: (
{ type: 'operation3', event: Event } |
{ type: 'operation4', event: Event, eventChangeParams: { ... } }
) }
) = { type: 'normal' };
画面が取りうる状態を Union 型を使うことで単一の変数で表現しています。これによっておかしな状態になるコードやおかしな状態を前提としたコードを書けば、それは型エラーとして実行前に検出されます。例えばダイヤログ1とダイヤログ2が同時に表示されているとか、操作2を行っているときにカレンダー選択状態になっているとか、そういったことが起こらないように強制されます。
操作2から操作4は全て既存の予定に対する操作なので、それがどの予定なのかを event: Event
プロパティで持っています(ここでの Event
は標準DOMの Event
とは無関係な、このアプリで予定を表現するオブジェクトを指しています)。さらに操作4の範囲選択状態の時点では、ダイヤログ2で入力した付加情報を eventChangeParams: { ... }
の形で持っています。ちなみにここでは操作Xを operationX
と表現してますが、本来は updateEvent
とかわかりやすい識別子にすべきです。
状態遷移を書く
画面が取りうる状態遷移を関数で書いていきます。
function startOperation1() {
if(state.type !== 'normal') throw new Error('invalid state transition');
state = { type: 'dialog1', operation: { type: 'operation1' } };
}
function startOperation2(event: Event) {
if(state.type !== 'normal') throw new Error('invalid state transition');
state = { type: 'dialog1', operation: { type: 'operation2', event } };
}
function startOperation3(event: Event) {
if(state.type !== 'normal') throw new Error('invalid state transition');
state = { type: 'calendarRangeSelection', operation: { type: 'operation3', event } };
}
function startOperation4(event: Event) {
if(state.type !== 'normal') throw new Error('invalid state transition');
state = { type: 'dialog2', operation: { type: 'operation4', event } };
}
function startOperation4CalendarRangeSelection(eventChangeParams) {
if(state.type !== 'dialog2' || state.operation.type !== 'operation4') throw new Error('invalid state transition');
state = { type: 'calendarRangeSelection', operation: {
type: 'operation4', event: state.operation.event, eventChangeParams,
} };
}
function endOperation() {
state = { type: 'normal' };
}
無効な状態遷移は例外を投げるようにしています。
画面表示を実装
ここまでできればあとは、state
に基づいて画面の表示を分岐させる、ユーザ操作が行われたときに状態遷移関数を呼び出す、の2点を実装すればいいだけです。ここではそのイメージを Vue.js で書きます。
まずは画面状態の変数と状態遷移関数のみを状態マシンとして別に切り出したファイルを作ります。
import { shallowRef, readonly } from 'vue';
export function useComponentStateMachine() {
const state = shallowRef<
{ type: 'normal' } |
{ type: 'dialog1', operation: (
{ type: 'operation1' } |
{ type: 'operation2', event: Event }
) } |
{ type: 'dialog2', operation: (
{ type: 'operation4', event: Event }
) } |
{ type: 'calendarRangeSelection', operation: (
{ type: 'operation3', event: Event } |
{ type: 'operation4', event: Event, eventChangeParams: { ... } }
) }
>({ type: 'normal' });
function startOperation1() {
// state はリアクティブなので .value をつける。それ以外は以前と同じ。
if(state.value.type !== 'normal') throw new Error('invalid state transition');
state.value = { type: 'dialog1', operation: { type: 'operation1' } };
}
// 他の状態遷移関数も同じなので省略
// state は readonly を通すことで外から直接変更不能になり、状態遷移関数を通してしか更新できなくなる
return { state: readonly(state), startOperation1, /* 略 */ };
}
上を使って view を実装します。以下のようなイメージです。
<script lang="ts">
import { useComponentStateMachine } from './component-state-machine.ts';
export default {
setup() {
const stateMachine = useComponentStateMachine();
function newEvent() {
stateMachine.startOperation1();
}
function calendarEventSelected(
event: Event, operation: "operation2" | "operation3" | "operation4"
) {
if(operation === 'operation2') {
stateMachine.startOperation2(event);
} else if(operation === 'operation2') {
stateMachine.startOperation3(event);
} else if(operation === 'operation2') {
stateMachine.startOperation4(event);
}
}
function calendarRangeSelected(range) {
if(stateMachine.state.value.type !== 'calendarRangeSelection') throw new Error();
if(stateMachine.state.value.operation.type === 'operation3') {
// 操作3の処理(予定の指定日時への複製)
} else if(stateMachine.state.value.operation.type === 'operation4') {
// 操作4の処理(予定の日時変更)
}
stateMachine.endOperation();
}
function dialog1Closed(e) {
if(stateMachine.state.value.type !== 'dialog1') throw new Error();
if(stateMachine.state.value.operation.type === 'operation1') {
// 操作1の処理(予定の作成)
} else if(stateMachine.state.value.operation.type === 'operation2') {
// 操作2の処理(予定の更新)
}
stateMachine.endOperation();
}
function dialog2Closed(e) {
if(stateMachine.state.value.type !== 'dialog2') throw new Error();
if(stateMachine.state.value.operation.type === 'operation4') {
stateMachine.startOperation4CalendarRangeSelection(e.detail);
}
}
return {
state: stateMachine.state, newEvent, calendarEventSelected, calendarRangeSelected,
dialog1Closed, dialog2Closed,
};
},
}
</script>
<template>
<button type="button" @click="newEvent">新規予定</button>
<calendar
:mode="state.type === 'calendarRangeSelection' ? 'rangeSelection' : 'normal'"
@eventSelected="calendarEventSelected" @rangeSelected="calendarRangeSelected"
/>
<dialog1
:open="state.type === 'dialog1'"
:event="state.operation.event"
@closed="dialog1Closed"
/>
<dialog2
:open="state.type === 'dialog2'" @closed="dialog2Closed"
/>
</template>
まとめ
実際に書けばわかりますが、画面状態を一つの TypeScript の型で表すことで、IntelliSense を使って面白いくらいすらすらコードが書けます。おかしなコード、危ないコードを書こうとするとすぐ型エラーとして指摘されます。