React 19正式版发布之后,我一直在找一个真正值得用新特性重写一遍的小项目。后台管理系统太板正,电商页面又是老套路,最后我把目标锁定在了井字棋上——这个项目大概是React 19新特性的最好测试场:交互密集、状态流转清晰、随时可以塞进一个AI对手和一堆动画。项目做完之后,我记录了过程中最值得说的几个点,包括把每一步落子当成表单动作、用useOptimistic解决AI思考时的界面卡顿、以及Context直接当Provider和ref作为prop这类简化写法。
如果你也想在真实项目里体验React 19的Actions、useOptimistic、ref直传这些能力,又不想一上来就啃大项目,这篇文章应该能给你一条完整的参考路径。我会从状态设计说到AI算法,再到交互打磨,最后讲几个实际踩过的坑。
1. 为什么偏偏是井字棋
1.1 一个小项目能覆盖多少React 19新特性
井字棋规则简单,但代码结构并不单薄。它有两位玩家、九个格子、八种胜利组合,还要处理回合切换、胜负判定、平局、重开和比分统计。这样一个量级的项目,恰好能把React 19几个核心新能力都过一遍。
| React 19 特性 | 在井字棋里的具体用法 |
|---|---|
| useActionState | 把每一步落子、悔棋、重开都串成动作,通过formData传参 |
| useOptimistic | AI“思考”期间让玩家落子立刻显示,不卡界面 |
| ref 作为 prop | 单元格组件直接接收ref,去掉forwardRef包装 |
| Context 直接作为 Provider | 用<GameContext value={...}>代替.Provider |
| useTransition | 包裹AI决策这类非紧急计算,避免阻塞交互 |
| 异步 Actions | AI决策可以安全地在action里await,isPending自动管理按钮状态 |
这六个特性如果分开写demo,每个都很难体会到真实的价值。但放在井字棋里,它们互相咬合得非常好:落子本质上是状态转换,天然适合用Action表达;AI需要等待,正好用异步action加乐观更新来解决体验问题;棋盘组件频繁需要聚焦控制,ref直传又省掉了一层样板代码。
1.2 功能清单和最终效果
我做的这个版本不是单纯的“双人轮流点格子”,而是往“好玩”方向加了一些东西:
- 双人对战模式和人机对战模式
- AI分三档:随缘、中等、看穿一切
- 悔棋:双人模式回退一步,人机模式回退一整轮
- 比分统计,换边继续
- 落子弹跳动画和胜利连线动画
- 键盘可操作、屏幕阅读器可读的基础无障碍支持
整个过程没有引入状态管理库,没有UI组件库,所有逻辑手写。最终项目的核心逻辑代码量不算大,但每个功能点都能对应到一个React 19的写法变化,这就是我想要的“练手密度”。
2. 状态模型:在React 19里写起来更干净
2.1 数据结构:用moves数组代替一层层嵌套快照
井字棋的状态说多不多,说少也不少:九格棋盘、当前玩家、胜负结果、比分、历史记录。最开始我打算用history快照栈来支持悔棋,后来发现对于九宫格这种规模,直接存一手一手的落子记录、每次从零重算棋盘反而是最不容易出错的方案。
const WIN_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], ]; function createInitialGame(mode = 'ai', aiLevel = 'medium') { return { mode, // 'ai' | 'pvp' aiLevel, // 'easy' | 'medium' | 'hard' moves: [], // 按顺序记录落子下标,用于悔棋和重算 cells: Array(9).fill(null), current: 'X', // 人机模式下玩家永远是X,AI是O winner: null, // 'X' | 'O' | 'draw' | null winningLine: null, scores: { X: 0, O: 0 }, }; } function evaluate(cells) { for (const line of WIN_LINES) { const [a, b, c] = line; if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) { return { winner: cells[a], line }; } } return { winner: cells.every(Boolean) ? 'draw' : null, line: null }; } function buildState(mode, aiLevel, moves, scores) { const cells = Array(9).fill(null); let current = 'X'; let winner = null; let winningLine = null; for (const index of moves) { if (winner || cells[index]) break; cells[index] = current; const result = evaluate(cells); if (result.winner) { winner = result.winner; winningLine = result.line; break; } current = current === 'X' ? 'O' : 'X'; } return { mode, aiLevel, moves, cells, current, winner, winningLine, scores }; } function applyMove(state, index) { if (state.cells[index] || state.winner) return state; return buildState(state.mode, state.aiLevel, [...state.moves, index], state.scores); }把状态还原收敛成buildState这一个纯函数之后,落子、悔棋、重开都只是“增加moves”或“减少moves”的问题。九格棋盘每步重算什么性能代价都可以忽略,换来的是逻辑完全可预测。这个设计后来成了整个项目的地基,后面接Action和Optimistic更新时非常省事。
2.2 Context本身就能当Provider用
React 19之前我们写跨层共享状态,永远是<GameContext.Provider value={...}>。React 19之后,Context自己就是一个可以渲染的Provider节点,传值直接用value属性:
const GameContext = createContext(null); function GameProvider({ children }) { const game = useTicTacToe(); return <GameContext value={game}>{children}</GameContext>; }对比老的写法,少了一层包裹,语义上也更直接——“这个Context的值是什么”一眼就明白。组件树里读取时,我用React 19新加的use()代替useContext:
function StatusBar() { const game = use(GameContext); const text = game.winner ? game.winner === 'draw' ? '平局' : `${game.winner} 获胜` : `轮到 ${game.current}`; return <div aria-live="polite">{text}</div>; }use()相比useContext最实用的地方是它能在条件分支和循环里调用,不像hooks必须在组件顶层。这个项目里用到的不深,但我在写单元格渲染逻辑时确实体验到了灵活性。
2.3 ref作为prop:终于不用forwardRef了
井字棋的每个格子是独立按钮,有时候需要在开局、重开或者悔棋后悔棋后把焦点移回某个格子。在React 18里,给函数组件传ref必须套forwardRef,写法巨丑:
// React 18 写法 const Cell = forwardRef(function Cell({ value, onClick }, ref) { return <button ref={ref} onClick={onClick}>{value}</button>; });React 19里ref就是普通prop,函数组件里想怎么传就怎么传:
function Cell({ ref, index, value, onClick }) { return ( <button ref={ref} type="button" onClick={onClick} aria-label={`第${index + 1}格${value ? `:${value}` : ',空'}`} > {value ?? ''} </button> ); }删除forwardRef不仅少了一层嵌套,也让我重构单元格组件时少了一份“这里为什么包一层”的困惑。后来我把焦点管理加进悔棋逻辑时,直接在父组件里拿到格子ref,干净利落。
3. 把落子设计成“表单动作”:useActionState实践
3.1 为什么落子适合用Action表达
React 19把Actions概念往前推了一大步。所谓Action,本质上是一个会被React跟踪状态的函数:它接收上一个状态和一份输入,返回值变成新状态。这不就是“点击格子→提交一步棋→棋盘状态更新”的天然抽象吗?
刚开始我打算用useReducer,但useActionState额外给了三个好处:
- 异步action自动管理
isPending,AI思考期间可以据此禁用按钮 - action和表单提交天然打通,FormData就是一套现成的命令协议
- 状态更新由React协调,和useOptimistic、useTransition的配合比手写reducer更顺
3.2 用formData当命令通道
我故意在一个reducer里处理三种命令:落子、悔棋、重开。操作类型放在FormData的op字段里,单元格下标放cell字段。这样整个棋盘就是一个“命令收口”的地方,逻辑集中且好追踪。
const [game, dispatchMove, isPending] = useActionState(async (prev, formData) => { const op = formData.get('op'); if (op === 'reset') return resetState(prev); if (op === 'undo') return undoState(prev); const index = Number(formData.get('cell')); if (!Number.isInteger(index) || index < 0 || index > 8) return prev; if (prev.cells[index] || prev.winner) return prev; const afterPlayer = applyMove(prev, index); if (afterPlayer.winner || afterPlayer.mode === 'pvp') { return withScore(afterPlayer, prev); } // AI 回合,模拟一点“思考”时间更符合直觉 await sleep(400 + Math.random() * 500); const aiIndex = chooseAiMove(afterPlayer.cells, afterPlayer.aiLevel); if (aiIndex < 0) return afterPlayer; const afterAi = applyMove(afterPlayer, aiIndex); return afterAi.winner ? withScore(afterAi, afterPlayer) : afterAi; }, createInitialGame('ai', 'medium'));withScore只在状态从无胜者变为有胜者时加一次分,避免重复累计:
function withScore(next, prev) { if (!next.winner || next.winner === 'draw') return next; return { ...next, scores: { ...next.scores, [next.winner]: next.scores[next.winner] + 1 }, }; }这里的核心思路是:不要每一步都去算比分,而是比较相邻两个状态的胜负字段变化。AI模式下玩家一落子,紧接着AI就落子,一次action里走完两个回合,实现简单,也不会出现“玩家刚下完AI还没反应”的中间态。
3.3 按钮和action的接线方式
action可以直接挂在<form>上,但我在实践里碰到了一个麻烦:如果所有格子都是form内部的submit按钮,点击格子后浏览器会自然提交表单,这没问题;可我需要在点击的那一瞬间先触发乐观更新,再让表单提交,顺序上总觉得隔着浏览器一层,不够可控。
所以我的最终方案是用<button type="button">接React的onClick,在事件里手动构造FormData再dispatch:
function handleCellClick(index) { if (display.cells[index] || display.winner || isPending) return; addOptimisticMove({ type: 'move', index }); const fd = new FormData(); fd.set('op', 'move'); fd.set('cell', String(index)); dispatchMove(fd); }这样流程完全透明:先做乐观更新,再派发action。React 19保留了<form action={dispatchMove}>这种声明式写法,如果你的场景是搜索、登录这类“一个提交键”的表单,直接用就行,比我这种多命令游戏要合适得多。
3.4 isPending的正确用法
useActionState返回的isPending在异步action执行期间是true,这个flag非常好用,但要注意别滥用。我一开始图省事,在九个格子上全写了disabled={isPending},结果AI思考的半秒内格子全部灰色,视觉反馈非常僵硬。
后来改成两层控制:
- 格子的disabled只看
display.cells[index]和display.winner,玩家自己的格子已经落子就锁住 - 只有悔棋和重开按钮接
isPending,AI没想完就不允许打断
注意:
isPending不等于“AI正在思考”,它只是action执行期间的pending标记。如果你的action里有网络请求或复杂计算,用同一个flag去禁所有交互,用户体验会变得很死板。
4. AI对手:从随缘落子到极小化极大
4.1 三种难度的设计思路
人机对战如果没有难度梯度,玩两局就腻了。我做了三档AI,核心逻辑非常简单,但体验差异非常大:
| 难度 | 策略 | 体验表现 |
|---|---|---|
| easy | 纯随机走空位 | 玩家基本必赢,适合新手找自信 |
| medium | 能赢就赢,能堵就堵,否则优先中心 | 有来有回,普通人玩起来最舒服 |
| hard | 极小化极大 + 10%随机失误 | 理论上不输,偶尔放水显得“像人” |
4.2 简易AI与启发式AI
const emptyIndices = (cells) => cells.map((v, i) => (v ? -1 : i)).filter((i) => i >= 0); function findWinningIndex(cells, player) { for (const line of WIN_LINES) { const [a, b, c] = line; const marks = [cells[a], cells[b], cells[c]]; if (marks.filter((v) => v === player).length === 2 && marks.includes(null)) { return line.find((i) => cells[i] === null); } } return -1; } function chooseAiMove(cells, level) { const empty = emptyIndices(cells); if (empty.length === 0) return -1; if (level === 'easy') { return empty[Math.floor(Math.random() * empty.length)]; } if (level === 'medium') { const winMove = findWinningIndex(cells, 'O'); if (winMove !== -1) return winMove; const blockMove = findWinningIndex(cells, 'X'); if (blockMove !== -1) return blockMove; if (!cells[4]) return 4; return empty[Math.floor(Math.random() * empty.length)]; } if (Math.random() < 0.1) { return empty[Math.floor(Math.random() * empty.length)]; } return minimax(cells, 'O', 'O').move; }启发式AI的关键是“先看自己有没有一步赢,再看对手有没有一步赢”,这两条在井字棋里覆盖了绝大多数非平局场景。中心格的价值在于同时连接四条胜利线,属于教科书级别的优先位置。
4.3 极小化极大:让AI“开挂”
hard难度的核心是极小化极大算法。它的基本逻辑像两个棋手在做“如果我怎么走、对方会怎么回”的推演:AI找到一个能让自身胜率最大的走法,同时假设对手每一步都走最优解。
function minimax(cells, current, ai) { const result = evaluate(cells); if (result.winner === ai) return { score: 10, move: -1 }; if (result.winner && result.winner !== 'draw') return { score: -10, move: -1 }; if (result.winner === 'draw') return { score: 0, move: -1 }; const legalMoves = emptyIndices(cells); let best = current === ai ? { score: -Infinity, move: -1 } : { score: Infinity, move: -1 }; for (const move of legalMoves) { const next = [...cells]; next[move] = current; const outcome = minimax(next, current === 'X' ? 'O' : 'X', ai); outcome.move = move; if (current === ai) { if (outcome.score > best.score) best = outcome; } else { if (outcome.score < best.score) best = outcome; } } return best; }井字棋的搜索树非常小,最大深度九层,每层最多九个分支,总共也就几十万个节点,纯JS算下来毫秒级。我还在hard难度里加了10%的随机失误,不然AI永远抢先手/堵死所有路线,玩家体验其实是“和一根电线杆下棋”。
4.4 把AI逻辑放纯函数里的价值
chooseAiMove和minimax都是纯函数:同样的棋盘输入,同样的难度,结果可预期。放在action里调用时,我可以放心让它们被重复执行而不产生副作用。这也是React 19推荐写Action的方式——不要在action内部塞Math.random()之外的隐式依赖,不要让action依赖外部可变变量。
如果你在调试时遇到StrictMode下AI会走两步不同棋的问题,先检查AI决策是否使用了外部状态。把随机种子、难度参数都显式传入函数,问题基本就消失了。
5. useOptimistic:让玩家落子先“落”下来
5.1 没有Optimistic时是什么体验
最初我把AI回合放在action里做异步等待,玩家点完格子后,按钮上的X要等至少400毫秒才出现。400毫秒在真实体验里非常漫长,尤其是你明明点了一个格子,界面却毫无反应,第一反应是自己没点到。
这个问题的根源不是“状态更新慢”,而是我在用“等待真实结果”的方式渲染UI,完全没有必要。
5.2 改造流程:乐观更新 + 源状态同步
useOptimistic的思路是先“骗一下”用户:你点了格子,我立刻把X画上去,后台再异步跑真实逻辑,跑完用真实状态覆盖。
const [display, addOptimistic] = useOptimistic( game, (current, optimistic) => { if (optimistic.type !== 'move') return current; if (current.cells[optimistic.index]) return current; return applyMove(current, optimistic.index); } ); function move(index) { if (display.cells[index] || display.winner || isPending) return; addOptimistic({ type: 'move', index }); const fd = new FormData(); fd.set('op', 'move'); fd.set('cell', String(index)); dispatchMove(fd); }这里的game是useActionState返回的真实状态,display是渲染时用的状态。点击格子后,addOptimistic立刻在源状态上叠加一步落子,渲染层马上出现X。然后action在后台执行:玩家落子、AI思考、AI落子,最终把新的真实状态交给game。
当真实状态推进到和乐观值一致时,乐观层自动“退场”,display就等于game。整个过程玩家看到的是:自己落子秒显示,半秒后AI落子,没有任何卡顿。
5.3 两个必须注意的边界条件
useOptimistic的updateFn必须在面对已经存在的格子里是“幂等”的。因为乐观值会一直挂着直到源状态追上,中间可能因为各种re-render被反复调用。我这里用if (current.cells[optimistic.index]) return current;做拦截,避免同一手棋被重复放置。
另一个坑是并发乐观更新。如果玩家快速点了两个格子,第一次的乐观值还没被真实状态吸收,第二次的乐观值又加进来,渲染层可能出现两个X。我的解决方式很简单:把isPending纳入点击守卫,pending期间不允许再落子。井字棋本身也不该允许连下两步,这个限制和游戏规则天然一致。
注意:useOptimistic不是用来替代状态管理的,它是“源状态之上的表达层”,源状态永远以useActionState为准。切不要把dispatch和addOptimistic混用,否则会出现乐观值和真实状态对不上的问题。
5.4 实际体验对比
我做了个简单对比:没有useOptimistic的版本,AI思考400ms,玩家点击后X出现要等400ms;加了useOptimistic之后,X是同步出现的,AI的O在真实状态生效时才出现。从玩家视角看,这就是“我一下完,AI马上接招”,节奏感完全不同。
尤其当AI思考时间拉到800ms以上时,没有乐观更新的版本会让玩家怀疑自己点击是否成功。如果你做的游戏涉及网络请求、服务端校验这类延时场景,useOptimistic的价值会更明显——它本质上是给UI一层“即时反应”的缓冲,让用户不必等待真实数据往返。
6. 动画、悔棋、无障碍和踩坑收尾
6.1 落子和胜利连线:小动画让“好玩”落地
落子动画我用了最简单的CSS pop-in,格子被填充的瞬间加一个弹性缩放:
.cell.filled { animation: pop-in 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.4); } @keyframes pop-in { 0% { transform: scale(0.6); opacity: 0.3; } 100% { transform: scale(1); opacity: 1; } }胜利连线用SVG画在棋盘上层。格子是300x300区域,每个格子100x100,取连续三格的起点和终点坐标,画一条带断续效果的线:
function WinLineOverlay({ line }) { if (!line) return null; const pos = (i) => ({ x: (i % 3) * 100 + 50, y: Math.floor(i / 3) * 100 + 50, }); const start = pos(line[0]); const end = pos(line[2]); return ( <svg className="win-overlay" viewBox="0 0 300 300" aria-hidden="true"> <line x1={start.x} y1={start.y} x2={end.x} y2={end.y} /> </svg> ); }SVG的stroke-dasharray加CSS动画,让连线像一条流动的虚线从起点滑到终点,视觉上比整条线直接弹出来舒服得多。给SVG元素加上aria-hidden="true",避免它被屏幕阅读器当成额外内容朗读。
6.2 悔棋和重新开始:moves数组的好处体现出来了
因为状态完全由moves驱动,悔棋就是砍掉最后一手或两手的索引再重算:
function undoState(state) { if (!state.moves.length) return state; const steps = state.mode === 'ai' ? 2 : 1; const moves = state.moves.slice(0, Math.max(0, state.moves.length - steps)); return buildState(state.mode, state.aiLevel, moves, state.scores); } function resetState(state) { return buildState(state.mode, state.aiLevel, [], state.scores); }resetState保留比分,适合连续对战;如果哪一局大比分落后想重开一局清零比分,再做个小按钮调用createInitialGame即可。悔棋按钮我接上了isPending,AI没想完就不能打断对局,这个判断在早期版本被我漏了,结果出现了“AI还在思考,玩家抢先悔棋,AI又按旧状态落子”的错乱。
6.3 无障碍:让井字棋也能被“读”出来
游戏类小项目最容易忽略无障碍,但井字棋的格子就是九个按钮,天然具备键盘可操作性,只需要额外做好三件事:
- 每个格子有清晰的
aria-label,如实心格标为“第5格:X”,空格标为“第5格,空” - 状态栏加
aria-live="polite",胜负和轮到谁的信息能主动播报 - 胜利连线SVG加
aria-hidden,避免阅读器把视觉装饰读成乱码
另外格子之间的键盘导航顺序是DOM顺序,也就是从左到右、从上往下,和井字棋的阅读顺序一致,用户Tab键走一遍基本不会有认知负担。
6.4 踩坑记录:StrictMode、竞态和动画丢帧
最后集中说一下开发中遇到的几个值得留痕的问题。
第一个是StrictMode下的双调用。React开发模式会刻意重复执行一些纯函数来暴露副作用,我在action里写Math.random()选AI走子时,遇到过AI同一局面走出不同棋的情况。这不算bug,但它提醒我一个原则:action要写得可重入,AI决策里的随机性要么接受“第二次执行结果生效”,要么把随机因子显式提取出来。
第二个是乐观更新和disabled状态的配合。禁用状态必须用display计算,不能用源状态game。如果你用game.cells去判断格子是否已经落子,在乐观更新生效的瞬间,源状态还没变,按钮会短暂地仍可点击,导致连点或重复dispatch。
第三个是动画丢帧。胜利连线SVG依赖winningLine坐标,如果重开时只是把winningLine置空,但把SVG卸载的逻辑写在effect里,会出现一帧旧线闪烁。后来我改成直接在渲染层判断:winningLine为null就不渲染SVG,代码更直白,也没有闪烁问题。
开发到后半段我最深的一个体会是:React 19的这些新能力并不是为了炫技,而是把过去需要用额外库或者别扭写法才能做到的事情,变成了框架底层的默认能力。井字棋虽然是个九宫格小游戏,但Actions、乐观更新、ref直传这三样东西合起来,已经足够应付很多真实业务里的复杂交互场景。如果看完你也想动手试试,别贪多,哪怕只把useActionState用在登录表单上,都比单独跑十个demo要有收获。