井字棋这个项目我在带前端新人的时候用过太多次了。它看着小但有完整的界面、有交互逻辑、有状态管理还有胜负判定这种典型的算法场景HTML、CSS、JS三个基本功全都能练到做完了还能发给朋友玩正反馈来得特别快。这篇文章就把我从零手写一个井字棋网页版的完整过程拆开讲从棋盘结构到胜负判断再到进阶的人机对战一步一步带着你写代码可以直接抄原理也尽量说透。适合刚学完前端基础、想做第一个小项目的同学也适合想快速了解前端游戏开发入门套路的开发者。1. 项目整体设计与思路拆解1.1 为什么我推荐井字棋作为前端入门实战很多人做第一个前端项目时喜欢直接上购物车、博客系统但说实话那些项目对新手来说战线太长很容易写着写着就失去耐心。井字棋不一样它有几个天然适合练手的特点规则极简三分钟就能说完状态有限总共就9个格子和两个玩家逻辑复杂度刚刚好但麻雀虽小五脏俱全DOM操作、事件处理、数组状态、条件判断、循环渲染这些核心技能全都会用到。我把这类项目叫做“15分钟能跑起来、1小时能玩明白、一下午能做漂亮”的黄金练手项目。你不需要搭建复杂的工程环境一个浏览器加一个编辑器就够了。跑起来之后你马上能体会到“我写的东西真的在动”的成就感这种正反馈对学习特别重要。我见过很多新手一上来就追求大而全结果卡在环境配置上项目没写成心态先崩了。井字棋这种小项目恰恰能让你把注意力集中在最核心的“写代码”这件事上而不是被工具链拖住。等你把井字棋吃透了再去玩那种带工程化的项目会发现很多套路都是相通的。1.2 技术选型HTML、CSS、JS各司其职很多新手写项目时喜欢把啥都堆在一个HTML文件里方便是方便但思路容易乱。咱们做这个井字棋从一开始就要养成“结构、样式、行为”分离的意识这三者的分工非常清晰。HTML负责搭骨架也就是“棋盘长什么样、有哪些元素”。这里最合理的做法就是用3x3的九宫格每个格子是一个独立元素方便后续单独绑定点击事件。CSS负责外在表现棋盘的分隔线、格子的尺寸、叉和圆圈的样式全都交给CSS。这里我推荐用Grid布局来排九宫格比Flex拼接或者浮动要清晰得多代码量也少。更重要的是CSS还能配合JS做状态切换——比如当前玩家的标记、赢家格子的高亮都通过添加类名来控制这样就不用频繁操作元素内容。JS负责核心逻辑包括游戏状态当前轮到谁、棋盘上哪些位置已被占用、点击事件的处理、胜负判断、平局判断、重置游戏等等。这是整个项目的灵魂也是最值得花时间琢磨的部分。好的软件都是从清晰的架构开始的哪怕只是一个几十行的井字棋也值得把这三层分开。后面添加AI对手、动画效果之类的扩展你就能体会到架构清晰带来的好处了。2. 核心细节解析与实操要点2.1 棋盘状态的数据结构设计动手写代码之前我想先说一个很多新手容易忽略的步骤先把游戏状态想清楚。我用一个长度为9的数组board来保存棋盘状态下标0到8分别对应棋盘左上到右下的9个格子。数组里的值就用简单的字符串默认都是空字符串玩家X落子后对应位置变成X玩家O落子后变成O。这样一个数组就能表示整个棋盘的所有可能性判断胜负、检测平局、渲染界面都读取它就行。let board [, , , , , , , , ]; // 初始棋盘全空 let currentPlayer X; // 当前落子方初始为X let gameOver false; // 游戏是否结束这里插一句我的经验数据状态不要跟DOM绑定。很多人一上来就把“哪个格子里显示什么”直接写死在HTML里然后在JS里扒拉DOM去判断那样写出来的代码又绕又容易出bug。把状态放在数据里、让界面跟随数据变化才是前端开发最顺的思路。井字棋虽然小但你在这一步建立的习惯会直接影响后面写复杂应用的质量。currentPlayer用来管理回合gameOver是个状态锁——游戏一旦结束就禁止继续点击落子防止出现“赢了还能接着下”的诡异局面。这三个变量就是整个游戏的全部状态清晰明了。2.2 胜负判断逻辑8种赢法全覆盖井字棋的胜负判断是所有逻辑里最容易写乱的地方但其实它的本质非常朴素如果某一行、某一列或者某一条对角线上全是同一个玩家那这个玩家就赢了。3x3的棋盘一共就8种赢的位置组合——3行加3列加2条对角线。我推荐把这8种组合提前列成一个二维数组遍历判断即可这样写出来的代码一目了然不容易漏也不容易错。const winPatterns [ [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 winPatterns.some(pattern { return pattern.every(index board[index] player); }); }这里用了some和every两个数组方法代码非常简洁。every用来判断某一种赢法里的三个格子是否全是同一个玩家some用来判断这8种赢法里是否至少有一种成立。很多人问我为什么不写一堆if判断比如“board[0] player board[1] player board[2] player”这样固然可以但8个组合写下来代码会变得极长而且容易看错、看漏。用数据驱动的方式赢法表一目了然以后想扩展玩法比如5x5棋盘也能直接复用。2.3 交互细节事件绑定、回合切换与状态锁游戏逻辑里最容易踩坑的是交互细节。先说事件绑定我给每个格子都绑一个点击事件但更推荐利用事件委托——把监听器绑在棋盘容器上然后通过event.target判断用户点到了哪个格子。这样做的好处是代码清爽而且即使未来动态添加格子事件也能自动覆盖到。const boardEl document.getElementById(board); boardEl.addEventListener(click, handleCellClick); function handleCellClick(e) { const cell e.target.closest(.cell); if (!cell) return; const index Number(cell.dataset.index); // 后续逻辑判断格子是否为空、更新状态、渲染界面 }这里用了closest来防止点到格子内部的装饰元素比如以后格子里面有图片或动画事件也不会走偏。dataset.index是HTML5提供的能力可以直接在HTML标签上挂自定义数据非常适合这种“格子需要知道自己下标”的场景。回合切换逻辑很简单X落子后currentPlayer变成OO落子后再变回X。但要注意这个切换必须在“落子合法”的前提下执行而且落完子要立刻判断这次落子是否导致游戏结束。顺序应该是判断可落子 → 更新棋盘数组 → 重新渲染格子 → 判断胜负 → 判断平局 → 切换玩家。这个顺序一旦乱了就会出各种稀奇古怪的问题。状态锁gameOver也在这里发挥作用。一旦checkWin返回true或者棋盘填满就把gameOver设为true点击事件里第一行就判断它——如果游戏已结束直接返回什么都不做。这个小细节能避免非常多的边界问题。3. 实操过程与核心环节实现3.1 第一步搭HTML骨架先写语义化结构把上面的思路落到代码上先写HTML。我推荐把整个游戏放在一个居中的容器里上面是标题和状态提示中间是3x3棋盘下面是重置按钮。结构尽量简单清晰后面用CSS和JS都好处理。!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title井字棋小游戏/title link relstylesheet hrefstyle.css /head body div classgame-container h1井字棋/h1 p classstatus idstatus轮到X/p div classboard idboard div classcell>* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f0f4f8; } .game-container { text-align: center; padding: 40px; background: #fff; border-radius: 16px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); } .board { display: grid; grid-template-columns: repeat(3, 120px); grid-template-rows: repeat(3, 120px); gap: 8px; margin: 24px auto; width: fit-content; } .cell { width: 120px; height: 120px; background: #ecf0f3; border-radius: 8px; display: flex; justify-content: center; align-items: center; font-size: 64px; font-weight: bold; cursor: pointer; user-select: none; transition: background 0.2s ease; } .cell:hover { background: #e0e5ec; }一个容易忽略的细节是box-sizing: border-box我见过太多新手在布局时因为没设置这个属性导致格子的实际尺寸比预期大了一圈或者小了一圈整个棋盘就歪了。加上这个之后宽高就是最终盒子的宽高省掉很多心智负担。字体和居中等细节我也建议尽早做。用flex把格子内容居中后面JS放入的X或O就能自动居中不用再额外调位置。3.3 第三步用JS驱动游戏状态与渲染核心逻辑来了。我先把整个JS的完整代码贴出来再拆开逐段解释。// ---------- 游戏状态 ---------- let board [, , , , , , , , ]; let currentPlayer X; let gameOver false; const winPatterns [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ]; // ---------- DOM节点 ---------- const boardEl document.getElementById(board); const statusEl document.getElementById(status); const resetBtn document.getElementById(resetBtn); // ---------- 渲染 ---------- function render() { const cells document.querySelectorAll(.cell); cells.forEach((cell, index) { cell.textContent board[index]; }); statusEl.textContent gameOver ? 游戏结束 : 轮到${currentPlayer}; } // ---------- 胜负判断 ---------- function checkWin(player) { return winPatterns.some(pattern pattern.every(index board[index] player) ); } function checkTie() { return board.every(cell cell ! ); } // ---------- 点击处理 ---------- function handleCellClick(e) { if (gameOver) return; const cell e.target.closest(.cell); if (!cell) return; const index Number(cell.dataset.index); if (board[index] ! ) return; // 已落子的格子不能再点 board[index] currentPlayer; if (checkWin(currentPlayer)) { gameOver true; render(); setTimeout(() alert(玩家 ${currentPlayer} 获胜), 10); return; } if (checkTie()) { gameOver true; render(); setTimeout(() alert(平局), 10); return; } // 切换玩家 currentPlayer currentPlayer X ? O : X; render(); } // ---------- 重置 ---------- function resetGame() { board [, , , , , , , , ]; currentPlayer X; gameOver false; render(); } // ---------- 事件绑定 ---------- boardEl.addEventListener(click, handleCellClick); resetBtn.addEventListener(click, resetGame); // 初始渲染 render();整体逻辑就是点击 → 判断合法性 → 更新状态 → 判断结局 → 切换玩家 → 渲染界面。我尤其想提醒新手注意render()这个函数它负责把board数组里面的状态同步到界面上。每次数据变化后调用一次render界面就会跟着刷新这样界面和数据永远是同步的不会出现数据说赢了但界面看起来还没落子的情况。这里给出两个实操中的小建议第一弹窗用setTimeout(() alert(...), 10)包一下。因为点击事件还没彻底结束如果直接弹窗某些浏览器下画面可能还没绘制完弹窗内容没问题但界面表现会有点“卡卡的、像是没反应过来”。延迟10毫秒让渲染先完成体验会舒服很多。第二判断格子是否已占用用board[index] ! 来判断。有些人喜欢检查cell.textContent是否为空但这又把数据和DOM混在一起了。直接查数组干净、可靠。3.4 第四步加提示、重置与结束判定游戏能正常跑起来之后再往细了做体验部分。状态提示栏非常关键它让玩家一眼就知道“该谁走了”。我在render里已经做了这件事——根据当前回合更新提示文字。你还可以让它更醒目一点比如X的前景色用蓝色O的前景色用红色通过CSS类名切换。重置按钮的逻辑也很简单把board恢复成空数组currentPlayer重置为XgameOver重置为false再调用一次render。这里千万别忘记重置gameOver我见过很多新人点了重置按钮之后游戏仍然动不了排查半天发现是状态锁没解开。结束判断目前的实现是先查胜负再查平局都没有才切换玩家。这个顺序是有讲究的。如果最后一步恰好填满了棋盘checkWin先执行能正确判断是“赢”而不是“平局”顺序反了就翻车。比如X在最后一格达成三连如果先查平局棋盘确实满了可能误判成平局结果就错了。所以一定要先判赢再判平。4. 进阶扩展给井字棋加一个AI对手4.1 双人模式升级为单机模式难度分级思路能完整跑通双人对战这个项目就已经很完整了。但如果你想再往前走一步给自己加点挑战我强烈建议加一个“人机对战”模式。这是我个人认为井字棋最有意思的扩展方向它能让你接触到最基础的博弈算法——虽然不是严格意义上的“人工智能”但你能体会到“让电脑做出决策”的核心过程。难度分级是游戏开发里的一个经典思路。初级AI可以设计成“随机落子但优先占空位”对手会觉得不太聪明但偶尔能赢你中级AI可以设计成“能赢就赢该堵就堵”高级AI可以考虑用极小化极大Minimax算法让电脑永远不输。对新手来说从“能赢就赢、该堵就堵”开始写最容易理解。4.2 简单AI实现先堵截、再抢位这个AI策略的核心只有两个优先级第一步看看我有没有哪个位置一放就赢有就下那里这叫“直接取胜”第二步看看你下一步一放就赢的位置我先站住这叫“防守堵截”。如果这两种情况都没发生就优先占中心其次占四个角最后才占边上的格子。function getComputerMove() { // 1. 如果能直接赢就下那里 for (let i 0; i 9; i) { if (board[i] ) { board[i] currentPlayer; if (checkWin(currentPlayer)) { board[i] ; return i; } board[i] ; } } // 2. 如果玩家下一步要赢了堵住他 const opponent currentPlayer X ? O : X; for (let i 0; i 9; i) { if (board[i] ) { board[i] opponent; if (checkWin(opponent)) { board[i] ; return i; } board[i] ; } } // 3. 优先占中心、再占角、最后占边 const priority [4, 0, 2, 6, 8, 1, 3, 5, 7]; for (const i of priority) { if (board[i] ) return i; } return -1; // 没有空位理论上不会走到 }这个思路用到了“试下法”——先把棋子临时放进去判断能不能赢再撤回来。这是游戏AI里很常用的手段你以后写五子棋、四子棋也能复用这一套。把电脑的落子逻辑写成纯函数getComputerMove()方便测试也方便替换难度策略。接入到主流程也很简单玩家落子之后如果游戏没结束就切换成电脑的回合用setTimeout延迟一小会儿再让电脑落子制造一种“电脑在思考”的感觉不然回合切换太瞬移了体验很突兀。4.3 进一步优化的方向如果你觉得简单AI已经不够打了可以往两个方向继续深挖。第一个方向是改造胜负判断把它扩展成可配置的比如支持5x5棋盘、支持四连胜利让代码通用性更强。第二个方向是实现极小化极大算法Minimax本质是把整棵博弈树遍历一遍给每个局面打分电脑会选择分数最高的那条路径这样它就永远不会输。这个算法概念听着吓人但用在井字棋这种状态量很小的游戏里实现起来并没有你想的那么难网上也有很多现成的伪代码可以参考。5. 常见问题与排查技巧实录5.1 点击没反应八成是事件绑定出了问题我在带人做这个项目时遇到最多的问题就是“我点了格子一点反应都没有”。排查顺序一般是这样的先看浏览器控制台有没有报错有报错就按报错信息改没有报错就看事件到底绑没绑上。你可以临时在handleCellClick里加一句console.log(clicked)点击格子后如果控制台没输出说明事件绑定环节有问题比如JS文件没引入成功、脚本写在DOM前面导致找不到元素、或者选择器写错了。控制台是最老实的老师学会看报错是前端的基本功。5.2 游戏都结束了还能继续下子状态锁没过这个问题的根源在于漏掉了gameOver判断或者重置时忘了把gameOver重置。游戏结束后点击事件处理器开头的if (gameOver) return;就是一道闸门任何结束后的操作都会被拦截。类似的落子之后如果忘记判断“格子是否已经被占用”也会出现同一个格子被反复覆盖的情况。这类“状态锁”的边界问题在游戏开发里几乎是必考题井字棋正好能帮你把这个思维练会。5.3 样式错位多半是盒模型和Grid布局的锅棋盘格子歪了、间距不对、格子忽大忽小这种问题十有八九是没设置box-sizing: border-box。还有一个常见坑是给.cell设置了固定宽高又在格子内部加了padding或者border结果实际占位超出了你原本设想的尺寸。解决方案很简单全局设置box-sizing: border-box然后所有宽高都用同一个标准来理解。Grid布局的另一个小坑是gap属性老旧浏览器可能不支持不过现在主流浏览器都没问题可以放心用。5.4 判断赢法时的边界情况一次性测全它们才算完“我觉得逻辑没问题但它就是判断不对”这种事我也没少遇到。一个靠谱的办法是手工构造测试场景去验证。比如你可以在控制台手动执行board [X,X,O,O,O,X,X,X,O]然后调用checkWin看看结果是否符合预期。把三种情况都测一遍X赢、O赢、平局以及没有结束就能在几秒钟内把边界情况过一遍不用一局局瞎点。尤其是“最后一步落子同时填满棋盘且形成胜利线”的情况必须确保判定为赢而不是平局这就是判断顺序要“先赢后平”的原因。整个项目从零写到这里我自己印象最深的不是那些花哨的布局技巧而是“数据驱动界面”这个思路带来的清爽感——一个小小的井字棋只要把board这个数组维护好几乎所有界面变化都能顺手解决。这个习惯我是带过很多项目之后才真正体会到的希望你做完这个项目时也能有这个感觉。最后再提一句代码写完记得自己也认认真真玩几局不仅是检查bug也是测试手感井字棋虽然简单但界面交互顺不顺、状态提示明不明显自己玩一遍心里立刻就有数了。
企业数字化 ERP 产品动态
相关推荐
IDevPanel 开源免费的 Windows 开发环境管理面板 前言
做后端/全栈开发的同学一定懂这些痛:
端口被占了,netstat -ano 查 PID,再 taskkill 强杀,一套流程下来 2 分钟没了本地十几个服务(MySQL、Redis、Nginx、各种 Spring Boot 项目),每天开机… · 2026/9/24 18:59:50
元宇宙渲染帧率压测实战:核心指标、工具链与瓶颈排查方法 最近团队做了一轮针对元宇宙虚拟空间的帧率压测,目标很激进:40个虚拟角色同屏、城市级场景资源、全程维持满帧。折腾了两周,测试报告堆了几十页,最值钱的不是那几行指标,而是过程中踩过的坑和总结出来的一套方法。今天… · 2026/9/24 18:59:44
AI原生数据治理选型指南:五大平台能力分化与决策框架 1. 当数据治理撞上AI原生,选型逻辑为什么突然变了过去几年做数据治理,大家聊得最多的是元数据采集覆盖率、血缘解析准确率、数据质量规则跑批时长这些指标。但从2025年下半年开始,我陆续参与了几个大型企业的数据平台升级评审,发现… · 2026/9/24 19:36:40
GNG生长型神经气体网络:自适应聚类的动态拓扑解法 1. 什么是GNG生长型神经气体网络?它为什么能甩开K-means和DBSCAN几条街? “GNG生长型神经气体网络”——光看这名字,很多人第一反应是:又一个拗口的学术黑话。但如果你正在处理客户分群、异常检测、传感器数据压缩,或者… · 2026/9/24 19:36:40
acore-db-app:Python封装库,让AzerothCore数据库操作化繁为简 维护AzerothCore服务端的朋友应该都有过这种经历:开发到后期,各种数据修复、批量任务、跨库同步的需求接踵而来,每天不是在写SQL,就是在写连接数据库的Python脚本。我自己的痛点是,pymysql裸用起来倒是不难,… · 2026/9/24 19:36:40
MySQL 状态查看与 Navicat 连接失败排查指南 上午后两节课,正好讲到了MySQL状态和Navicat链接MySQL,这两块其实都是日常开发里最高频的操作:一个是判断数据库到底健不健康,一个是让你从黑窗口里解放出来。如果你刚装好MySQL不知道下一步干什么,或者被Navicat连接时… · 2026/9/24 19:36:40
Python智慧教室源码实战:专注度分析、作弊检测与动态点名 简介:这是一套面向教育技术开发者与Python学习者的智慧教室综合实践源码,围绕课堂专注度分析、考试作弊检测与动态点名三大场景展开,适合希望将计算机视觉、自然语言处理落地到教学管理的中级开发者参考。压缩包共218个文件、约17.04MB&#… · 2026/9/24 19:36:34
基于YOLOv8的渔船作业监控系统:从环境搭建到边缘部署全流程 简介:这是一套面向计算机、人工智能、自动化等专业学生与教师的毕业设计级项目资源,围绕YOLOv8实现渔船作业监控系统,可用于毕设、课程设计、大作业或项目立项演示。压缩包共97个文件,约24.21MB,以70个Python源码文件为… · 2026/9/24 0:00:13
1D-CNN时间序列建模实战:从Conv1d原理到工业落地 简介:面向时间序列数据建模的一维卷积神经网络完整实现,适合深度学习入门者及需要快速验证时序模型的研究者,能够从音频、文本、传感器或股价等序列中挖掘局部特征与时间依赖。压缩包体积很小,只有3KB,内含3个Python脚… · 2026/9/24 0:00:26
柔软的L:汉语语流中被忽视的舌肌张力控制 1. 这个“L”不是字母表里的L,而是舌尖上的L最近在几个方言群和语音教学社群里,反复看到有人发一句:“也说字母L:柔软的长舌”。初看以为是英语发音课笔记,点开才发现全是方言爱好者、播音系学生、语言康复师甚至戏曲演… · 2026/9/24 0:00:44