井字棋这个项目,我在带前端新人的时候用过太多次了。它看着小,但有完整的界面、有交互逻辑、有状态管理,还有胜负判定这种典型的算法场景,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
变成O,O落子后再变回X。但要注意,这个切换必须在“落子合法”的前提下执行,而且落完子要立刻判断这次落子是否导致游戏结束。顺序应该是:判断可落子 → 更新棋盘数组 → 重新渲染格子 → 判断胜负 → 判断平局 → 切换玩家。这个顺序一旦乱了,就会出各种稀奇古怪的问题。
状态锁
gameOver
也在这里发挥作用。一旦checkWin返回
true
或者棋盘填满,就把
gameOver
设为
true
,点击事件里第一行就判断它——如果游戏已结束,直接返回,什么都不做。这个小细节能避免非常多的边界问题。
3. 实操过程与核心环节实现
3.1 第一步:搭HTML骨架,先写语义化结构
把上面的思路落到代码上,先写HTML。我推荐把整个游戏放在一个居中的容器里,上面是标题和状态提示,中间是3x3棋盘,下面是重置按钮。结构尽量简单清晰,后面用CSS和JS都好处理。
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>井字棋小游戏</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="game-container">
<h1>井字棋</h1>
<p class="status" id="status">轮到:X</p>
<div class="board" id="board">
<div class="cell" data-index="0"></div>
<div class="cell" data-index="1"></div>
<div class="cell" data-index="2"></div>
<div class="cell" data-index="3"></div>
<div class="cell" data-index="4"></div>
<div class="cell" data-index="5"></div>
<div class="cell" data-index="6"></div>
<div class="cell" data-index="7"></div>
<div class="cell" data-index="8"></div>
</div>
<button class="reset-btn" id="resetBtn">重新开始</button>
</div>
<script src="script.js"></script>
</body>
</html>
每个格子都带上
data-index
属性,这个下标对应刚才说的
board
数组的索引,是连接DOM和数据结构的关键桥梁。布局上用
.board
作为容器,里面9个
.cell
,没有任何多余的内容嵌套,干净利落。
3.2 第二步:CSS Grid布局铺棋盘
接下来写样式,核心就是让9个格子排成3x3的棋盘。这里用
display: grid
是最省事的方案,
grid-template-columns: repeat(3, 1fr)
三列等宽,每格宽高固定,用
gap
控制格间距很容易就能做出棋盘线的感觉。
* {
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
重置为X,
gameOver
重置为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,也是测试手感;井字棋虽然简单,但界面交互顺不顺、状态提示明不明显,自己玩一遍心里立刻就有数了。
1万+



被折叠的 条评论
为什么被折叠?



