Firebase托管p5.js游戏出现无输入自动循环关卡的间歇性Javascript Bug
我现在可以在任何环境下的所有浏览器中100%复现该错误。经过大量反复试验,我发现在问卷和欢迎页面之后的第一个说明页面(向玩家分配角色的页面)快速按右方向键,就可以100%触发该Bug。我猜测快速按键会导致事件监听器在预期时机前被多次触发,进而导致某些函数参数莫名变为undefined。我不知道该如何处理,在方向键按下后使用sleep()加延迟是否能解决问题?
概述
我使用p5.js开发了一款电子游戏,作为Google Firebase Web应用托管。我使用自定义事件监听器来循环切换游戏关卡。约30%-40%的用户反馈存在无用户输入时游戏快速循环所有关卡的Bug,我仅在特定场景下能以约10%的概率复现该问题,Bug触发时控制台和错误上报服务通常无任何报错信息。
详细说明
我正在使用名为Prolific的受试者招募服务开展在线心理学研究,研究流程包含若干份问卷,之后是游戏任务。我用p5.js开发了该游戏,研究的不同任务条件被编写为不同的p5 sketch。为按正确顺序循环试次,我编写了自定义事件监听器,当前试次满足结束条件时触发,移除当前sketch并启动下一个。我将问卷和游戏通过Google Firebase托管为Web应用,有意向参与的用户可私下联系获取应用链接。
约30%-40%的研究受试者反馈了一个我很难复现的异常Bug:游戏可以顺畅运行几个试次后会突然崩溃,无任何用户输入的情况下快速循环剩余所有试次,直到抵达研究结束页面。我尝试在所有主流操作系统(Windows、Mac OS、Linux)的多个设备上用所有主流浏览器(Firefox、Chrome、Safari)复现错误,所有场景下我仅能以约10%的概率复现该问题,且仅在特定场景下出现。
绝大多数情况下,崩溃发生时控制台和我的错误监控服务Sentry.io均无未捕获异常(报错信息)上报。仅不到10%的崩溃场景下会出现报错,均为TypeErrors类型,最常见的有两类:
NetworkError when attempting to fetch resource / Failed to fetch resource。我不清楚该报错的成因,它在应用正常运行和崩溃时都可能间歇性出现,因此我认为它不是问题根源。应用仅会加载Web应用public目录下的图片资源,但有时崩溃发生时,本该加载的图片没有显示(但P5没有上报任何错误)。- 如下文示例代码所示,函数参数
a用于标识当前试次要在画布上绘制的sketch。崩溃场景下我有时会遇到a is undefined报错。根据Javascript作用域链,我传给defineSketch(a)函数的参数param_seq[i]是全局作用域,肯定已定义,因此该报错不符合我的预期。我猜测它要么是问题根源,要么是崩溃引发的衍生错误,我能想到的唯一可能性是偶尔出现的异常异步代码执行或其他时序问题,导致defineSketch的参数变为undefined,进而引发崩溃,但我不知道如何验证或调试该问题。
潜在相关信息
我仅能在以下场景复现该Bug:
- Chromium 93浏览器(未尝试其他版本,不到50%的运行概率会崩溃)
- Chrome 92浏览器(未尝试其他版本,不到50%的运行概率会崩溃)
- 从Prolific跳转启动应用时的Safari 13.1.3浏览器(未尝试其他版本,直接访问应用URL时不会出现Bug,不到50%的运行概率会崩溃)
- 从Prolific跳转启动应用时的Firefox 92浏览器(旧版本Firefox不会复现该Bug,直接访问应用URL时不会出现Bug,不到50%的运行概率会崩溃)
一个可能的重要信息是:在Safari和Firefox中,只有从Prolific跳转启动应用时才会出现Bug。Prolific会在URL末尾附加查询字符串用于受试者身份识别,以便结算报酬。我不清楚为什么这会引发问题,但不排除查询字符串异常干扰数据库通信的极端场景。
另外,我使用$作为一个属性的名称,我用于收集问卷数据的其中一个Javascript库使用了jQuery,我最近意识到$是jQuery的快捷方式,这可能也会引发问题。
最小可复现示例
该游戏逻辑复杂、代码量较大,很难编写完整的最小可复现示例。下方示例代码包含了用于循环不同试次的自定义事件监听器逻辑,以及生成p5 sketch(试次)的函数,每个sketch对应不同的用户交互方式或试次结束条件。虽然该最小示例中没有问卷内容,但我还是引入了问卷开发所需的Javascript库,避免遗漏相关影响因素。
JS代码:
// listened variable // we set a listner which will be fired each time the value of bool is changed. let sketchIsRunning = { $: false, listener: function(val) {}, set bool(val) { this.$ = val; this.listener(val); }, get bool() { return this.$; }, registerListener: function(listener) { this.listener = listener; } }; /* Function to define sketch with parameter 'a' */ function defineSketch(a) { switch (a) { case 0: return function(p) { let x = 100; let y = 100; p.setup = function() { p.createCanvas(700, 410); }; p.draw = function() { p.background(0); p.fill(255); p.rect(x, y, 50, 50); }; /* Remove sketch on mouse press */ p.mousePressed = function() { p.remove(); sketchIsRunning.bool = !sketchIsRunning.bool console.log('sketch is running ?', sketchIsRunning.bool) }; }; break; case 1: return function(p) { let x = 200; let y = 200; p.setup = function() { p.createCanvas(700, 410); // Length of time to show sketch p.sketch_length = p.random(1e3, 5e3); // Start time of sketch p.sketch_start = window.performance.now(); }; p.draw = function() { p.background(0); p.fill(255); p.ellipse(x, y, 50, 50); p.rect(y, x, 50, 50); /* Remove sketch after random time */ if (window.performance.now() - p.sketch_start >= p.sketch_length) { p.remove(); sketchIsRunning.bool = !sketchIsRunning.bool console.log('sketch is running ?', sketchIsRunning.bool) } }; }; break; case 2: return function(p) { let x = 0; let y = 0; p.setup = function() { p.createCanvas(700, 410); }; p.draw = function() { p.background(0); p.fill(255); p.ellipse(x, y, 50, 50); }; /* Remove sketch on key press */ p.keyPressed = function() { p.remove(); sketchIsRunning.bool = !sketchIsRunning.bool console.log('sketch is running ?', sketchIsRunning.bool) }; }; break; default: console.log("No match") break; } } /* Initialize sketch variable */ let trialSketch; /* Array of parameters */ let param_seq = [0, 1, 2]; // we nest a call to the function itself to loop through the param.seq array const instanceP5sketches = (i = 0) => { sketchIsRunning.$ = !sketchIsRunning.$; trialSketch = defineSketch(param_seq[i]); new p5(trialSketch); sketchIsRunning.registerListener(function(val) { if (param_seq.length - 1 >= i) { instanceP5sketches(i + 1); } else { console.log('No more sketches.') } }); } // Begin looping through the sketches instanceP5sketches();
HTML代码:
<!DOCTYPE html> <html> <head> <!-- Load in survey format scripts --> <script src="https://unpkg.com/jquery"></script> <script src="https://unpkg.com/survey-jquery@1.8.34/survey.jquery.min.js"></script> <link href="https://unpkg.com/survey-knockout@1.8.34/modern.css" type="text/css" rel="stylesheet" /> <!-- Load in p5.js --> <script src="https://cdn.jsdelivr.net/npm/p5@1.2.0/lib/p5.js"></script> </head> <body></body> </html>
问题根因
该问题核心是试次切换逻辑没有加状态锁,事件触发未做防抖处理,同时存在变量命名冲突风险,具体如下:
- 快速按键时,同一个试次的结束回调会被多次触发,还没等下一个试次完成初始化,切换逻辑就被重复执行,导致循环变量
i累加过快,超出param_seq数组的长度,param_seq[i]变成undefined,传入defineSketch后就会触发a is undefined报错,后续逻辑异常就会出现快速循环所有关卡的问题 - 现有代码每次调用
instanceP5sketches都直接覆盖监听器,高速触发时会出现时序混乱,旧的监听器回调和新的回调同时执行,进一步加剧循环异常 - 用
$作为sketchIsRunning的属性名,和jQuery全局的$存在冲突风险,部分场景下该属性会被意外篡改,导致状态判断错误
修复方案
不要用sleep()加延迟的方案,该方法会阻塞主线程,反而会导致更多不可控的时序问题,按以下步骤修改即可解决:
- 新增全局切换锁,禁止切换过程中重复触发切换逻辑
- 替换
sketchIsRunning中与jQuery冲突的$属性名 - 优化监听器注册逻辑,每次注册前清空旧的回调,避免多回调同时执行
- 给按键事件增加防抖处理,进一步避免快速重复触发
核心修改代码
// 新增全局切换锁 let isSwitching = false; // 替换$属性为value,避免和jQuery冲突 let sketchIsRunning = { value: false, listener: function(val) {}, set bool(val) { this.value = val; this.listener(val); }, get bool() { return this.value; }, registerListener: function(listener) { // 先清空旧的监听器 this.listener = function() {}; this.listener = listener; } }; // 防抖函数,用于按键事件 function debounce(func, wait = 300) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); } } // 修改instanceP5sketches逻辑,增加锁判断和越界检查 const instanceP5sketches = (i = 0) => { // 切换中直接返回,禁止重复执行 if (isSwitching) return; isSwitching = true; sketchIsRunning.value = !sketchIsRunning.value; // 提前判断i是否越界,避免传入undefined if (i >= param_seq.length) { console.log('No more sketches.'); isSwitching = false; return; } trialSketch = defineSketch(param_seq[i]); new p5(trialSketch); sketchIsRunning.registerListener(function(val) { if (param_seq.length - 1 >= i) { isSwitching = false; instanceP5sketches(i + 1); } else { console.log('No more sketches.'); isSwitching = false; } }); } // 给按键触发的sketch的keyPressed方法加防抖 // 示例:对应case 2的sketch修改如下 p.keyPressed = debounce(function() { p.remove(); sketchIsRunning.bool = !sketchIsRunning.bool console.log('sketch is running ?', sketchIsRunning.bool) });
内容的提问来源于stack exchange,提问作者nguzman

