资讯动态

从扫雷项目掌握JavaScript数据结构与算法实战

发布时间:2026/9/14 14:17:09 来源:尧图企业网站定制
1. 为什么我建议把扫雷当作你的下一个练手项目如果你正处于学完基础语法、四处找项目练手的阶段我强烈建议认真做一个扫雷游戏。不夸张地说扫雷是检验编程基本功的一块试金石。它看起来简单规则一句话就能说完——格子下面是地雷点开数字代表周围八个格子里的雷数把所有非雷格子翻开就算赢——但真上手写你会在里面遇到数组越界、递归栈溢出、随机分布、状态管理、交互细节等一系列问题每一个都是实际开发中绕不开的老朋友。我当时做这个项目的契机很朴素学了几个月JavaScript看过不少教程也跟着敲过一些页面但总觉得那些代码不是自己的换个需求就写不出来了。扫雷就是一个恰到好处的中间难度项目——比待办清单有挑战性又比一个完整的管理系统容易上手。做完之后你对数组操作、递归、边界判断、甚至简单的算法复杂度分析都会有实打实的体感这些东西是刷题刷不出来的。这篇文章会把我的完整实现思路和盘托出包括棋盘数据结构的选型、布雷算法里藏着哪些坑、翻开空格为什么要用队列而不是递归、胜利判定的几种实现方式各自有什么取舍以及我调试过程中踩过的一堆实际坑。如果你也想做一个能拿得出手的扫雷或者你正在带学生做课程设计这篇文章应该能帮你省下不少弯路。2. 动手前的硬核准备核心技术栈和数据结构选型2.1 我为什么选了原生JavaScript HTML5 CSS3很多人一上来就想着上框架Vue也好React也好但对于扫雷这种规模的项目原生三件套反而更能让你把注意力放在算法和逻辑本身。框架的响应式机制会替你管理界面更新你反而体会不到“数据变了、如何同步到视图”这个过程是怎么一步步发生的。我更推荐的做法是先用原生JavaScript把整个游戏逻辑写完跑通了数据层和渲染层分清楚之后再尝试用你熟悉的框架把它重写一遍。这样你能清晰地感受到框架到底解决了什么问题而不是稀里糊涂地复制粘贴别人的组件代码。对扫雷来说核心逻辑无非是这几块棋盘数据的存储和初始化布雷逻辑保证每一盘都能解开而不是开局就被雷炸死计算每个格子周围的地雷数量翻开格子时的扩散逻辑点到一个空白格子要自动展开周围一片旗帜标记、计时器、剩余雷数显示等辅助交互胜利和失败的判定这些功能用原生JS写大约三百到五百行代码就能完成结构清晰调试方便完全没有必要为了“用框架”而用框架。2.2 棋盘的数据结构一维数组还是二维数组这是第一个值得认真想清楚的设计决策。扫雷的棋盘本质是一个二维网格所以大部分人第一反应是用board[row][col]这种二维数组来存。这没错但如果你了解过一些性能敏感或者存储敏感的棋类游戏实现你会发现很多人会用一维数组来模拟二维结构下标通过row * colCount col换算。我个人的建议是如果你是在学习阶段放心大胆用二维数组。原因很简单二维数组的下标语义和游戏里的行列完全对应写起来不会绕。而且扫雷棋盘最大也就几十乘几十二维数组多出来的那点内存完全可以忽略不计。等到你真正做到比如上百万格子的地图生成再去研究怎么把二维映射到一维那样更合理。不过无论用哪种结构有一个细节一定要提前想清楚边界上的格子只有3个邻居中间的格子有8个邻居。很多人第一次写扫雷踩的坑就在这里——访问board[row - 1][col - 1]时没检查row - 1是不是负数直接数组越界程序崩溃。后面我会写一个统一处理边界判断的工具方法把这个问题一次解决掉。2.3 棋盘上每个格子需要存哪些数据每个格子只存一个数字是不够的。你要在一个格子的数据结构里同时表达几层信息mine布尔值表示这个格子里有没有雷around数字表示周围八个格子里的雷数revealed布尔值表示这个格子是否已经被翻开flagged布尔值表示玩家是否在这里插了红旗这四个字段是最基本的缺一不可。如果你用对象来存长这样{ mine: false, around: 0, revealed: false, flagged: false }有些实现里会把mine和around合并成一个字段比如用-1表示雷、0表示周围雷数。这种方案确实省内存但可读性差了很多。我建议新手还是用分开字段的方式后续判断逻辑写起来清楚得多。等你熟练了再去做这种“字段合并”的优化也不迟。3. 布雷算法里的两个大坑边界污染和开局必死3.1 随机布雷的第一版写法错在哪布雷的直觉写法是随机生成行列坐标如果这个位置还没雷就放一颗雷直到放满指定数量。function placeMines(rowCount, colCount, mineCount) { const mines []; while (mines.length mineCount) { const r Math.floor(Math.random() * rowCount); const c Math.floor(Math.random() * colCount); if (!board[r][c].mine) { board[r][c].mine true; mines.push([r, c]); } } }这段代码看起来没问题但你思考一下极端情况当棋盘上剩余空位越来越少时随机命中空位的概率越来越低可能循环几十次才能布下一颗雷。如果棋盘非常小或者雷数非常多这个循环可能变得很慢。当然对扫雷这种小型棋盘来说影响小到可以忽略真正严重的不是这个而是下一个问题。3.2 最经典的坑玩家首点即中雷想象一下这个场景玩家满怀期待打开游戏左键点下的第一个格子“砰”一声就炸了。这虽然算不上程序bug——毕竟扫雷本来就有运气成分——但从体验上讲这是很糟糕的设计。你打开一局新游戏第一步就被炸死你还会觉得这个游戏公平吗市面上绝大多数扫雷实现都有“首点保护”机制玩家第一次点击的格子以及周围一圈不允许有地雷如果随机布雷布到了这个区域就重新布。这样做的原理是给玩家一个安全的第一步从那里开始展开。实现方式也不复杂function placeMinesWithProtection(firstRow, firstCol, mineCount) { let placed 0; while (placed mineCount) { const r Math.floor(Math.random() * rowCount); const c Math.floor(Math.random() * colCount); const inSafeZone Math.abs(r - firstRow) 1 Math.abs(c - firstCol) 1; if (!board[r][c].mine !inSafeZone) { board[r][c].mine true; placed; } } }注意细节Math.abs(r - firstRow) 1这个判断同时涵盖了上下左右和对角线正好对应“周围八个格子”。不需要单独写八个判断条件这就体现出数学技巧在编程里的作用了。3.3 雷数上限校验还有一个不太容易想到的校验就是雷的数量不能超过棋盘格子总数。如果你允许用户自定义雷数一定要在创建棋盘前做一次合法性检查const maxMines rowCount * colCount - 9; // 留出首点保护的区域 if (mineCount maxMines) { throw new Error(雷的数量过多无法保证首点安全); }我见过有人设置了一个10x10的棋盘然后填了100颗雷结果布雷函数陷入死循环页面直接卡死。这种问题排查起来还挺费劲的因为代码逻辑看起来完全正确就是不知道哪里不对。后来我把布雷函数的循环次数设置了上限超过就抛出异常才知道是雷数的问题。4. 数字计算的两种思路每次现算还是初始化算好4.1 两张方案对比翻开一个格子之后要显示它周围有多少颗地雷。这个“周围地雷数”的计算主流有两种做法方案A初始化时算好存起来布雷完成之后遍历所有不是雷的格子统计它们周围八个方向上的雷数存到around字段里。之后游戏过程中翻开格子时直接读取这个值时间复杂度是O(1)。方案B翻开时实时计算玩家每次翻开一个格子临时遍历它周围的八个邻居数一下有几颗雷然后显示。不需要预先存储但每个格子的计算开销在游戏过程中产生。两种方案在扫雷这个场景下性能都够用但我要推荐方案A理由不只是性能。方案A更适合做后续的扩展。比如你以后想做成像《Minesweeper Online》那样的大规模多人游戏或者棋盘做大到1000x1000方案B在开局大量翻开格子时的重复计算会很浪费。更重要的是方案A把“计算”和“展示”分开了代码结构更清晰——初始化阶段专注准备数据游戏阶段专注处理交互。4.2 方向数组把八个方向的判断压缩成一段代码新手写周围雷数统计时最容易写成下面这样let count 0; if (board[r - 1]?.[c - 1]?.mine) count; if (board[r - 1]?.[c]?.mine) count; // ... 再写六个这样能跑但代码很臃肿而且每个方向都要单独处理边界很容易漏掉某个方向。更优雅的做法是用方向数组const directions [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; function countAroundMines(board, row, col) { let count 0; for (const [dr, dc] of directions) { const nr row dr; const nc col dc; if (nr 0 nr rowCount nc 0 nc colCount board[nr][nc].mine) { count; } } return count; }方向数组的好处就是网格类游戏中几乎通用的处理方式。你不需要为每个方向单独写逻辑边界判断统一在循环里做一个条件搞定四个方向的越界检查。初始化时把所有非雷格子的around值算好for (let r 0; r rowCount; r) { for (let c 0; c colCount; c) { if (!board[r][c].mine) { board[r][c].around countAroundMines(board, r, c); } } }到这里棋盘数据就准备好了。但请注意around为0的格子也在这一步被正确标记了这个标记在后面翻格子的扩散逻辑里至关重要。5. 翻开空格的连锁反应为什么我不建议用递归5.1 扩散机制的本质扫雷玩起来最爽的瞬间就是一键点开一整片空白区域。这个机制的规则很简单当你翻开一个周围雷数为0的格子时自动翻开它周围所有格子如果周围那些格子也有雷数为0的继续向外扩散。这本质上是一个图的遍历问题把棋盘看成网格图每个格子是一个节点相邻格子之间有边你要从起点出发把所有连通的“雷数为0且未翻开”的节点找出来并把它们的邻居也翻开。5.2 递归写法的隐患最常见的实现方式是递归。逻辑直白代码简洁function revealCell(row, col) { const cell board[row][col]; if (cell.revealed || cell.flagged || cell.mine) return; cell.revealed true; if (cell.around 0) { for (const [dr, dc] of directions) { const nr row dr; const nc col dc; if (nr 0 nr rowCount nc 0 nc colCount) { revealCell(nr, nc); } } } }这段代码逻辑很清晰在绝大多数情况下也能正常工作但有一个理论和实践双重层面的隐患递归深度。假设你玩一个高级棋盘30x16共480个格子整个棋盘只有99颗雷。如果玩家点开一个区域这一整片连通区域可能包含三四百个格子。在这种极端情况下递归的调用深度可能会很深虽然JavaScript引擎的栈空间通常能容纳几千层调用但理论上仍然存在栈溢出的风险。更重要的是如果你以后要在更复杂的棋盘实现里复用这段逻辑递归深度是很不好控制的变量。5.3 迭代方案用队列实现广度优先展开用队列做广度优先遍历是更稳、也更容易理解的做法。逻辑是这样的翻开一个格子后如果它是0把它的邻居加入队列继续处理直到队列为空。function revealCell(row, col) { const queue [[row, col]]; while (queue.length 0) { const [r, c] queue.shift(); const cell board[r][c]; if (cell.revealed || cell.flagged || cell.mine) continue; cell.revealed true; if (cell.around 0) { for (const [dr, dc] of directions) { const nr r dr; const nc c dc; if (nr 0 nr rowCount nc 0 nc colCount) { queue.push([nr, nc]); } } } } }这段代码就是前面递归版本的迭代形态但不会再栈溢出。理论上你可以用栈实现深度优先效果类似。不过扫雷的扩散逻辑用广度优先更符合人的直觉因为视觉上是均匀向外一圈一圈展开的。这里还有一个非常重要的细节为什么入队列前不检查格子的状态queue.push的时候直接推出队列的时候才做真正判断。这是因为同一个格子可能被多个方向同时加入队列如果入队时才去重就还需要一个visited数组来额外标记代码反而更复杂。先推入、出队时判断可以保证同一个格子最多被执行一次展开逻辑因为一旦它被revealed后续出队时就会被跳过。5.4 翻开普通数字格子和雷格子的处理扩散逻辑只适用于around 0的格子。如果你点开的是一个around 3的数字格子那就只翻开它自己显示数字3不做扩散。这一点要单独判断清楚别把数字格子也一股脑丢进队列。踩雷的处理则更直接如果点开的格子有雷游戏结束把所有雷展示出来标记玩家点中的那颗雷为红色。6. 胜利判定两种实现方式选你觉得顺手的6.1 数已翻开的格子数扫雷的胜利条件说穿了是所有不是雷的格子都被翻开。所以最简单直接的判断就是每次翻开一个格子后统计已翻开的非雷格子数量如果等于总格子数 - 地雷数就胜利了。function checkWin() { let revealedCount 0; for (let r 0; r rowCount; r) { for (let c 0; c colCount; c) { if (board[r][c].revealed !board[r][c].mine) { revealedCount; } } } return revealedCount rowCount * colCount - mineCount; }这个实现最直观但每次翻开格子都要遍历整个棋盘时间复杂度是O(n)。扫雷棋盘小没问题。但从代码品味上讲可以做得更好。6.2 用剩余未翻开格子数判断O(1)解法另一种思路是维护一个计数器游戏开始时未翻开的非雷格子数就是总格子数 - 雷数。每次成功翻开一个非雷格子计数器减1。当计数器归零玩家胜利。let cellsLeft rowCount * colCount - mineCount; function revealCell(row, col) { // ... 展开逻辑 if (!board[r][c].mine !wasRevealedBefore) { cellsLeft--; if (cellsLeft 0) { // 胜利 } } }注意用wasRevealedBefore来判断这个格子是不是本次操作才翻开的。因为扩散逻辑里一个格子可能被多个邻居重复“尝试”翻开只有从没翻开过的格子才需要递减计数。这种方式不需要遍历棋盘效率更高代码也更干净。我后来在功能扩展时把计数器暴露出去同时实现了“剩余雷数显示”和“安全格子数计算”两个功能一份数据两处收益。6.3 一个特别容易忽略的规则旗子插错了怎么办很多新手在做旗帜功能时会认为“格子被插了旗就什么都不能操作了”。这个理解有误。经典扫雷里如果你在插旗的格子上再点一下右键旗子会消失格子回到未标记状态之后左键点击可以正常翻开。这个交互细节实现起来很简单但很影响手感function toggleFlag(row, col) { const cell board[row][col]; if (cell.revealed) return; // 已翻开的格子不能插旗 cell.flagged !cell.flagged; // 更新界面上剩余的雷数显示 }还有一个容易被忽略的点就是“双击/双键同时按下”快捷判定。在Windows经典扫雷里如果你已经翻开了一个数字格子并且它的周围旗子数等于该数字同时按下左右键会直接展开周围剩余未翻开的格子。这个功能国内玩家可能用得少但它是提升操作效率的重要交互。实现起来甚至可以复用我们的扩散逻辑——把未被旗子标记的相邻格子当作起点调用展开函数注意跳过雷格子。7. 界面渲染数据层和视图层的第一次解耦7.1 不推荐的写法在循环里直接改DOM初学者最常见的做法是每次操作都重新遍历二维数组然后一个个document.createElement或者innerHTML重建整个棋盘。这种做法最大的问题在于性能高级棋盘480个格子每次点击都重建一次虽然也不算特别卡但当扩散逻辑触发几百个格子同时更新时页面会明显顿一下而且状态不连贯闪烁感很强。更专业一点的做法是用“数据驱动视图”的思路先保证数据层正确再根据数据差异更新DOM。对于扫雷这个场景最简单有效的方式是在棋盘上预先创建好所有格子对应的DOM元素用一个二维数组保存它们的引用。操作之后只更新状态变化的那部分格子而不是全部重建const cellElements []; function createBoardDOM() { for (let r 0; r rowCount; r) { cellElements[r] []; for (let c 0; c colCount; c) { const cellDiv document.createElement(div); cellDiv.className cell; cellDiv.dataset.row r; cellDiv.dataset.col c; cellElements[r][c] cellDiv; boardContainer.appendChild(cellDiv); } } } function updateCellDOM(row, col) { const cell board[row][col]; const el cellElements[row][col]; el.className cell; if (cell.revealed) { el.classList.add(revealed); if (cell.mine) { el.classList.add(mine); } else if (cell.around 0) { el.textContent cell.around; el.classList.add(num- cell.around); } } else if (cell.flagged) { el.classList.add(flagged); } }这种“先建DOM骨架、后按状态更新类名”的方式和现代前端框架的思想是一致的。你不需要引入任何库就能体会到“数据变了视图跟着变”的美妙。7.2 用CSS类而不是逐条改style这里有一个非常影响代码质量的习惯不要在JavaScript里直接改el.style.xxx。颜色、字体、背景、边框这些都属于表现层应该交给CSS。JavaScript只负责决定“这个格子应该是什么状态”至于状态长什么样由CSS类定义。这样做的好处是样式调整不需要改逻辑代码状态和样式彻底分离代码体积更小。举个例子数字1-8的颜色经典扫雷是有固定配色的.num-1 { color: #0000FF; } .num-2 { color: #008000; } .num-3 { color: #FF0000; }你要做的就是给DOM元素加上对应类名。颜色的微调以后只需要改CSS文件。7.3 事件绑定用事件委托避免给480个格子挨个绑监听初学者常见的写法是循环所有格子给每个绑定事件for (let r 0; r rowCount; r) { for (let c 0; c colCount; c) { cellElements[r][c].addEventListener(click, () handleClick(r, c)); } }这也行但不是最优雅的方案。更好的做法是事件委托——只给棋盘容器绑定一个click事件通过event.target判断点到了哪个格子boardContainer.addEventListener(click, (e) { const cellEl e.target.closest(.cell); if (!cellEl) return; const row parseInt(cellEl.dataset.row); const col parseInt(cellEl.dataset.col); handleClick(row, col); });事件委托的好处是如果未来你支持了动态增删棋盘格子比如调整难度切换棋盘大小不需要重新绑定事件性能上也更优毕竟浏览器只需要维护一个事件监听器。右键事件同理在容器上监听contextmenu事件阻止默认菜单调用toggleFlag。8. 我实际踩过的几个大坑和绕过它们的办法8.1 坑一布雷时死循环导致页面卡死这个问题前面提到过值得单独拿出来再讲一遍完整的排查过程。当时我设置了一个10x10的棋盘、99颗雷打开浏览器一点开始页面直接无响应。我的第一反应是代码里哪里有死循环查来查去没发现问题。后来在布雷循环里加了个计数器打印输出发现循环执行了几十万次还没结束才意识到是“几乎没空位能布雷”导致的。解决办法是提前校验if (mineCount rowCount * colCount) { throw new Error(雷数不能大于等于格子总数); }这个校验放在游戏初始化最前面从根源上杜绝了死循环。8.2 坑二边界格子访问越界刚开始写计算周围雷数时我没有用方向数组每个方向单独写判断结果漏了左下和右上两个方向导致这两个方向的雷永远统计不到。用方向数组之后这个问题自然消失了因为所有方向统一走一套越界判断不可能漏。不过方向数组同样有一个隐蔽的坑需要提醒你遍历方向数组时当前格子自身的坐标偏移是[0, 0]不在方向数组里。你数组里写八个方向就够了别手滑写九个进去。8.3 坑三首点保护区域的半径设定我做首点保护时犯过一个错只保护了玩家点击的那个格子没保护周围的八个格子。结果玩家点了一个边角格子旁边就是地雷翻开后虽然没有直接炸死但视觉效果非常难受——一个孤零零的格子四周全是雷。后来我把保护范围扩大到以首点为中心、边长为3的九宫格体验好了很多。这也是现代扫雷的通行做法。8.4 坑四游戏结束后还能继续操作这个坑相当隐蔽。玩家踩雷后棋盘已经判定为游戏结束但我没有设置游戏状态锁玩家还能继续点其他格子、插旗、翻牌。玩起来特别诡异——明明已经输了界面却还能动。后来我加了一个gameState变量取值是playing | win | lose在handleClick和toggleFlag开头判断不是playing状态就直接return。8.5 坑五计时器泄漏我加了计时器功能之后遇到一个奇怪的问题每次重开一局计时器会越走越快。排查下来发现是setInterval创建后游戏重开时没有清除上一个定时器导致多个定时器同时累加。解决办法是游戏初始化时先clearInterval再创建新的function startTimer() { clearInterval(timerId); timerId setInterval(() { time; updateTimeDisplay(); }, 1000); }8.6 坑六重复点击同一个格子扩散逻辑中如果玩家快速双击同一个格子会触发两次展开。第二次进入时格子已经revealed了逻辑上会被跳过但不排除极端情况下出现重复计数。解决方案就是在handleClick里先判断格子状态已经翻开的格子直接return同时也能防止数字格子被连续点击时出现视觉闪烁。9. 进阶可玩性优化从“能玩”到“好玩”9.1 难度分级经典扫雷分为初级8x8、10颗雷中级16x16、40颗雷高级30x16、99颗雷。你可以把这几个配置做成下拉菜单或者按钮组方便切换。不同难度下格子大小要通过CSS类动态调整否则高级棋盘在手机上会挤成一团。9.2 剩余雷数显示显示剩余雷数可以提升玩家对局势的判断。实现思路是剩余雷数 总雷数 - 已插旗数。每次插旗或取消插旗时更新界面上的数字。注意玩家插旗不一定插对地方这个数字可能变成负数。要不要把负数标红提醒我的建议是标红这是一个很细节但很有利于体验的交互。9.3 失败时的动画反馈踩雷瞬间可以加一个简单的震动效果或者让踩中的雷格子变红并闪烁几秒再弹出失败提示。这个反馈让玩家明确知道失败原因而不是一片红雷铺开根本看不清自己点的是哪颗。9.4 背景音乐和音效按钮虽然是可选功能但加一个很轻的“点击声”和“踩雷声”会显著提升游戏的整体质感。注意一定要提供静音按钮否则在公共场合打开页面会很尴尬。9.5 键盘操作支持对于扫雷这种网格游戏键盘上下左右移动光标空格键翻开格子F键插旗能极大提升高频玩家的操作效率。这个功能实现起来并不复杂维护一个cursorRow和cursorCol状态监听keydown事件移动光标同时给对应的格子添加高亮类名。10. 关于这套代码我最后的几条建议扫雷这个项目每个人写出来的代码风格都会不一样。有人喜欢把所有逻辑塞进一个文件有人喜欢拆分成模块。我尝试过不同的组织方式后目前的偏好是数据逻辑和DOM操作分离游戏状态管理和渲染更新各司其职。这样带来的直接好处就是调试时思路很清晰——数据不对就查数据处理函数显示了问题就查渲染函数不用在一堆互相纠缠的代码里大海捞针。如果你现在打算动手写我的建议是不要复制粘贴我上面任何一段代码先自己把思路捋清楚画一画流程图想一想棋盘的数据结构写一个最小版本跑通再逐步加功能。遇到问题的时候多打印console.log看看数据到底是什么样的大部分问题都会一目了然。实在卡住了再来参考这篇文章里的实现和反思。最后说一点题外话我见过不少人觉得扫雷这种小游戏太“小儿科”不屑于去写。但实际上越是这种看似简单的小项目越能暴露一个人对基础知识的掌握程度。数组索引、边界判断、状态管理、事件绑定、递归与栈、数据与视图的关系这些概念单拎出来都很简单但放在一个真实可交互的项目里它们就会互相咬合产生各种意想不到的问题。能把扫雷写得流畅、优雅、无明显bug的人写复杂项目时踩坑的概率一定会低很多。我现在带新人时也经常让他们从扫雷开始练手每一版我都会认真看他们的数据结构和扩散逻辑——这两块写得好不好基本上就能判断这个人的代码功底了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价