
1. 从经典扫雷到扩展版一个老玩法的新设计思路1.1 经典规则里的痛点扫雷这套玩法从当年系统自带小游戏开始就一直没怎么变过一个网格几个雷左键翻开格子右键插旗标记数字表示周围八个格子里的雷数。规则看似简单但真正上手后有几个经典的体验瓶颈玩过的人应该都有体会。第一个痛点是“运气死”。第一下点击完全看命如果踩到雷游戏直接结束。虽然很多版本有“首次点击必安全”的保护但再往后点几步经常会在后期突然踩雷尤其遇到二选一的局面纯靠猜毫无操作空间。第二个痛点是信息反馈太弱。棋盘上只有“数字、空白、旗子、问号”几种状态新手看不懂数字意味着什么老手也觉得信息密度不够——比如某个格子两边的雷数已经确定了但没有辅助提示只能在脑子里硬记。第三个痛点是模式太死板。传统的初级9x9、10颗雷中级16x16、40颗雷高级16x30、99颗雷玩多了就成了肌肉记忆缺新鲜感。我做这个“扫雷扩展版”主要就是想解决这三件事增加确定性策略减少无脑猜雷丰富交互反馈让每一步操作都有迹可循扩充玩法模式让同一个核心逻辑能适配更多场景。整体思路是“经典规则做骨架扩展机制做血肉”不是推倒重来而是沿着原有玩法做增量。1.2 扩展版的定位不是重做是增强“扩展”这两个字很关键。我没有去改扫雷最核心的判定逻辑——翻开格子、计算邻雷数量、标记雷位这些基础规则如果动掉了那就不是扫雷了。真正的扩展在于三块一是地图维度的扩展。除了预设的初级、中级、高级完全开放自定义。棋盘可以从8x8一直拉到40x40雷数密度可以自己在10%到40%之间调。实际做下来一个32x32、雷密度20%左右的棋盘策略深度已经远超传统高级模式玩起来感觉完全不一样。二是规则维度的扩展。引入“生命值”概念之后玩家有三次踩雷机会不会因为一次失误就全盘结束。这个改动看着不大但对体验的改变是质变——玩家敢于尝试高风险高收益的开局策略而不是每一步都畏首畏尾。三是辅助维度的扩展。扫雷高手都知道“局部推理 概率判断”是上限新手卡住的点往往是不懂推理方法。扩展版加入“建议提示”功能不是直接告诉答案而是高亮出那些在逻辑上已经可以确定的格子。这样做的好处是玩家在观察高亮区域的过程中会慢慢自己总结出推理方法实际上是在“玩中学”。这个定位决定了后面所有技术方案的选型方向核心算法必须稳扩展机制必须松耦合UI必须有更好的反馈表现力。换句话说不能为了扩展把老玩法的底子搞坏。1.3 这个项目适合谁如果你是纯玩家这篇文章能让你看到一个经典游戏在交互设计上的可能性。如果你是想练手的前端或游戏开发者文中涉及的棋盘建模、状态管理、递归展开、渲染优化这些点都是日常开发里高频用到的技术。我会把每一块设计的“为什么这么做”也讲清楚这样你想迁移到自己项目里时直接搬思路就行。2. 扩展点怎么设计玩法层级的取舍2.1 棋盘模式从固定规格到全自定义传统扫雷的棋盘规格是写死的我第一版也偷懒写死了但很快就发现问题不同屏幕尺寸下同样规格的棋盘显示比例差很多小屏上格子挤成一团大屏上又稀疏得不行。所以第二版直接改成参数化生成棋盘的行数、列数、雷数全部由配置决定预设模式只是几组推荐参数。这里的核心计算是雷密度控制。我按“总格数 × 密度百分比”来生成雷数但会做一个下限保护——雷数最低不能少于1颗最高不能超过总格数的50%超过35%之后基本上就没有安全区可言了纯粹变成了概率游戏。实际体验下来15%到25%是策略体验最舒服的区间新手入门用10%老手挑战用30%。实现上有个小细节棋盘数据用一维数组存而不是二维数组。index row * col colIndex这样遍历的时候性能好而且做序列化存档也方便。渲染层需要拿到行列坐标时再换算回来多一次除法开销但换来的是整体数据操作的简洁性很值。2.2 生命值机制一次失误不等于游戏结束生命值机制是我在整个扩展里觉得最值得的一个改动。原版扫雷里踩雷就是终局这导致玩家的试错成本太高很多新手在犹豫中消耗大量时间。扩展版给玩家预设三条命踩雷后损失一条命同时雷原地“爆炸”变成空白格周围格子照常展开游戏继续。这个机制实现起来不复杂状态管理里多维护一个lives字段就行。麻烦的是UI表达——玩家必须第一时间知道自己少了一条命而且要明确知道是哪个格子炸了。我最终的处理是踩雷瞬间在格子位置播放一个扩散特效棋盘上方的心形图标对应熄灭一颗同时会有短暂的红光边框提示。这个视觉链路的完整度比单纯改个数字重要得多。还有一个细节三条命用完之后游戏仍然不会立即结束而是进入“临终模式”——再踩雷直接终局。这样设计是给玩家一个心理缓冲不至于屏幕上刚出现“游戏结束”四个字时感到突然。2.3 道具系统三类辅助工具怎么做到平衡道具系统是最容易做崩的部分。如果道具过强扫雷就变成了“用道具堆过去”的无脑游戏如果道具过弱又完全没人用。我最终设计了三个道具每个都卡在“提供信息但不替代思考”的边界上提示一次随机高亮一个在当前逻辑下可以100%确定的格子。注意不是提示“哪里安全”而是提示“这里必然可以推理出结果”所以对高手来说这个道具的价值更多是节省时间。安全排雷移除一个已经确定是雷的格子。这个道具厉害但使用次数有限制一局最多用两次防止玩家瞎猜雷位然后直接排掉。区域扫描翻开一个3x3区域内所有非雷格子。这个在开局阶段用比较赚后期反而用处不大因为后期棋盘信息多了3x3区域通常已经通过推理覆盖。道具系统的实现难点在于“提示一次”的判定算法。要找出“在当前局面下必然可确定的格子”本质上是在模拟人的推理过程对每个未翻开格子检查其周围已标记的雷数如果等于该格子的数字标注则剩余的未知格子都是安全格如果未翻开的剩余格子数等于该格子剩下所需的雷数则那些格子必然都是雷。这个算法并不难难的是写得不漏不重后面会细讲。2.4 挑战模式给高手的通关型玩法除了自由对局扩展版还加了一个“挑战模式”本质上是一系列带有目标条件的关卡。比如“在12步以内翻开第一块雷区”、“用不超过两颗雷的代价清理一个区域”、“在30秒内完成一个15x15的棋盘”。挑战模式的底层逻辑并不复杂本质就是“局参数 目标参数 结果校验”。但做起来有个意外收获开发者自己为了测试这些关卡被迫重新审视了很多平时不会注意到的玩法细节比如“最快完成一局的时间”受首击位置影响的方差有多大、合理的目标时间应该定多少。这个过程对打磨整个游戏的手感很有帮助。3. 核心技术点拆解数据建模与算法实现3.1 棋盘数据模型用状态矩阵管理一切我把棋盘的整体状态组织成几个并行的数据结构各司其职// 棋盘核心状态 const board { rows: 16, // 行数 cols: 16, // 列数 mines: new Set(), // 雷的位置索引集合 revealed: new Set(), // 已翻开的格子索引 flagged: new Set(), // 已插旗的格子索引 numbers: new Map(), // 格子索引 - 数字标注 };这里有几个选型考量值得说一下雷位用Set而不是Array是因为查找“某个位置是不是雷”是高频操作Set.has()是常数级时间数组的话要用includes每次都遍历数据量一大就明显拖慢。revealed和flagged用Set也是同理。而且Set天然保证不会重复添加同一个格子省去了去重逻辑。数字标注用Map而不是直接在格子对象上维护一个字段是因为棋盘上大量的格子是空白格没有数字用Map只存有数字的格子能节省内存。虽然现代设备不缺这点内存但这个习惯在更大规模的网格类项目里很管用。3.2 地雷生成算法从随机分布到无解防守地雷生成最直接的思路是随机选N个不重复的位置。但这里面有一个必须处理的细节——首击保护。扫雷圈子里有个默认规则叫“首击不踩雷”扩展版实现了更高一档的“首击区域保护”玩家第一次点击的格子包括它周围一圈共9格全部都不会有雷。这样玩家的第一个动作必定翻开一片区域开局体验会好很多。实现逻辑是function generateMines(board, safeCenterIndex, mineCount) { const safeZone getSurroundingIndexes(board, safeCenterIndex); safeZone.push(safeCenterIndex); // 中心格也纳入安全区 const candidates []; for (let i 0; i board.rows * board.cols; i) { if (!safeZone.includes(i)) candidates.push(i); } // Fisher-Yates 随机打乱取前 N 个 shuffle(candidates); return new Set(candidates.slice(0, mineCount)); }有人可能会问为什么不用Math.random()直接生成N个索引再去重因为当雷密度高的时候用随机生成的方式很容易碰撞碰撞后重试次数不可控极端情况下会出现死循环。Fisher-Yates洗牌后取前N个时间复杂度是O(n)而且逻辑上是确定性的一次性操作不存在重试。另一个需要考虑的点是雷的分布均匀性。纯随机生成的雷位偶尔会聚集成一团导致一大片区域没有信息玩起来很枯燥。扩展版做了一个可选的“均匀分布”开关做法是把棋盘划分成若干个子区域每个子区域内雷的数量按比例分配然后每个区域内部再洗牌取雷。实测下来这种方式的游戏体验更平稳很少出现连续大空白区域。3.3 数字计算邻域遍历的正确姿势翻开一个格子后要计算它周边有多少颗雷。这里的“周边”指的是八个方向上、下、左、右、左上、右上、左下、右下。最容易写错的地方是边界。棋盘角落的格子周围只有3个邻格边上的格子周围只有5个直接硬编码8个方向然后挨个遍历很容易越界。正确写法是用“方向数组 边界检查”function getSurroundingIndexes(board, index) { const row Math.floor(index / board.cols); const col index % board.cols; const dirs [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; const result []; for (const [dr, dc] of dirs) { const nr row dr; const nc col dc; if (nr 0 nr board.rows nc 0 nc board.cols) { result.push(nr * board.cols nc); } } return result; }这个函数是整个扫雷算法里最基础、调用频率最高的函数——每翻开一个格子、每计算一次数字、每执行一次道具判定都要用到它。所以边界条件必须一次写对我自己的测试里这个函数的覆盖率做到了100%分支覆盖。数字计算的逼真度还有一层数字标注的更新时机。传统写法是在生成完雷之后遍历所有格子计算数字。但扩展版因为要支持“踩雷后雷变空白”所以雷位有变化时数字也要联动更新。我封装了一个recalculateNumbers(board)函数每次雷位变化后调用一次保证数据永远是最新状态。3.4 扩散展开BFS与递归的选型翻开一个空白格周围无雷需要把它周围的格子全部展开再检测这些格子是否为空白如果是继续展开。这套逻辑有一个经典的名字洪泛填充Flood Fill也叫BFS广度优先搜索。递归写法很简单直观function revealCell(board, index) { if (board.revealed.has(index)) return; board.revealed.add(index); const num board.numbers.get(index); if (num undefined) { // 空白格 for (const neighbor of getSurroundingIndexes(board, index)) { revealCell(board, neighbor); } } }递归写法代码量小但它有隐患如果棋盘规模很大比如40x40而且雷很少那一次展开可能覆盖上千个格子。递归深度如果超过调用栈上限会直接爆栈。我在实际测试中32x32的棋盘就偶尔出现过递归深度过大导致的卡顿。最终我选用了BFS 队列的迭代写法稳而且可中断function revealCell(board, index) { const queue [index]; while (queue.length 0) { const current queue.shift(); if (board.revealed.has(current)) continue; board.revealed.add(current); const num board.numbers.get(current); if (num undefined) { for (const neighbor of getSurroundingIndexes(board, current)) { if (!board.revealed.has(neighbor) !board.flagged.has(neighbor)) { queue.push(neighbor); } } } } }queue.shift()理论上存在数组头部操作的低效问题但当队列规模在几千量级时这个开销完全可以忽略。如果想较真可以改用真正的队列结构或直接声明一个数组当索引指针用但在这个场景里属于过度优化。3.5 首次踩雷的容错逻辑扩展版的生命值机制有一个比较难处理的点踩雷后这颗雷要“变成”空白格然后继续执行展开逻辑。这意味着雷位本身要动态修改而且修改后数字标注要重新计算——因为一个雷变成空白后它周围格子的数字会减一。这个逻辑的顺序很重要我踩过一次坑先把雷从mines集合里移除调用recalculateNumbers(board)重新计算周边数字把踩中的格子加入revealed对该格子执行BFS展开扣减生命值如果顺序反了先展开再移除雷就会出现该格子周围数字显示异常的情况。这个细节不亲自踩坑很难发现。4. 实操落地从第一行代码到完整项目4.1 环境选型与工具链扩展版我选择用原生JavaScript Canvas 2D实现没有引入游戏引擎。原因有三第一扫雷的渲染量不大Canvas完全够用。第二没有引擎依赖意味着整个项目只需要一个HTML文件加一个JS文件就能跑起来分发和部署成本极低。第三也是最重要的一点核心逻辑不受引擎API的掣肘写起来更自由。界面布局上棋盘用Canvas渲染UI控件用DOM元素实现。棋盘区域用Canvas是因为格子重绘的频率高——每次点击、插旗、展开都要刷新局部区域Canvas的ctx.fillRect系列方法非常高效。DOM控件则天然支持响应式布局做按钮、弹窗、状态栏比Canvas再画一遍要省事得多。开发过程中我习惯用浏览器开发者工具实时调试每次修改代码后刷新页面看效果。Canvas调试有个技巧在draw函数里加一个showDebugInfo开关开启后会在棋盘下方打印当前的状态信息比如“已翻开格子数/总格子数”、“雷数”、“生命值”以及上一次点击的坐标。这个小技巧在排查逻辑问题时特别管用。4.2 棋盘渲染从实时重绘到分层绘制渲染部分的核心是drawBoard()函数。它每帧执行以下步骤function drawBoard() { // 1. 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 绘制未翻开的格子灰色底 凸起边框 // 3. 绘制已翻开的格子白色底 细边框 // 4. 绘制数字标注 // 5. 绘制旗子标记 // 6. 绘制踩雷特效 }分层绘制的顺序不能乱。如果先画数字再画格子数字就会被覆盖。另外不要每帧都重新绘制整个棋盘——只有格子状态变化时才需要局部重绘。我在状态变化时调用一个requestRender(board)函数用一个dirty标记变量来判断是否需要重绘实测能省不少资源。格子尺寸的计算也要用心。棋盘是动态的格子大小不能写死。我按canvas.width / cols和canvas.height / rows各算一个值再取两者中的较小值然后居中绘制棋盘保证任意尺寸下都不变形。canvas.width本身需要适配屏幕分辨率。我的做法是取window.innerWidth和window.innerHeight再乘以window.devicePixelRatio做高清适配canvas.width container.clientWidth * devicePixelRatio; canvas.height container.clientHeight * devicePixelRatio; ctx.scale(devicePixelRatio, devicePixelRatio);如果不做这一步在Retina这类高分辨率屏上画出来的格子边缘会发虚看起来很不专业。4.3 交互事件点击区域判定与右键拦截Canvas本身是一个整体元素没有“某个格子点击”这种内置事件。所以点击事件拿到坐标后需要换算成棋盘格子的行列索引。换算逻辑很简单canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; const col Math.floor(x / cellSize); const row Math.floor(y / cellSize); const index row * board.cols col; handleLeftClick(index); });这里有个细节容易踩坑不要用e.offsetX和e.offsetY它们在不同浏览器里的行为不一致用getBoundingClientRect换算最稳妥。右键插旗同样要处理。浏览器默认在右键按下时会弹出上下文菜单所以必须调用e.preventDefault()来禁用。同时插旗和翻开是互斥操作一个已翻开的格子不能再插旗一个已插旗的格子点击时不会展开。这些判断条件要放在事件处理的最前面。另外移动端没有右键这个概念需要在触屏上做“长按插旗”的手势兼容。实现方式是touchstart时记录时间戳touchend时判断按下时长是否超过500毫秒超过则触发插旗操作。注意还要在touchmove时标记手指移动过否则滑动屏幕会误触发插旗。4.4 状态管理用单一状态源避免“数据打架”这个项目的状态管理是我做得比较得意的一部分。整个游戏的状态集中在一个对象里const state { phase: idle, // idle | playing | won | lost board: null, // 棋盘数据 lives: 3, // 剩余生命值 timer: 0, // 用时秒 usedItems: { hint: 0, safe: 0, scan: 0 }, };所有操作都是“无副作用纯逻辑” “最后统一更新UI”。每执行一个动作先修改state然后再调用render()。这样做的好处是想排查“为什么界面上格子状态不对”时直接打印state就能看到完整的逻辑状态不用去猜测哪个UI组件被改坏了。有个具体例子道具“安全排雷”使用时要校验目标格子是不是雷、使用次数是否超限。这些校验全部在逻辑层完成通过了才允许UI层展示变化。如果校验失败弹窗提示原因。逻辑层和UI层严格分层在这个小型项目里让维护成本降了一个档次。4.5 单元测试逻辑代码的自我保护很多人写小游戏觉得“跑起来能玩就行”不做测试。但扫雷这个游戏比较特殊它的核心逻辑分支非常多边界情况、插旗与翻开的互斥、雷位变化后的数字重算、踩雷后的BFS展开……我单独把核心逻辑抽成了一个纯函数模块然后用一个简单的测试脚本跑自动化测试。测试用例覆盖了角落格子的邻域计算、数字标注精确性、BFS展开的边界情况、踩雷后的状态迁移、道具使用的次数限制。每个用例都断言函数的输出符合预期。这一步投入的时间大概是一到两天但后续迭代新功能时跑一遍测试几分钟就能确认“没改坏东西”这个安全感太值了。举一个测试覆盖出来的典型bug某次重构时我把getSurroundingIndexes里的边界判断条件写错了一个导致左下角格子的邻域少算了一个格子数字标注全部错乱。如果没有自动化测试这个bug钻进去排查起来少说要两三个小时。5. 开发中那些绕不开的坑与解决方案5.1 难点首击保护的地雷生成冲突首击保护实现后出现过一个隐蔽问题当雷数很多、棋盘很小时保护区域占的比例很大可能会导致“剩余格子数小于雷数”的冲突。比如在一个5x5的棋盘上布置15颗雷首击保护区域占9格剩下只有16格可选但只需要15颗理论上够用可如果保护区域算法和雷数生成逻辑没有联动判断极端配置下就会生成失败。解决方案是在生成雷之前做一次合法性检查const maxMines board.rows * board.cols - safeZoneSize; if (mineCount maxMines) { // 自动缩减少雷数或提示配置不合法 mineCount maxMines; }这个检查虽然只有三行代码但能把整个系统的健壮性提升一个档次。5.2 难点BFS展开时插旗格子的处理方式BFS展开的过程中遇到已插旗的格子应不应该展开这个问题在不同扫雷版本里答案不一样。有的版本允许展开有的版本要求必须取消标记后才能展开。扩展版的做法是插旗的格子既不会被BFS展开也不能被点击翻开。这个选择的逻辑是——既然玩家已经明确标记这里有一颗雷程序就应该尊重这个判断而不是强行去触发爆炸或展开。当然这个规则要写清楚否则玩家会困惑“为什么我点了没反应”。5.3 难点响应式布局下的棋盘缩放桌面端和移动端的屏幕尺寸差异很大棋盘不能固定像素尺寸。我做了一个动态缩放方案棋盘最大宽度不超过容器宽度的90%最大高度不超过视口高度的70%在此基础上计算格子尺寸确保整个棋盘完整可见。移动端下格子尺寸不得小于32像素否则手指点击精度会严重下降误触率飙升。这个适配逻辑虽然不复杂但直接决定了游戏在不同设备上的表现力。做跨端项目时这个部分值得提前规划而不是最后再来补救。5.4 难点性能优化与断点续排大棋盘下BFS展开可能涉及上千个格子的遍历。为提升性能我做了两个优化第一是路径压缩。数字标注为“已知”的格子不需要反复检查周边雷数实现上是把“数字标注”和“雷位信息”缓存在两个Map里更新时按需做最小范围重算。第二是“错误状态恢复”。开发期间我给自己加了一个快捷键按下R键可以将整个棋盘数据重置到开局状态而不是强制刷新页面。这个看似“偷懒”的功能在反复调试道具逻辑时帮我节约了大量时间。性能优化的思路是先用最简单的写法实现功能跑起来了再测瓶颈。千万不要一开始就优化到位那叫过早优化会把精力浪费在不重要的地方。5.5 难点状态迁移的复杂度管理扫雷的状态不是简单的一个数字而是一个状态机idle等待开始→playing进行中→won胜利→lost结束。每个状态下用户允许的操作是不同的。比如idle状态下点击棋盘应该触发“生成雷位并开始计时”playing状态下点击棋盘才执行翻开的逻辑won或lost状态下点击棋盘应当直接忽略。这个状态机如果不用清晰的状态字段管理很容易出现“游戏结束了还能继续点击翻开格子”的bug。我的做法把状态迁移统一收敛到transition(newPhase)函数里每个状态迁移必须经过这个函数不允许在业务逻辑代码里随意改状态。这样代码里所有“可能改变状态”的地方都一目了然排查问题时能顺着调用链找线索。6. 版本迭代与扩展方向6.1 从v1到v2的演进记录v1版本其实很粗糙只有标准的9x9、16x16、16x30三种模式没有生命值、没有道具、没有计时器。做出来后自己玩了十几局趣味性撑不过半小时——说白了就是换了个皮的原版扫雷。v2加入了生命值和道具系统游戏性一下子有了层次。测试者反馈最多的一句话是“道具好少不够用”于是我把道具数量从全局2次调整成每局每种道具限用3次又在挑战模式里允许通过完成目标来获得额外道具次数。这样既保证了道具的使用深度又不会出现无脑堆道具的失衡玩法。v3重点打磨了UI和音效。Canvas绘制的格子加上了按下/弹起的过渡动效踩雷时加入了震动回馈移动端通过navigator.vibrate实现桌面端用画面抖动模拟配合轻快的音效整个游戏的触感一下子细腻多了。6.2 后续可以扩展的方向如果后续继续迭代我比较看好的方向是自定义雷型。现在的雷只有“踩中即炸”一种可以扩展成“有延迟引信的雷”、“需要连续点击才能拆除的雷”、“只伤害周围一圈的特殊雷”以及“双人对抗模式——两个玩家在一个棋盘上交替翻开格子看谁先触发终局”。这些扩展的共同点是核心判定逻辑不变只增加新的“雷类型”所以旧代码完全能复用。另外排行榜功能也很有价值。把每局的用时、步数、翻开的格子数记录下来对外展示一个“效率分”的排行会极大增加重复可玩性。配合每日挑战的随机棋盘种子就能实现“每天一个只有你独有的谜题”这个玩法在独立游戏圈已经验证过很多次了。6.3 向其他项目迁移的经验做完这个项目后我最大的收获是理解了“小游戏其实很适合做复杂逻辑的实验场”。扫雷的规则简单但涉及的算法覆盖了数组操作、集合管理、状态机迁移、BFS展开、事件处理、Canvas渲染、自适应布局等一整套前端知识。把这些点各自啃透迁移到任何其他游戏项目、工具类应用甚至数据可视化项目里都能直接用上。我把算法逻辑单独封装成模块后曾经直接复用到另一个解谜游戏的“区域自动展开”功能里改了不到二十行代码就接上了。这说明核心逻辑与表现层的解耦带来的长期收益远超当初多花的那点设计时间。7. 最后的几句实在话做完整个扫雷扩展版我最大的感受是经典的玩法就像一张白纸真正的功夫全在于你往上面添什么、不添什么。添得太多就失去了原味添得太少又撑不起“扩展”二字。生命值和道具系统之所以能立住是因为它们精准地降低了挫败感、增加了决策空间而不是为了加机制而加机制。如果读了这篇分享你也想动手做一个我给的建议是先把原版扫雷的逻辑从零写一遍一行的官方教程都别看遇到问题自己推。这一步走完你对状态管理、数组遍历、边界条件的理解会上升一个台阶。然后再开始叠加扩展玩法每加一个功能就自己测几十局觉得不好玩就推倒重来。小游戏这个体量的成本完全支撑得起你反复试错。我个人在实际开发中最受益的一个习惯是每写一个功能模块顺手把该模块的纯逻辑抽出来加上测试用例保持逻辑、渲染、事件三者分层。这个习惯帮我在项目越滚越大时依然能做到“每次改动心里都有底”。希望这个项目的经验对你也能有点用。