多页签后台有一个反复出现的需求:用户从编辑页切到列表页,再切回来时,表单输入、滚动位置和局部筛选不能丢。最直接的写法是条件渲染:
jsx
{activeTab === "editor" && <Editor />}
问题也很直接。条件变成 false 后,Editor 被卸载,组件内部状态随之消失。把所有状态提升到全局可以解决一部分问题,却会让临时 UI 状态进入业务 store。用 CSS 把节点隐藏则会保留状态,但副作用、订阅和更新仍可能继续运行。
React 19.2 提供了 <Activity />。它在当前版本有 visible 和 hidden 两种模式:
jsx
import { Activity } from "react";
function Workspace({ activeTab }) {
return (
<>
<Activity mode={activeTab === "editor" ? "visible" : "hidden"}>
<Editor />
</Activity>
<Activity mode={activeTab === "list" ? "visible" : "hidden"}>
<ResultList />
</Activity>
</>
);
}
根据 React 官方说明,hidden 会隐藏子树、卸载 Effect,并把该子树的更新推迟到 React 没有更紧急工作的时候。切回 visible 后,内容重新显示,Effect 再次挂载,更新恢复正常优先级。它和"组件继续在后台照常运行,只是看不见"并不是一回事。
这个区别决定了 Activity 适合什么。编辑器、复杂筛选面板、可返回的路由页面,通常希望保留 React 状态和已经构建的 UI,同时不让隐藏页面的订阅长期占用资源。Activity 正好位于卸载和 CSS 隐藏之间。它也可以提前渲染用户很可能访问的下一页,让数据、样式和图片在后台准备,但不抢占眼前交互。
第一类迁移风险来自 Effect。假设组件通过 Effect 订阅 WebSocket:
jsx
useEffect(() => {
const unsubscribe = socket.subscribe(roomId, onMessage);
return unsubscribe;
}, [roomId]);
进入 hidden 后,这个订阅会清理。再次显示时会重新订阅。如果业务要求页面隐藏期间仍持续接收并缓存消息,就不能把数据生命周期完全交给这个页面 Effect。更合理的做法是把连接和消息缓存放到页面之外的服务层,页面 Effect 只负责展示层订阅。Activity 会迫使我们分清"界面不可见时应该暂停的工作"和"业务上必须持续的工作"。
第二类风险是误把 Activity 当成无限缓存。保留十几个大型页面的状态和节点仍会占用内存。React 可以降低隐藏更新的优先级,却不会替应用决定何时彻底释放页面。实际项目最好给页签设置上限,区分临时切换和长期离开。最近使用的少量页面放进 Activity,超过数量后真正卸载,往往比把所有历史页面永久隐藏更稳。
第三类风险是没有测量就预渲染。提前渲染下一页能缩短切换等待,但也会产生请求、解析响应、创建对象和图片解码。如果"下一页"预测不准,后台工作只是在消耗流量。可以先记录页面转移概率,只对高概率路径预渲染,并在慢网、低性能设备和节流模式下关闭。React 19.2 还提供 Performance Tracks,可以在 Chrome DevTools 的性能记录中观察调度优先级、渲染和 Effect 工作,适合判断隐藏子树是否挤占了可见交互。
迁移时不建议一口气改路由层。先挑一个状态丢失最明显的双页签场景,记录三个指标:切换后的可交互时间、隐藏期间的网络与 CPU 活动、往返切换后的内存变化。然后检查所有 Effect 的清理与重建是否符合业务预期。若组件依赖"只在首次挂载执行一次"的副作用,还要把这类隐含假设改掉,因为可见性切换会让 Effect 再次挂载。
Activity 也不能替代服务端状态缓存。查询结果、乐观更新和失效策略仍应由数据层处理。它保留的是 React 子树及其界面状态,并调整隐藏工作的生命周期和优先级。把这条边界守住,组件不会因为"想保留一个输入框"而背上整套后台任务。
我更愿意把 Activity 看成一种页面生命周期工具。它解决的不是"怎么把 DOM 藏起来",而是用户暂时离开一个界面时,哪些状态应保留,哪些副作用应暂停,哪些数据应移到更长寿命的层。先回答这三个问题,再替换条件渲染,代码会清楚很多。