JavaScript 中的可取消异步函数
(这篇文章解释了如何使用生成器来处理对async
函数的重复调用。查看这个要点了解最终方法,或者继续阅读以了解更多信息!🎓)
JavaScript 就像一个由可怕的异步调用组成的迷宫,每个调用都一样。我们都写过类似的代码——但在这篇文章中,我将讨论async
和await
。这些关键字得到了广泛的支持,可以帮助你将代码迁移到更易读的版本。📖👀
最重要的是,我将介绍一个关键的陷阱:如何处理多次运行的异步方法,以免影响其他工作。🏑💥
让我们从示例开始。此函数将获取一些内容,将其显示在屏幕上,然后等待几秒钟才引起注意:
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);
});
}
现在我们可以像这样重写它async
,await
不需要回调:
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);
}
是不是感觉好多了?它跳转了,很容易从上到下看到步骤:获取资源,转换为 JSON,写入页面,等待五秒钟,然后调用另一个方法。🔜
这是陷阱!
但这里有一点可能会让读者感到困惑。这不是一个“一次性”执行的常规函数——每次调用时await
,我们基本上都会推迟到浏览器的事件循环,以便它能够继续工作。⚡🤖
换句话说:假设你正在阅读使用的代码fetchAndFlash()
。如果你没有读过这篇文章的标题,运行这段代码会发生什么?
fetchAndFlash('page1');
fetchAndFlash('page2');
你可能以为一个操作会在另一个操作之后发生,或者一个操作会取消另一个操作。但事实并非如此——两者几乎并行运行(因为 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));
};
每次点击都会将另一个任务添加到任务链中。我们也可以用一个辅助函数来实现这一点:
// 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);
现在,您只需调用,它就会在任何其他调用fetchAndFlashChain()
之后按顺序发生。🔗fetchAndFlashChain()
但这篇博文的方案并非如此——如果我们想取消之前的操作怎么办?你的用户刚刚点击了另一个加载按钮,所以他们可能并不关心之前的操作。🙅
方法二:屏障检查
在我们现代化的版本中fetchAndFlash()
,我们使用了await
三次关键字,但实际上只有两个不同的原因:
- 进行网络抓取
- 等待 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);
}
这种方法虽然有效,但有点拗口。而且也不容易推广,而且你必须记住在所有重要的地方添加检查!
不过,有一种方法——使用生成器来为我们概括。
背景:生成器
虽然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);
}
这个程序的两个版本都会打印 1、20 和 300。有趣的是,我可以在任一for
循环内做任何我喜欢的事情,包括break
早期,并且里面的所有状态都myGenerator
保持不变 - 我声明的任何变量,以及我所处的位置。
虽然这里不可见,但调用生成器的代码(特别是.next()
它返回的迭代器函数)也可以使用变量来恢复它。我们很快就会看到。
我们可以将这些部分结合起来,这样当我们决定停止时,某些任务就不再继续执行,也可以继续执行并输出一些输出。嗯——听起来很适合我们的问题!✅
解决方案🎉
fetchAndFlash()
我们最后一次重写一下。实际上,我们只是改变了函数类型本身,并将其替换await
为yield
:调用者可以等待我们——我们接下来会看到如何操作:
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);
}
这段代码现在没什么实际意义,如果我们尝试使用它,它会崩溃。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
}
};
}
这很神奇,但希望它也合情合理。我们调用传入的生成器并获取一个迭代器。然后,我们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
};
太棒了!现在,您可以fetchAndFlash()
从任何地方拨打电话,并且之前的通话都会尽快取消。
附言:可中止的提取
敏锐的读者可能会注意到,我上面介绍的只是取消了一个方法,但并没有中止任何正在进行的工作。我说的是fetch
,它提供了一种在某种程度上受支持的中止网络请求的方法。如果异步函数正在下载一个非常大的文件,这可能会节省用户的带宽,因为我们所做的操作不会阻止下载——一旦文件已经占用了宝贵的字节,我们就会取消它。
完毕
如果您已经读到这里,希望您对 JavaScript 的工作方式有了更多的了解。
当您需要执行异步工作时,JS 不能阻塞,可能会发生对您的方法的多次调用,并且您可以采取策略来处理这个问题 - 要么链接,要么像文章的整个论点那样,取消以前的调用。
谢谢阅读!👋
鏂囩珷鏉ユ簮锛�https://dev.to/chromiumdev/cancellable-async-functions-in-javascript-5gp7