跳转到内容

井字棋教程

本教程会用 IO 的 Tree、derived 以及批量更新,完成一个简洁的井字棋。

你将学到:

  • Tree 路径 Unit 的订阅方式。
  • derived() 如何组合业务状态。
  • commit/batch 降低渲染与更新成本。

用一个 Scope 承载全部游戏状态,更新更集中。

import { io } from '@iostore/store';
type Player = 'X' | 'O';
type GameState = {
squares: Array<Player | null>;
next: Player;
};
export const game = io<GameState>({
squares: Array(9).fill(null),
next: 'X',
});

derived() 计算只读派生值。

import { derived } from '@iostore/store/derived';
import { game } from './game';
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],
] as const;
function calculateWinner(squares: Array<'X' | 'O' | null>) {
for (const [a, b, c] of LINES) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
export const winner = derived([game.squares], (squares) =>
calculateWinner(squares),
);
export const status = derived(() => {
const win = winner.get();
if (win) return `胜者:${win}`;
if (game.squares.get().every(Boolean)) return '平局';
return `下一手:${game.next.get()}`;
});

useIO 读取状态,用一次 commit 完成落子和轮换。

import { useIO } from '@iostore/react';
import { game, status, winner } from './game';
function handleClick(index: number) {
if (winner.get() || game.squares[index].get()) return;
game.commit((draft) => {
draft.squares[index] = draft.next;
draft.next = draft.next === 'X' ? 'O' : 'X';
});
}
function Square({ index }: { index: number }) {
const value = useIO(game.squares[index]);
return (
<button className="square" onClick={() => handleClick(index)}>
{value}
</button>
);
}
export function Board() {
const label = useIO(status);
return (
<div className="board">
<div className="status">{label}</div>
<div className="grid">
{Array.from({ length: 9 }).map((_, index) => (
<Square key={index} index={index} />
))}
</div>
</div>
);
}

重置也是一次 commit

export function resetGame() {
game.commit((draft) => {
draft.squares = Array(9).fill(null);
draft.next = 'X';
});
}

订阅更新日志并在新 Store 中回放,UI 逻辑无需更改。

import { io } from '@iostore/store';
import { replay } from '@iostore/store/patches';
import type { IoUpdate } from '@iostore/store/patches';
import { game } from './game';
const updates: IoUpdate[] = [];
const stop = game.subscribeUpdate((u) => updates.push(u));
// 需要回放时:
export function replayFromStart() {
const nextGame = io({
squares: Array(9).fill(null),
next: 'X',
});
replay(nextGame, updates);
stop();
return nextGame;
}
下一手:X
重置
import { batch, io } from '@iostore/store';
import { derived } from '@iostore/store/derived';
import { useIO } from '@iostore/react';
type Player = 'X' | 'O';
const game = io({
squares: Array<Player | null>(9).fill(null),
next: 'X' as Player,
});
const boardStyle = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
fontFamily: 'system-ui, sans-serif',
};
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(3, 64px)',
gridAutoRows: '64px',
gap: '8px',
};
const squareStyle = {
width: '64px',
height: '64px',
fontSize: '24px',
lineHeight: '1',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #cbd5e1',
borderRadius: '8px',
background: '#fff',
cursor: 'pointer',
};
const resetStyle = {
padding: '6px 12px',
border: '1px solid #cbd5e1',
borderRadius: '6px',
background: '#f8fafc',
cursor: 'pointer',
};
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],
] as const;
function calculateWinner(squares: Array<Player | null>) {
for (const [a, b, c] of LINES) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
const winner = derived([game.squares], (squares) => calculateWinner(squares));
const status = derived(() => {
const win = winner.get();
if (win) return `胜者:${win}`;
if (game.squares.get().every(Boolean)) return '平局';
return `下一手:${game.next.get()}`;
});
function handleClick(index: number) {
if (winner.get() || game.squares[index].get()) return;
const current = game.next.get();
batch(() => {
game.squares[index].set(current);
game.next.set(current === 'X' ? 'O' : 'X');
});
}
function resetGame() {
batch(() => {
for (let i = 0; i < 9; i += 1) {
game.squares[i].set(null);
}
game.next.set('X');
});
}
function Square({ index }: { index: number }) {
const value = useIO(game.squares[index]);
return (
<span
role="button"
tabIndex={0}
style={squareStyle}
onClick={() => handleClick(index)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick(index);
}
}}
>
{value}
</span>
);
}
export default function TicTacToe() {
const label = useIO(status);
return (
<div style={boardStyle}>
<div>{label}</div>
<div style={gridStyle}>
{Array.from({ length: 9 }).map((_, index) => (
<Square key={index} index={index} />
))}
</div>
<div
role="button"
tabIndex={0}
style={resetStyle}
onClick={resetGame}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
resetGame();
}
}}
>
重置
</div>
</div>
);
}