前端通过轮询实现翻译任务实时进度方法详解(GitHub 文档翻译工具项目)

批量翻译的进度展示,是最容易被低估的前端难题。仓库里几百个 markdown 文件,交给翻译队列后少则几十秒、多则几分钟,用户盯着页面却不知道任务到底跑完没有。最开始我用的是最简单的 setInterval 定时拉接口,结果一上线就乱套:页面切到后台定时器照跑、接口偶发报错没人兜底、进度条偶尔还会倒退。我对比了 WebSocket、SSE 和轮询三条路,最后选了 TanStack Query 的轮询能力,按任务状态动态调整轮询间隔,配上乐观更新和失败重试,才算把”进度会动、且稳定”这件事做扎实。下面把完整的实现思路和踩过的坑都写出来。

一、为什么选轮询而不是 WebSocket

先说结论:翻译任务对实时性的要求是”秒级即可”,轮询完全够用,而 WebSocket 带来的维护成本在这个场景里不划算。项目部署在 Vercel 的 Serverless 环境,函数是短生命周期实例,维持一条长连接要么单独起服务、要么换平台,这两条路对我来说都是大改。SSE 虽然能单向推送,但它同样是长连接,在 Serverless 上照样要处理保活和超时,没比 WebSocket 省多少事。轮询就是每隔几秒发一次普通 HTTP 请求,任何静态托管、任何函数平台都原生支持,没有任何额外基础设施。

为了把账算清楚,我从五个维度做了个对比:

维度 轮询 WebSocket
部署复杂度 低 高
兼容性 任意环境 需长连接
实时性 秒级 亚秒级
Vercel 适配 原生支持 需要专门服务
资源消耗 略高(HTTP 头) 略低

实际跑下来,2 秒一次轮询和 WebSocket 推送在用户体感上几乎没差别,但部署和维护成本差了一个量级。任务进度对实时性要求是”秒级即可”,轮询是性价比更高的选择。

二、轮询策略

轮询频率不能拍脑袋定一个固定值。任务在等待队列里时,后端可能还没开始处理,拉再勤也没有新数据,纯属浪费请求;任务真正跑起来后进度才会变化,这时候才需要勤快一点;一旦进入终态,继续轮询就是白耗资源。所以我让轮询间隔跟着任务状态走,用 refetchInterval 传函数而不是固定数字。

下面这段代码解决的就是”任务处于哪个状态、就按哪个节奏拉数据”的问题:

const { data } = useQuery({
  queryKey: ["job", jobId],
  queryFn: () => fetchJob(jobId),
  refetchInterval: (query) => {
    const status = query.state.data?.status;
    if (status === "COMPLETED" || status === "FAILED") return false;
    if (status === "RUNNING") return 2000;
    return 5000;
  },
});

这段实现有两个细节值得注意。一是返回 false 会直接清掉定时器,任务完成后轮询自动停,不会留下悬空请求;二是如果状态又从终态变回非终态,库会自动恢复轮询,等于天然支持”重试后任务重新入队”的场景。规则收敛下来就四条:

  • 终态:停止轮询;
  • 运行中:2 秒间隔;
  • 等待中:5 秒间隔;
  • 浏览器标签不可见时由浏览器节流,不需额外处理。

顺带说一句,TanStack Query 的轮询只对”有活动观察者”的查询生效,组件卸载后定时器自然消失,不用担心内存泄漏。标签页隐藏时它也默认暂停轮询,这也是我选它的原因之一——省带宽这件事库帮我们做掉了。

三、接口设计

轮询策略定好了,接口就得配套设计。翻译任务本质上是个状态机:等待、运行、完成、失败,前端所有 UI 决策——轮询间隔、进度条形态、按钮是否可点——都依赖后端返回的状态。所以接口不能只回一个”完成与否”,得把任务级和片段级的进度一起交出来。

接口本体是一行:

GET /api/translation-jobs/:id

