如何在DOM更新期间禁用排队的点击事件?

huangapple go评论69阅读模式
英文:

How to disable queued click events during DOM update?

问题

Sure, here's the translated content you requested:

我有一个“上传”按钮,其功能使用fetch()来解析用户选择的CSV文件,并计算1000 - 2000毫秒以生成一系列的d3图表。我的用户报告说,如果他们在此期间点击其他按钮,会出现错误。所以我尝试使用以下任何/所有正常建议的方法来禁用按钮:

$("#my-button").off("click");
$("#my-button").prop("disabled", true);
$("#my-button").css("pointer-events", "none");

只要它们保持原样,它们都可以工作。但是当我在上传函数执行结束时添加代码行以重新启用点击时,事情就会出错。

我从调试中看到的是以下“日志消息”<用户操作>的时间线:

0000毫秒<用户点击上传按钮>
0001毫秒:“按钮已禁用!”
0500毫秒<用户点击任何按钮>
1000毫秒:“按钮已启用!”
2000毫秒:“按钮被点击!”

换句话说,在上传函数正在运行并且按钮被禁用时,用户点击了按钮,因此从我的代码中似乎没有触发任何点击事件。但是一旦我的上传函数完成并重新启用按钮,用户之前的点击事件突然触发,就好像它一直在等待队列中一样。

我发现处理这种情况的唯一方法是在我的上传函数末尾添加一个$(document).ready(<启用按钮>)调用。这是可以的,但不理想。我希望确保在上传函数期间根本不会触发点击事件。

有没有关于如何实现这一点的想法?

英文:

I have an "upload" button whose function uses fetch() to parse a user-selected CSV file and computes for 1000 - 2000 ms to generate a series of d3 charts. My users have reported that bugs occur if they click any other buttons during this time. So I tried disabling buttons using any/all of the normal suggested methods:

