问题现象
吹泡泡童话 上线后,用户反馈「点着点着,页面就卡死」。本地静态审查代码没有发现问题,于是直接对线上页面做复现。
排查过程
1. Playwright 线上复现
对游戏区域连续点击 200 次后,页面主线程彻底冻结——连 location.href 都取不回来;继续施压时,浏览器渲染进程直接崩溃。
2. 反编译线上产物
抓取线上 scene chunk 并反编译,确认线上运行的正是「内层碰撞循环里直接调用 mergeBubbles」的旧写法,与本地代码一致。
3. 算法模拟定位根因
把 handleCollisions 的算法原样搬进 Node 模拟,20 组随机泡泡全部死循环。
根因:边遍历边融合
for (let i = 0; i < list.length; i++) {
const a = list[i];
for (let j = i + 1; j < list.length; j++) {
if (可以融合) this.mergeBubbles(a, b); // ← 问题在这
}
}
mergeBubbles(a, b) 做了两件事:销毁 a、b,并在数组尾部 push 一个新泡泡。于是:
- 内层循环继续拿已经销毁的
a去匹配; - 新泡泡位于
a、b的中点,距离正好是d/2,而d < r_a + r_b,所以新泡泡必然与a重叠; j < list.length里length每次循环都在增长,条件永真 → 死循环;- 每迭代一次数组就膨胀一个元素,每帧创建成千上万个 Phaser Sprite/Tween → 内存爆炸,渲染进程崩溃。
一个几何事实(中点必然重叠)+ 一个遍历习惯(循环中修改集合),叠加成必现的卡死。
修复:扫描与融合分离
// 扫描阶段:只记录配对,不修改数组
for (let i = 0; i < list.length; i++) {
if (可以融合) merges.push([a, b]);
else this.separate(a, b);
}
// 融合阶段:逐对处理,跳过已销毁,单帧封顶 8 次
for (const [a, b] of merges) {
if (a.dead || b.dead) continue;
if (mergedCount >= MAX_MERGES_PER_FRAME) break;
this.mergeBubbles(a, b);
}
- 先扫描、后融合,遍历期间数组不被修改;
- 融合前检查
a.dead || b.dead,杜绝重复融合; - 单帧融合数量封顶,避免级联融合的突发开销。
修复后同一模拟跑 120 帧 × 20 组,全部正常终止,数组长度稳定。融合玩法不变:同色泡泡触碰照常融合,满 3 次照常触发 10 秒「泡泡童话」。
经验
- 不要在遍历集合的过程中修改集合。push、删除、销毁对象都会让
length和索引失去语义。 - 死循环不一定藏在显眼的
while (true)里,也可能藏在「看似有界的双重循环」中——只要循环条件引用了会被循环体修改的量。 - 排查「点着点着卡死」这类问题,算法级模拟比盯着代码看更快:把核心逻辑抽出来跑一遍,几十毫秒就能给出答案。