返回结构定义如下:

type Job = {
  id: string;
  status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
  totalSegments: number;
  doneSegments: number;
  failedSegments: number;
  estimatedRemainingMs: number;
  segments?: Array<{
    id: string;
    path: string;
    status: "PENDING" | "RUNNING" | "DONE" | "FAILED";
  }>;
};

totalSegments 和 doneSegments 是进度条的数据源,estimatedRemainingMs 是后端粗估的剩余时长,作为前端 ETA 的兜底。segments 默认只返回前 20 条,详情通过点击展开。这个截断是有讲究的——一个大文档库可能有上千个片段,一次拉全量既慢又占带宽,分页配合懒展开是文档类工具的常见做法。

四、进度条与 ETA

进度条由 doneSegments / totalSegments 决定,这个比例计算时要防除零:任务刚创建时 totalSegments 可能还没统计完,直接相除会得到 NaN。ETA 我一开始用瞬时速度算,结果发现某一段翻译卡住时,剩余时间会剧烈跳动,用户截图反馈”时间在乱跳”。后来改成滑动平均,用最近几次采样点估计整体速率,曲线平稳多了。

function estimateEta(history: { at: number; done: number }[]) {
  if (history.length < 2) return null;
  const dt = history.at(-1)!.at - history[0].at;
  const dd = history.at(-1)!.done - history[0].done;
  if (dd <= 0) return null;
  const speed = dd / dt; // 片段/毫秒
  const remaining = history.at(-1)!.done === 0
    ? null
    : Math.round((total - history.at(-1)!.done) / speed);
  return remaining;
}

history 保留最近 5 次数据即可。窗口太长反应迟钝,任务结束了还在报一个”5 分钟后完成”;窗口太短又回到瞬时速度那种抖动。5 个点是平衡下来的经验值。另外采样要带上时间戳而不是只存片段数,否则轮询间隔本身的不均匀会把速率算偏。

五、乐观更新

进度展示是”读”的一面,用户对”重试片段””取消任务”这类操作是”写”的一面。写操作如果傻等接口返回再更新 UI,按钮点击后会有半秒到一秒的”无响应感”,在慢网络下更明显。乐观更新的思路是先改本地缓存让界面立刻响应,请求失败再回滚,让用户感觉一切都在掌控中。

const retry = useMutation({
  mutationFn: (segmentId) => retrySegment(segmentId),
  onMutate: async (segmentId) => {
    await qc.cancelQueries({ queryKey: ["job", jobId] });
    const prev = qc.getQueryData(["job", jobId]);
    qc.setQueryData(["job", jobId], (old) => patchSegment(old, segmentId, { status: "PENDING" }));
    return { prev };
  },
  onError: (_e, _v, ctx) => qc.setQueryData(["job", jobId], ctx!.prev),
  onSettled: () => qc.invalidateQueries({ queryKey: ["job", jobId] }),
});

这段代码踩过一个坑:onMutate 里必须先 cancelQueries 再 setQueryData。我第一版漏了取消,结果在途的轮询响应回来把刚写好的乐观状态覆盖掉,界面闪烁一下又变回旧数据。onError 里用 onMutate 返回的 prev 回滚,onSettled 里重新拉一次数据对齐,保证本地和服务器最终一致。

六、失败与断网

轮询的本质是高频请求,网络抖动、接口超时在所难免。失败处理的原则是”先静默、后提示”:偶发一次拉取失败不立刻弹错误框,不然用户在弱网下会被错误提示刷屏。TanStack Query 默认自动重试 3 次,带指数退避,绝大多数瞬时故障在这一层就被消化掉了。

  • 轮询失败不立刻报错,TanStack Query 自动重试 3 次;
  • 仍失败则展示”连接中断,点击重试”按钮;
  • 网络恢复后重新发起请求。

