用户切到别的页面后,后台跑完的长任务该怎么通知
在 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('补全完成')。
可一旦用户切走,列表组件触发 Unmount,useEffect 的清理函数尽职尽责地执行了 clearInterval。随着组件卸载,轮询停了,任务 ID 丢了,回调函数也跟着进了垃圾回收。
后端 Worker 依然在服务器上勤勤恳恳地跑完并把状态标记为完成。但此时此刻,前端已经没有任何一个“观察者”在等待这个结果了。
通俗比喻:餐厅点餐与叫号器的故事为什么页面一切走通知就丢了? 想象你去餐厅点了一份需要慢炖半小时的牛肉:
- 错误的做法:你在 1 号包厢点完餐,派你的一位朋友站在包厢门口死死盯着厨房,并叮嘱他“做好了就去按餐厅大喇叭”。可半小时里你觉得闷,带着朋友换到了 2 号包厢——这时候 1 号包厢空了,刚才负责盯厨房的朋友也跟着走了。厨房做好了牛肉,出来一看原包厢空无一人,通知自然石沉大海;
- 正确的做法:你在前台点餐,前台直接发给你一个随身叫号器(全局任务管理器)。无论你在 1 号包厢、2 号包厢还是去大堂闲逛,叫号器一直在你口袋里响,通知绝不会丢。
为什么不能直接把任务轮询扔给 Service Worker 或 WebSocket?很多团队遇到跨页面通知,第一反应是上长连接(WebSocket / SSE)或者推送到 Service Worker。 但对于桌面端工具(如 Electron)或轻量本地服务:
- 本地轮询短任务的开销极其微小,增加全双工长连接会大幅增加断线重连、心跳保活和连接泄漏的处理成本;
- 核心矛盾不是“传输通道是长连接还是轮询”,而是前端内存里究竟由谁负责接管这个异步任务的生命周期。即便用 WebSocket 推送,如果消息监听器绑定在被卸载的页面上,推送过来的消息照样会被吞掉。
各个页面各自为政
如果只是这一个页面写了蠢代码倒也罢了。我顺藤摸瓜审计了整站的所有长任务,发现几乎成了“重灾区”:
- 导师主页补全:写在导师列表组件里;
- 邮件发送重试:写在发件箱抽屉组件里;
- 模板批量生成:写在模板编辑器组件里,切出编辑区直接失联;
- 学术画像抓取:甚至直接用
Promise.then裸等,切走连错误都抓不到。
大家各自在自己的小天地里定义轮询、定义错误处理、定义重试逻辑。它们的共同通病是:
- 任务生命周期属于全局应用(用户无论在哪个页面,任务都在跑);
- 观察者的生命周期却绑定在短暂的局部路由上;
- 页面寿命往往比任务更短。
只要用户手快切走,系统就陷入“薛定谔的通知”:只有死守原地的用户才能得到回音,勤快多任务并行的用户反而得不到任何反馈。
建立“全局任务登记处”
修这个 bug 的直觉反应千万不能是“让页面别卸载”(比如用 display: none 强行 Keep-Alive,那会导致可怕的内存泄漏和状态混乱)。
正确做法是把“任务观察”的权责从具体路由中剥离,在应用壳层(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 严格去重。
职责边界:全局管通知,页面管数据全局中心千万不要越界去存业务大对象!
职责维度 页面组件(局部) 全局任务中心(Global) 发起任务 ✓ ✗ 列表即时刷新(如果页面仍在激活状态) ✓ ✗ 页面卸载后接管任务终态 ✗ ✓ 弹出成功 / 失败 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();
});
});逆向思维:测生命周期,而不只测Happy Path如果一个缺陷的根因是“A 的存活时间比 B 短”,那么在测试代码里,就必须主动摧毁 A,再去检验 B 是否能体面退场或履职。
寿命决定归属
前端架构里很多离奇的灵异现象,本质上都是对象生命周期错位的产物:
- 局部组件创建了全局副作用(定时器、事件监听器),卸载时没清理干净,导致内存泄漏;
- 局部组件认领了全局生命周期的职责(后台轮询、长流程任务),卸载时一并被带走,导致逻辑夭折。
页面可以发起一个比自己寿命更长的任务,但绝不能指望“短命”的自己去给“长寿”的任务收尸报信。把扬声器移到门外还不够,必须把守在门外报信的人,也一并留在走廊里。