原生JavaScript井字棋实战:从布局、逻辑到AI扩展
1. 先别急着写代码井字棋项目到底在做什么1.1 需求拆解一个简单游戏背后的功能清单很多前端初学者在练习项目时会陷入一个误区一上来就噼里啪啦写HTML结构把九个格子画出来然后发现不知道下一步该干什么写着写着代码就乱成一团。井字棋看似简单但它是一个典型的有状态、有交互、有判定逻辑的完整小游戏麻雀虽小五脏俱全。在做之前我建议你先拿出纸笔把玩家能遇到的所有场景列出来。拿我自己当时练手举例整理出来的功能清单大概是这样的游戏需要一个3x3的棋盘共9个格子每个格子都能点击。两名玩家轮流落子先手用“X”后手用“O”棋子在格子里要清晰可见。任何人只要在横、竖、斜任意方向连成三子立刻判定获胜并且游戏终止。9个格子全部下满却没人连成三子判定为平局。一局游戏结束后要有明显的胜负反馈同时提供一个“再来一局”的按钮可以随时重置棋盘。这个过程看起来不起眼但它是整个项目最重要的一步。因为当你把功能列清楚后代码结构其实已经呼之欲出了HTML负责“有什么”CSS负责“长什么样”JS负责“怎么玩”。这个分层思路在任何前端项目里都适用哪怕以后你做React、Vue项目组件拆分的底层逻辑也是这套。1.2 技术选型为什么不碰框架坚持用原生三件套你可能会有疑问现在前端开发都上框架了练这么个井字棋有必要用原生HTML、CSS、JS吗我的观点是这个项目恰恰是最适合用原生的场景没有之一。井字棋的复杂度恰好卡在一个甜蜜点如果只用HTML和CSS你会发现交互做不出来判断逻辑无处安放如果上框架又显得杀鸡用牛刀——为了一个棋盘引入一整套虚拟DOM机制反而把学习重点从“逻辑思维”带偏到“框架API记忆”上。原生JavaScript处理这种规模的状态管理代码量大概在100行上下逻辑链路短出问题好排查对理解DOM操作、事件机制、作用域这些核心概念帮助极大。另外提一个实际场景这类小游戏经常被用来做入职笔试、面试考察或者前端课程作业面试官想看的正是你用最朴素的手段把复杂逻辑理清楚的能力。如果你一开口就说要用React写反而会让人怀疑你的基础是否扎实。我见过不少候选人在白板上手写原生JS时卡在事件绑定和状态同步上这恰恰说明“基础三件套”没那么简单。1.3 核心玩法规则与程序设计的对应关系井字棋的规则一句话就能说完两人轮流在3x3方格中落子先连成三子者胜。但你如果把规则翻译成程序逻辑就需要建立几个关键概念棋盘状态用数组表示。一个长度为9的数组board下标0到8分别对应9个格子元素值用null表示空、X表示先手棋子、O表示后手棋子。为什么用数组而不是用9个独立变量因为数组可遍历、可索引、可重置你要做胜负判定时只需要遍历一遍数组不需要写9个变量的判断组合这就是数据结构思维的第一课。当前回合是一个状态。用一个currentPlayer变量记录轮到谁每次落子后切换。这个变量是游戏流转的“心脏”很多新手的代码出bug就出在这个地方——要么忘了切换要么切换了但没有同步更新界面上的提示文字。获胜条件是静态规则。3x3井字棋的获胜组合是有限的一共8种3行、3列、2条对角线。你可以先手写一个包含全部获胜索引组合的二维数组winLines每次落子后去比对。这个做法叫“查表法”代码简单到极点性能在这个场景下完全够用。不要一开始就想什么“通用N子棋算法”那是后面的优化方向先把功能做对再说。2. 页面骨架与视觉设计先搭出一个能看的棋盘2.1 HTML结构状态栏、棋盘、控制区三段式布局HTML是整个项目的骨架写得好不好直接影响后续JavaScript的查找与操作。我习惯把页面分成三个区块每个区块职责单一状态栏显示当前轮到谁、胜负结果或平局提示。棋盘区就是一个3x3的网格容器里面9个格子。控制区放置“重新开始”按钮。直接上代码这是一个非常标准的骨架!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title井字棋小游戏/title link relstylesheet hrefstyle.css /head body main classgame-container h1井字棋/h1 div idstatusBar classstatus-bar轮到X/div div idboard classboard !-- 9个格子用JS动态生成比手写9个div更好维护 -- /div button idrestartBtn classrestart-btn重新开始/button /main script srcscript.js/script /body /html这里有一个细节值得说明为什么棋盘的9个格子不直接在HTML里手写而要交给JavaScript动态生成因为棋盘格子的数量和布局逻辑属于程序运行的一部分用JS生成可以避免重复代码也与数组状态一一对应。你在HTML里只需要留一个div idboard容器在JS里循环9次每次创建一个格子元素并设置好下标索引代码更干净后续如果想扩展成4x4棋盘也更容易。另一个细节是id命名。项目虽小命名也要规范statusBar、board、restartBtn一眼就能看出用途。很多新手习惯用div1、div2这种名字回头自己都认不出哪个是哪个排查问题的时候徒增烦恼。2.2 CSS布局Flex还是Grid怎么选棋盘布局最自然的方案是CSS Grid因为井字棋本身就是二维网格的典型场景。用Grid只需要五行代码就能实现完美的3x3结构.board { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); gap: 8px; width: 320px; height: 320px; margin: 20px auto; background: #2c3e50; padding: 8px; border-radius: 12px; }.board直接设固定宽高320px配合gap做出格子间距背景色和padding让棋盘形成一圈边框效果。Grid在这里相比Flex的优势非常明显Flex是一维布局做3列需要配合flex-wrap: wrap而且每一行的宽度计算不够直接Grid直接声明三列三行每个子元素自动落位代码意图一目了然。接下来是每个格子。格子的核心要求是正方形、字体居中、点击时有视觉反馈。正方形怎么保证如果你用固定宽高比如width: 100px; height: 100px换一个总宽度就会撑破。更稳妥的办法是用aspect-ratio: 1属性配合百分比宽度.cell { background: #ecf0f1; border: none; border-radius: 8px; font-size: 56px; font-weight: bold; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s ease; }aspect-ratio: 1让元素的宽高比永远保持1:1配合Grid的等分宽度格子会自动适应整个棋盘在任何设备上都保持正方形省去一堆媒体查询的麻烦。这个属性在几年前还害怕兼容性现在主流浏览器都没问题可以放心用。2.3 交互反馈从悬停到落子用样式告诉玩家“你能点”网页交互最关键的原则是给用户实时反馈。玩家把鼠标移到一个空格子上他需要知道“我点这里是有反应的”这就是hover伪类的作用。.cell:not(.taken):hover { background: #bdc3c7; transform: scale(1.02); }注意我用了:not(.taken)这个细节很重要——如果格子已经被占用了就不该再出现悬停高亮效果否则玩家会觉得还能点击产生误导。这属于“视觉状态与逻辑状态同步”的体现。当格子被占用后我会给格子添加一个.taken类同时再把棋子的颜色区分开。X用蓝色系O用红色系对比清晰玩家一眼就能看出区别.cell.x { color: #2980b9; } .cell.o { color: #e74c3c; } .cell.taken { cursor: not-allowed; }cursor: not-allowed告诉玩家“这里已经不能点击了”虽然JavaScript层面我们也会拦截点击但视觉上的反馈能让体验提升一个档次。这就是我一直强调的好的交互不只是“功能能用”而是“玩家凭直觉就知道怎么用并且知道现在处于什么状态”。你在做这一步时可以把这些细节统统加上再看看效果——你会发现即便只是一个井字棋给人的质感也完全不一样了。3. 游戏核心逻辑编码过程中最需要动脑的部分3.1 全局状态设计数组、玩家与锁定开关写JavaScript之前先想清楚需要哪些全局变量。我最终用的是三个const board new Array(9).fill(null); let currentPlayer X; let gameOver false;board数组存棋盘状态currentPlayer存当前玩家gameOver是一个布尔锁。为什么需要gameOver这个锁因为游戏结束后玩家必须不能再点击任何一个格子直到点击“重新开始”。没有这个锁你只能靠判断数组里是否还有null来阻止后续点击但这样不够纯粹——游戏一旦分出胜负就应该立即进入“锁定”状态这个意图用gameOver表达最直接。const board new Array(9).fill(null)这段代码有个小细节new Array(9)创建长度为9的空数组.fill(null)把每个位置填充为null表示空棋盘。注意不能用Array.from({length: 9})的初始空槽状态去玩因为稀疏数组的map等遍历方法会跳过空槽后面写代码时容易踩坑。统一填充null是最稳的。3.2 动态生成棋盘并绑定事件接下来在JS里生成9个格子。这一步有三件事创建元素、添加类名和索引、绑定点击事件。常规写法如下const boardEl document.getElementById(board); for (let i 0; i 9; i) { const cell document.createElement(div); cell.classList.add(cell); cell.dataset.index i; // 用data-index记录下标 boardEl.appendChild(cell); cell.addEventListener(click, () handleCellClick(i)); }这里重点说说handleCellClick(i)里的闭包问题。如果你写成cell.addEventListener(click, handleCellClick)然后在函数内部通过this或event.target去判断点击的是哪个格子也不是不行。但用handleCellClick(i)直接传下标逻辑链路最短也最容易调试——你点击第几个格子函数接到的参数就是几完全不需要从DOM上反查元素再映射数组下标。事件绑定完成后点击处理函数的核心逻辑是function handleCellClick(index) { // 如果格子已被占用或者游戏已结束直接返回 if (board[index] ! null || gameOver) return; // 更新数组状态 board[index] currentPlayer; // 更新DOM显示 const cell boardEl.children[index]; cell.textContent currentPlayer; cell.classList.add(currentPlayer.toLowerCase(), taken); // 检查胜负 if (checkWin(currentPlayer)) { gameOver true; document.getElementById(statusBar).textContent 玩家 ${currentPlayer} 获胜; return; } // 检查平局 if (board.every(cell cell ! null)) { gameOver true; document.getElementById(statusBar).textContent 平局; return; } // 切换玩家 currentPlayer currentPlayer X ? O : X; document.getElementById(statusBar).textContent 轮到${currentPlayer}; }这段代码是游戏心脏顺序非常重要。先更新数组状态再更新DOM然后检查胜负/平局最后切换玩家。新手最大的问题就是顺序错乱先切换玩家再判断胜负结果判断的永远是切换后的玩家导致赢家判定错误。记住这个顺序你就能少踩一个大坑。关于DOM更新我直接用cell.textContent currentPlayer设置文本内容而不是创建文本节点再appendChild因为textContent更简洁性能在这个规模下根本不构成问题。我甚至见过有人用innerHTML去修改那属于把简单问题复杂化。3.3 胜负判定查表法的原理与实现前面提到过我把所有获胜组合预先定义成一个数组const winLines [ [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(player) { return winLines.some(([a, b, c]) board[a] player board[b] player board[c] player ); }为什么用some而不是forEach或者普通for循环因为some会在回调返回true时立即中断遍历是一种“存在性判断”语义上就比forEach更贴切。winLines.some(([a, b, c]) ...)里用到了数组解构直接把每个子数组的三个下标解出来可读性极强。这里还有一个关键点checkWin接收player参数在调用时传的是currentPlayer也就是“刚落子的选手”。这样设计的好处是函数逻辑清晰——它不关心“游戏整体状态”只负责回答“某个玩家是否已经连成三子”。职责单一测试起来也方便。如果你想做一点性能上的极致优化可以在每步只检查与当前落子位置相关的行列对角线不必检查全部8条线。但在这个3x3的规模下检查全部8条线的开销微乎其微代码却简洁得多。过早优化是万恶之源这句话在写井字棋时同样适用。3.4 再玩一局重置逻辑的两种思路重置逻辑有两种典型做法难度不同但我建议你都想想第一种最直接把变量重置成初始值然后清空DOMfunction resetGame() { board.fill(null); currentPlayer X; gameOver false; document.getElementById(statusBar).textContent 轮到X; Array.from(boardEl.children).forEach(cell { cell.textContent ; cell.classList.remove(x, o, taken); }); }第二种更“暴力”直接销毁整个棋盘重新生成。把棋盘容器设为innerHTML 再调用一次生成棋盘的函数。这种做法的好处是状态绝对干净不会有旧事件或旧类名残留缺点是每次重置都要重新创建DOM节点略浪费。我在实际教学中更推荐第一种因为forEach清理类名的过程本身就是一个很好的“回归检查”清除所有与棋子相关的类名确保下次开局不会残留样式。但我也不反对第二种项目无分好坏适合自己理解的就是最好的。这里没有标准答案关键是你能把每种方案背后的利弊说清楚面试官问起来你也心里有底。4. 踩过的坑这些细节会让你怀疑人生4.1 事件绑定与闭包陷阱第一坑就是事件绑定里的var和let问题。如果你用var i写循环然后绑定cell.addEventListener(click, () handleCellClick(i)),最终你会发现点哪个格子都是第9个格子因为var没有块级作用域循环结束后i已经变成了9所有闭包捕获的都是同一个最终值。解决办法就是上面代码里用的let i它每次迭代都会创建一个新的绑定闭包拿到的才是正确的下标。如果你因为历史代码原因必须用var也有经典解法——用立即执行函数包一层for (var i 0; i 9; i) { (function(index) { cell.addEventListener(click, () handleCellClick(index)); })(i); }这个在ES6之前天天见现在用let就能解决但理解其背后的原理对面试很有帮助——它考察的就是“闭包捕获变量”这一知识点。4.2 状态与DOM不同步改了数组忘了改页面我曾经在写井字棋时遇到过一个问题连续点格子到第4步时游戏就“失去响应”了点哪个格子都没反应。排查了半天发现原因是——我更新数组后没有把格子文本更新上去而胜负判定依赖的是DOM文本读取结果读出来的永远是空字符串判断永远不成立游戏卡死。这个坑的根源就是“数据与视图分离”没做到位。正确做法是我前面定义的流程所有状态变更先改数组然后由数组驱动DOM更新。任何时候都不要做“从DOM里读数据、拿DOM数据去判断”这种事否则代码会越来越乱。数据是唯一事实来源DOM只是它的投影这是现代前端开发的共识。4.3 重复点击同一格的隐患还有一个有趣的问题如果玩家在一格已经落子后又快速双击同一格会发生什么我在初版代码里见过真实的bug——因为更新DOM和设置taken类之间有微小的时间差双击时第二次点击仍然能触发回调导致当前玩家连续落了两次子棋盘状态瞬间错乱。解决方式就是落子前的双重校验if (board[index] ! null || gameOver) return;这个校验在函数开头执行无论点击多快第一次点击已经把board[index]设置成了非空值第二次点击进来就会被拦截。所以状态锁永远优先于UI判断这是防御性编程在游戏逻辑里的典型应用。4.4 常见问题速查表现象可能原因解决方案点击格子没反应事件未绑定或者gameOver初始值错误检查监听器是否注册用console.log验证回调是否进入点的格子是同一个var闭包陷阱把循环变量改成let或用IIFE包裹胜负判定永远失败从DOM读文本而不是读数组统一以board数组为判断依据重置后棋盘还有残留样式只清了数组没清类名重置时同时移除x、o、taken类双击导致连下两子状态锁失效函数开头判断board[index] ! null页面在手机上挤作一团缺少视口缩放设置确认meta nameviewport已添加4.5 用调试工具快速定位问题我用这个项目给初学者讲调试方法时一定会让他们学会用浏览器的开发者工具做三件事第一加断点。在handleCellClick函数第一行左侧点击刷新页面后点击格子执行会暂停右侧面板能看到board数组、currentPlayer、gameOver三个变量的实时值一眼就能发现哪一步状态不对。第二看Console面板的报错。JS代码一旦出现语法错误或者引用空值浏览器会直接告诉你错误发生在第几行第几列。很多新手看到红字就慌其实红字恰恰是指路明灯。比如Cannot read properties of null (reading addEventListener)那就是getElementById拿到的元素不存在检查一下id拼写即可。第三用Elements面板检查类名变化。点击格子后展开对应的div元素确认class属性里是否新增了x和taken类。如果DOM已经变了但样式没变那就是CSS选择器没写对如果DOM根本没变那就是JS逻辑没执行到。这三招配合起来90%的井字棋bug都能在几分钟内定位。我特别建议新手养成“先看状态值再改代码”的习惯而不是盲目改一行刷新一次那是在瞎蒙。5. 更进一步从经典井字棋到前后端全覆盖5.1 给人机对战加一个“不会输”的AI经典井字棋玩几局就没意思了因为两个人都遵循最优策略的话结果必然是平局。所以很多人做完双人对战后第一反应是加一个人机模式。最简单的AI策略是这样的轮到电脑时它先检查自己能否一步获胜能就下那里然后再检查玩家是否下一步能获胜能就堵住如果都没有就随机选择一个空格。这个策略的实现不复杂核心是一个“模拟落子然后调用checkWin”的思路function getComputerMove() { // 1. 自己能不能赢 for (const [a, b, c] of winLines) { const line [board[a], board[b], board[c]]; if (line.filter(v v O).length 2 line.includes(null)) { const idx [a, b, c].find(i board[i] null); return idx; } } // 2. 堵玩家 for (const [a, b, c] of winLines) { const line [board[a], board[b], board[c]]; if (line.filter(v v X).length 2 line.includes(null)) { const idx [a, b, c].find(i board[i] null); return idx; } } // 3. 随机空格 const empty board.map((v, i) v null ? i : null).filter(v v ! null); return empty[Math.floor(Math.random() * empty.length)]; }这段AI并不完美遇到真正的数学家玩家依然会输但应付一般朋友绰绰有余。如果你想实现不可能输的AI就得引入“极小化极大算法”Minimax让AI把未来所有可能的落子分支全部遍历一遍挑选对自己最有利的那一步。这个算法写出来也就四五十行但理解它需要先理解递归很多人在这一步卡住属正常现象。我的建议是先把简单的“赢棋-堵棋-随机”策略做出来能跑通再研究Minimax。5.2 记录战绩与历史回放加一个战绩统计是提升项目完成度的好方法。你只需要在全局变量里增加let scores { X: 0, O: 0, draws: 0 }在每次游戏结束时更新再把结果显示在页面底部。如果你想让数据刷新后依然保留可以考虑localStorage把scores序列化成JSON存起来localStorage.setItem(tictactoe_scores, JSON.stringify(scores));下次页面加载时读取并解析const saved localStorage.getItem(tictactoe_scores); if (saved) { scores JSON.parse(saved); }历史回放功能更有意思每次落子时把board数组的副本推入一个记录栈回放时定时器逐个复原每个状态。这里需要留意的是“副本”问题——直接推board数组进去只是推了引用后续操作会改变同一个数组所以必须用展开运算符创建新数组history.push([...board])。这个细节我踩过写出来提醒一下。5.3 更大的棋盘从井字棋到N子棋井字棋的规则可以轻松扩展到4x4、5x5甚至更大的棋盘只要把“3连”变成“N连”即可。在这个扩展里胜负判定就不能再硬编码8条线了你得遍历所有格子在每个格子处向右、向下、向右下、向上四个方向寻找连续的棋子。这个算法比查表法复杂一些但也更有“算法味”是练习遍历和边界判断的好素材。如果要做这个扩展建议把棋盘结构从board new Array(9)改成board Array.from({ length: size * size }, () null)对应的下标计算从index变成row * size col。这一步会牵动前面所有逻辑所以我在做完基础版之后一般会专门花时间重构一次体会一下“因为需求变化导致代码结构调整”的过程。这种重构经验比多写十个普通页面都有价值。6. 写在最后的亲测心得这个井字棋项目我前前后后带过不少人写过也是我个人认为前端入门阶段性价比最高的练手项目之一。它不涉及网络请求不涉及构建工具不需要数据库所有逻辑都在一个页面内闭环但麻雀虽小状态管理、事件机制、DOM操作、界面反馈、逻辑组合这些核心能力全都练到了。我见过很多人学前端半年能背出一堆API名字但真让他从零写一个几十行的小游戏却迟迟动不了手。问题不在于知识量而在于缺少那种“把需求拆成代码”的思维方式。井字棋刚好提供了一个极低门槛的入口让你在完全可控的复杂度里去体验一次完整的编码流程——写结构、调样式、理逻辑、修bug。最后分享一个我自己的小习惯写完这个项目后我会特意把它跑在手机浏览器里用触摸操作玩几局。你会在真机上发现很多开发工具模拟器看不到的问题比如点击延迟、布局错位、字体大小不合适。这些小问题的修复过程比写十个页面都长经验。你在做的时候也可以试试做完那一刻的成就感值得你好好享受一下。