连断网这种极端情况也处理过:用户拔网线再插回来,库监听到 online 事件会自动恢复轮询,不需要用户手动刷新页面。这里注意一点,重试只对”查询”生效,乐观更新里那种 mutation 失败要自己写回滚,两者机制不一样,别混用。

七、并发与限流

翻译工具最常见的并发场景是用户同时开好几个任务的详情页。我最初担心请求会叠加成风暴,实测下来发现 TanStack Query 做了一层去重:多个组件订阅同一个 queryKey 时,在途请求只发一次,定时器各自跑、网络请求共享。这一点让并发处理省了不少心。

  • 同一用户同时打开多个任务详情页时,每个任务独立轮询;
  • 浏览器 HTTP/1.1 限制并发 6 个,前端按可见性合并请求;
  • 标签页隐藏时,TanStack Query 会暂停轮询。

还有一条要提醒:如果某个页面同时轮询五个任务,加上其他接口,很容易顶到浏览器单域名 6 个连接的上限,后面的请求会排队。所以列表页这种”不紧急”的轮询要把间隔拉大,或者干脆切到”进入详情页才轮询”的模式。

八、性能优化

轮询本身带不来压力,带压力的是每次轮询拉回来的数据量。翻译任务详情动辄上千个片段,如果每次请求都全量返回,带宽和解析时间都吃不消。优化的核心思路是”只拿变化的部分”。

  • 增量返回:详情接口支持 ?since=timestamp;
  • 列表懒加载:分页拉取片段,避免一次拉满;
  • 进度条组件用 CSS 变量 + transform,避免 layout thrash。

进度条这条单独说下:用 width 属性更新进度会触发重排,2 秒一次的轮询还好,但一旦轮询间隔缩到 1 秒,在低端设备上能看到明显的卡顿。换成 transform: scaleX() 走合成层,动画交给 GPU,流畅度提升很明显,代码改动量却只有几行。

九、可观测性

功能做完只是第一步,真正上线后”进度准不准””轮询有没有失控”都需要数据说话。我给轮询链路加了三个观测点:轮询频率是否在预期内、错误是否在可接受范围、进度条展示是否平滑。

  • 指标:平均轮询次数、终态前最后一次拉取时间;
  • 错误率:拉取失败的占比;
  • 用户体验:进度条跳变次数。

进度条跳变次数是个容易被忽略的硬指标——一次任务如果进度条来回跳了好几次,说明前后端的数据一致性有问题,比如乐观更新和真实状态打架。这个指标在测试阶段能直接暴露问题,比靠用户吐槽反馈要快得多。

十、回归测试

轮询逻辑涉及时间维度的状态变化,光靠手工点几下根本测不全。我把测试拆成三层,每层管一类问题:

  • e2e:模拟后端状态变化,验证 UI 同步;
  • 单元:测试 ETA 函数;
  • 视觉:进度条边缘状态(0%、100%)。

e2e 我做了个 mock 后端,让任务状态按脚本逐步变化,验证进度条、ETA、按钮状态每一步都跟得上。ETA 函数是纯计算,单元测试最划算,把各种边界输入喂进去:历史点不足两个、速率为零、任务已完成。视觉上重点盯 0% 和 100% 两个边缘状态——0% 防止除零,100% 检查是否在终态前就闪到满格、然后又跳回来。

十一、给同类项目的建议

这套方案我从头搭了一遍,如果让我再给类似的”任务型进度”项目做,会直接带走四条经验:

  1. 状态分桶,不同状态用不同轮询间隔;
  2. 乐观更新要带回滚;
  3. 终态停止轮询,避免资源浪费;
  4. 接口支持增量返回,节省带宽。

常见问题(FAQ)

Q1:为什么不用 SSE?

Vercel 上 SSE 长连接不友好,轮询更稳。

Q2:轮询频率多少合适?

运行中 2 秒,等待中 5 秒,终态停止。

Q3:标签隐藏时还在轮询吗?

TanStack Query 默认会暂停,可手动配置。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部