在 Firebase 应用程序中使用 P5.js 时出现奇怪的间歇性 Javascript 错误

Bizarre Intermittent Javascript Bug While Using P5.js in Firebase App

编辑、更新:

我现在可以在任何上下文中跨所有浏览器 100% 地重现错误。通过多次试验和错误,我发现通过在调查和欢迎屏幕之后的第一个指令屏幕(玩家角色被分配给他们的地方)上快速单击右箭头键,我可以使错误 100% 出现。我的猜测是,快速按键会导致事件侦听器在它应该被触发之前多次被触发,导致某些函数参数由于某种原因未定义。不确定如何进行。在按下箭头键后延迟使用 sleep() 会有帮助吗?


TL;博士

在 p5.js 中编写了一个视频游戏并将其托管为 Google Firebase 网络应用程序。我使用自定义事件侦听器来遍历视频游戏关卡。大约 30% - 40% 的用户报告了一个错误,即游戏在没有用户输入的情况下快速循环所有级别。我只能在大约 10% 的时间内重现错误,而且只能在特定的上下文中重现。当错误发生时,控制台或通过错误报告服务通常不会出现错误消息。

更长的解释

我正在 运行 使用名为 Prolific 的招募对象服务在线进行心理学研究。该研究包括多项调查,然后是视频游戏任务。我使用 p5.js 编写了视频游戏。研究有几种不同的任务条件,它们被编程为不同的 p5 草图。为了循环执行正确的试验顺序,我使用了一个自定义的书面事件侦听器,该侦听器在满足结束当前试验的适当条件时触发,删除当前草图并开始下一个草图。我通过 Google Firebase 作为网络应用程序托管调查和视频游戏。该研究的工作方式是,Prolific 上想要参与的用户会被定向到网络应用程序 URL(如果您私下给我发消息,我可以提供这个 URL)。

大约 30% - 40% 的研究对象报告了一个我难以重现的异常错误。游戏会 运行 顺利进行几次试验,然后突然崩溃,在没有任何用户输入的情况下快速循环所有剩余的试验,直到研究结束。我试图在所有主要操作系统(Windows、Mac、OS、Linux 上重现所有主要浏览器(Firefox、Chrome、Safari)上的错误) 在几台不同的机器上。在所有情况下,我只能在大约 10% 的时间内重现错误,即便如此,也只能在特定情况下重现(见下文)。

绝大多数时间(在我端和用户端),控制台中不会出现未处理的异常(错误消息),也不会在崩溃期间被我的错误监控服务报告(Sentry.io)。每当出现错误时(应用程序崩溃的 运行 的比例不到 10%),它们总是采用 TypeErrors 的形式,有两个错误最常见:

  1. NetworkError when attempting to fetch resource / Failed to fetch resource 。我不确定为什么会出现此错误,并且在应用程序成功和崩溃 运行 期间间歇性出现,这让我相信这不是罪魁祸首。加载的唯一资源是 Web 应用程序 public 目录中的图像。但是,有时在崩溃 运行s 时,在崩溃时(我无法确定是在崩溃点之前、期间还是之后),有时应该加载的图像没有显示(但P5没有报错)

  2. 如下面的示例代码所示,函数参数 a 用于标识要为当前试验绘制哪个草图到 canvas。我有时会在应用程序崩溃 运行 时收到错误 a is undefined。这对我来说没有意义,因为根据 Javascript 范围链,我作为参数 a 传递给函数 defineSketch(a) 的变量 param_seq[i] 应该具有全局范围, 并且是明确定义的。我的猜测是,这要么是罪魁祸首,要么是崩溃导致的一些衍生错误。但我唯一能想到的是,偶尔会发生一些奇怪的代码异步评估或其他一些时序问题,导致 defineSketch 的参数未定义,从而导致崩溃。虽然不知道如何验证或调试它。

可能相关的详细信息

我只能在以下情况下重现错误:

在 Safari 和 Firefox 中,除非从 Prolific 启动应用程序,否则该错误不会出现,这一点可能很重要。 Prolific 将查询字符串附加到 URL 的末尾,以启用主题识别,以便他们可以按时间付费。不知道为什么这会成为一个问题,但我可以看到一个奇怪的场景,其中查询字符串以某种方式干扰了与我的数据库的通信。

此外,我使用$作为属性的名称。我用来在 Web 应用程序中收集调查数据的 Javascript 库之一使用 jQuery,我最近意识到 $ 是 jQuery 的快捷方式,因此这可能引起问题。

最小工作示例

视频游戏比较复杂,涉及的代码很多,所以很难写出一个最小的工作示例。下面的示例代码包含用于循环遍历不同试验的自定义事件侦听器的逻辑以及生成 p5 草图(试验)的函数,每个草图都具有不同形式的用户交互或试验结束条件。尽管 MWE 中没有调查,但我包含了 Javascript 编程所需的库,以防万一。

// 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();
<!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>

非常感谢您对此提供的任何帮助,我已经无计可施了。

查看原始源代码后,确定该错误可能是在实例模式草图的 setup() 函数被调用之前触发 keyPressed 事件的结果。这是该问题的概念证明(如果您足够快地按下空格键,您将在表示设置已完成的绿色方块之前绘制红色方块):

let currentSketch;

function sketch1(p) {
  let c = 0;
  
  p.setup = () => {
    p.createCanvas(p.windowWidth, p.windowHeight);
    p.noLoop();
  };
  
  p.draw = () => {
    p.text('Press the space bar 3 times to continue...', 10, 10)
  };
  
  p.keyPressed = (e) => {
    if (p.key === ' ') {
      if (++c >= 3) {
        // Switch to sketch 2
        p.remove();
        currentSketch = new p5(sketch2);
      }
    }
    
    e.preventDefault();
    return false;
  };
}

function sketch2(p) {
  let bg;
  
  let events = [];
  let color = 'red';
  
  p.preload = () => {
    // Load an image to delay setup, prevent caching
    bg = p.loadImage(`https://www.paulwheeler.us/files/windows-95-desktop-background.jpg?r=${p.random()}`);
  };
  
  p.setup = () => {
    p.createCanvas(p.windowWidth, p.windowHeight);
    events.push('green');
    color = 'blue';
  };
  
  p.draw = () => {
    p.clear();
    p.image(bg, 0, 0, p.width, p.height);
    let x = 5;
    let y = 5;
    for (let e of events) {
      p.fill(e);
      p.square(x, y, 20);
      x += 25;
      if (x + 25 >= p.width) {
        x = 5;
        y = 20;
      }
    }
  };
  
  p.keyPressed = (e) => {
    if (p.key === ' ') {
      events.push(color);
    }
    
    e.preventDefault();
    return false;
  };
}

currentSketch = new p5(sketch1);
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>

这个故事的寓意是您需要在用户输入处理函数中采取防御措施,而不是假设 setup() 函数已经完成。