用户切换页面后,后台长任务完成时如何通知
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 这类桌面工具,或者轻量的本地服务:
- 短任务的本地轮询开销很小。增加全双工长连接,会让断线重连、心跳保活和连接泄漏的处理变得复杂;
- 真正的问题不在于“用长连接还是轮询”,而在于前端内存中究竟由谁负责管理这个异步任务的生命周期。即使使用 WebSocket 推送,如果监听器绑定在已经卸载的页面上,消息仍然会被丢弃。
每个页面各自处理
如果只有这一个页面有问题,还比较容易理解。我继续检查了整个站点的长任务,发现这类问题相当普遍:
- 导师主页补全:逻辑写在导师列表组件中;
- 邮件发送重试:逻辑写在发件箱的抽屉组件中;
- 模板批量生成:逻辑写在模板编辑器中,切换出编辑区后就无法再收到结果;
- 学术画像抓取:直接使用
Promise.then等待结果,用户切换页面后,连错误都无法捕获。
每个页面都在自己的范围内定义轮询、错误处理和重试逻辑。它们共同的问题是:
- 任务的生命周期属于整个应用:无论用户在哪个页面,任务都会继续运行;
- 但监听者的生命周期绑定在局部路由上;
- 页面的存活时间通常比任务短。
只要用户切换得足够快,结果就会变得不确定:只有一直停留在原页面的用户能收到通知,而同时进行多个任务、频繁切换页面的用户反而收不到任何反馈。
建立全局的任务登记处
修复这个问题时,最直接的想法是“让页面不要卸载”,比如使用 display: none 强制保留组件。但这会带来内存泄漏和状态混乱的问题,不是合适的做法。
更合理的方式是把任务的监听职责从具体页面中分离出来,在应用的壳层(App Shell)建立一个统一的后台任务中心(Task Registry)。
[具体页面] [全局任务中心 (Global Task Store)]
│ │
├─ 1. 发起任务 (拿到 task_id) ─────────>│
│ ├─ 2. 登记 task_id 和任务上下文
│ ├─ 3. 集中轮询,监听任务状态
▼ (页面切换或卸载) │
├─ 4. 后端返回 SUCCESS 或 FAIL
▼
[调用全局 Toast] ("全量补全已完成")页面发起异步任务后只需要做两件事:
- 发起 API 请求,拿到
taskId; - 向全局任务中心登记:
registerBackgroundTask({ id: taskId, type: 'enrichment', label: '导师主页补全' })。
完成这两步之后,页面就不再需要负责任务的后续状态。用户无论如何切换页面,全局任务中心都会在应用的根上下文中持续轮询。任务结束时,由它统一触发 Toast,并按 taskId 进行去重。
职责划分:全局负责通知,页面负责数据全局任务中心不应该保存业务的大对象。
职责 页面组件(局部) 全局任务中心(全局) 发起任务 ✓ ✗ 页面处于激活状态时即时刷新列表 ✓ ✗ 页面卸载后接管任务的最终状态 ✗ ✓ 弹出成功或失败的 Toast ✗ ✓ 跨路由的状态去重 ✗ ✓ 全局中心只保存显示通知所需的最少元数据,例如 ID、标题和简要的结果文案。如果页面需要根据任务结果更新复杂的表格或表单,可以在用户切回页面时,通过 SWR 或 React Query 的
refetchOnFocus自动刷新。不应该把所有业务状态都放进全局 Store。
把“组件卸载”写进测试
以前编写前端集成测试时,常见的步骤是:
- 渲染页面组件;
- 点击按钮触发异步请求;
- 使用
await waitFor等待 Toast 节点出现在 DOM 中; - 测试通过。
这种测试的覆盖率看起来很高,但它假设用户会一直停留在当前组件中等待结果,因此没有覆盖真实的故障场景。
新的测试需要主动模拟用户切换页面的情况:
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 能否正常完成后续工作。
对象的生命周期决定归属
前端架构中的很多问题,本质上都来自对象生命周期错位:
- 局部组件创建了全局副作用(如定时器或事件监听器),卸载时没有清理,导致内存泄漏;
- 局部组件承担了全局生命周期的职责(如后台轮询和长流程任务),卸载时任务也随之中断。
页面可以发起一个比自己存活时间更长的任务,但不能指望存活时间较短的页面去处理长期任务的结果。与其把通知放在页面里,不如把负责通知的组件也放在页面之外。