原文:https://webdeveloper.beehiiv.com/p/cancel-promises-javascript
作者:Zachary Lee
译者:ChatGPT 4 Turbo
编者注:Promise 的取消并不是真正的取消,只是一种提前 reject。当然,如果用 AbortController 的方式,是可以把 signal 传给 request fn 来实现请求的取消的。
在 JavaScript 中,你可能已经知道如何取消一个请求:你可以使用 xhr.abort()
来取消 XHR 和使用 signal
来取消 fetch。但是如何取消一个常规的 Promise 呢?
目前,JavaScript 的 Promise 原生并不提供取消常规 Promise 的 API。所以,我们接下来讨论的是如何丢弃/忽略一个 Promise 的结果。
方法 1:使用新的 Promise.withResolvers()
现在可以使用的一个新 API 是 Promise.withResolvers()。它返回一个对象,包含一个新的 Promise 对象和两个函数用于 resolve 或 reject 它。
以下是代码的样子:
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
现在我们可以这样做:
const { promise, resolve, reject } = Promise.withResolvers();
因此我们可以利用它来暴露一个 cancel
方法:
const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
let rejected = false;
const { promise, resolve, reject } = Promise.withResolvers<T>();
return {
run: () => {
if (!rejected) {
asyncFn().then(resolve, reject);
}
return promise;
},
cancel: () => {
rejected = true;
reject(new Error('CanceledError'));
},
};
};
然后我们可以用以下测试代码来使用它:
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
const ret = buildCancelableTask(async () => {
await sleep(1000);
return 'Hello';
});
(async () => {
try {
const val = await ret.run();
console.log('val: ', val);
} catch (err) {
console.log('err: ', err);
}
})();
setTimeout(() => {
ret.cancel();
}, 500);
这里,我们预设任务至少需要 1000ms,但我们在接下来的 500ms 内取消它,因此你会看到:
注意,这并不是真正的取消而是一种提前 reject。原始的 asyncFn()
将继续执行直至它解决或拒绝,但这并不重要,因为已经通过 Promise.withResolvers<T>()
创建的 promise 已经被拒绝了。
方法 2:使用 AbortController
就像我们取消 fetch 请求一样,我们可以实现一个监听器以实现提前拒绝。它看起来像这样:
const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
const abortController = new AbortController();
return {
run: () =>
new Promise<T>((resolve, reject) => {
const cancelTask = () => reject(new Error('CanceledError'));
if (abortController.signal.aborted) {
cancelTask();
return;
}
asyncFn().then(resolve, reject);
abortController.signal.addEventListener('abort', cancelTask);
}),
cancel: () => {
abortController.abort();
},
};
};
它的效果与上述相同,但使用了 AbortController。你可以在这里使用其他监听器,但 AbortController 提供了额外的好处,即如果你多次调用 cancel
,它不会触发多于一次的 'abort'
事件。
基于这段代码,我们可以进一步构建一个可取消的 fetch。这在像连续请求的场景中很有用,其中你可能想要丢弃之前的请求结果,并使用最新的请求结果。
const buildCancelableFetch = <T>(
requestFn: (signal: AbortSignal) => Promise<T>,
) => {
const abortController = new AbortController();
return {
run: () =>
new Promise<T>((resolve, reject) => {
if (abortController.signal.aborted) {
reject(new Error('CanceledError'));
return;
}
requestF n(abortController.signal).then(resolve, reject);
}),
cancel: () => {
abortController.abort();
},
};
};
const ret = buildCancelableFetch(async signal => {
return fetch('http://localhost:5000', { signal }).then(res =>
res.text(),
);
});
(async () => {
try {
const val = await ret.run();
console.log('val: ', val);
} catch (err) {
console.log('err: ', err);
}
})();
setTimeout(() => {
ret.cancel();
}, 500);
请注意,这并不影响服务器端的处理逻辑;它仅仅导致浏览器丢弃 / 取消请求。换句话说,如果你发送了一个 POST 请求来更新用户信息,它可能仍然会生效。因此,这更常用于发出 GET 请求以获取新数据的情况。
构建一个简单的顺序请求 React Hook
我们可以进一步封装一个简单的顺序请求 React hook:
import { useCallback, useRef } from 'react';
const buildCancelableFetch = <T>(
requestFn: (signal: AbortSignal) => Promise<T>,
) => {
const abortController = new AbortController();
return {
run: () =>
new Promise<T>((resolve, reject) => {
if (abortController.signal.aborted) {
reject(new Error('CanceledError'));
return;
}
requestFn(abortController.signal).then(resolve, reject);
}),
cancel: () => {
abortController.abort();
},
};
};
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
export function useSequentialRequest<T>(
requestFn: (signal: AbortSignal) => Promise<T>,
) {
const requestFnRef = useLatest(requestFn);
const currentRequest = useRef<{ cancel: () => void } | null>(null);
return useCallback(async () => {
if (currentRequest.current) {
currentRequest.current.cancel();
}
const { run, cancel } = buildCancelableFetch(requestFnRef.current);
currentRequest.current = { cancel };
return run().finally(() => {
if (currentRequest.current?.cancel === cancel) {
currentRequest.current = null;
}
});
}, [requestFnRef]);
}
然后我们就可以简单地使用它了:
import { useSequentialRequest } from './useSequentialRequest';
export function App() {
const run = useSequentialRequest((signal: AbortSignal) =>
fetch('http://localhost:5000', { signal }).then((res) => res.text()),
);
return <button onClick={run}>运行</button>;
}
这样,当你快速多次点击按钮时,你将只会得到最后一次请求的数据,丢弓早前的请求。
构建一个优化的顺序请求 React Hook
如果我们需要一个更全面的顺序请求 React Hook,上面提供的示例仍有改善空间。比如:
- 我们可以使用一个唯一的
AbortController
,直到它真正需要使用,减少每次都创建一个的成本。 - 我们可以使用泛型构建一个支持传递任何参数的请求方法。
这里是代码:
import { useCallback, useRef } from 'react';
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
export function useSequentialRequest<Args extends unknown[], Data>(
requestFn: (signal: AbortSignal, ...args: Args) => Promise<Data>,
) {
const requestFnRef = useLatest(requestFn);
const running = useRef(false);
const abortController = useRef<AbortConstantsoller | null>(null);
return useCallback(
async (...args: Args) => {
if (running.current) {
abortController.current?.abort();
abortController.current = null;
}
running.current = true;
const controller = abortController.current ?? new AbortController();
abortController.current = controller;
return requestFnRef.current(controller.signal, ...args).finally(() => {
if (controller === abortController.current) {
running.current = false;
}
});
},
[requestFnRef],
);
}
值得注意的是,在 finally
块中,我们需要检查当前的 controller
是否等于 abortController.current
,以防止竞态条件:这确保我们只在当前活动的请求完成时更新状态。相反,如果它们不相等,则意味着 finally
块属于一个已取消的请求,不应该修改 running.current
状态。
以下是如何使用它:
import { useState } from 'react';
import { useSequentialRequest } from './useSequentialRequest';
export default function Home() {
const [data, setData] = useState('');
const run = useSequentialRequest(async (signal: AbortSignal, query: string) =>
fetch(`/api/hello?query=${query}`, { signal }).then((res) => res.text()),
);
const handleInput = async (queryStr: string) => {
try {
const res = await run(queryStr);
setData(res);
} catch {
// 忽略
}
};
return (
<>
<input
placeholder="请输入"
onChange={(e) => {
handleInput(e.target.value);
}}
/>
<div>响应数据:{data}</div>
</>
);
}
你可以 在线体验:尝试快速输入,它将取消之前的请求,同时始终保留最新的响应。