JavaScript 中的可取消异步函数

2025-06-08

JavaScript 中的可取消异步函数

(这篇文章解释了如何使用生成器来处理对async函数的重复调用。查看这个要点了解最终方法,或者继续阅读以了解更多信息!🎓)

JavaScript 就像一个由可怕的异步调用组成的迷宫,每个调用都一样。我们都写过类似的代码——但在这篇文章中,我将讨论asyncawait。这些关键字得到了广泛的支持,可以帮助你将代码迁移到更易读的版本。📖👀

最重要的是,我将介绍一个关键的陷阱:如何处理多次运行的异步方法,以免影响其他工作。🏑💥

让我们从示例开始。此函数将获取一些内容,将其显示在屏幕上,然后等待几秒钟才引起注意:

function fetchAndFlash(page) {
  const jsonPromise = fetch('/api/info?p=' + page)
      .then((response) => response.json());
  jsonPromise.then((json) => {
    infoNode.innerHTML = json.html;

    setTimeout(() => {
      flashForAttention(infoNode);
    }, 5000);
  });
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以像这样重写它asyncawait不需要回调:

async function fetchAndFlash(page) {
  const response = await fetch('/api/info?p=' + page);
  const json = await response.json();
  infoNode.innerHTML = json.html;

  // a bit awkward, but you can make this a helper method
  await new Promise((resolve) => setTimeout(resolve, 5000));

  flashForAttention(infoNode);
}
Enter fullscreen mode Exit fullscreen mode

是不是感觉好多了?它跳转了,很容易从上到下看到步骤:获取资源,转换为 JSON,写入页面,等待五秒钟,然后调用另一个方法。🔜

这是陷阱!

但这里有一点可能会让读者感到困惑。这不是一个“一次性”执行的常规函数​​——每次调用时await,我们基本上都会推迟到浏览器的事件循环,以便它能够继续工作。⚡🤖

换句话说:假设你正在阅读使用的代码fetchAndFlash()。如果你没有读过这篇文章的标题,运行这段代码会发生什么?

fetchAndFlash('page1');
fetchAndFlash('page2');
Enter fullscreen mode Exit fullscreen mode

你可能以为一个操作会在另一个操作之后发生,或者一个操作会取消另一个操作。但事实并非如此——两者几乎并行运行(因为 JavaScript 在我们等待时无法阻塞),并且会以任意顺序完成而且你也不清楚最终页面上会呈现什么样的 HTML。⚠️

两个任务如何并行运行并相互覆盖

需要明确的是,基于回调的这个方法版本也存在同样的问题,只是更明显——而且是以一种非常令人作呕的方式。在将代码现代化以使用async和 时await,我们使其变得更加模棱两可。😕


让我们来介绍几种解决这个问题的不同方法。系好安全带!🎢

方法一:链条

根据调用async方法的方式和原因,它可能会将多个方法“串联”起来。假设你正在处理一个点击事件:

let p = Promise.resolve(true);
loadButton.onclick = () => {
  const pageToLoad = pageToLoadInput.value;
  // wait for previous task to finish before doing more work
  p = p.then(() => fetchAndFlash(pageToLoad));
};
Enter fullscreen mode Exit fullscreen mode

每次点击都会将另一个任务添加到任务链中。我们也可以用一个辅助函数来实现这一点:

// makes any function a chainable function
function makeChainable(fn) {
  let p = Promise.resolve(true);
  return (...args) => {
    p = p.then(() => fn(...args));
    return p;
  };
}
const fetchAndFlashChain = makeChainable(fetchAndFlash);
Enter fullscreen mode Exit fullscreen mode

现在,您只需调用,它就会在任何其他调用fetchAndFlashChain()之后按顺序发生。🔗fetchAndFlashChain()

但这篇博文的方案并非如此——如果我们想取消之前的操作怎么办?你的用户刚刚点击了另一个加载按钮,所以他们可能并不关心之前的操作。🙅

方法二:屏障检查

在我们现代化的版本中fetchAndFlash(),我们使用了await三次关键字,但实际上只有两个不同的原因:

  1. 进行网络抓取
  2. 等待 5 秒后闪烁

在完成这两个点之后,我们可以停下来问自己:“嘿,我们仍然是最活跃的任务吗?用户最近最想做的事情吗?”🤔💭

我们可以通过用nonce标记每个不同的操作来实现这一点。这意味着创建一个唯一的对象,将其存储在本地和全局,并查看全局版本是否与本地版本不同(因为另一个操作已启动)。

这是我们更新的fetchAndFlash()方法:

let globalFetchAndFlashNonce;
async function fetchAndFlash(page) {
  const localNonce = globalFetchAndFlashNonce = new Object();

  const response = await fetch('/api/info?p=' + page);
  const json = await response.json();
  // IMMEDIATELY check
  if (localNonce !== globalFetchAndFlashNonce) { return; }

  infoNode.innerHTML = json.html;

  await new Promise((resolve) => setTimeout(resolve, 5000));
  // IMMEDIATELY check
  if (localNonce !== globalFetchAndFlashNonce) { return; }

  flashForAttention(infoNode);
}
Enter fullscreen mode Exit fullscreen mode

这种方法虽然有效,但有点拗口。而且也不容易推广,而且你必须记住在所有重要的地方添加检查!

不过,有一种方法——使用生成器来为我们概括。

背景:生成器

虽然await将执行推迟到它等待的事情完成(在我们的例子中,要么是网络请求,要么只是等待超时),但生成器函数基本上做相反的事情,将执行移回到调用它的位置

感到困惑?值得快速回顾一下:

function* myGenerator() {
  const finalOut = 300;
  yield 1;
  yield 20;
  yield finalOut;
}
for (const x of myGenerator()) {
  console.info(x);
}
// or, slightly longer (but exactly the same output)
const iterator = myGenerator();
for (;;) {
  const next = iterator.next();
  if (next.done) {
    break;
  }
  console.info(next.value);
}
Enter fullscreen mode Exit fullscreen mode

这个程序的两个版本都会打印 1、20 和 300。有趣的是,我可以在任一for循环内做任何我喜欢的事情,包括break早期,并且里面的所有状态都myGenerator保持不变 - 我声明的任何变量,以及我所处的位置。

虽然这里不可见,但调用生成器的代码(特别是.next()它返回的迭代器函数)也可以使用变量来恢复它。我们很快就会看到。

我们可以将这些部分结合起来,这样当我们决定停止时,某些任务就不再继续执行,也可以继续执行并输出一些输出。嗯——听起来很适合我们的问题!✅

解决方案🎉

fetchAndFlash()我们最后一次重写一下。实际上,我们只是改变了函数类型本身,并将其替换awaityield:调用者可以等待我们——我们接下来会看到如何操作:

function* fetchAndFlash(page) {
  const response = yield fetch('/api/info?p=' + page);
  const json = yield response.json();

  infoNode.innerHTML = json.html;

  yield new Promise((resolve) => setTimeout(resolve, 5000));

  flashForAttention(infoNode);
}
Enter fullscreen mode Exit fullscreen mode

这段代码现在没什么实际意义,如果我们尝试使用它,它会崩溃。yield each 的意义Promise在于,现在调用此生成器的某个函数可以await 为我们执行,包括检查 nonce。现在,你不必在 wait 等待某事时插入这些代码——你只需使用 即可yield

最重要的是,由于此方法现在是一个生成器,而不是async函数,所以该await关键字实际上是一个错误。这绝对是确保你编写正确代码的最佳方法!🚨

我们需要的函数是什么?好了,这就是这篇文章的真正魔力:

function makeSingle(generator) {
  let globalNonce;
  return async function(...args) {
    const localNonce = globalNonce = new Object();

    const iter = generator(...args);
    let resumeValue;
    for (;;) {
      const n = iter.next(resumeValue);
      if (n.done) {
        return n.value;  // final return value of passed generator
      }

      // whatever the generator yielded, _now_ run await on it
      resumeValue = await n.value;
      if (localNonce !== globalNonce) {
        return;  // a new call was made
      }
      // next loop, we give resumeValue back to the generator
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

这很神奇,但希望它也合情合理。我们调用传入的生成器并获取一个迭代器。然后,我们await对其产生的每个值进行迭代,并像网络响应一样继续使用结果值,直到生成器完成。重要的是,这让我们能够泛化在每次异步操作后检查全局 nonce 和本地 nonce 的能力。

扩展:如果发起了新呼叫,则返回一个特殊值,因为它有助于了解单个呼叫是否被取消。在示例代码中,我返回了一个Symbol,它是一个可以进行比较的唯一对象。

最后,我们实际使用makeSingle并包装我们的生成器以供其他人使用,所以现在它就像常规异步方法一样工作:

// replaces fetchAndFlash so all callers use it as an async method
fetchAndFlash = makeSingle(fetchAndFlash);

// ... later, call it
loadButton.onclick = () => {
  const pageToLoad = pageToLoadInput.value;
  fetchAndFlash(pageToLoad);  // will cancel previous work
};
Enter fullscreen mode Exit fullscreen mode

太棒了!现在,您可以fetchAndFlash()从任何地方拨打电话,并且之前的通话都会尽快取消。


附言:可中止的提取

敏锐的读者可能会注意到,我上面介绍的只是取消了一个方法,但并没有中止任何正在进行的工作。我说的是fetch,它提供了一种在某种程度上受支持的中止网络请求的方法。如果异步函数正在下载一个非常大的文件,这可能会节省用户的带宽,因为我们所做的操作不会阻止下载——一旦文件已经占用了宝贵的字节,我们就会取消它。

完毕

如果您已经读到这里,希望您对 JavaScript 的工作方式有了更多的了解。

当您需要执行异步工作时,JS 不能阻塞,可能会发生对您的方法的多次调用,并且您可以采取策略来处理这个问题 - 要么链接,要么像文章的整个论点那样,取消以前的调用。

谢谢阅读!👋

鏂囩珷鏉ユ簮锛�https://dev.to/chromiumdev/cancellable-async-functions-in-javascript-5gp7
PREV
如何在 Linux 中为初学者设置 Django 环境
NEXT
使用 Chrome UX 报告监控竞争对手的分步指南 什么是 CrUX 如何使用 好的,但是有没有不使用 SQL 的解决方案呢?获取 CrUX 数据最简单的方法是什么?如何获取页面级的随时间变化数据?但是监控怎么办?给我一个可以克隆的东西就好!总结