Frontend

用户切换页面后,后台长任务完成时如何通知

1996 字约 7 分钟

摘要补全任务仍在后端运行,右上角也挂着全局 Toast,但用户切换页面后回来,完成的结果却没有任何提示。这是一次组件生命周期与任务归属错位的排查记录。

在 Auto Email Sender 中,很多操作都比较耗时:补全导师的学术主页、爬取公开名录、用大模型批量生成画像匹配,每项都可能需要几十秒甚至几分钟。

从产品体验的角度,用户不会一直盯着进度条等待。他们通常会切换到其他页面继续工作,心里想着“反正任务完成后,右上角会弹出提示”。

但实际情况很快给出了反例:用户在导师列表页点击“全量补全”,然后切换到邮件模板页面写文案。两分钟后切回列表,发现数据确实已经更新了,但全程没有弹出任何成功或失败的 Toast 提示。

后端的 Celery 任务正常执行并写入了数据库,右上角的 Toast 组件也挂载在根路由最外层的全局容器中。为什么通知没有显示?

通知组件正常,问题在于谁在监听

我最先怀疑的是 Toast 被某些页面的 z-index 遮挡,或者路由跳转时清空了 Toast 的状态。

排查后发现并非如此。Toast 容器挂载在根布局(Root Layout)中,与页面的生命周期是分开的。我在控制台手动调用 toast.success("测试"),无论怎样切换路由,弹窗都能正常显示。

所以问题应该出在上游:到底是谁在“监听任务完成”并调用 toast?

查看导师列表页的代码后,原因很快就清楚了:

[列表页组件挂载] ──> 发起任务 ──> 拿到 task_id ──> 组件内开启 setInterval 轮询
                                                       │
                                                    用户切换路由
                                                       ↓
                                            [列表页组件 Unmount]
                                                       ↓
                                         clearInterval,状态清空,回调丢失

页面提交后台任务后,把 taskId 保存在当前组件的 React State 中,并启动一个 setInterval,定时请求 /api/tasks/{taskId}/status。一旦检测到 SUCCESS,就调用 toast.success('补全完成')。

用户切换页面后,列表组件被卸载,useEffect 的清理函数执行了 clearInterval。轮询停止了,任务 ID 丢失了,回调函数也随之被垃圾回收。

后端 Worker 仍然在正常运行,并把任务标记为完成。但此时前端已经没有任何组件在等待这个结果。

通俗比喻:餐厅点餐与叫号器

为什么页面一切换,通知就丢了? 假设你在餐厅点了一份需要慢炖半小时的牛肉:

  • 错误的做法:你在 1 号包厢点完餐,安排一位朋友守在包厢门口盯着厨房,并嘱咐他“做好了就去大堂喊我”。半小时里你觉得无聊,带着朋友换到了 2 号包厢。这时 1 号包厢已经没有人,负责盯厨房的朋友也跟过去了。牛肉做好之后,服务员回到 1 号包厢,发现没有人,通知就没有送到;
  • 正确的做法:你在前台点餐,前台给你一个随身叫号器(全局任务管理器)。无论你在哪个包厢或大堂,叫号器都会在你身上响,通知不会丢失。
为什么不直接使用 Service Worker 或 WebSocket?

很多团队遇到跨页面通知的问题时,第一反应是建立长连接(WebSocket 或 SSE),或者把轮询交给 Service Worker。 但对于 Electron 这类桌面工具,或者轻量的本地服务:

  1. 短任务的本地轮询开销很小。增加全双工长连接,会让断线重连、心跳保活和连接泄漏的处理变得复杂;
  2. 真正的问题不在于“用长连接还是轮询”,而在于前端内存中究竟由谁负责管理这个异步任务的生命周期。即使使用 WebSocket 推送,如果监听器绑定在已经卸载的页面上,消息仍然会被丢弃。

每个页面各自处理

如果只有这一个页面有问题,还比较容易理解。我继续检查了整个站点的长任务,发现这类问题相当普遍:

  • 导师主页补全:逻辑写在导师列表组件中;
  • 邮件发送重试:逻辑写在发件箱的抽屉组件中;
  • 模板批量生成:逻辑写在模板编辑器中,切换出编辑区后就无法再收到结果;
  • 学术画像抓取:直接使用 Promise.then 等待结果,用户切换页面后,连错误都无法捕获。

