JS游戏源码实战:从零构建canvas小游戏到避坑指南
发布时间:2026/10/6 19:13:02 作者:尧图编辑部 阅读量:1,286

简介这份压缩包汇集了用JavaScript编写的多款经典小游戏如字母华容道、打蜜蜂、俄罗斯方块、拼图、推箱子、五子棋等共60个文件以htm网页文件、ini配置、txt说明文档为主要类型包含少量gif动态图、db数据文件及CSS样式整包仅78KB非常适合JavaScript初学者、前端开发爱好者用来入门游戏开发与交互编程。资源内容覆盖变量与数据类型、DOM操作、事件处理、定时器动画、Canvas绘图等核心知识点同时包含碰撞检测、得分系统、AI逻辑等游戏机制示例。通过阅读和修改这些源码读者既能快速掌握JavaScript语法与浏览器API的实际用法也能理解从页面元素控制到完整游戏逻辑的实现路径。目前已有868人学习下载是边玩边学、动手实践的轻量级入门素材。1. 网上下的 js 游戏源代码为什么到你手里就黑屏你在 GitHub 或某些源码站上搜 js 游戏源代码经常看到 star 上千、截图光鲜的仓库。下载、解压、双击 index.html结果不是白屏就是黑屏控制台刷满红色报错。这不是代码坏了而是你拿到的是一套假定你在服务器环境里运行的代码。原生 JS 游戏受浏览器安全策略限制ES Module、fetch 加载关卡数据、音频资源这些常规操作在 file:// 协议下全部会被拦。真正能打开即用的只有上古的单 HTML 内联一切的写法。这篇文章我就按一线实际做小游戏的方案从零把一份 js 游戏源代码拆开核心套路是什么、最小可玩的代码怎么组织、参数怎么调、哪些坑一踩一个准。适合想复现别人项目、或者自己从零写一个能交差的小游戏的人。2. 从零拼一个最小游戏骨架游戏循环、canvas 与状态机2.1 一份能跑的 js 游戏源代码骨子里只有三样东西先别管画面多华丽任何浏览器里跑的游戏剥到只剩骨架必然是三件事游戏循环、渲染上下文、状态集合。游戏循环负责每帧刷新一次渲染上下文负责把画面画到 canvas 上状态集合负责记住玩家、敌人、分数现在是什么情况。绝大多数你下载的源码主体就是在无限循环这三步。先看一个最小可运行的游戏循环代码这是后续一切的地基// 最小游戏循环骨架 const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); // 拿到 2d 渲染上下文 let lastTime 0; // 上一帧的时间戳 let gameState ready; // 状态机ready 等待开始running 运行中over 结束 // 这是核心浏览器每次刷新屏幕前都会调用一次 update function gameLoop(timestamp) { // timestamp 是浏览器传给我们的当前时间单位毫秒 const deltaTime (timestamp - lastTime) / 1000; // 换算成秒 lastTime timestamp; // 防止切后台回来后 deltaTime 爆炸把单帧间隔钳制在上限 const dt Math.min(deltaTime, 0.05); // 清空画布否则上一帧画面会残留形成拖影 ctx.clearRect(0, 0, canvas.width, canvas.height); if (gameState running) { update(dt); // 更新游戏逻辑位置、碰撞、分数 } render(); // 绘制当前画面 // 请求下一帧形成无限循环 requestAnimationFrame(gameLoop); } function update(dt) { // 先放空实现后面章节往里填 } function render() { // 先放空实现后面章节往里填 } // 启动循环。第一次调用时 lastTime 是 0会导致第一个 deltaTime 巨大 // 所以上面 clamp 到 0.05 就是为了兜住第一帧 requestAnimationFrame(gameLoop);2.2 这个骨架里的参数为什么必须这样设requestAnimationFrame是 js 游戏源码里最常见的启动循环的方式它的刷新频率和显示器同步通常是 60Hz也就是每秒调用 60 次gameLoop。和setInterval(fn, 16.7)相比它的优势是你切到别的标签页时浏览器会自动暂停循环不消耗 CPU同时它传入的时间戳精度更高适合计算deltaTime。deltaTime是这里最关键的参数。游戏里的移动距离写法应该是x speed * dt其中 dt 是距离上一帧过去的秒数。这样无论显示器是 60Hz 还是 144Hz角色移动速度都一致。如果不乘 dt游戏在高刷屏上会加速跑这是新手源码里最常见的 bug。gameState是一个字符串状态机。这里只放三个状态ready、running、over。在gameLoop里判断状态而不是在update里判断好处是画面渲染永远不中断。哪怕游戏结束了背景和分数板依然在画布上显示只是逻辑不更新而已。很多下下来的源码黑屏原因就是状态机写反了——把render也放进了running分支导致游戏一结束画面就整个消失。还有一个细节Math.min(deltaTime, 0.05)这个钳制。当用户切走标签页几分钟再切回来timestamp会突然跳一大截deltaTime可能变成 3 秒。如果不钳制物体的位置直接飞出屏幕。0.05 秒意味着单帧逻辑最多模拟 50 毫秒的游戏时间超过的部分直接丢弃。这个参数没有严格标准0.05 到 0.1 之间都能用我习惯取 0.05因为物理模拟的步长越短越稳定。3. 把骨架喂成能玩的游戏打砖块的完整实现与参数调优3.1 游戏对象拆解球拍、球、砖块用数组管理一切骨架有了现在往里填真东西。我拿打砖块Breakout做例子它是 js 游戏源代码里出现频率最高的类型因为它麻雀虽小、五脏俱全键盘输入、碰撞检测、粒子效果、胜负判定全都有但每条逻辑都不复杂。先定义游戏里的所有实体。常见做法是用普通对象或类来管理。我这里为了贴近大部分开源源码的风格用类来写方便后续扩展// 游戏实体定义球拍、球、砖块 class Paddle { constructor(canvasWidth) { this.width 100; this.height 14; this.x (canvasWidth - this.width) / 2; // 初始在画布水平居中 this.y 560; // 距离画布顶部 560 像素也就是贴近底部 this.speed 300; // 移动速度单位像素/秒 this.color #4a90d9; } } class Ball { constructor(canvasWidth, canvasHeight) { this.radius 7; this.x canvasWidth / 2; this.y canvasHeight / 2; this.vx 180; // 水平速度单位像素/秒 this.vy -200; // 垂直速度负值表示向上飞 this.color #e94f37; } }这里需要重点解释的是speed和vx、vy的单位。在刚刚的骨架里update(dt)收到的dt单位是秒所以这里的速度单位统一是像素/秒。移动计算就是x vx * dt。如果你在别人的源码里看到直接x vx而没有* dt那说明这份源码把速度写成了像素/帧它跑在 60Hz 屏幕上正好但换个 120Hz 的屏幕就会快一倍。接手这种源码时要么把所有速度都除以 60 再乘以 60要么干脆全部改成* dt的形式。3.2 碰撞检测的两种写法AABB 与圆对矩形打砖块的核心是碰撞。球撞到砖块要反弹撞到球拍要反弹漏过球拍则游戏结束。碰撞检测在 JS 游戏源代码里有两种主流写法AABB轴对齐包围盒和圆对矩形。砖块是矩形用 AABB 检测最方便球是圆形和矩形碰撞要单独处理。代码如下// 球和矩形砖块的碰撞检测 function ballRectCollision(ball, rect) { // 先找到圆心上离矩形最近的点这个点一定在矩形边上或内部 const closestX Math.max(rect.x, Math.min(ball.x, rect.x rect.w)); const closestY Math.max(rect.y, Math.min(ball.y, rect.y rect.h)); // 计算这个最近点和圆心之间的距离平方 const dx ball.x - closestX; const dy ball.y - closestY; const distSq dx * dx dy * dy; // 如果距离小于半径说明撞上了 return distSq ball.radius * ball.radius; } // 碰撞后的反弹方向处理 function resolveBallCollision(ball, rect, fromSide) { if (fromSide horizontal) { ball.vx -ball.vx; // 撞左右边水平速度反向 } else { ball.vy -ball.vy; // 撞上下边垂直速度反向 } // 重要把球推出砖块防止下一帧还判定碰撞导致速度连续反向 // 这个推出操作是防抖的关键后面避坑章会细说 }这里有个新手最容易翻车的设计砖块碰撞反弹时怎么知道该反向哪个速度简单粗暴的做法是速度直接取反——ball.vx -ball.vx; ball.vy -ball.vy这样后果是一帧之内如果检测到两次碰撞速度就弹回去了表现是球穿过砖块。正确的做法是在碰撞前记录撞击方向比较球心与砖块中心的偏移x 方向偏移大就反 vxy 方向偏移大就反 vy。ballRectCollision里的几何原理值得多说一句它把圆是否碰到矩形转化为圆是否碰到离它最近的矩形边上的点。如果圆心在矩形内部closestX就在rect.x到rect.x rect.w内此时closestX等于ball.xdx为 0距离完全由 y 方向决定。这个算法不需要开平方根性能很好在大量砖块的场景下也能跑满 60 帧。3.3 键盘输入与球拍控制用按键状态字典而不是事件响应键盘控制球拍大部分人第一反应是监听keydown/keyup事件然后在事件里直接改球拍位置。这是错的。直接改位置的后果是按住方向键时球拍一顿一顿地动而不是连续平滑移动。原因很简单——keydown事件触发后如果长按系统会先触发一次然后延迟几百毫秒再用低频率连续触发这个节奏和游戏帧率完全不同步。正确做法是维护一个按键状态字典事件只负责记录哪个键被按下了真正的移动在update里每帧读取// 按键状态管理 const keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; // 用 e.code 而不是 e.key因为方向键的 code 是 ArrowLeft 等不受输入法影响 if (e.code Space) { e.preventDefault(); // 防止空格触发页面滚动 } }); window.addEventListener(keyup, (e) { keys[e.code] false; }); // 在 update 中每帧读取按键状态 function updatePaddle(paddle, dt) { if (keys[ArrowLeft]) { paddle.x - paddle.speed * dt; } if (keys[ArrowRight]) { paddle.x paddle.speed * dt; } // 边界钳制防止球拍飞出画布 paddle.x Math.max(0, Math.min(paddle.x, canvas.width - paddle.width)); }e.code和e.key的区别是这里的一个隐藏坑。e.key返回的是字符比如你在中文输入法下按了ae.key可能是 a 也可能被输入法吃掉。而e.code返回的是键盘物理按键的编号比如说KeyA、ArrowLeft永远不受输入法状态影响。在游戏里一律用e.code。Math.max和Math.min的嵌套是边界钳制的标准写法Math.max(0, Math.min(x, maxVal))把值限定在[0, maxVal]区间内。这个闭区间为什么要这么写因为如果拆分写if (x 0) x 0; if (x maxVal) x maxVal;在高速移动下可能出现一帧之内 x 从负数直接跳到超过 maxVal 的情况嵌套写法一次到位。3.4 完整的主循环整合与游戏状态流转现在把上面所有碎片拼进update和render// 游戏状态对象整合 const game { paddle: new Paddle(canvas.width), ball: new Ball(canvas.width, canvas.height), bricks: [], score: 0, lives: 3, gameState: running, }; // 初始化砖墙10 列 5 行用双重循环铺开 function initBricks() { game.bricks []; const rows 5; const cols 10; const brickWidth 60; const brickHeight 18; const gap 4; const offsetTop 60; const offsetLeft (canvas.width - (cols * (brickWidth gap) - gap)) / 2; for (let r 0; r rows; r) { for (let c 0; c cols; c) { game.bricks.push({ x: offsetLeft c * (brickWidth gap), y: offsetTop r * (brickHeight gap), w: brickWidth, h: brickHeight, alive: true, color: hsl(${r * 30 200}, 70%, 55%), // 用色相环给每行不同颜色 }); } } } // 主更新函数 function update(dt) { if (game.gameState ! running) return; // 更新球拍位置 updatePaddle(game.paddle, dt); // 更新球的位置先移动再碰撞 game.ball.x game.ball.vx * dt; game.ball.y game.ball.vy * dt; // 边界反弹左右壁和上壁 if (game.ball.x - game.ball.radius 0) { game.ball.x game.ball.radius; // 先推回边界内 game.ball.vx -game.ball.vx; // 再反向 } if (game.ball.x game.ball.radius canvas.width) { game.ball.x canvas.width - game.ball.radius; game.ball.vx -game.ball.vx; } if (game.ball.y - game.ball.radius 0) { game.ball.y game.ball.radius; game.ball.vy -game.ball.vy; } // 球拍碰撞球在球拍区域内且向下运动时才检测 if (game.ball.vy 0 game.ball.y game.ball.radius game.paddle.y game.ball.y game.ball.radius game.paddle.y game.paddle.height game.ball.x game.paddle.x - game.ball.radius game.ball.x game.paddle.x game.paddle.width game.ball.radius) { // 反弹并让反弹角度受撞击位置影响 const hitPos (game.ball.x - game.paddle.x) / game.paddle.width; // 0 到 1 game.ball.vx (hitPos - 0.5) * 500; // 撞击中心为 0 速撞击边缘获得强水平速度 game.ball.vy -Math.abs(game.ball.vy); // 垂直速度稳定向上 } // 球漏过球拍生命减一 if (game.ball.y canvas.height game.ball.radius) { game.lives--; if (game.lives 0) { game.gameState over; } else { resetBall(); // 重置球的位置重新发球 } } // 遍历砖块检测碰撞并消除 for (const brick of game.bricks) { if (!brick.alive) continue; if (ballRectCollision(game.ball, brick)) { brick.alive false; game.score 10; // 反弹方向判定看球心相对于砖块中心往哪边偏 if (Math.abs(game.ball.x - brick.x - brick.w / 2) Math.abs(game.ball.y - brick.y - brick.h / 2)) { game.ball.vx -game.ball.vx; } else { game.ball.vy -game.ball.vy; } } } }这段代码是典型的先移动再检测模式。为什么要先移动再检测如果先检测再移动球在碰撞瞬间的位置是上一帧的位置会导致球看起来隔空反弹——明明还差几个像素才碰到砖块就已经往回飞了。先移动再检测碰撞发生在球已经侵入砖块边缘的那一帧视觉上虽然有一到两帧的重叠但配合后面的推出逻辑看起来是贴合的。球拍碰撞里有个精妙的参数(hitPos - 0.5) * 500。hitPos是球撞击球拍的位置比例0 是最左端1 是最右端0.5 是正中间。减去 0.5 后范围变成 -0.5 到 0.5再乘 500得到 -250 到 250 的水平反弹速度。这样球打在球拍左边就会向左弹打在右边就向右弹玩家可以通过移动球拍控制球的走向。这个 500 就是操控灵敏度太小了球几乎垂直上弹游戏变呆太大了球容易以极斜的角度飞出边界。500 适配 800 宽的画布画布越大这个值要相应调大。砖块碰撞的反弹方向判定也值得细看。比较|球心到砖块中心水平距离|和|球心到砖块中心垂直距离|哪边距离大就说明球更接近砖块的哪个方向面就反向哪个速度。这是二维碰撞里最朴素且稳定的策略比用速度方向判断更不容易出错——因为当球速度很快时它可能在侵入砖块的一瞬间速度和碰撞面并不垂直。4. 从能玩到好用源码参数配置化与性能边界4.1 为什么别人的源码一改就崩魔法数字与配置分离当你下载一份 js 游戏源代码想换个难度、改个颜色、调个速度你会发现很多源码根本没法改——数值散落在代码各处改一个还要找关联。这就是魔法数字问题。成熟的、值得参考的 js 游戏源码一定会把可调参数集中在文件顶部的配置对象里。下面是我推荐的组织方式它是从实践里总结出的最省事的方案// 游戏配置所有可调参数集中在这里 const CONFIG { // 画布尺寸 canvasWidth: 800, canvasHeight: 600, // 球拍 paddle: { width: 100, height: 14, speed: 300, // px/s200~500 之间手感差异明显 color: #4a90d9, }, // 球 ball: { radius: 7, speed: 220, // 球的基准速率px/s maxSpeed: 450, // 碰撞后速率上限防止球越来越快失控 color: #e94f37, }, // 砖墙 bricks: { rows: 5, cols: 10, brickWidth: 60, brickHeight: 18, gap: 4, }, // 难度参数 difficulty: { paddleSpeedFactor: 1.0, // 随关卡提升球拍速度系数 ballSpeedFactor: 1.0, // 随关卡提升球速系数 }, };参数集中后改游戏难度就成了改数值而不是翻代码。这里要注意的是参数命名要带单位。speed: 300和speed: 3如果没有注释单位你过一个星期再看就忘了它是像素/秒还是像素/帧。我见过最头疼的源码是参数名叫v、s、m没有任何单位说明配合deltaTime一起使用调试起来像破译密码。4.2 球速失控的数学解释与钳制策略打砖块有一个著名的体验问题球撞到砖块后如果反弹角度碰巧非常平水平速度会很大垂直速度很小球会像闪电一样在砖墙和球拍之间来回拉锯。这是物理模拟的能量积累效应——每次碰撞都会因为推出操作引入微小的速度误差误差在极端角度下被放大。解决方式是在参考源码里最常见的两种做法。第一种是速度钳制每次计算完反弹后把速率限制在最小值和最大值之间// 球速钳制防止球速无限增大 function clampBallSpeed(ball, minSpeed, maxSpeed) { const speed Math.hypot(ball.vx, ball.vy); // 当前速率 if (speed minSpeed) { // 归一化后乘最小速度 ball.vx (ball.vx / speed) * minSpeed; ball.vy (ball.vy / speed) * minSpeed; } if (speed maxSpeed) { ball.vx (ball.vx / speed) * maxSpeed; ball.vy (ball.vy / speed) * maxSpeed; } }第二种是角度钳制——限制反弹后球的垂直速度不能太低确保它不会变成平飞状态。我推荐的组合拳是每次球拍碰撞后把vy的绝对值固定在一个下限之上比如Math.max(Math.abs(ball.vy), 120)再保持vx不变。这样既保留了操控性又不会出现极端拉锯。4.3 性能参数边界多少砖块才让 canvas 撑不住打砖块的砖块数量常见配置是 10 列 5 行总共 50 个。这个数量在 canvas 2D 里完全是小意思。但如果你做的游戏是满屏子弹、满屏敌人就要考虑性能边界。canvas 的绘制性能瓶颈在fillRect和drawImage的调用次数现代浏览器单帧绘制几百个基础矩形依然能维持 60 帧但到几千个就开始掉帧了。优化手段是按需重绘——把背景和静止的砖墙先缓存到离屏 canvas每帧直接drawImage整块缓存而不是逐块绘制。这里涉及的取舍是内存换 CPU离屏 canvas 会占用更多显存但会大幅减少绘制调用。对打砖块 50 个砖块来说完全没必要对弹幕游戏或像素游戏大批量敌人就需要做。还有一个新手源码里常见的问题——每帧创建新对象。比如在render里写ctx.fillStyle rgb(${Math.random()*255}...每帧都生成随机颜色和字符串。字符串拼接在新手源码里是最隐蔽的性能杀手因为它在每帧里触发垃圾回收。正确做法是颜色配置化或者只在实体状态变化时更新颜色值。5. 接手第三方 js 游戏源码的常见问题与避坑排查5.1 双击 index.html 黑屏控制台报错 CORS 或 module 加载失败现象是源码文件结构完整但直接用浏览器打开 HTML 文件时白屏或黑屏控制台出现类似Access to script at file:///... from origin null或Failed to load module script。原因是浏览器安全策略限制ES Module 严格禁止在 file 协议下加载fetchAPI 在 file 协议下也完全不可用。很多写于 2019 年之后的源码默认你会用一个本地服务器运行。解决方式是本地起一个静态服务器。装 Node.js 后在源码目录下执行npx serve .或者用 Python 的python -m http.server 8080然后访问http://localhost:8080。这个步骤是所有 js 游戏源码运行流程里最基础、也最常被忽略的一步。注意npx serve首次运行会从网络拉取包如果你在离线环境用 Python 那行更稳妥。5.2 球和砖块碰撞后粘在一起抖动或直接穿模现象是球撞到砖块后没有干脆地弹开而是在砖块边缘反复抖动几帧才离开或者高速时直接穿过砖块没有触发碰撞。原因有两个。第一是推出操作缺失碰撞后球还留在砖块内部下一帧又检测到碰撞速度再次反向表现为抖动。第二是高速穿透——球在单帧内的移动距离超过了砖块的厚度碰撞检测时球已经跳过了砖块位置。解决方式是组合拳碰撞后将球位置沿法线方向推出到碰撞面之外这一步必须在反弹前做同时将单帧最大位移限制为物体尺寸的 1/3。具体到代码可以在update里拆分子步把一大步的移动拆成 2 到 4 个小步每步都做碰撞检测。这样即使球速很快也不容易穿透薄砖块。5.3 空格键让页面滚动游戏按钮按了没反应现象是在游戏里按空格或方向键页面跟着上下滚动游戏里的跳跃或发射却没触发。原因是键盘事件没有阻止默认行为。空格和方向键在浏览器里的默认行为就是滚动页面如果你在keydown里没有调用e.preventDefault()浏览器会先执行滚动再执行你的逻辑。解决方式是监听时对游戏用到的键统一阻止默认行为但要注意不能阻止所有按键——否则 F12 开发者工具打不开刷新快捷键也失效。这里的关键是代码里when条件判断把preventDefault的范围限定在游戏控制键内。5.4 音频在游戏里播放不出来控制台提示 autoplay 策略现象是源码有音效但打开游戏后所有声音都不响点击页面后才恢复。原因是浏览器自动播放策略用户没有与页面交互之前不允许任何音频播放这是为了防止网页一打开就出声音骚扰用户。解决方式是把音频的初始化放到第一次点击或按键事件里常见写法是挂一个点击开始按钮在点击回调里创建AudioContext并调用resume()。这个操作必须在用户手势的同步代码里完成——如果在一个异步回调里做浏览器就会拒绝。我之前踩过这个坑把audioCtx.resume()放在了setTimeout里结果用户点完按钮还是要再点一下才能出声。5.5 拿到压缩混淆的源码变量全是 a、b、c 怎么改现象是源码被压缩过变量名全是a、b、c缩进全没有了整个文件密密麻麻几行几万字符。原因是发布时为了减小体积做了压缩混淆在 GitHub 上这类文件通常说明写着production build或dist。这种文件不适合直接修改。解决方式是去源码仓库找src目录或未压缩版本。GitHub 上正规项目的仓库结构里src下放源码dist下放压缩产物。如果没有 src那就不要改源码而是通过配置或暴露的 API 调整参数。很多成熟的 JS 游戏会在入口文件暴露一个全局配置对象比如window.GameConfig你可以在 HTML 里引完游戏脚本后再引一个自己的脚本覆盖配置值。这种覆盖而非修改的方式能让你在不碰压缩源码的情况下调整游戏难度和规则。5.6 切后台再回来游戏时间和现实时间对不上现象是玩家切到别的标签页几分钟后回来发现游戏里已经通关了或关卡时间对不上。原因是requestAnimationFrame在标签页后台时会暂停而游戏逻辑里如果用Date.now()或performance.now()来计算经过时间暂停期间的时间被计入了。前面骨架里用Math.min(deltaTime, 0.05)解决的就是这个问题的一部分但如果你把游戏逻辑建立在总运行时间而不是每帧增量上切后台导致的跳变依然会出现。解决方式是所有计时一律用累计帧时间即game.totalTime dtdt是已经钳制过的增量。不要在update里用Date.now()取实时时间来判断超时或倒计时。这个习惯从写第一个小游戏就养成后面做任何实时对战或物理模拟都不会翻车。6. 给游戏加后悔药localStorage 存档与读档的完整实现游戏做到能玩之后最值得加的功能是本地存档。js 游戏源代码里常见的存档手段是localStorage它把数据以字符串形式存在浏览器里刷新页面后数据还在。这和 cookie 不同cookie 会随请求发给服务器而 localStorage 只存在于浏览器本地而且容量是 5MB 左右存游戏进度绰绰有余。实现存档的关键是什么时候存和存什么。我一般会在两个时机存档一是玩家每通过一关二是游戏结束时把最高分写进去。存的内容是纯数据不存状态对象——状态对象包含方法和引用序列化后会丢失所有原型信息取出来直接变成一个普通对象。// 存档与读档实现 const SAVE_KEY breakout_save_v1; // 存档把关键数据序列化 function saveGame() { const data { score: game.score, lives: game.lives, level: game.level, highestScore: game.highestScore, updatedAt: Date.now(), // 存时间戳方便以后做排行榜或数据清理 }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (e) { // 隐私模式下 localStorage 可能不可用这里要兜底 console.warn(存档失败可能是隐私模式限制了存储); } } // 读档解析并校验数据完整性 function loadGame() { const raw localStorage.getItem(SAVE_KEY); if (!raw) return null; try { const data JSON.parse(raw); // 校验必要的字段是否存在防止脏数据 if (typeof data.score ! number || typeof data.lives ! number || typeof data.level ! number) { return null; } return data; } catch (e) { // JSON.parse 失败直接清掉脏数据避免每次启动都报错 localStorage.removeItem(SAVE_KEY); return null; } }这段代码里有三个值得拿走的经验。第一JSON.stringify序列化时机对象时要小心所有属性值必须是基本类型或嵌套对象如果有undefined或函数序列化时会悄悄丢掉。第二读档一定要做字段校验因为 localStorage 里的数据玩家可以用开发者工具随便改直接信任会导致游戏崩溃。第三整个存读过程包在try/catch里是必须的尤其移动端的隐私模式会直接禁止写入 localStorage不捕获异常的话游戏一进隐私模式就白屏。读档后怎么恢复游戏状态常见做法是写一个restoreGame(data)函数把data.level塞回砖墙初始化函数把data.score和data.lives直接赋值。要记住的是不要覆盖game.gameState——读档后应该让玩家处于 ready 状态点击开始再进入 running而不是直接续玩。这个设计能避免玩家刷新页面后还没来得及看界面游戏就自己开始自动运行了。这里我分享一个血泪经验早期我做的存档是直接存整个game对象还用JSON.stringify(game)一步到位结果读档时所有方法都没了——因为序列化不保存函数。后来我才明白游戏存档只能存事实数据不能存行为。对象里的方法、构造函数、原型链在JSON.stringify面前全是累赘。从那以后我写任何游戏的第一天就先定义好哪些数据需要持久化再为这些数据单独写一个导出函数而不是图省事把整个游戏对象丢给序列化。另外存档的 key 里带上版本号_v1也是个好习惯以后你改了数据结构旧存档读出来时校验失败直接清掉就行不会因为新旧结构不匹配把玩家卡在坏档里。希望帮到你。本文还有配套的精品资源点击获取