了解 JavaScript 中禁用按钮上的排队点击事件

Understanding Queued Click Events on Disabled Button in JavaScript

提问人:justasking 提问时间:9/7/2023 更新时间:9/7/2023 访问量:80

问:

我对以下代码片段的行为感到困惑。我有一个按钮,在单击它后运行长时间的计算时被禁用。

        function delay() {
            console.log('start');
            for (let x = 0; x < 4000000000; x++) {
                Math.sqrt(20);
            }
            console.log('done');
        }

        function click() {
            button.disabled = true;
            setTimeout(() => {
                delay();
                button.disabled = false;
            }, 0);
        }
        const button = document.getElementById('button');
        button.addEventListener('click', click);

该按钮成功禁用并运行了长时间的计算,但发生了一些奇怪的事情:如果我在按钮处于禁用状态时多次单击该按钮,它会将一个单击事件排队,并在完成当前计算后再次运行 click() 函数。

我的理解是,禁用的元素不应触发或排队任何事件。谁能解释这种行为?

我已经阅读了 JavaScript 的微任务和宏任务,以及 UI 在它们之间如何更新。基于此,我预计该按钮在禁用时对点击完全无响应。

但是,我观察到的是,尽管按钮被禁用,但如果我在禁用时多次单击它,则单击事件仍然会排队。

javascript html dom dom-events web-frontend

评论

0赞 Teemu 9/7/2023
根据行为,我会说单击禁用的按钮是排队的,浏览器在从队列中弹出事件时检查状态,但在调用处理程序之前。在 JS 执行之前,该事件不会触发(此时为 函数),当脚本完成时,再次处于 false 状态。在小提琴中,如果在函数运行之前单击两次(在 500 毫秒内),则代码将按预期仅运行一次。如果在函数运行时第二次单击,则将获得所描述的行为。delaydisableddelaydelay
1赞 justasking 9/7/2023
这很奇怪,因为即使您在延迟函数的运行时发送垃圾邮件单击按钮,它仍然只会注册一个点击事件。如果您在启用的按钮上执行此操作,该按钮会阻止大量功能,它将堆积多个点击事件。即使您在禁用的按钮上将指针事件设置为“无”,该行为也会如您所注意到的那样持续存在。无论如何,事件似乎都是在内部生成的。
0赞 Teemu 9/7/2023
是的,我测试了一次性事件,即使当它重新连接到功能中的按钮时也会触发两次。只是我们的期望(我也是)不正确。{once: true}delay

答:

0赞 Florian 9/7/2023 #1

它与浏览器如何处理快速事件序列和元素的禁用状态有关。

当您快速单击该按钮时,即使它被禁用,浏览器仍可能注册单击事件,因为它处理事件的方式是快速连续的。如果按钮被禁用,然后在短时间内重新启用,则尤其如此,就像您的代码一样。

以下是正在发生的事情的细分:

你点击按钮。 调用 click 函数。 该按钮立即被禁用。 setTimeout 函数将延迟函数调度为在清除当前调用堆栈后运行。

当该按钮被禁用时,您再次单击它。由于事件的快速序列,浏览器可能仍会注册此单击事件。 延迟功能运行,完成后,按钮将重新启用。 将处理排队的单击事件,即使该事件是在禁用按钮时注册的。 若要避免此行为,可以使用标志来检查计算是否已在运行,并防止再次调用该函数:

    let isRunning = false;

function delay() {
    console.log('start');
    for (let x = 0; x < 4000000000; x++) {
        Math.sqrt(20);
    }
    console.log('done');
}

function click() {
    if (isRunning) return;

    isRunning = true;
    button.disabled = true;
    setTimeout(() => {
        delay();
        button.disabled = false;
        isRunning = false;
    }, 0);
}

const button = document.getElementById('button');
button.addEventListener('click', click);

通过此修改,即使浏览器在禁用按钮时注册了单击事件,单击函数也会立即返回,而无需执行任何操作,因为 isRunning 标志设置为 true。

评论

1赞 justasking 9/7/2023
即使进行了修改,行为也保持不变。即使我只在明显的延迟后单击一次禁用按钮,也没有任何变化。
0赞 Alexander Nenashev 9/7/2023 #2
  1. 会发生什么情况:当您使用循环阻止线程时,浏览器仍会收集事件。循环后,立即启用按钮,然后将控制权返回给浏览器。浏览器现在能够处理收集到的事件并启用您的按钮,因此它会触发事件,从而再次运行循环。解决方案:在单独的任务中也启用该按钮,以便浏览器在处理事件时仍将禁用该按钮。
  2. 您可以使用 .async/await
  3. 若要使 UI 更新与代码同步,请添加到超时逻辑中。例如,如果尝试显示带有 UI 的消息,则不会更新。这是因为下一个呈现帧是将来的,但您使用循环阻止了呈现,因此该帧会等待您将控件返回给浏览器。使用会使您等待渲染的帧,然后开始循环。requestAnimationFramestartsetTimeout(..., 0)requestAnimationFrame

const flushUI = () => new Promise((resolve => setTimeout(() => requestAnimationFrame(resolve))));

async function delay() {
    const id = (Math.random()*100|0).toString().padStart(2, '0');
    console.log(id, ': start');
    await flushUI();
    const start = performance.now();
    for (let x = 0; x < 4000000000; x++) {
        Math.sqrt(20);
    }
    console.log(id, ': done in', performance.now() - start + 'ms');
}

async function click() {
    button.disabled = true;
    await flushUI(); // could be omitted since delay() flushes too but doesn't hurt because delay() could be changed in the future and lose the flushing inside it
    await delay();
    await flushUI();
    button.disabled = false;
}
const button = document.getElementById('button');
button.addEventListener('click', click);
button{
  padding: 20px 30px;
  border-radius:4px;
  user-select:none;
  cursor:pointer;
}
<button id="button">Click</button>