
做扫雷这个练习项目的时候我正想找一个能串起事件绑定、递归、数组操作的题目。网上扫雷小游戏的实现很多但大多数是把代码贴出来就不解释了真正能讲清楚布雷、数字统计、递归展开这些核心点怎么设计的反而少见。这篇就按我实际动手的流程来写从数据结构选型到成品可玩把每一个关键决策背后的理由都说明白。无论你是刚学JavaScript不久的前端新手还是想找个周末项目巩固一下基础的老手跟着做完你会得到的不只是一个小游戏还包括一套“先想清楚再写代码”的工程习惯。1. 扫雷小游戏项目设计先想清楚再写代码很多人拿到项目直接开始写界面写到一半才发现数据结构和交互逻辑对不上又回头重构。我个人的习惯是先把关卡定清楚这个扫雷小游戏到底要做什么、用多大规模、哪些是第一版必须有的哪些可以后面再加。理清楚之后代码写起来会顺很多。1.1 为什么我推荐用原生JavaScript做这个项目扫雷这个项目特别适合用原生JavaScript来做原因很简单它的核心逻辑其实只有三块数组数据、递归展开、事件处理。这三块正好是前端基础里最常用、也最容易被轻视的部分。如果用框架很多东西被框架替你处理掉了反而少了锻炼的机会。而且原生实现还有一个实际好处文件结构简单。一个HTML文件就能撑起来不需要构建工具、不需要npm依赖。做完之后复制到任何地方都能打开发给别人也能直接看效果。对于练手项目来说这种“零门槛交付”体验很重要它让你把注意力全部放在算法和交互上不会被工程化工具分散。我当时定下第一版的功能边界非常克制就做好这几件事三种经典难度初级9x9、中级16x16、高级30x16、左键翻开格子、右键标记旗帜、数字统计、递归展开、计时器、剩余地雷计数、重新开局。什么排行榜、自定义难度、音效全部留到后面再说。第一版功能越少核心逻辑越容易调试这个取舍在项目实战里尤其重要。1.2 数据结构选型一维数组的账要怎么算扫雷棋盘本质上是一个二维网格所以很多人的第一反应是用二维数组也就是grid[row][col]。这样写确实直观grid[2][3]一看就知道是第二行第三列。但我最终选择了一维数组用一个cells数组存储所有格子每个格子是一个对象。const cells []; for (let i 0; i rows * cols; i) { cells.push({ mine: false, // 是否是地雷 revealed: false, // 是否已翻开 flagged: false, // 是否被标记为旗帜 count: 0 // 周围地雷数 }); }一维数组的好处在于渲染和事件绑定特别方便。生成网格的时候我只需要按顺序创建对应数量的格子元素然后用index直接建立DOM元素和数据对象的对应关系。点击事件里通过>function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } } function placeMines(excludeIndexes) { const candidates []; for (let i 0; i rows * cols; i) { if (!excludeIndexes.includes(i)) { candidates.push(i); } } shuffle(candidates); const mineIndexes candidates.slice(0, mineCount); mineIndexes.forEach(idx { cells[idx].mine true; }); }注意这里的excludeIndexes是首击保护用的我们马上讲到。这个布雷方式有一个隐含的好处候选数组天然不会重复选到同一个格子所以不需要额外的“是否已经埋过雷”判断。2.2 首击保护第一次点击绝不踩雷原版扫雷有个经典设定玩家第一次点击绝不会踩雷。很多初学者写完布雷之后发现第一次点击就爆炸把格子翻开一看雷是固定的体验非常差。实现首击保护通常有两个思路。第一个思路是布雷完成后如果玩家第一次点击踩到雷就把这颗雷移到一个安全位置。第二个思路更优雅第一次点击之前不布雷等玩家点完确定好安全区再布雷。我用的就是第二种。let firstClick true; function handleFirstClick(index) { const exclude getNeighbors(index); exclude.push(index); placeMines(exclude); firstClick false; }这里的getNeighbors(index)返回目标格子周围的8个格子下标。为什么要把周围一圈也排除掉因为扫雷有一个很重要的体验设计点击一个数字为0的空格会自动展开一大片区域。如果只排除点击位置本身点击空格的瞬间递归展开到邻居的时候发现邻居有雷就会立刻踩雷体验会非常差。把周围9格都设为安全区首击展开之后至少能留出一个小范围的缓冲地带。还有一个细节如果某个极端自定义模式下地雷数量接近可用格子数排除安全区后剩下的格子可能不够布雷。代码里最好做一个校验如果candidates.length mineCount就提示调整参数避免运行时出错。2.3 数字统计两种写法的取舍布雷完成之后就要给每个非雷格子计算周围8格的地雷数量。这个逻辑有两种写法。第一种写法是遍历所有非雷格子对每个格子再遍历它的8个邻居数一数邻居中有几颗雷。这种写法的时间复杂度为O(8N)代码直观适合初学者。第二种写法反过来遍历所有地雷每找到一颗雷就给它的8个非雷邻居的count加1。cells.forEach((cell, idx) { if (cell.mine) { getNeighbors(idx).forEach(n { cells[n].count; }); } });地雷的count值不会用到因为地雷格子永远不会显示出数字所以不需要跳过地雷邻居。两种写法都能得到正确结果但我实际项目里更倾向于第二种。原因是扫雷棋盘上地雷数量通常远小于总格子数第二种遍历次数少很多。更重要的是这种“由雷向邻居广播”的思路在大规模场景下更好扩展。比如以后要做“无猜模式”在布雷和数字统计之间可能还需要插入别的逻辑广播方式更直观。不过我也要说清楚如果你是为了读代码方便选第一种完全没问题。N最大也就480这个量级性能差异几百微秒都不到。重点是要想清楚自己为什么选而不是复制一种写法就完事。2.4 递归展开零附近自动开花的机制扫雷最核心的体验就是点击一个数字为0的格子周围所有相邻的空区域全部展开直到碰到有数字的格子边界为止。这个机制实现起来就是递归。function revealCell(index) { const cell cells[index]; if (cell.revealed || cell.flagged) return; cell.revealed true; revealedCount; if (cell.mine) { handleLose(index); return; } if (cell.count 0) { getNeighbors(index).forEach(n revealCell(n)); } }这个函数要特别注意两个地方。第一revealed标记必须在递归之前设置。你想想看A格子是0它先把自己标记为已翻开然后依次检查邻居B、C、D。如果邻居B也是0B再检查它的邻居其中包括A。此时A已经标记为revealed就直接返回不会重复入栈。如果你把标记动作放在递归之后第一次访问A时A还是未翻开状态递归到B之后B又递归回到AA再次进入逻辑就会无限循环浏览器直接卡死。这个bug我在调试里反复遇到细节放到后面说。第二检查flagged很重要旗帜标记的格子不应该被空格展开强制翻开。比如你怀疑某格是雷手动标了旗结果旁边点开一个空格把这个格子顺手翻开了那标记就没有意义了。所以展开时遇到带旗的格子直接跳过。还有一个性能问题值得说有些实现喜欢用数组保存一份“已访问”集合每次访问都从集合里查一遍。这里完全没必要直接在格子对象上用布尔字段标记就行。扫雷棋盘最大480格递归深度不会超过480层栈空间完全够用不需要别出心裁改成非递归写法。3. 完整代码实现搭一个能玩的扫雷小游戏算法想清楚了代码写起来就是体力活。这一章给出页面骨架、核心样式和完整JavaScript逻辑。我尽量按实际项目的组织方式展示方便你直接对照着抄。3.1 页面骨架与基础样式HTML结构保持极简顶部是信息栏中间是棋盘容器。div idapp div classheader select iddifficulty option valueeasy初级 9x9/option option valuemedium中级 16x16/option option valuehard高级 30x16/option /select button idresetBtn重新开始/button div classinfo span idmineCount10/span span idtimer000/span /div /div div idboard/div /div棋盘我用CSS Grid布局列数和格子数量由JavaScript动态生成时设置。CSS里把格子的基础状态做出来再通过状态类切换显示效果。#board { display: grid; gap: 0; width: max-content; border-top: 3px solid #999; border-left: 3px solid #999; user-select: none; } .cell { width: 32px; height: 32px; background: #c0c0c0; border: 2px solid; border-color: #fff #999 #999 #fff; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; cursor: pointer; box-sizing: border-box; } .cell.revealed { border-color: #999; background: #e0e0e0; cursor: default; } .cell.mine-revealed { background: #ffcccc; } .cell.flagged { color: #d00; }格子的立体边框是扫雷的经典视觉语言没翻开的格子有凸起效果翻开后变成平面。这套样式值得保留老玩家一看就有亲切感。3.2 初始化、布雷与渲染JavaScript部分我习惯先定义配置和全局状态再写初始化函数。const configs { easy: { rows: 9, cols: 9, mines: 10 }, medium: { rows: 16, cols: 16, mines: 40 }, hard: { rows: 16, cols: 30, mines: 99 } }; let rows, cols, mineCount; let cells []; let boardEl; let firstClick true; let timerId null; let seconds 0; let revealedCount 0; let flagCount 0; function getNeighbors(index) { const r Math.floor(index / cols); const c index % cols; const result []; for (let dr -1; dr 1; dr) { for (let dc -1; dc 1; dc) { if (dr 0 dc 0) continue; const nr r dr; const nc c dc; if (nr 0 nr rows nc 0 nc cols) { result.push(nr * cols nc); } } } return result; }初始化时根据当前难度重新生成数据并渲染棋盘格子。function initGame() { const cfg configs[difficultySelect.value]; rows cfg.rows; cols cfg.cols; mineCount cfg.mines; cells []; for (let i 0; i rows * cols; i) { cells.push({ mine: false, revealed: false, flagged: false, count: 0 }); } firstClick true; revealedCount 0; flagCount 0; seconds 0; clearInterval(timerId); timerId null; updateTimerDisplay(); updateMineDisplay(); boardEl.innerHTML ; boardEl.style.gridTemplateColumns repeat(${cols}, 32px); for (let i 0; i rows * cols; i) { const div document.createElement(div); div.className cell; div.dataset.index i; boardEl.appendChild(div); } }注意initGame里把clearInterval放在生成新棋盘之前。计时器这个坑我在调试部分专门讲。3.3 点击与旗帜标记事件绑定我选择挂在棋盘容器上用事件委托来处理。这样不管格子数量多少只需要绑定一次而且重新开局后也不用解绑旧事件。boardEl.addEventListener(click, (e) { const index Number(e.target.dataset.index); if (index undefined || index null) return; onCellClick(index); }); boardEl.addEventListener(contextmenu, (e) { e.preventDefault(); const index Number(e.target.dataset.index); if (index undefined || index null) return; onCellRightClick(index); });左键点击的逻辑分几步如果是第一次点击先布雷再翻开如果当前格子已经翻开或是旗帜直接忽略如果游戏已经结束忽略所有操作。function onCellClick(index) { if (gameOver) return; const cell cells[index]; if (cell.revealed || cell.flagged) return; if (firstClick) { const safeZone getNeighbors(index); safeZone.push(index); placeMines(safeZone); calculateCounts(); firstClick false; startTimer(); } revealCell(index); checkWin(); }右键标记的逻辑也很直接已翻开的格子不能标记已经是旗帜的取消标记未标记且剩余旗帜数量大于0则标记。function onCellRightClick(index) { if (gameOver) return; const cell cells[index]; if (cell.revealed) return; if (cell.flagged) { cell.flagged false; flagCount--; } else { if (flagCount mineCount) return; cell.flagged true; flagCount; } const el boardEl.children[index]; el.classList.toggle(flagged); el.textContent cell.flagged ? F : ; updateMineDisplay(); }旗帜显示我用的字母F。原本想找一个更像旗子的符号但考虑到不同系统对符号的渲染差异太大有的会显示成emoji表情看着很乱字母F简单、干净、跨平台一致。界面风格完全可以用CSS再做得花哨一些但核心标识尽量保持稳定。3.4 胜负判定与重新开局胜利条件很明确所有非地雷格子都被翻开。所以每次翻开后统计revealedCount如果等于rows * cols - mineCount就赢了。function checkWin() { const totalSafe rows * cols - mineCount; if (revealedCount totalSafe) { gameOver true; stopTimer(); cells.forEach((cell, idx) { if (cell.mine) { boardEl.children[idx].classList.add(flagged); boardEl.children[idx].textContent F; } }); } }失败处理则是在revealCell里触发的。踩到雷时把所有雷显示出来踩中的那颗雷用特殊样式标红。function handleLose(index) { gameOver true; stopTimer(); cells.forEach((cell, idx) { const el boardEl.children[idx]; if (cell.mine) { el.textContent *; el.classList.add(revealed); } }); const hitEl boardEl.children[index]; hitEl.classList.add(mine-revealed); }重新开局时把难度下拉框的值读出来再initGame一遍所有数据和DOM都会重置。resetBtn.addEventListener(click, () { gameOver false; initGame(); });到这里一个能从头玩到尾、能赢能输的扫雷小游戏就完成了。接下来讲讲我在实际调试过程中踩过的坑这些才是最费时间的部分。4. 调试实录容易翻车的细节全在这里代码写出来能跑是一回事把交互体验打磨到“像真正的扫雷”是另一回事。这一章记录我过程中遇到过的几个典型问题每一个都是实际会出现的场景。4.1 右键标记与浏览器默认菜单的冲突第一次做的时候我以为给棋盘绑定一个mousedown判断event.button 2就能处理右键。实际测试发现在Mac触控板上双指点击时mousedown的button参数在某些浏览器里并不稳定而且无论如何鼠标右键松开时浏览器都会弹出默认菜单。正确做法是用contextmenu事件。在事件处理器里先e.preventDefault()阻止默认菜单再执行标记逻辑。这个事件在Windows和Mac上都能触发兼容性最稳。boardEl.addEventListener(contextmenu, (e) { e.preventDefault(); const index Number(e.target.dataset.index); if (index undefined || index null) return; onCellRightClick(index); });还有一个坑如果点击的目标是#board容器本身而不是某个格子e.target.dataset.index会是undefined。我一开始没判断就直接Number(undefined)转成了NaN然后拿去访问cells[NaN]不会报错但逻辑会莫名其妙失效。后来加上了空值判断这类问题就消失了。4.2 递归死循环一个隐藏很深的bug我在2.4节讲过递归展开的标记顺序问题。实际项目里我第一次写的revealCell是这么写的function revealCell(index) { const cell cells[index]; if (cell.revealed || cell.flagged) return; if (cell.count 0) { getNeighbors(index).forEach(n revealCell(n)); } cell.revealed true; revealedCount; }乍一看逻辑没问题先递归再标记。但运行起来一点空白区域就直接卡死浏览器标签页无响应只能强制关闭。原因就是我在递归调用之前没有把当前格子标记为已翻开。具体过程是这样点击A格子A的count为0进入递归分支把A的邻居B推进递归。B也是0B又把邻居遍历一遍其中包含A。此刻A仍然是未翻开状态于是A再次进入递归A又去遍历邻居又访问B双方互相加入对方递归永远出不来了。修复方式特别简单把cell.revealed true这行提到递归之前。但顺手就能修好的bug排查起来却花了我很长时间。这也说明一个问题递归函数里状态的更新顺序和递归调用的顺序不只是风格问题而是正确性问题。4.3 计时器叠加重置游戏的连带问题计时器我一开始写得很随意。点击棋盘时启动setInterval每秒更新一次秒数。表面没问题直到我连续点了两次重新开局发现新棋盘的计时器每秒增加2秒再点一次变3秒。原因挺简单的我每次开局都没有清理旧计时器。第一次开局创建了一个定时器重置游戏后又创建了一个新定时器两个定时器同时运行秒数自然就叠加了。而且旧定时器里引用的还是同一个秒数变量所以新局也会被它污染。修复也很简单在每个需要重置计时器的地方先clearInterval(timerId)再置空。function stopTimer() { clearInterval(timerId); timerId null; } function startTimer() { if (timerId ! null) return; timerId setInterval(() { seconds; updateTimerDisplay(); }, 1000); }startTimer里判断timerId ! null是为了防止第一次点击后、棋盘还没结束又点了另一个格子导致创建第二个定时器。这种细节看起来不起眼但实际体验中非常影响品质。4.4 常见问题速查表我把调试过程中遇到的各种现象整理成了表格排障时直接对照定位就行。现象可能原因解决方案右键点击弹出浏览器菜单没有阻止contextmenu默认行为在contextmenu处理器中调用e.preventDefault()第一次点击就爆炸布雷发生在点击前且没有排除点击位置改为首击后再布雷排除点击格及周围8格点击空白区域页面卡死递归展开时状态标记顺序错误导致A和B互相递归确保cell.revealed true在递归调用之前执行重新开局后秒数跳动异常旧计时器未清理重置时先clearInterval(timerId)再重新初始化旗帜数量显示负数没有限制旗帜数量上限旗帜数地雷总数时禁止继续标记点击旗帜格子却翻开了左键点击逻辑中没有判断flagged状态在revealCell入口处检查cell.flagged数字颜色和图标变化看不出来没有设置每次翻开后的样式重置翻开时统一加上revealed类再根据内容设置额外样式5. 进阶扩展把扫雷小游戏做成自己的作品基础版本做完之后你会发现这个项目就像一块画布想往哪个方向扩展都行。这一章分享几个我实际试过、觉得收益很高的扩展方向。5.1 交互增强移动端长按与自定义难度原版扫雷是纯粹的鼠标操作游戏但放到手机上右键标记变成了一个难题。我试过两种方案一是双击标记缺点是容易误触二是长按标记这更接近主流交互习惯。长按实现起来也不难在触摸事件里加一个定时器按下500毫秒后触发标记。但要注意长按之后还要阻止跟手的click事件触发不然会变成“既标记又翻开”。可以用一个布尔变量记录长按是否触发了在click事件里判断后直接return。自定义难度也值得加。很多玩家玩熟了固定三档之后想试试更大的棋盘。这时候只需要在配置对象里加一项从界面上让用户填写行数、列数和雷数再校验一下参数是否合法就行。比如行列乘积要超过地雷数否则没有安全区地雷数不能超过总格子数的某个比例否则游戏体验会崩。5.2 代码组织从“能跑”到“好维护”我第一版把所有逻辑都写在全局作用域里写起来快但代码往后扩展会越来越吃力。具体的改进方向是把逻辑拆成几个模块至少包括配置、棋盘数据、渲染、游戏控制。不一定要用ES Module同一个文件里用函数按区域分组加注释分块读起来就会舒服很多。如果想把事情做得更彻底把数据逻辑和DOM操作解耦。也就是说定义纯函数来处理布雷、展开、胜负判定这些函数完全不碰DOMDOM只负责把数据变化同步到界面。这样做的直接好处是以后想用命令行测试核心逻辑只需要在Node环境里跑数据函数不用搭一套浏览器环境。我见过不少初学者把这两层混在一起结果就是每次想改样式或者改规则都得在一堆classList.add和算法逻辑的夹缝里找代码。项目不大时还能忍受一旦加上自定义难度、存档、排行榜就会变成噩梦。趁这个项目规模小动手做一次模块化重构比做任何高大上的项目都能学得多。还有一个小而实用的经验数字的颜色方案可以直接参考大众认知里的扫雷配色1是蓝色2是绿色3是红色4是深蓝5是深红6是青色7是黑色8是灰色。这些颜色通过CSS类实现翻开时根据count值加上对应类。老玩家一眼就能认出数字含义不需要额外适应。最后说一点我在实际开发里的体会。扫雷这个项目的难点从来不是“写出来”而是“写对”。布雷、递归、事件委托这些概念单独拿出来都不难但把它们组合在一个交互闭环里很多隐蔽问题就出现了。比如递归死循环比如计时器叠加这些坑你踩过一次对整个前端运行机制的理解会比读十篇教程都深。如果你正在找一个练手项目我强烈建议你亲自把每一行代码敲一遍尤其是那个递归展开函数多折腾几次收获比想象中大得多。