假设任务页面每秒查询一次进度,而某次请求要两秒多才返回。这时继续按固定节奏发请求,同一个任务就可能出现多次查询同时在途。再切到另一个任务,旧请求的返回还可能更新页面。
第六篇讨论后台任务如何等待和重试。这一篇看浏览器一侧:查询什么时候发起,什么时候结束,页面离开后谁还可以提交结果。
Vimorph 的视频翻译、音色设计页面都有轮询调用。本文从当前共享函数的边界出发,提供独立的 React + TypeScript 示例,补上取消和旧结果保护;示例使用本地替身,不调用业务接口,也不代表产品页面已经具备下面的全部保护。
固定间隔不会等待异步回调
setInterval 会按自己的节奏调用回调。把回调写成 async,并不会让定时器等它返回的 Promise 完成后再计时。对耗时不稳定的查询,可以选择"本轮结束,再等一个间隔"的调度方式。MDN 的说明也讨论了耗时超过间隔的情况。
这样得到的是请求间的休息时间,不是严格每秒一次。假设查询花 2.5 秒,正常间隔设为 1 秒,下一次大约在这一轮开始 3.5 秒后发起;这些数字只解释调度关系,不是接口性能数据。
轮询还需要明确结束条件:任务已完成,不再查询;临时查询失败,适当延后;页面不再关注这个任务,停止本地轮询。最后一种情况不需要把服务器任务也标成失败。
共享函数的能力与页面接入
当前 frontend/lib/polling.ts 中的 startPolling 已经在 await tick() 之后安排下一轮,并用局部 inFlight 防止同一实例重叠。tick 返回 false 时停止;抛错时进入退避分支。
这里的"同一实例"很关键。React Effect 清理旧实例后,可以建立新实例;两者没有共享的请求锁。现有 stop() 清理调度与可见性监听,不会取消已经执行的 tick,也不会拦截 tick 内部的 setState。
错误能否到达调度器同样取决于接入方式。当前部分视频翻译回调内部 catch 后正常返回,外层就收不到拒绝,不能据此触发错误退避。本文示例让查询错误继续抛出,由轮询器统一安排下一次查询。
请求结束后再安排下一次
本例把轮询器缩到一个实例、一条调用链。首次通过零延时定时器启动,之后只在当前 tick 结算后建立下一次定时器,不再提供手动并行执行入口。
| 操作 | 职责 |
|---|---|
| 清除定时器 | 撤销尚未开始的下一轮 |
| AbortController.abort() | 通知配合 AbortSignal 的请求停止 |
| 返回后检查 signal | 阻止失效实例继续更新页面 |

