
贪吃蛇这个题目几乎被写烂了但真正拿 HTML 从零撸一个能跑、手感不别扭、手机上也点得动的版本细节比想象中多。我前后写过四五个版本从最早的一个 canvas 加定时器硬怼到后来把移动、渲染、输入彻底拆开踩的坑基本都集中在两个地方一是蛇身数据怎么存二是主循环用什么节奏推进。这篇就把 HTML 贪吃蛇小游戏 的完整源码摊开讲顺便把每个设计决策背后的理由说清楚最后给一份可以直接复制保存成 .html 文件就运行的完整代码。源码不长两百行出头但每一行我都会告诉你为什么这么写而不是给你一堆看不懂的魔法数字。适合刚学完 HTML、CSS、JS 基础语法想找个东西练手的人也适合做了几年业务、想回头重新梳理一遍 Canvas 与游戏循环的老手后者能拿到的其实更多。1. 为什么我建议用纯 HTML 三件套写贪吃蛇1.1 一个贪吃蛇到底要拆成几件事很多人一上来就想写代码结果写到一半发现逻辑全缠在一起蛇怎么动、食物怎么出现、什么时候算死、按了键为什么不走。问题出在没做需求拆解。把贪吃蛇摊开看它其实只包含四件事而且这四件事可以用很清晰的方式切开。第一件是状态也就是现在世界长什么样。蛇的每一节坐标、当前方向、食物位置、分数、游戏处于什么阶段准备中、进行中、暂停、结束这些全是数据跟画不画出来没关系。第二件是规则也就是时间往前走一格世界怎么变。蛇头往前挪一格、尾巴收一格、吃到食物就多长一格、撞墙撞自己就结束。第三件是渲染把状态翻译成屏幕上的像素。第四件是输入把键盘或手指的动作翻译成状态的变化。这四件事里最容易出 bug 的是把它们混在一起。比如常见的写法是在按键事件里直接改蛇的坐标然后立刻重画这样既能跑也会在某次快速连按的时候出现蛇穿模。正确的做法是按键只改一个待生效方向真正的移动统一在主循环里做。这一个约定就能消掉七成的诡异问题。1.2 框架还是原生选型背后的取舍有人会问写这么个小东西有必要上框架吗我的答案是作为练习千万别上。原因不复杂。贪吃蛇的唯一性能敏感点是每帧重画而不是 DOM 操作。你用 React 或 Vue 去管理蛇的每一个格子本质上是在拿虚拟 DOM 的 diff 去对付一个 canvas 绘制任务纯属绕远路。而且这类小项目最大的价值就是让你亲手摸一遍requestAnimationFrame、canvas.getContext、事件冒泡这些底层东西套了框架这层就全被遮住了。所以 HTML 贪吃蛇小游戏 的最佳技术组合就是一份 HTML 骨架 一点 CSS 做居中排版 原生 JS 管逻辑零依赖双击就能打开发给别人也不用教他怎么装环境。那 Canvas 和 DOM 元素哪个好我两种都写过。用div拼格子的版本代码更直白每个格子一个元素改className就能变色但格子上到两三百个之后重排重绘的开销就很难看了而且圆角、渐变、动画都受限。Canvas 只用一个元素所有绘制走fillRect和arc可控性强得多还能顺手做点粒子效果。这个项目我选 Canvas。1.3 整体架构状态、时钟、渲染三层分离把前面说的四件事落成代码结构大致是这样数据层snake数组、dir/nextDir方向对象、food坐标、score、state字符串状态。时钟层requestAnimationFrame负责每帧回调内部再维护一个时间累加器acc只有累加到超过当前速度阈值时才调用一次step()。渲染层draw()每次从数据层读一遍全量重画不做局部更新。输入层键盘和触摸事件只做一件事——调用setDir()修改nextDir。这个结构最大的好处是可测试。你想验证碰撞逻辑对不对不用真的看着屏幕直接手动调十次step()看snake数组变化就行。这一点比能跑起来重要得多。2. 上手之前网格、坐标与蛇身的数据结构2.1 把画布切成格子一切从坐标系开始贪吃蛇的世界不是连续的像素空间而是离散的格子。这个认知必须先立住否则后面所有计算都会别扭。我的画布是 400 × 400 像素格子边长 20 像素于是网格是 20 列 × 20 行。所有位置都用格子坐标表示比如蛇头在{x: 9, y: 10}意思是它占第 10 列、第 11 行那个格子。要画到屏幕上时才做一次换算像素x 格子x * 20。这个逻辑坐标与渲染坐标分离的习惯一定要养成好处是移动逻辑里全是整数加减不需要考虑小数和取整误差碰撞判断直接比大小就行。为什么选 20×20 这个网格因为再小蛇就没什么活动空间再大格子就太小、手指不好操作。20 是经验值。顺带说一句格子边长一定要能整除画布宽高否则最后一列会剩几个像素画出网格线的时候你就能看到一条歪掉的缝。2.2 蛇身为什么用数组而不是链表蛇身最自然的表示就是一个坐标数组snake[0]是蛇头越往后越是尾巴。有人会想用链表理由是移动就是头加一个节点、尾删一个节点链表 O(1) 更快。这个理由在贪吃蛇里站不住。首先蛇的长度最多也就几十上百数组的unshift和pop在现代引擎里快得可以忽略。其次也是更关键的碰撞检测需要遍历整个身体数组可以顺序访问缓存友好链表每次都要跳指针反而更慢。第三调试的时候控制台console.log(snake)直接就能看到一个坐标数组一目了然链表打出来是一坨嵌套对象看着就头疼。所以这里的选择非常明确数组。2.3 移动的本质入队与出队想清楚一件事蛇往前走一格视觉上是整条蛇前移但如果每一节都去改坐标代码会很脏。换个角度蛇没动只是头部长出了一个新格子尾部掉了一个格子。这就是整个移动逻辑的全部snake.unshift(newHead); // 头部推入新坐标 if (!ate) snake.pop(); // 没吃到食物就砍掉尾巴吃到食物时不砍尾巴蛇自然就长了一格。这个写法优雅的地方在于长身体不需要任何特殊逻辑只是少执行一次pop()而已。同理判断是否吃到食物只需要比较新蛇头和食物的坐标是否相等。提示unshift在数组头部插入pop从尾部删除这两个操作配在一起正好模拟蛇的蠕动。别写成pushshift那样蛇头在数组末尾可读性会差很多。2.4 方向缓冲解决一秒内按两次反向就撞自己这是新手最容易踩、也最不容易发现的坑。假设蛇正向右走你手指飞快地先后按了上和左。如果按键直接修改当前方向那么第一次按上方向变向上第二次按左方向变向左——看起来没问题。但如果你的按键处理是改方向并立即移动或者两次按键落在同一个 tick 内就可能出现方向被改成向左、蛇头直接撞进自己身体的第二格。解决办法是引入两个方向变量dir是当前帧真正生效的方向nextDir是按键写入的缓冲方向。按键只能改nextDir并且要做两重校验function setDir(nx, ny) { if (nx -dir.x ny -dir.y) return; // 禁止 180 度掉头 if (nx dir.x ny dir.y) return; // 同方向重复按键直接忽略 nextDir { x: nx, y: ny }; }注意这里校验的是dir而不是nextDir。因为如果校验nextDir你在一个 tick 内连按上、左就可能有漏洞。校验dir意味着一个 tick 内所有按键都相对于当前真实朝向判断最多只会生效一次方向变化安全得多。3. 完整源码实现附逐段解析3.1 页面骨架与 CSS先把舞台搭好HTML 部分非常简单一个容器、一个计分栏、一个 canvas、两个按钮、一层遮罩。这里有两个容易被忽略的细节值得单独说。第一个是meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno。没有它手机上打开页面会被缩放成一个奇怪的比例。user-scalableno是为了防止双指缩放跟滑动操作打架。第二个是 canvas 上那行touch-action: none这是给触屏滑动准备的不加的话浏览器会把手势当成页面滚动你的touchmove事件根本收不到连续的坐标滑动识别就会断断续续。CSS 部分用 flex 把内容水平垂直居中box-sizing: border-box全局重置canvas 加圆角和一层阴影让它从深色背景里浮起来。整体配色走深色系背景#1b1f27蛇头用亮绿#7ee787食物用红#ff5f6d对比度足够长时间盯着眼睛不累。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title贪吃蛇/title style * { box-sizing: border-box; } html, body { margin: 0; height: 100%; display: flex; align-items: center; justify-content: center; background: #1b1f27; font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serif; color: #e8ecf3; -webkit-tap-highlight-color: transparent; } .wrap { text-align: center; user-select: none; } .hud { display: flex; justify-content: space-between; padding: 0 4px 10px; font-size: 14px; } .hud b { color: #7ee787; } canvas { display: block; border-radius: 10px; background: #0f131a; box-shadow: 0 8px 30px rgba(0,0,0,.5); touch-action: none; } .tip { margin-top: 10px; font-size: 12px; color: #7a8494; } .btns { margin-top: 12px; display: flex; gap: 10px; justify-content: center; } button { padding: 8px 22px; border: 0; border-radius: 8px; cursor: pointer; background: #2b3240; color: #e8ecf3; font-size: 14px; } button:hover { background: #39424f; } .mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; flex-direction: column; background: rgba(10,13,18,.78); } .mask.show { display: flex; } .mask h2 { margin: 0 0 8px; font-size: 26px; } .mask p { margin: 0 0 18px; color: #9aa4b2; } /style /head body div classwrap div classhud span得分 b idscore0/b/span span最高 b idbest0/b/span /div canvas idgame width400 height400/canvas div classtip方向键 / WASD 控制空格暂停/div div classbtns button idbtnStart开始/button button idbtnReset重开/button /div /div div classmask idmask h2 idmaskTitle游戏结束/h2 p idmaskDesc再来一局/p button idbtnAgain重新开始/button /div /body /html3.2 游戏状态与初始化初始化做的事情就一件把世界恢复成开局的样子。蛇摆在中间偏左的位置长度 3朝右分数清零速度回到基准值。var COLS 20, ROWS 20, CELL 20; var BASE_SPEED 150, MIN_SPEED 70; var snake, dir, nextDir, food, score, best, speed, state, acc, lastTs, rafId; function reset() { var cy Math.floor(ROWS / 2); // 垂直居中那一行 snake [ { x: 8, y: cy }, // 蛇头 { x: 7, y: cy }, { x: 6, y: cy } // 蛇尾 ]; dir { x: 1, y: 0 }; // 当前方向向右 nextDir { x: 1, y: 0 }; // 缓冲方向 score 0; speed BASE_SPEED; acc 0; lastTs 0; scoreEl.textContent 0; placeFood(); draw(); }这里cy用Math.floor(ROWS / 2)而不是写死 10是为了以后想改网格尺寸时不用再去找魔法数字。这种小习惯看着无所谓等你哪天想把 20×20 换成 30×30 的时候就知道它的价值了。3.3 食物生成拒绝 while 暴力随机生成食物有个经典错误写法do { 随机一个坐标 } while (蛇身上有它)。这个写法在蛇很短的时候没问题但当蛇长到占满八九成网格时每次随机都大概率撞上蛇身循环次数会暴涨极端情况下会卡住一下就一下。虽然贪吃蛇很少长到那么满但这种概率不可控的写法没必要留。我用的方式是先收集所有空格子再从里面随机挑一个function placeFood() { var empty [], x, y, i, hit; for (y 0; y ROWS; y) { for (x 0; x COLS; x) { hit false; for (i 0; i snake.length; i) { if (snake[i].x x snake[i].y y) { hit true; break; } } if (!hit) empty.push({ x: x, y: y }); } } if (!empty.length) { food null; return; } // 满屏通关 food empty[(Math.random() * empty.length) | 0]; }(Math.random() * empty.length) | 0是向下取整的快捷写法比Math.floor少敲几个字效果一样。另外注意末尾那个food null的分支——当蛇长到占满整个网格时游戏其实应该算通关这个分支就是给那种极端情况留的出口。这段是 O(格子数 × 蛇长)400 个格子乘以最多 400 节蛇也就 16 万次比较在现代浏览器里是一瞬间的事。只有吃到食物时才会调用完全不用担心性能。3.4 单步推进 step()碰撞检测的边界细节step()是整个游戏的心脏它把时间前进一格这件事翻译成状态变化。顺序很重要必须严格按照先算新头 → 再判死 → 再决定吃没吃 → 最后入队出队来写。function step() { dir nextDir; // 缓冲方向正式生效 var head { x: snake[0].x dir.x, y: snake[0].y dir.y }; // 1. 撞墙 if (head.x 0 || head.y 0 || head.x COLS || head.y ROWS) { return endGame(撞墙了); } // 2. 撞自己注意跳过最后一节尾巴 for (var i 0; i snake.length - 1; i) { if (snake[i].x head.x snake[i].y head.y) { return endGame(咬到自己了); } } // 3. 判断是否吃到食物 var ate food head.x food.x head.y food.y; snake.unshift(head); if (ate) { score 10; scoreEl.textContent score; speed Math.max(MIN_SPEED, BASE_SPEED - Math.floor(score / 50) * 10); placeFood(); if (!food) return endGame(满屏通关); } else { snake.pop(); } }这里有一个必须讲清楚的高级细节为什么撞自己检测要写i snake.length - 1也就是跳过尾巴因为在这一个 tick 里尾巴是会移开的。蛇头新位置如果正好等于尾巴当前占据的格子那等尾巴一收这个格子就空了蛇头进去是安全的。如果不跳过尾巴就会出现明明能拐过去却判你死了的冤枉情况。那会不会漏判不会。因为蛇头的新位置若是食物位置说明吃到了而食物永远不会生成在蛇身上所以吃到食物和新头等于尾巴这两件事不可能同时发生。逻辑上完美自洽。注意这个跳过尾巴的写法只有在先判死、后移动的顺序下才正确。如果你把unshift放到判死之前尾巴状态就已经变了逻辑会整个错位。3.5 渲染层一次干净的 60 帧画布刷新渲染策略我选了最笨但最不容易出错的方式每帧清空重画不做任何增量更新。理由是画布只有 400×40020×20 的网格每帧画几十个圆角矩形开销极小没必要为了那点性能引入哪块需要重画的复杂度。function roundRect(c, x, y, w, h, r) { c.beginPath(); c.moveTo(x r, y); c.arcTo(x w, y, x w, y h, r); c.arcTo(x w, y h, x, y h, r); c.arcTo(x, y h, x, y, r); c.arcTo(x, y, x w, y, r); c.closePath(); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 网格底纹 ctx.strokeStyle rgba(255,255,255,.035); ctx.lineWidth 1; for (var i 1; i COLS; i) { ctx.beginPath(); ctx.moveTo(i * CELL .5, 0); ctx.lineTo(i * CELL .5, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL .5); ctx.lineTo(canvas.width, i * CELL .5); ctx.stroke(); } // 食物带一点点呼吸感 if (food) { var fx food.x * CELL CELL / 2; var fy food.y * CELL CELL / 2; var r 4.5 Math.sin(Date.now() / 220) * 1.2; ctx.fillStyle #ff5f6d; ctx.beginPath(); ctx.arc(fx, fy, r, 0, Math.PI * 2); ctx.fill(); } // 蛇身从尾到头画蛇头最后画保证在最上层 for (var j snake.length - 1; j 0; j--) { var s snake[j]; var pad j 0 ? 1 : 2.5; // 蛇头格子留边小一点显得更饱满 roundRect(ctx, s.x * CELL pad, s.y * CELL pad, CELL - pad * 2, CELL - pad * 2, j 0 ? 6 : 5); ctx.fillStyle j 0 ? #7ee787 : hsl(140, 45%, (42 - Math.min(j, 12)) %); ctx.fill(); } }三个值得说的点。第一网格线坐标加0.5是 Canvas 的经典技巧因为线条默认沿路径中心向两侧各扩散 0.5 像素落在整数坐标上会跨两个物理像素画出来发虚加 0.5 让它正好落在像素中心线条就清晰了。第二蛇身从尾到头倒序绘制这样蛇头压在最上面重叠时视觉上更自然。第三蛇身颜色用hsl按索引做了一点明暗渐变越靠尾巴越暗成本几乎为零但整条蛇立刻有了层次感比清一色一个绿好看太多。3.6 主循环与变速requestAnimationFrame 加时间累加主循环这块我见过两种写法。一种是setInterval(step, 150)简单粗暴另一种是requestAnimationFrame配时间累加。我选后者。setInterval的问题在于它跟浏览器的渲染节奏不同步容易在切换标签页回来之后堆积任务而且改速度只能重设 interval。而requestAnimationFrame天然跟随显示器刷新再配一个累加器速度想怎么变就怎么变function loop(ts) { rafId requestAnimationFrame(loop); if (state ! playing) { lastTs ts; return; } if (!lastTs) lastTs ts; var delta ts - lastTs; lastTs ts; if (delta 200) delta 200; // 切后台回来时防止一次性连跳好几步 acc delta; while (acc speed) { acc - speed; step(); // 按固定节奏推进一步 if (state ! playing) break; } draw(); // 每帧都重画保证动画顺滑 }逻辑就是每帧记录和上一帧的时间差累加到acc只要acc够一个步长speed就推进一步并扣掉时间。这样移动的节奏和渲染的帧率就彻底解耦了——渲染依然是 60 帧但蛇可能每 150 毫秒才动一次。while而不是if是为了在掉帧情况下补上多步保证速度不失真。至于那行delta 200的钳制是防着用户切到别的标签页待了十秒再切回来一次性连走十几步直接撞死这种离谱情况。配合step()里那行speed Math.max(MIN_SPEED, BASE_SPEED - Math.floor(score / 50) * 10)每得 50 分提速 10 毫秒最快 70 毫秒一步难度曲线是很温和的爬升新手不会在开局就被劝退。3.7 键盘与触屏输入键盘用一张映射表搞定方向键和 WASD 一起支持顺带处理空格暂停。var KEYMAP { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], W: [0, -1], S: [0, 1], A: [-1, 0], D: [1, 0] }; document.addEventListener(keydown, function (e) { if (e.key ) { e.preventDefault(); togglePause(); return; } var v KEYMAP[e.key]; if (!v) return; e.preventDefault(); if (state ready) startGame(); setDir(v[0], v[1]); });e.preventDefault()一定要加否则按方向键的时候页面会跟着滚动体验很差。用映射表而不是一串if-else好处是加键位只要往表里补一行。触屏则是靠起止两点算位移向量来判断滑动方向var touchStart null; canvas.addEventListener(touchstart, function (e) { var t e.changedTouches[0]; touchStart { x: t.clientX, y: t.clientY }; e.preventDefault(); }, { passive: false }); canvas.addEventListener(touchend, function (e) { if (!touchStart) return; var t e.changedTouches[0]; var dx t.clientX - touchStart.x; var dy t.clientY - touchStart.y; touchStart null; if (Math.abs(dx) 20 Math.abs(dy) 20) return; // 太短当成点击 if (Math.abs(dx) Math.abs(dy)) { setDir(dx 0 ? 1 : -1, 0); } else { setDir(0, dy 0 ? 1 : -1); } e.preventDefault(); }, { passive: false });这里{ passive: false }是关键。现代浏览器为了让滚动更流畅默认把触摸事件监听器标记为 passive也就是我不会阻止默认行为于是e.preventDefault()会被忽略甚至报警告。显式写上false才能让阻止生效。那 20 像素的阈值是为了把误触和滑动区分开太小会让轻微抖动也被识别成转向。状态切换和存档补齐之后代码就完整了function startGame() { if (state playing) return; if (state over || state ready) reset(); state playing; lastTs 0; acc 0; mask.classList.remove(show); } function togglePause() { if (state playing) { state paused; maskTitle.textContent 已暂停; maskDesc.textContent 按空格继续; mask.classList.add(show); } else if (state paused) { state playing; lastTs 0; mask.classList.remove(show); } } function endGame(reason) { state over; if (score best) { best score; bestEl.textContent best; try { localStorage.setItem(snake_best, String(best)); } catch (err) {} } maskTitle.textContent 游戏结束; maskDesc.textContent reason 本局得分 score; mask.classList.add(show); }3.8 完整源码汇总把上面的片段拼起来就是一个可以直接用的单文件版本。核心结构是 IIFE 包一层避免变量泄漏到全局开头reset()初始化最后requestAnimationFrame(loop)启动主循环。(function () { use strict; var canvas document.getElementById(game); var ctx canvas.getContext(2d); var scoreEl document.getElementById(score); var bestEl document.getElementById(best); var mask document.getElementById(mask); var maskTitle document.getElementById(maskTitle); var maskDesc document.getElementById(maskDesc); var COLS 20, ROWS 20, CELL 20; var BASE_SPEED 150, MIN_SPEED 70; var snake, dir, nextDir, food, score, speed, state, acc, lastTs; var best parseInt(localStorage.getItem(snake_best) || 0, 10) || 0; bestEl.textContent best; // ... reset / placeFood / step / roundRect / draw / loop / // setDir / 键盘与触摸监听 / startGame / togglePause / endGame document.getElementById(btnStart).addEventListener(click, startGame); document.getElementById(btnReset).addEventListener(click, function () { reset(); state playing; lastTs 0; acc 0; mask.classList.remove(show); }); document.getElementById(btnAgain).addEventListener(click, startGame); state ready; reset(); requestAnimationFrame(loop); })();4. 常见问题与排查实录4.1 高频 Bug 速查表下面这些是我和身边人写这个项目时几乎都会撞上的问题整理成一张表遇到对不上号的情况直接查。现象大概率原因处理方式蛇一开局就死dir和初始蛇身排列冲突比如蛇头在左、方向朝左让蛇头朝向右蛇身向左侧排列快速连按后穿模按键直接改了dir没有缓冲引入nextDir按键只写缓冲能吃食物但身长没增加吃到食物时也执行了pop()把pop()放进else分支食物出现在蛇身上用了 while 随机或判定漏了某一节改成收集空格子再随机挑选网格线发虚、模糊线条画在整数坐标上跨了物理像素坐标加0.5手机上滑动没反应监听器默认 passive或没写touch-action: none显式{ passive: false } CSS 处理切后台回来直接死时间累加器一次性补了十几步单帧delta上限钳制在 200ms暂停后继续蛇突然跳几步恢复时没重置lastTs恢复播放时把lastTs置 0这张表里最值得单独拎出来说的是快速连按后穿模。很多人调试半天找不到原因因为正常速度下根本复现不了只有手速特别快或者网速卡的时候才会出现。本质上就是输入频率和逻辑帧率不对齐的经典问题凡是游戏都有解法就是缓冲。4.2 手感调优速度曲线与操作延迟代码能跑是一回事玩起来爽不爽是另一回事。贪吃蛇的手感全在两个参数上初始速度和加速节奏。初始速度我试过 100、150、200 三个档。100 毫秒一步对新手太快开局就容易撞墙200 毫秒又慢得让人犯困。150 是比较舒服的中间值。加速方面我一开始是每吃一个食物就提速结果十分钟不到就快得没法玩了曲线太陡。后来改成每得 50 分提速 10 毫秒最低 70 毫秒封顶体感就顺多了——前二十个食物几乎感觉不到变化后期逐渐紧张但始终在你的反应能力范围内。还有个小细节是转向的响应延迟。有玩家反馈我明明按了转弯蛇还是直的往前撞死了。检查下来发现是nextDir在两处被赋值一处在按键一处在step()开头的dir nextDir外加setDir里的同方向判断。逻辑本身没问题问题出在玩家的预期——他们认为按键应该立刻生效而实际上要等到下一个 tick。这是固定步长游戏的固有特性没法消除但可以通过稍微调高帧率下的输入采样频率来缓解比如在keydown里直接记录而不是等 tick。不过实测下来150 毫秒的间隔里人的感知差异其实很小改不改都行。4.3 移动端的三个坑第一个坑是画布尺寸与屏幕尺寸不匹配。我给 canvas 写死了width400 height400在窄屏手机上会超出屏幕宽度产生横向滚动。解决办法是给 CSS 加max-width: 100%; height: auto;同时保证宽高比不变。但要注意只改 CSS 不改 canvas 属性会导致绘制被拉伸模糊。正确做法是保持 canvas 的逻辑尺寸不变用 CSS 缩放显示或者干脆根据屏幕宽度计算一个合适的格子大小再重设canvas.width。第二个坑是滑动手势和页面滚动的冲突。前面提过touch-action: none和{ passive: false }这两个必须一起上少一个都会有偶发的失灵。第三个坑是iOS 上的双击缩放。快速连点两下屏幕Safari 会尝试放大页面。解决办法是给 canvas 加touch-action: manipulation或者干脆none避免把双击解释成缩放指令。4.4 存档与边界情况最高分我用localStorage存了个snake_best键读写都包在try-catch里。为什么要包因为隐私模式下localStorage可能直接抛异常不包的话整个脚本就崩了游戏都开不起来。这种一个存储异常毁掉整个应用的情况在真实项目里很常见值得养成习惯。还有个边界是网格被填满。虽然正常玩很难玩到但代码里必须有处理否则placeFood()找不到空格子会返回undefined然后food.x就会报错。我在里面写了food null并让step()检测到之后走通关分支逻辑就闭合了。5. 还能怎么扩展从能玩到好玩5.1 低成本高收益的五个功能基础版跑通之后如果想继续加东西我按投入产出比排了个序前三个是我强烈建议加的难度选择开局给三档速度本质就是把BASE_SPEED设成 200 / 150 / 100五分钟就能加完但可玩性提升巨大。穿墙模式撞墙不死而是从对面出来只需把撞墙判断改成取模运算head.x (head.x COLS) % COLS。这个小开关能让同一张地图玩出完全不同的感觉。音效吃到食物和游戏结束各来一个短音用Audio对象加载两个极小的音频文件或者用 Web Audio 合成一个短促的声几乎不影响体积但沉浸感提升明显。障碍物随机在网格里撒几个固定方块撞上就死直接复用现有的碰撞检测循环即可。排行榜把最高分从单个数字改成一个数组存在localStorage里实现简单的本地排行榜。穿墙模式那个取模写法特别优雅一行代码替代四个方向的边界判断head.x (head.x COLS) % COLS; head.y (head.y ROWS) % ROWS;5.2 渲染性能与代码组织有人会担心每帧clearRect再全量重画会不会性能不够。实测在 20×20 的规模下一帧的绘制耗时在 0.2 毫秒以内跟 16.7 毫秒的帧预算比完全是零头。真要优化方向也不是局部重绘而是把不变的网格底纹预先画到一个离屏 canvas 上每帧直接drawImage贴过来这样能省掉几十条stroke调用。但这个优化对这个项目来说是杀鸡用牛刀除非你把网格扩到 100×100。代码组织上如果项目要继续长大我会按职责拆成几个文件state.js管数据和规则、render.js管绘制、input.js管输入、main.js做组装。拆的边界就是前面说的状态、规则、渲染、输入四层不要按蛇食物分数去拆那样每个文件都会横跨所有层耦合反而更严重。最后分享一个我在这个项目里反复用到的判断标准任何一行代码如果它既改数据又碰渲染就要警惕。贪吃蛇里最容易写歪的地方就是在step()里顺手调draw()或者按键事件里直接改蛇的坐标。把这两件事彻底分开代码的可读性和可调试性会立刻上一个台阶这个习惯搬到任何前端项目里都成立。