$(&quot;#my-button&quot;).off(&quot;click&quot;);
$(&quot;#my-button&quot;).prop(&quot;disabled&quot;, true);
$(&quot;#my-button&quot;).css(&quot;pointer-events&quot;, &quot;none&quot;);

All of them work if they're left as-is. But when I add lines of code to turn the click back on at the end upload function execution, things go wrong.

What I see from debugging is the following chronology of &quot;log messages&quot; and &lt;user actions&gt;:

0000ms: &lt;user clicks upload button&gt;
0001ms: &quot;buttons disabled!&quot;
0500ms: &lt;user clicks any button&gt;
1000ms: &quot;buttons enabled!&quot;
2000ms: &quot;button clicked!&quot;

In other words, the user clicks while the upload function is running and buttons are disabled so no click event is apparently happening from my code. But as soon as my upload function finishes and turns back on the buttons, the user's previous click event suddenly triggers as if it was waiting in a queue.

The only way I've found to handle this situation is to add a $(document).ready(&lt;enable buttons&gt;) call at the end of my upload function. This is OK, but not ideal. I want to make sure that click event isn't triggered at all during the upload function.

Any ideas on how to accomplish this?

答案1

得分: 1

一种解决方法是使用一个布尔标志来跟踪上传功能是否正在运行。当该标志设置为true时,您可以禁用除上传按钮之外的所有按钮。当该标志设置为false时,您可以重新启用所有按钮。

以下是一个示例实现:

let uploadRunning = false;

function handleUploadClick() {
  if (uploadRunning) {
    return;
  }

  disableButtons();

  // 在开始长时间运行的操作之前将uploadRunning设置为true
  uploadRunning = true;

  // 执行长时间运行的操作
  doSomeWork().then(() => {
    // 当操作完成时将uploadRunning设置为false
    uploadRunning = false;

    // 当操作完成时启用按钮
    enableButtons();
  });
}

function disableButtons() {
  $("button").prop("disabled", true);
  $("#upload-button").prop("disabled", false);
}

function enableButtons() {
  $("button").prop("disabled", false);
}

function doSomeWork() {
  // 在这里执行长时间运行的操作
  return new Promise(resolve => setTimeout(resolve, 2000));
}

在此实现中,uploadRunning标志用于防止在上传功能运行时点击任何按钮,除了上传按钮本身。当uploadRunning再次设置为false时,所有按钮都将重新启用。

这应该可以防止上传功能完成后触发排队的点击事件的问题。

英文:

One solution would be to use a boolean flag to keep track of whether or not the upload function is currently running. When the flag is set to true, you can disable all buttons except for the upload button. When the flag is set to false, you can enable all buttons again.

Here's an example implementation:

let uploadRunning = false;

function handleUploadClick() {
  if (uploadRunning) {
    return;
  }

  disableButtons();

  // Set uploadRunning to true before starting long-running operation
  uploadRunning = true;

  // Perform long-running operation
  doSomeWork().then(() =&gt; {
    // Set uploadRunning to false when operation completes
    uploadRunning = false;

    // Enable buttons when operation completes
    enableButtons();
  });
}

function disableButtons() {
  $(&quot;button&quot;).prop(&quot;disabled&quot;, true);
  $(&quot;#upload-button&quot;).prop(&quot;disabled&quot;, false);
}

function enableButtons() {
  $(&quot;button&quot;).prop(&quot;disabled&quot;, false);
}

function doSomeWork() {
  // Perform long-running operation here
  return new Promise(resolve =&gt; setTimeout(resolve, 2000));
}

In this implementation, the uploadRunning flag is used to prevent any buttons from being clickable while the upload function is running, except for the upload button itself. When uploadRunning is set to false again, all buttons are re-enabled.

This should prevent the issue of queued up click events triggering after the upload function finishes.

答案2

得分: 1

在这里必须发生的事情是,您的代码在此期间阻止了事件循环。浏览器确实使用多个进程,而渲染进程会等待事件循环空闲,负责排队UI事件的进程则不会。因此,在事件循环被释放之后,UI进程排队的所有事件将依次在短时间内触发。这可以很容易地演示如下:

let i = 0;
let blocked = false;
document.querySelector("button").onclick = (evt) => {
  if (blocked) {
    return; // 不会起作用
  }
  blocked = true; // 无用
  i = 0; // 重置计数器
  console.log("阻塞事件循环 5 秒。");
  console.log("在此期间随时点击任何地方");
  // 让控制台消息打印
  requestAnimationFrame(() => setTimeout(() => {
    // 阻塞事件循环 5 秒
    const t1 = performance.now();
    while(performance.now() - t1 < 5000) {}
    console.log("释放事件循环。");
    blocked = false;
  }));
};
onclick = (evt) => {
  if (blocked) {
    return; // 不会起作用
  }
  console.log("点击了 %s 次", ++i);
};

如您所见,同步日志 "释放事件循环。" 在下一个 "点击 1 次" 之前出现。这是因为虽然UI进程继续工作并排队事件,但负责执行JS的主进程被阻塞,并在原地恢复执行。因此,您所有防止点击发生的尝试都是无效的,因为事件回调只会在您解锁按钮之后才执行。对于这些情况,就好像按钮从未被阻塞一样。

为了避免这种情况,最好的方法是不阻塞事件循环。您没有真正展示您正在做什么,所以很难准确告诉您应该做什么,但一些常见策略包括:

  • 纯粹的算术计算/数据转换(重型计算)应该移动到第二个线程,使用 Web Worker。这可以确保主线程和其渲染进程能够无缝工作。这样,您可以在解锁按钮之前很好地等待异步作业完成。

  • 如果您的可视化是在 <canvas> 上完成的,并且绘图是问题的根本原因,请尝试改进它,并考虑使用 OffscreenCanvas,您也可以将其传递给Web Worker。

  • 如果问题是由于DOM操作引起的,您将无法将其传递给工作线程,因为它们无法访问DOM。因此,请确保您的代码不会触发不必要的 "回流"。这个操作会强制CSSOM重新计算所有元素的框。如果滥用,它可能会影响性能。如果必须在DOM操作期间获取计算的样式,请确保将它们批量放在同一个地方,并在获取了所有的getter之后进行可能会破坏布局的更改。

  • 如果同时呈现了多个可视化效果,尝试先呈现屏幕上可见的部分,然后等待第一批完成后再继续其他部分。在 setTimeout() 调用中间间隔它们,这将给浏览器时间来呈现它必须呈现的内容,并处理UI事件。来自 UI 任务源 的任务通常在事件循环中具有最高优先级。

  • 如果只有一个可视化效果仍然需要超过几百毫秒,可以尝试将其呈现算法分成 setTimeout() 调用中间间隔它们。

如果以上这些解决方案都不适用于您,最糟糕的解决方案可能是将重新启用按钮的代码包装在 setTimeout() 回调中。为了安全起见,您可以将第二个参数传递为 10 毫秒。但实际上,这是最糟糕的解决方案。

let i = 0;
let blocked = false;
document.querySelector("button").onclick = (evt) => {
  if (blocked) {
    return; // 不会起作用 
  }
  blocked = true;
  i = 0; // 重置计数器
  console.log("阻塞事件循环 5 秒。");
  console.log("在此期间随时点击任何地方");
  // 让控制台消息打印
  requestAnimationFrame(() => setTimeout(() => {
    // 阻塞事件循环 5 秒
    const t1 = performance.now();
    while(performance.now() - t1 < 5000) {}
    console.log("释放事件循环。");
    setTimeout(() => blocked = false, 10);
  }));
};
onclick = (evt) => {
  if (blocked) {
    return; // 不会起作用 
  }
  console.log("点击了 %s 次", ++i);
};
英文:

The thing that must be happening here is that your code blocks the event loop during this whole time.
Browsers do use multiple processes, and while the rendering one will wait for the event-loop to be free, the one responsible for queuing the UI events won't. So after the event-loop is freed, all the events queued by the UI process will be fired one after the other in a small burst. This can be demonstrated quite easily:

<!-- begin snippet: js hide: false console: true babel: false -->

<!-- language: lang-js -->

let i = 0;
let blocked = false;
document.querySelector(&quot;button&quot;).onclick = (evt) =&gt; {
  if (blocked) {
    return; // will not work 
  }
  blocked = true; // useless
  i = 0; // reset the counter
  console.log(&quot;blocking the event loop for 5s.&quot;);
  console.log(&quot;click anywhere during this period&quot;);
  // let the console message print
  requestAnimationFrame(() =&gt; setTimeout(() =&gt; {
    // block the event-loop for 5s
    const t1 = performance.now();
    while(performance.now() - t1 &lt; 5000) {}
    console.log(&quot;releasing the event loop.&quot;);
    blocked = false;
  }));
};
onclick = (evt) =&gt; {
  if (blocked) {
    return; // will not work 
  }
  console.log(&quot;clicked %s times&quot;, ++i);
};

<!-- language: lang-html -->

&lt;button&gt;click here&lt;/button&gt;

<!-- end snippet -->

As you can see, the synchronous log "releasing the event loop." appears before the next "clicked 1 times". This is because while the UI process kept working and queued the events, the main process, also responsible for the JS execution was blocked and resumed where it was. So all your attempts to prevent the click from happening were moot because the event callbacks would only be executed after you unlocked the button. For these, it's as if the button was never blocked.


To avoid that, the best is to not block the event-loop. You don't really show what you are doing, so it's hard to tell what you should do exactly, but some common strategies involve:

  • Purely arithmetic computation / data transformation (heavy ones), should be moved to a second thread, using a web worker. This allows to keep your main thread and its rendering process working seamlessly. This way you can await nicely that the asynchronous job is done before unlocking your buttons.

  • If your visualizations are done on a &lt;canvas&gt;and that the drawing is the culprit, try to improve it, and you can consider using an OffscreenCanvas that you'd also transfer to a web worker.

  • If the culprit is due to DOM manipulations, you won't be able to transfer that to a worker, they don't have access to the DOM. So instead, make sure that your code doesn't trigger unnecessary "reflows". This operation forces the CSSOM to recalculate all of the elements' boxes. Misused it can become a perf' killer. If you must get computed styles during your DOM manipulations, be sure to batch them all in the same place and to do the changes that would dirty the layout after you've had all the getters.

  • If you have several visualizations being rendered at the same time, try to render only the ones that would be visible in the screen first, and then wait that a first batch is done before continuing with the other ones. Space each of these in setTimeout() calls, this will give the browser time to render what it has to render, and to process the UI events. Tasks coming from the UI task source usually have the highest priority in the event-loop.

  • If you've got only one visualization that still takes more than a few hundred ms, you can try to split it's rendering algorithm and space it in setTimeout() calls.


If none of these solution is applicable to you, the worst solution possible would be to wrap the code that does reenable the button in a setTimeout() callback. To be safe you may pass something like 10ms as the second argument. But really, this is the worst solution.

<!-- begin snippet: js hide: true console: true babel: false -->

<!-- language: lang-js -->

let i = 0;
let blocked = false;
document.querySelector(&quot;button&quot;).onclick = (evt) =&gt; {
  if (blocked) {
    return; // will now work 
  }
  blocked = true;
  i = 0; // reset the counter
  console.log(&quot;blocking the event loop for 5s.&quot;);
  console.log(&quot;click anywhere during this period&quot;);
  // let the console message print
  requestAnimationFrame(() =&gt; setTimeout(() =&gt; {
    // block the event-loop for 5s
    const t1 = performance.now();
    while(performance.now() - t1 &lt; 5000) {}
    console.log(&quot;releasing the event loop.&quot;);
    setTimeout(() =&gt; blocked = false, 10);
  }));
};
onclick = (evt) =&gt; {
  if (blocked) {
    return; // will now work 
  }
  console.log(&quot;clicked %s times&quot;, ++i);
};

<!-- language: lang-html -->

&lt;button&gt;click here&lt;/button&gt;

<!-- end snippet -->

答案3

得分: 0

@Kaiido 提供了一个很好的问题演示。然而,将任务分派给工作线程、将画布更新隐藏在屏幕外或减少回流的解决方案并不适用于我的用例。

我的用例涉及复杂的计算,预计需要一些时间来完成并重新绘制 DOM(用户不关心与下一个绘制的互动度)。因此,我确实需要完全禁用 UI(并阻止排队)。

这是我的解决方案:

function handleEvent(event) {
  var selectedFile = document.getElementById("csvfile").files[0];
  var file = window.URL.createObjectURL(selectedFile);

  $(":button").css("pointer-events", "none"); // 禁用按钮

  fetch(file)
    .then(response => response.text())
    .then(async response => {
      // ... 进行重型数据转换和绘图

      // 在异步内的 DOM 重绘后启用 UI
      $(document).ready(function () {
        $(":button").css("pointer-events", "auto"); // 启用按钮
      });
    });
}

解释:

  1. 用户点击按钮并触发 handleEvent(同步)。
  2. 所有按钮的指针事件都被禁用。
  3. 数据被获取和转换(异步)。

如果我在代码的同步部分中天真地再次启用指针事件,那么它将无法实现我的目标。这是因为同步代码会继续运行,而不等待异步代码(因此按钮会在几毫秒内禁用,然后再次启用,与此同时异步代码继续在后台运行)。

此外,如果我在异步代码的末尾天真地再次启用指针事件,那么它也无法实现我的目标。这是因为一旦 DOM 操作完成并且按钮被启用,页面仍然必须重新布局新的 DOM,在我的情况下需要花费相当长的时间。在此期间,按钮将被启用,并将排队等待任何 UI 输入,一旦 DOM 重新绘制,它们将立即触发。

因此,我将两种不同的方法合并成一个:

  1. 一个函数,用于在 DOM 重绘后触发所有按钮的指针事件启用(使用 $(document).ready())。

这是在异步函数的末尾完成的,因此所有异步 DOM 修改都已完成,只剩下 DOM 重绘(我避免在代码的早期不小心触发 DOM 重绘,这会提前触发按钮的启用)。这意味着同步 UI 代码会继续运行并接收按钮点击事件,同时计算 DOM 操作并重新绘制 DOM,但按钮被禁用,因此这些指针事件会被处理(即它们不会排队),什么都不会发生。只有在 DOM 完全重新绘制后,才会允许按钮再次工作。

英文:

@Kaiido has a good demonstration of the problem. However, the solutions of offloading to a Worker, hiding the canvas updates offscreen, or reducing reflows were not appropriate to my use case.

My use case involves a complex calculation that is expected to take time to complete and redraw the DOM (users aren't worried about Interaction to Next Paint metrics). Therefore I really do need to disable UI entirely (and prevent queuing).

Here's my solution:

function handleEvent(event) {

  var selectedFile = document.getElementById(&quot;csvfile&quot;).files[0];
  var file = window.URL.createObjectURL(selectedFile);

  $(&quot;:button&quot;).css(&quot;pointer-events&quot;, &quot;none&quot;); // disable buttons

  fetch(file)
    .then(response =&gt; response.text())
    .then(async response =&gt; {

      // ...heavy data transformation and plotting goes here
    
      // enable ui AFTER dom redraws WITHIN async
      $(document).ready(function () {
        $(&quot;:button&quot;).css(&quot;pointer-events&quot;, &quot;auto&quot;); // enable buttons
      })
  
    })
}

Explanation:

  1. The user clicks a button and triggers handleEvent (synchronous)
  2. All button pointer events are disabled.
  3. Data is fetched and transformed (asynchronous)

If I naively enable pointer events again here in the synchronous part of the code, then it fails to achieve my goal. This is because the synchronous code continues running without waiting for the asynchronous code (so the buttons disable for a few milliseconds and then enable again, meanwhile the asynchronous code continues running in the background).

Additionally, if I naively enable pointer events again at the end of my asynchronous code, then it also fails to achieve my goal. This is because once the DOM manipulations complete and the buttons are enabled, the page must still reflow/layout the new DOM, which in my case takes a significant amount of time. During this time the buttons will be enabled and will queue any UI input, which will immediately trigger as soon as the DOM redraws.

So I combine two different approaches into one:

  1. A function to enable all button pointer events is set to trigger only after the DOM redraws (with $(document).ready()).

This is done at the end of the asynchronous function, therefore all async DOM modifications are complete and only the DOM reflow is left (and I avoid accidentally triggering a DOM reflow earlier in the code, which would prematurely trigger the buttons to enable).

This means that the synchronous UI code continues to run and receive button click events while the DOM manipulations are being calculated and while the DOM is being redrawn, but the buttons are disabled so these pointer events are handled (ie they don't queue) and do nothing. Only when the DOM is fully redrawn does it allow the buttons to work again.

huangapple
  • 本文由 发表于 2023年6月8日 03:39:16
  • 转载请务必保留本文链接:https://go.coder-hub.com/76426619.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定