下面是完整的 src/poller.ts:
ts
export type PollingOptions = {
intervalMs: number;
maxIntervalMs: number;
// true: 继续查询;false: 已到终态,不再查询。
tick: (signal: AbortSignal) => Promise<boolean>;
onError: (error: unknown, nextDelayMs: number) => void | Promise<void>;
};
export function startPolling(options: PollingOptions): { stop: () => void } {
const { intervalMs, maxIntervalMs, tick, onError } = options;
for (const value of [intervalMs, maxIntervalMs]) {
if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) {
throw new RangeError('轮询间隔必须是有效的正整数毫秒数');
}
}
if (maxIntervalMs < intervalMs) {
throw new RangeError('最大退避间隔不能小于正常查询间隔');
}
const controller = new AbortController();
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let delayMs = intervalMs;
function stop() {
if (stopped) return;
stopped = true;
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
controller.abort();
}
async function run() {
timer = undefined;
if (stopped) return;
let keepPolling: boolean;
try {
keepPolling = await tick(controller.signal);
} catch (error) {
// 只依据本实例是否停止,不凭错误名猜测取消。
if (stopped) return;
delayMs = Math.min(delayMs * 2, maxIntervalMs);
try {
await onError(error, delayMs);
} catch {
// 错误展示回调自身失败时,停止实例,避免未处理的 Promise 拒绝。
stop();
return;
}
if (!stopped) timer = setTimeout(run, delayMs);
return;
}
if (stopped) return;
if (!keepPolling) {
stop();
return;
}
delayMs = intervalMs;
timer = setTimeout(run, delayMs);
}
// 首轮也经过调度,effect 在执行前清理时可以撤销它。
timer = setTimeout(run, 0);
return { stop };
}
正常查询后恢复 intervalMs;查询失败后延迟翻倍,受 maxIntervalMs 限制。示例页面使用 1 秒基础间隔和 8 秒上限,因此连续故障的等待间隔为 2、4、8、8 秒。间隔参数要求处于有效范围的正安全整数;最大间隔不能小于基础间隔,两者相等时按固定间隔运行。
指数退避只控制重试频率,不会自动判断错误是否应该重试,也不会自动限制总重试次数。 如果错误持续发生,达到8秒上限后仍会继续尝试。调用方可以在 onError 中调用本次轮询返回的 stop(),处理已确认不可恢复的错误,或落实失败预算、任务截止时间。本例没有内置HTTP错误分类;429、503和网络异常是否重试,都应结合接口协议与当前业务判断。
stop 先设置停止标记,再清定时器和中止 signal。即使在途 Promise 后来才 resolve 或 reject,run 也会返回,不再安排下一轮。轮询器会等待 onError 通知结算;通知自身抛错或拒绝就停止实例,避免产生未处理的拒绝。在通知期间调用 stop,结算后也不会重新启动轮询。页面中的通知只是同步更新提示。
AbortSignal 只是取消通知。底层查询不配合时,已经开始的Promise可能继续执行;stop不能强制终止任意Promise。停止后不再调度,由轮询器负责;阻止旧结果更新页面,还需要下面的结果归属检查。
在 React 中限定结果的归属
调度器只能决定何时再运行,无法撤回 tick 已经做过的页面更新。所以检查必须放在 await query(signal) 后、setState 前。
每次 Effect 创建自己的轮询器,也就拥有独立的 signal。cleanup 调用 stop 后,这个旧 signal 会一直保持 aborted;新实例使用另一个 signal。即使查询实现忽略取消,旧回调仍会放弃结果。React 官方示例也在清理后忽略过时响应。
以下为完整的 src/App.tsx。它依赖上面的 poller 和下一节的 fake-api;TaskPanel 随 task.id 变化重新挂载,停止、重新查询和卸载按钮都在这份文件中。
tsx
import { useEffect, useState } from 'react';
import { createFakeQuery, type Mode, type Task, type TaskSnapshot } from './fake-api';
import { startPolling } from './poller';
const labels: Record<Mode, string> = {
normal: '正常查询', slow: '慢请求(2.5 秒)',
flaky: '前两次失败', stubborn: '忽略取消的慢请求',
};
function TaskPanel({ task }: { task: Task }) {
const [snapshot, setSnapshot] = useState<TaskSnapshot | null>(null);
const [message, setMessage] = useState('等待首次查询');
const [requestCount, setRequestCount] = useState(0);
const [stopped, setStopped] = useState(false);
const [session, setSession] = useState(0);
useEffect(() => {
setMessage(stopped ? '已停止本地查询' : '等待首次查询');
if (stopped) return;
setSnapshot(null);
setRequestCount(0);
const query = createFakeQuery(task);
const polling = startPolling({
intervalMs: 1000,
maxIntervalMs: 8000,
tick: async (signal) => {
setRequestCount((value) => value + 1);
const result = await query(signal);
// 请求可能没有响应 abort;旧实例仍必须放弃更新页面。
if (signal.aborted) return false;
setSnapshot(result);
setMessage(result.status === 'succeeded' ? '任务完成,停止查询' : '处理中,等待下次查询');
return result.status !== 'succeeded';
},
onError: (error, nextDelayMs) => {
const detail = error instanceof Error ? error.message : '查询失败';
setMessage(`${detail};${nextDelayMs / 1000} 秒后重试`);
},
});
return () => polling.stop();
}, [task, stopped, session]);
return (
<section className="panel" aria-labelledby="task-heading">
<div className="task-top"><h2 id="task-heading">任务 #{task.id}</h2><span>{labels[task.mode]}</span></div>
<p className="state" role="status">{message}</p>
<div className="progress"><div style={{ width: `${snapshot?.progress ?? 0}%` }} /></div>
<div className="stats"><span>示意进度 <strong>{snapshot?.progress ?? 0}%</strong></span><span>当前实例请求数 <strong>{requestCount}</strong></span></div>
<div className="actions">
<button onClick={() => setStopped(true)} disabled={stopped}>停止查询</button>
<button onClick={() => { setStopped(false); setSession((value) => value + 1); }}>重新查询</button>
</div>
<p className="hint">停止的是页面查询,不会取消服务器任务。重新查询会重置这个本地替身。</p>
</section>
);
}
export default function App() {
const [mode, setMode] = useState<Mode>('normal');
const [task, setTask] = useState<Task>({ id: 1, mode: 'normal' });
const [visible, setVisible] = useState(true);
return (
<main>
<p className="eyebrow">REACT + TYPESCRIPT · LOCAL DEMO</p>
<h1>让任务轮询有始有终</h1>
<p className="intro">请求结束后再调度,失败时退避,离开后放弃旧结果。</p>
<div className="toolbar">
<label>模拟场景<select value={mode} onChange={(event) => setMode(event.target.value as Mode)}>
{Object.entries(labels).map(([value, text]) => <option key={value} value={value}>{text}</option>)}
</select></label>
<button className="primary" onClick={() => { setTask({ id: task.id + 1, mode }); setVisible(true); }}>切换到新任务</button>
<button onClick={() => setVisible((value) => !value)}>{visible ? '卸载任务面板' : '挂载任务面板'}</button>
</div>
{visible ? <TaskPanel key={task.id} task={task} /> : <section className="panel"><p>任务面板已卸载,本地查询已停止。</p></section>}
<aside><strong>可操作的检查</strong><p>选择慢请求,观察请求不会按每秒叠加。选择失败场景,观察 2 秒、4 秒退避。选择忽略取消的慢请求,启动后切换新任务,旧响应不会覆盖新任务。</p><p>所有状态来自受控本地替身;没有调用 Vimorph 业务接口。</p></aside>
</main>
);
}
这里同时用了组件 key 和 signal 检查:前者让不同任务有各自的面板状态,后者阻止旧异步回调提交结果。Effect 依赖的是保持稳定的 task 对象;每次切换才创建新对象,不能在每次渲染时临时构造同一个任务的对象,否则会反复重建轮询。
同一任务重新查询时,session 会递增,促使旧Effect先执行cleanup,再建立新实例。因此,已经在查询时再次点击"重新查询",也会替换旧实例。停止、重查与任务切换都使用同一条清理路径;每个实例自己的signal用于隔离晚到结果。
取消只能减少支持取消的请求继续占用资源,不能代替结果归属检查。旧查询若不响应 abort,切换后仍可能与新实例的查询同时在途;本例保证旧实例不再提交结果,没有承诺跨实例的所有物理请求都互斥。
错误分支不靠错误名称猜测用户取消。实例停止后,晚到的成功和错误都因结果已失效而被丢弃;这并不表示某个普通网络错误被重新分类成了取消错误。
用受控查询复现晚到响应
为了让几种时序可以重复观察,createFakeQuery 提供正常、慢请求、前两次失败,以及故意忽略取消的慢请求四种模式。完整的 src/fake-api.ts 如下:
ts
export type Mode = 'normal' | 'slow' | 'flaky' | 'stubborn';
export type Task = { id: number; mode: Mode };
export type TaskSnapshot = {
taskId: number;
status: 'processing' | 'succeeded';
progress: number;
requests: number;
};
async function wait(ms: number, signal: AbortSignal, ignoreAbort: boolean): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
await new Promise<void>((resolve, reject) => {
if (!ignoreAbort && signal.aborted) {
reject(signal.reason);
return;
}
timer = setTimeout(resolve, ms);
onAbort = () => reject(signal.reason);
if (!ignoreAbort) {
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
} finally {
if (timer !== undefined) clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
}
}
// 受控本地替身:不访问服务器,不表示真实任务耗时或进度。
export function createFakeQuery(task: Task) {
let requests = 0;
return async (signal: AbortSignal): Promise<TaskSnapshot> => {
requests += 1;
const count = requests;
const slow = task.mode === 'slow' || task.mode === 'stubborn';
await wait(slow ? 2500 : 250, signal, task.mode === 'stubborn');
if (task.mode === 'flaky' && count <= 2) {
throw new Error('模拟查询失败;下次将按退避间隔重试');
}
const completed = task.mode === 'flaky' ? count - 2 : count;
return {
taskId: task.id,
status: completed >= 3 ? 'succeeded' : 'processing',
progress: Math.min(completed * 34, 100),
requests: count,
};
};
}
配合 signal 的等待会在注册监听后再次检查 aborted,正常完成和取消都清理监听及计时器。忽略取消的模式则故意等到结果返回,用于检查页面端的保护,而不是假装所有第三方 API 都支持取消。
请求序号和进度由这个本地闭包生成。重新查询会新建替身、重置计数;它没有重新提交真实生成任务。状态 processing / succeeded 也是示例约定,实际接 HTTP 接口时应先校验响应结构,再按协议判断是否终态。
运行与观察
已有 React + TypeScript 项目可以按文中的 src 路径保存三个完整文件,并让入口渲染 App。完整交付工程还包含入口、样式、依赖锁文件与测试;本次使用 React 19.2.7、TypeScript 5.9.3、Vite 8.2.1。
本地交付包中的 examples/react-task-polling-demo.zip 解压后,根目录为 react-task-polling-demo。公开附件地址尚未提供,因此这里不放无法访问的下载链接。使用满足 package.json 中 engines 要求的 Node,在工程目录运行:
text
cd react-task-polling-demo
npm ci
npm test
npm run build
npm run dev
选择模拟场景后,点击"切换到新任务"应用它:
| 场景 | 观察内容 |
|---|---|
| 正常查询 | 三次成功响应后结束查询 |
| 慢请求 | 本轮结束后才开始计算下一次间隔 |
| 前两次失败 | 显示 2 秒、4 秒退避,后续正常查询恢复 |
| 忽略取消的慢请求 | 查询发出后切换任务或卸载,旧结果不再提交 |
停止按钮只结束当前页面的查询,后台任务是否取消必须走另一套业务接口。示例没有该接口。
验证范围与接入边界
此前已实际运行原共享函数的5项测试、教学工程的22项测试,以及2项独立边界检查,原始记录继续保留。本次保留核心实现,新增10项参数与长期pending边界测试,教学工程合计32项通过,0失败、0跳过,TypeScript检查与页面构建也通过。测试使用可控Promise、假定时器和受控触发,没有靠增加等待时间掩盖竞态。
教学测试覆盖慢请求不重叠、退避封顶与成功恢复、终态停止、首次执行前停止、在途取消、忽略取消的晚到结果及错误回调。原业务测试里页面隐藏用例使用 document stub,不是浏览器兼容性测试;也不能根据测试名就认定它验证了成功后的全部退避恢复。
此前的浏览器记录覆盖正常完成、停止与重新查询、故障恢复、任务切换和卸载;本次补查了在途停止后立即重查、快速切换任务、同任务连续重查以及在途卸载四组页面操作。界面检查只说明可见行为,不用它证明所有物理请求互斥或枚举内部定时器;相应停止调度行为由受控测试另行断言。
下面是保留的本地Fake API示例截图,进度来自受控替身,不是Vimorph正式工作台:

示例没有单次请求timeout,也没有任务级deadline:前者限制一次查询持续多久,后者限制整项任务可以等待多久。若查询一直pending,本实例不会再发下一条,但也不会自行判定超时;真实HTTP客户端需要配置请求超时,并把失败交给明确的重试或停止策略。异步onError若一直不结算,也会暂停在通知阶段。
页面隐藏自动暂停和最大失败次数没有内置。停止查询或请求超时,都不代表服务器或供应商后台任务已取消;取消后台任务需要另外的业务接口。
本轮运行环境为 Node 26.7.0。真实供应方、Vimorph 业务端到端、Safari、移动端和后台计时器节流未验证。开发入口启用了 StrictMode,但不把一次页面操作当成所有组件生命周期组合的证明。
让一次轮询有明确的结束方式
持续查询不需要持续制造请求。把下一轮放在本轮结算之后,再给实例设置明确的停止与结果提交规则,就能把请求频率和页面生命周期连接起来。
接入页面时,先看清三件事:查询错误是否还能传到调度器,清理是否通知了在途请求,晚到结果是否仍有资格更新当前任务。把这些约定写进调用处,比单独封装一个定时器更有用。