ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

React 19井字棋:Actions、useOptimistic与ref直传

React 19井字棋:Actions、useOptimistic与ref直传 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传参useOptimisticAI“思考”期间让玩家落子立刻显示不卡界面ref 作为 prop单元格组件直接接收ref去掉forwardRef包装Context 直接作为 Provider用GameContext value{...}代替.ProvideruseTransition包裹AI决策这类非紧急计算避免阻塞交互异步 ActionsAI决策可以安全地在action里awaitisPending自动管理按钮状态这六个特性如果分开写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, // 人机模式下玩家永远是XAI是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()代替useContextfunction StatusBar() { const game use(GameContext); const text game.winner ? game.winner draw ? 平局 : ${game.winner} 获胜 : 轮到 ${game.current}; return div aria-livepolite{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} typebutton 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自动管理isPendingAI思考期间可以据此禁用按钮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 typebutton接React的onClick在事件里手动构造FormData再dispatchfunction 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玩家自己的格子已经落子就锁住只有悔棋和重开按钮接isPendingAI没想完就不允许打断注意isPending不等于“AI正在思考”它只是action执行期间的pending标记。如果你的action里有网络请求或复杂计算用同一个flag去禁所有交互用户体验会变得很死板。4. AI对手从随缘落子到极小化极大4.1 三种难度的设计思路人机对战如果没有难度梯度玩两局就腻了。我做了三档AI核心逻辑非常简单但体验差异非常大难度策略体验表现easy纯随机走空位玩家基本必赢适合新手找自信medium能赢就赢能堵就堵否则优先中心有来有回普通人玩起来最舒服hard极小化极大 10%随机失误理论上不输偶尔放水显得“像人”4.2 简易AI与启发式AIconst 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 classNamewin-overlay viewBox0 0 300 300 aria-hiddentrue line x1{start.x} y1{start.y} x2{end.x} y2{end.y} / /svg ); }SVG的stroke-dasharray加CSS动画让连线像一条流动的虚线从起点滑到终点视觉上比整条线直接弹出来舒服得多。给SVG元素加上aria-hiddentrue避免它被屏幕阅读器当成额外内容朗读。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即可。悔棋按钮我接上了isPendingAI没想完就不能打断对局这个判断在早期版本被我漏了结果出现了“AI还在思考玩家抢先悔棋AI又按旧状态落子”的错乱。6.3 无障碍让井字棋也能被“读”出来游戏类小项目最容易忽略无障碍但井字棋的格子就是九个按钮天然具备键盘可操作性只需要额外做好三件事每个格子有清晰的aria-label如实心格标为“第5格X”空格标为“第5格空”状态栏加aria-livepolite胜负和轮到谁的信息能主动播报胜利连线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要有收获。
返回列表