每个页面都在自己的范围内定义轮询、错误处理和重试逻辑。它们共同的问题是:

  1. 任务的生命周期属于整个应用:无论用户在哪个页面,任务都会继续运行;
  2. 但监听者的生命周期绑定在局部路由上;
  3. 页面的存活时间通常比任务短。

只要用户切换得足够快,结果就会变得不确定:只有一直停留在原页面的用户能收到通知,而同时进行多个任务、频繁切换页面的用户反而收不到任何反馈。

建立全局的任务登记处

修复这个问题时,最直接的想法是“让页面不要卸载”,比如使用 display: none 强制保留组件。但这会带来内存泄漏和状态混乱的问题,不是合适的做法。

更合理的方式是把任务的监听职责从具体页面中分离出来,在应用的壳层(App Shell)建立一个统一的后台任务中心(Task Registry)。

[具体页面]                    [全局任务中心 (Global Task Store)]
   │                                      │
   ├─ 1. 发起任务 (拿到 task_id) ─────────>│
   │                                      ├─ 2. 登记 task_id 和任务上下文
   │                                      ├─ 3. 集中轮询,监听任务状态
   ▼ (页面切换或卸载)                       │
                                          ├─ 4. 后端返回 SUCCESS 或 FAIL
                                          ▼
                                      [调用全局 Toast] ("全量补全已完成")

页面发起异步任务后只需要做两件事:

  1. 发起 API 请求,拿到 taskId;
  2. 向全局任务中心登记:registerBackgroundTask({ id: taskId, type: 'enrichment', label: '导师主页补全' })。

完成这两步之后,页面就不再需要负责任务的后续状态。用户无论如何切换页面,全局任务中心都会在应用的根上下文中持续轮询。任务结束时,由它统一触发 Toast,并按 taskId 进行去重。

职责划分:全局负责通知,页面负责数据

全局任务中心不应该保存业务的大对象。

职责页面组件(局部)全局任务中心(全局)
发起任务✓✗
页面处于激活状态时即时刷新列表✓✗
页面卸载后接管任务的最终状态✗✓
弹出成功或失败的 Toast✗✓
跨路由的状态去重✗✓

全局中心只保存显示通知所需的最少元数据,例如 ID、标题和简要的结果文案。如果页面需要根据任务结果更新复杂的表格或表单,可以在用户切回页面时,通过 SWR 或 React Query 的 refetchOnFocus 自动刷新。不应该把所有业务状态都放进全局 Store。

把“组件卸载”写进测试

以前编写前端集成测试时,常见的步骤是:

  1. 渲染页面组件;
  2. 点击按钮触发异步请求;
  3. 使用 await waitFor 等待 Toast 节点出现在 DOM 中;
  4. 测试通过。

这种测试的覆盖率看起来很高,但它假设用户会一直停留在当前组件中等待结果,因此没有覆盖真实的故障场景。

新的测试需要主动模拟用户切换页面的情况:

test("页面卸载后,后台任务完成仍必须触发全局 Toast", async () => {
  // 1. 挂载页面并触发后台任务
  const { unmount } = render(<EnrichmentPage />);
  fireEvent.click(screen.getByText("开始全量补全"));
  expect(mockTaskRegistry.has("task-123")).toBe(true);
 
  // 2. 关键步骤:立即卸载页面,模拟用户切换路由
  unmount();
 
  // 3. 模拟 Worker 在此之后才完成任务
  mockServer.emitTaskStatus("task-123", "SUCCESS");
 
  // 4. 验证挂在外部容器中的 Toast 仍然正常显示
  await waitFor(() => {
    expect(screen.getByText("导师主页补全已完成")).toBeInTheDocument();
  });
});
反向思考:测试生命周期,而不只测试正常路径

如果缺陷的根源是“A 的存活时间比 B 短”,那么在测试中就应该主动销毁 A,再检查 B 能否正常完成后续工作。

对象的生命周期决定归属

前端架构中的很多问题,本质上都来自对象生命周期错位:

  • 局部组件创建了全局副作用(如定时器或事件监听器),卸载时没有清理,导致内存泄漏;
  • 局部组件承担了全局生命周期的职责(如后台轮询和长流程任务),卸载时任务也随之中断。

页面可以发起一个比自己存活时间更长的任务,但不能指望存活时间较短的页面去处理长期任务的结果。与其把通知放在页面里,不如把负责通知的组件也放在页面之外。

评论