从井字棋项目掌握前端核心:数据结构、状态管理与AI实现思路

发布时间:2026/9/23 2:38:37
从井字棋项目掌握前端核心:数据结构、状态管理与AI实现思路 井字棋CodePen 上每天都能搜出一堆版本看起来就是个前端三件套入门小项目。但说实话我见过太多人照着网上的模板敲一遍就跑结果连为什么棋盘用数组存而不是直接改 DOM、AI 为什么不先抢中间格这种最基础的问题都答不上来。这个项目的价值恰恰不在能跑而在于它同时覆盖了数据结构设计、DOM 事件流、状态管理思维、算法分支处理这几块硬骨头。这篇文章不写 Demo 级流水账我就按我自己从零写一遍的完整思路来讲——包括 HTML 骨架怎么搭、CSS 如何兼顾手感和胜负反馈、JS 的判胜逻辑怎么写得干净、以及单机 AI 的三种进阶写法。适合已经会基础标签和语法、想认真做一个小项目练手的人。1. 动手写代码前先把游戏规则翻译成程序逻辑很多人一上来就写div拼格子这是典型的方向性错误。代码是规则的投影规则想不清楚代码写多少改多少。1.1 井字棋规则里藏着的三个核心概念井字棋的规则用一句话说9 个格子两人轮流在空格上画 X 或 O谁先把三个连续格子连成一线横、竖、斜都算谁赢。但这句人话要翻译成程序至少拆出三个对象棋盘状态9 个格子当前分别是什么。这是整个游戏唯一的数据源我习惯用board数组表示后面详细说。当前行动者轮到谁下。它是一个布尔值或者字符串每次落子后翻转。动作结果落子之后游戏是继续、分出胜负还是平局。这是逻辑判断的核心。很多教程把这三样东西散落在全局变量里player1、player2、turn、winner满天飞。我一开始也这样写但后来发现一旦加 AI、加重置、加计分状态一多就容易乱。更好的做法是把它们装进一个对象比如let gameState { board: Array(9).fill(), currentPlayer: X, gameOver: false, winner: null, winLine: [] };这样设计的好处是任何时候你想知道这局进行到哪了只需要看这一个对象的属性不用满文件找变量。后续加重置功能无非是把gameState重新赋一遍初值。1.2 棋盘用一维数组还是二维数组我的选择这是个老生常谈的问题但真的是分水岭。二维数组board[row][col]符合人类直觉可是判胜的时候你要写双重循环遍历行、列、对角线代码量明显更大。一维数组board[0]到board[8]虽然抽象一点但配合索引映射写出判胜代码非常简洁——因为三连线的所有可能组合是有限的直接枚举即可。我的建议是用一维数组。理由很实际渲染 DOM 的时候正好按索引遍历document.querySelectorAll(.cell)[index]直接一一对应省去坐标换算。判胜时把 8 种胜利组合预定义成二维数组一次some遍历搞定代码量只有二维方案的零头。后续如果做 AI 评估函数一维数组更方便复制和比较比如board.slice()就能快速生成模拟棋盘。这个选择不是哪个更高级而是哪个让你少写 100 行 debug 代码。对于小体量项目简单直接就是最大的优点。2. HTML 骨架子必须静态动态内容全交给 JS井字棋的 HTML 部分特别容易写成两种极端一种是 9 个 div 全在 HTML 里手写另一种是 HTML 里只有一个空容器9 个格子全部用 JScreateElement动态创建。两种我都试过各自有坑。2.1 我推荐的初始结构容器 9 个预置格子第一种手写 9 个格子没有错但配合事件委托时你得给每个格子加>div classgame-container h1井字棋/h1 div classturn-info轮到span idturn-indicatorX/span/div div classboard idboard div classcell>.board { display: grid; grid-template-columns: repeat(3, 120px); grid-template-rows: repeat(3, 120px); gap: 4px; background-color: #2c3e50; padding: 4px; border-radius: 8px; }这个方案的精髓在于每个格子自己不设边框棋盘背景色是深色格子是浅色gap: 4px留出的空隙就自然形成了网格线。好处是颜色、线宽统一还自带圆角效果。如果你用传统border画线会出现相邻格子边框重合变粗的问题还得额外写margin-left: -1px或者border-collapse之类的 hack 来救属于典型的用错工具难受半天。格子内部的样式重点在如何保证 X 和 O 都居中且醒目.cell { background-color: #ecf0f1; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 64px; font-weight: 700; cursor: pointer; user-select: none; transition: background-color 0.2s ease; }user-select: none是用来防止玩家快速连点格子时选中文字手机端尤其明显。这个属性很多新手根本想不到但不加的话玩起来总是会出现蓝色选区观感很掉价。3.2 胜负高亮把状态用颜色说出来一个只会显示 X/O 的棋盘只是完成了一半。好的交互设计是让玩家一眼看懂状态轮到谁、谁赢了、赢在哪三条线。我通过给格子加状态类来实现.cell.win-line { background-color: #27ae60; color: #fff; animation: pop 0.3s ease; } .cell.disabled { cursor: not-allowed; }当判胜逻辑执行完如果发现赢家就把赢家所在三条线的格子全部加上win-line类。用背景色和高亮动画把这局结束了赢的棋在这里这个信息瞬间传达给玩家。动画我用一个简单的keyframes让格子微微放大一下keyframes pop { 0% { transform: scale(0.8); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } }这个细节成本极低但游戏体验的提升非常明显——没有高亮的时候玩家经常要自己数半天到底哪三个连成线了尤其是对角线胜利时。3.3 鼠标悬停的反馈怎么做才不出 bug新手最爱犯的一个错在 CSS 里写了.cell:hover { background-color: xxx }然后不管格子有没有被占悬停都会变色。这会给玩家错误的暗示——这里还能点。正确的做法是配合 JS 状态给格子加类.cell:not(.filled):hover { background-color: #bdc3c7; }CSS 里用:not(.filled)过滤只有空格子才能 hover 变色。JS 在格子落子后统一给格子加filled类。这样可点性的视觉暗示就和真实状态严格对齐了不会出现点了没反应却还能 hover 的诡异情况。CSS 的:not()选择器在这种场景下比 JS 判断要简单得多能不用 JS 表达的样式逻辑就别用 JS。4. JS 核心逻辑事件委托、落子流程、判胜算法一条线JS 才是这个项目的灵魂。很多人的代码能跑起来但经不起问如果连续快速点击会怎样、如果先横着赢再竖着赢会不会判定错这类问题。下面是我验证过多次的稳定写法。4.1 事件委托不要在 9 个格子上绑 9 个监听器如果你写document.querySelectorAll(.cell).forEach(...)给每个格子单独绑事件效果没错但很啰嗦而且一旦格子被动态替换比如以后想自己做复盘动画监听器就丢了。事件委托把监听器绑到父容器上利用事件冒泡机制统一处理代码干净、性能也更好document.getElementById(board).addEventListener(click, handleCellClick); function handleCellClick(event) { const cell event.target; if (!cell.classList.contains(cell)) return; const index Number(cell.dataset.index); makeMove(index); }这里有个微妙的点event.target不一定是你绑事件的元素本身。假设格子内部以后塞了span来画 X/O 的符号玩家点击的实际元素可能是span这时event.target.classList.contains(cell)就是 false。稳妥的写法是判断cell.closest(.cell)。对于纯文字格子event.target就是格子本身但养成closest的习惯总没错。4.2 落子主流程检查、更新、渲染、切换四步走一个规范的落子流程应当严格按顺序执行且每一步都要有守卫条件function makeMove(index) { // 1. 守卫条件游戏已结束或格子已被占用直接返回 if (gameState.gameOver) return; if (gameState.board[index] ! ) return; // 2. 更新数据源先更新数据再更新视图 gameState.board[index] gameState.currentPlayer; // 3. 渲染视图 renderCell(index); // 4. 判断胜负 / 平局 if (checkWin()) { endGame(true); return; } if (isDraw()) { endGame(false); return; } // 5. 切换玩家 switchPlayer(); }这里最重要的一条原则是先更新数据再更新界面。因为后续的胜负判断依赖的是数据而不是 DOM。如果你先改了 DOM 上的文字再去检查 board等于舍近求远还容易出错。数据驱动视图是前端状态管理的最小范式这个项目就是最好的练手场景。renderCell的实现很简单function renderCell(index) { const cell document.querySelector(.cell[data-index${index}]); cell.textContent gameState.board[index]; cell.classList.add(filled); }注意我用的选择器是[data-index...]配合模板字符串。也可以用传给handleCellClick的cell参数直接更新但独立渲染函数的好处是重置游戏时可以用同样的函数把格子恢复空白保持渲染逻辑唯一。4.3 判胜算法8 条胜利组合一次性枚举井字棋的胜利条件只有 8 种3 行、3 列、2 条对角线。代码实现不用写循环直接预定义数组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 checkWin() { return WIN_LINES.find(line { const [a, b, c] line; if (gameState.board[a] gameState.board[a] gameState.board[b] gameState.board[a] gameState.board[c]) { gameState.winLine line; gameState.winner gameState.board[a]; return true; } return false; }); }用find返回第一个满足条件的三元组存进winLine后面渲染高亮时直接用这三个索引。判断表达式的写法是固定套路拿第一个元素当基准要求它非空然后用和另外两个比较。这里有一个很多人忽略的细节board[a] 的情况必须排除。比如初始状态下 0、4、8 三个格子都为空如果不用board[a]做非空判断程序会把空字符串当成三个相等而误判胜利。4.4 平局判定与游戏结束状态平局判定很简单没有任何空格子且没有胜利者。这里不需要写双重循环board数组的every即可function isDraw() { return gameState.board.every(cell cell ! ); }endGame负责做三件事标记gameOver、更新界面提示信息、高亮胜利线如果是胜利的话function endGame(isWin) { gameState.gameOver true; const indicator document.getElementById(turn-indicator); if (isWin) { indicator.textContent ${gameState.winner} 获胜; gameState.winLine.forEach(i { document.querySelector(.cell[data-index${i}]).classList.add(win-line); }); } else { indicator.textContent 平局; } }最后切换玩家function switchPlayer() { gameState.currentPlayer gameState.currentPlayer X ? O : X; document.getElementById(turn-indicator).textContent gameState.currentPlayer; }这一套流程做完双人模式就完整可玩了。先确保这个版本稳定运行再去做 AI因为 AI 本质上只是代替第二个玩家调用makeMove而已。5. 单人模式AI 对手的三种写法从会下到不会输很多教程把 AI 做成随机落子玩家毫无挑战性玩一把就腻了。我做了一个三阶段版本每阶段都是独立的函数方便你对比理解。5.1 初级 AI随机落子用来跑通流程最简单的 AI 实现是从空格子里随机挑一个。这个版本虽然是人工智障但它完成了最重要的事——验证 AI 和游戏主循环的接口对接没毛病。function aiMoveRandom() { const emptyIndices gameState.board.reduce((acc, cell, idx) { if (cell ) acc.push(idx); return acc; }, []); const randomIndex emptyIndices[Math.floor(Math.random() * emptyIndices.length)]; makeMove(randomIndex); }注意 AI 不能直接调用makeMove之外的其他函数乱改棋盘它必须走和玩家一样的落子流程这样所有守卫逻辑判胜、平局、切换都能复用。这体现了封装的价值你不需要为 AI 单独写一套判胜逻辑。5.2 中级 AI先赢、再堵、抢中心用井字棋的经验规则来提升 AI 水平如果 AI 能一步赢就下那一步如果玩家下一步要赢就堵住否则抢占中心格再否则随机。这四条规则写出来非常直观function aiMoveSmart() { // 1. 若自己能赢直接赢 for (let i 0; i 9; i) { if (gameState.board[i] ) { gameState.board[i] O; if (checkWin()) { gameState.board[i] ; makeMove(i); return; } gameState.board[i] ; } } // 2. 若玩家下一步能赢堵住 for (let i 0; i 9; i) { if (gameState.board[i] ) { gameState.board[i] X; if (checkWin()) { gameState.board[i] ; makeMove(i); return; } gameState.board[i] ; } } // 3. 中心格优先 if (gameState.board[4] ) { makeMove(4); return; } // 4. 随机兜底 aiMoveRandom(); }这个写法的核心技巧是模拟落子再撤销。我先把一个临时值写进board[i]调用checkWin()判断结果然后立刻恢复为空最后才真正调用makeMove(i)。因为checkWin只读数据不修改 UI所以这种临时实验是安全的。唯一需要注意的是临时改board之后必须恢复否则会影响后续判断。这是所有棋类 AI 的基础操作后面学 minimax 也是这个思路。这个程度的 AI 对付普通玩家已经基本够用至少不会出现AI 眼看着你三连还不堵的蠢操作。它唯一的弱点是没有策略性——它只会应对你当前的威胁不会主动制造双连通两个方向同时有威胁让你防不胜防。5.3 进阶思路双连通威胁与 minimax 预告真正让井字棋 AI不会输的武器是双连通创造。你可以先在中间落子把两个角都占了形成两个方向同时差一子的局面玩家只能堵一边AI 下另一边就赢了。这个过程在经验规则 AI 里很难用几条 if 写全但用 minimax极小极大搜索算法可以轻松覆盖。minimax 的思路是把当前棋盘当作根节点AI 落子后的每一种可能结果都展开成一棵树双方都假设对方足够聪明最终选择保证自己不败的最优解。井字棋的搜索空间只有 9! 种局面性能完全不是问题。不过对这篇教程来说经验规则 AI 已经能代表可用阶段的完整实现。以我个人的经验新手能独立完成到中级 AI 这个阶段对递归和回溯的理解都会上一个台阶。minimax 可以作为下一个挑战项目单独研究。6. 重置逻辑、事件陷阱与完整代码整合模块都齐了剩下的就是重置和集成。这块也是最容易出 bug 的地方。6.1 重置功能数据重置 视图重置缺一不可重置按钮的核心是把gameState恢复初始值然后把所有格子的 DOM 也恢复。这两个动作缺一个都会出现数据全空了但界面上还留着 X/O的诡异状态document.getElementById(reset-btn).addEventListener(click, resetGame); function resetGame() { gameState.board Array(9).fill(); gameState.currentPlayer X; gameState.gameOver false; gameState.winner null; gameState.winLine []; document.querySelectorAll(.cell).forEach(cell { cell.textContent ; cell.classList.remove(filled, win-line); }); document.getElementById(turn-indicator).textContent X; }一个容易踩的坑很多新手在resetGame里直接给gameState赋一个新的对象字面量但其他函数里闭包捕获的gameState引用还是旧的。为了避免这种引用错乱我建议把gameState用let定义重置时逐字段赋值而不是整体替换对象。这个经验在大型项目里同样适用状态重置尽量原地修改保持外部引用稳定。6.2 完整代码单文件即可运行整合后整个项目只需要一个 HTML 文件CSS 和 JS 内嵌即可。下面是完整可运行版本!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title井字棋游戏/title style /* ... 上文所有样式合并 ... */ /style /head body !-- 上文结构部分 -- script // 同上文所有 JS 逻辑合并 /script /body /html我建议你在本地实际敲一遍而不是直接复制因为自己敲的过程会暴露很多我以为会了的地方。敲完以后用浏览器打开按下面表格的用例逐条测试。6.3 我踩过的三个高频陷阱整理一下我在写这个项目时真正花过时间排查的问题比很多教程里的注意事项更值得你看第一个坑重复绑定事件导致 AI 连下两步。把aiMove()绑定在某个按钮上以后又用setTimeout触发了一次结果 AI 在短时间内执行了两次makeMove跳过了玩家回合。解决方法是AI 函数入口处先校验gameState.currentPlayer O并且把setTimeout的返回值存起来在重置时clearTimeout。第二个坑点击格子触发了两次移动。这是因为事件冒泡——格子的click先触发handleCellClick然后冒泡到board容器上又触发了一次。我在容器上也绑了监听器导致双重执行。解决办法是监听器只绑在容器上通过event.target判断不要在格子上再单独绑一次。这个问题的排查思路是在makeMove第一行加console.log每点一次格子如果打印了两次就是重复绑定。第三个坑胜负判断只在点击后执行AI 回合结束后忘了查。我的 AI 函数在makeMove之后直接 return没有等makeMove内部的checkWin逻辑执行完。实际上makeMove里已经处理了问题出在我自己在 AI 函数里又写了一套独立的终局判断两套逻辑打架。解决办法是AI 必须复用makeMove流程绝对不再写第二套判胜代码。6.4 测试用例清单最后分享一份我常用的手动测试清单适合开发者快速验证游戏逻辑测试场景操作步骤预期结果正常落子轮流点击空格格子显示 X/O轮次提示切换重复落子同一格子点两次第二次点击无反应横向胜利X 下 0、1、2三个格子高亮提示 X 获胜纵向胜利O 下 0、3、6三个格子高亮提示 O 获胜斜向胜利X 下 0、4、8对角线高亮提示 X 获胜平局按从来不分胜负的走法下满提示平局无格子高亮终局后再点胜利后点任意空格无任何反应重置游戏结束后点重置棋盘清空轮次回到 X这套用例不用全自动手动跑一遍也就两三分钟但能把逻辑边界全部覆盖到。我每次给这个项目加新功能比如加计分板、加音效都会先跑一遍这份清单确认核心逻辑没被改坏。从双人对战到 AI 对手再到完整的重置交互这个项目做完你对 DOM 操作、数据驱动渲染、状态切换这些前端基本功的理解会非常扎实。别急着往里面堆功能先把这些逻辑吃透再考虑加计分动画、本地存储这些花活。另外如果你想让棋盘自适应不同屏幕记得在 CSS 里把格子尺寸改成min(120px, 25vw)这类响应式写法我在手机端测试时就发现固定 120px 在窄屏上会溢出改成百分比后问题马上解决了。这个小细节算是给做完基础版的人留的一个进阶作业。