はじめに
hyperappでReact チュートリアルの tic-tac-toe(OXゲーム)を作った際の技術メモです。
- Hyperappとは(参考:2018 年は Hyperapp の年だ)
Web アプリのフロントエンド用 JavaScript ライブラリ。React, Preact, Vue といった代表的なものよりもずっと小さく、1 KB という超軽量サイズ。他のライブラリに依存することなく使えて、さらにスピードもある
Elmアーキテクチャーに基づいてて、アプリケーション設計はElmやReact、Reduxと似てるけど、ボイラープレートは少ないし、TypeScriptにも対応して、とにかくシンプル。
環境
環境設定はこの記事と同じです。
制作
それでは早速作っていきます。
以下の順で作ります
- state(アプリケーションにおけるデータモデル全体)
- actions(stateを更新する)
- components(views)(stateとactionsを受け取り、DOMツリーを構築)
state
OXゲームの状態を定義します。
-
squares
:現在のボードの状態 -
nowPlayer
:現在のプレイヤー -
history
:squares
の履歴
export default interface IAppState {
squares: Squares;
nowPlayer: Player;
history: Squares[];
}
export type Player = "X" | "O";
type Sign = Player | null;
export type Squares = Sign[];
Actions
アプリの状態を更新する関数を定義します。
状態の更新にはimmer
を使いました。
「直接ステートを更新せずに、新たなステートを返す」というコードが直感的に書けるライブラリです。
-
mark
:盤面にOXをマークします。 -
jumpTo
:履歴(何手目)を指定してその状態に遷移します。
import produce from "immer";
import IAppState, { Player, Squares } from "./state";
export interface IAppActions {
mark: (index: number) => (state: IAppState) => IAppState;
jumpTo: (step: number) => (state: IAppState) => IAppState;
}
export const actions: IAppActions = {
mark: index => state => {
if (state.squares[index] || judge(state.squares)) {
// 既にそのマスがマークされている、もしくは決着がついている場合更新しない
return state;
}
return produce(state, draft => {
// 指定のマスにマーク
draft.squares[index] = state.nowPlayer;
// プレイヤー交代
draft.nowPlayer = state.nowPlayer === "X" ? "O" : "X";
// ボードの状態を履歴へ追加
draft.history.push(state.squares);
});
},
jumpTo: step => state =>
produce(state, draft => {
// 履歴を復元
draft.squares = draft.history[step];
// プレイヤーを復元
draft.nowPlayer = step % 2 === 0 ? "X" : "O";
draft.history.splice(step, draft.history.length);
})
};
// 勝敗判定
export function judge(board: Squares): Player | null {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
];
for (const line of lines) {
const [a, b, c] = line;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
return null;
}
Components
アプリのロジックが完成したのでステートレスなビューを書けば完成です。
親コンポーネントからのpropsでなくグローバルなステートとアクションをそのまま使えるLazy Components
がすごく便利です。
参考:HyperappのLazy Components
- 個々のマス
import { Component, h } from "hyperapp";
import { IAppActions } from "../actions";
import IAppState from "../state";
export const Square: Component<{ index: number }, IAppState, IAppActions> = ({
index
}) => ({ squares }, { mark }) => (
<button class="square" onclick={() => mark(index)}>
{squares[index]}
</button>
);
- 盤面
import { h } from "hyperapp";
import { Square } from "./square";
const Board = () => (
<div>
<div class="board-row">
<Square index={0} />
<Square index={1} />
<Square index={2} />
</div>
<div class="board-row">
<Square index={3} />
<Square index={4} />
<Square index={5} />
</div>
<div class="board-row">
<Square index={6} />
<Square index={7} />
<Square index={8} />
</div>
</div>
);
export default Board;
- ゲーム画面
import { h, View } from "hyperapp";
import { IAppActions, judge } from "../actions";
import IAppState, { Player } from "../state";
import Board from "./board";
import History from "./history";
const Game: View<IAppState, IAppActions> = state => {
const status = ({ squares, nowPlayer }: IAppState) => {
const winner: Player | null = judge(squares);
return winner ? `Winner: ${winner}` : `Next Player: ${nowPlayer}`;
};
return (
<div class="game">
<div class="game-board">
<Board />
</div>
<div class="game-info">
<div>{status(state)}</div>
<History />
</div>
</div>
);
};
export default Game;
- 履歴の表示
import { Component, h } from "hyperapp";
import { IAppActions } from "../actions";
import IAppState from "../state";
const History: Component<never, IAppState, IAppActions> = () => (
{ history },
{ jumpTo }
) => (
<ol>
{history.map((_, move) => {
const desc = move ? `Go to move #${move}` : `Go to game start`;
return (
<li>
<button onclick={() => jumpTo(move)}>{desc}</button>
</li>
);
})}
</ol>
);
export default History;
Index.ts
import { app } from "hyperapp";
import { actions } from "./actions";
import Game from "./components/game";
import IAppState from "./state";
const initialState: IAppState = {
squares: Array(9).fill(null),
nowPlayer: "X",
history: []
};
app(initialState, actions, Game, document.body);
index.html
はIndex.ts
を呼ぶだけなので省略します。
css は react tutorial そのまま使わせてもらいました。
Hyperapp は react + redux よりシンプルなコードでElmアーキテクチャ風の開発が可能なところが好きです。