React.Activity 的隐性成本
- 原文链接:www.peterp.me/articles/hi…
- 原文作者:Peter Piekarczyk
2026 年 5 月 7 日
React.Activity 乍一听很美好。
你可以让 UI 继续留在树上,让状态继续留着,把某个屏幕或子树藏起来,又不必付出「整棵拆掉、之后再全部重建」的代价。
这一点确实成立,也是大多数人第一次理解 Activity 时的直觉。
但人们常常忽略的是:Effects 会发生什么。
当某个 Activity 边界变为 hidden(隐藏)时,React 会保留子树的状态,同时也会清理该子树下 Effects 与活跃订阅。当边界再次变为 visible(可见)时,React 会恢复旧状态,并重新创建那些 Effects。
这和「保持挂载,只是不可见」是非常不同的取舍。
它更像是下面这三件事的组合,而不是单纯的「隐藏 DOM」:
- 保留状态
- 停掉副作用
- 之后再全部重新启动
如果你的应用几乎不用 useEffect,这种取舍也许还能接受。
但如果你的 React Native 屏幕里塞满了嵌套的设计系统组件、订阅、测量、观察者、查询消费者、atom 消费者,以及大量 useEffect 加 setState 的组合,这笔账就会贵得多。
Activity 实际在做什么
React 官方文档在这里写得很清楚,至少包含下面四点:
- 隐藏的内容会保留其状态
- 隐藏的内容在 props 变化时仍会重新渲染,只是优先级更低
- 隐藏内容的 Effects 会被销毁
- 当边界再次可见时,这些 Effects 会被重新创建
所以,是的,Activity 有助于保留短暂的 UI 状态。某个标签页回来时,可以不丢表单输入、展开区块,或与滚动相关的本地状态。
但它不会保留已挂载的 effect 生命周期。
这个细节比听起来重要得多,因为它决定了你对「回来有多快」的预期。
为什么在 React Native 里会变得诡异
React Native 应用往往会自然累积大量「长得像 effect」的工作。
并不是因为大家都在写烂代码,而是因为很多移动端行为本来就像这样:
- 订阅应用状态
- 订阅键盘事件
- 响应布局变化
- 响应屏幕尺寸
- 接入分析埋点
- 通过设计系统 hook 挂载观察者
- 在 mount 之后派生一些本地状态
这都是正常的 React Native 代码。
问题在于:当一个密集子树从 Activity 边界里回来时会发生什么。
如果某个屏幕由大量嵌套的主题组件构成,而每一层都会挂载若干 Effects,那么再次显示该屏幕时,可能触发一连串:
- 订阅建立
- 观察者建立
- effect 清理的「反向回放」
- 后续的
setState - 额外渲染
当你把 Activity 解释成「状态被保留了」时,这笔成本不会出现。
它会在用户点回该屏幕、却感觉比预期更卡时出现。
隐蔽的放大器:useEffect 加 setState
事情在这里开始真正叠加,因为 Effect 与额外渲染会成倍放大。
React 自己的 lint 规则会指出:在 Effect 里同步调用 setState 会迫使多走一轮渲染。先 React 渲染,然后 Effect 运行,然后 setState 触发,然后 React 再渲染一次。
一个组件这么做通常问题不大,多一轮渲染往往可以接受。
一整个复杂屏幕同时这么做,就是另一回事了。
下面这种写法看起来很无害,但在列表或表单里一多就很显眼:
function Row({ item }) {
const [formattedPrice, setFormattedPrice] = useState("")
useEffect(() => {
setFormattedPrice(formatPrice(item.price))
}, [item.price])
return <Text>{formattedPrice}</Text>
}
这类工作通常应该放在 render 里,避免无意义的 Effect 往返:
function Row({ item }) {
return <Text>{formatPrice(item.price)}</Text>
}
现在想象:在藏在 Activity 后面的密集屏幕上,第一种写法重复了很多次。
当屏幕再次可见时,你不只是「把它恢复回来」。你会重新创建那些 Effects,重新触发那套状态工作,并可能为整份列表再付一轮渲染代价。
生态里到处都是这类东西
写这篇文章之前,我想先核实一下,因为很容易夸大其词。
大方向成立:由 effect 支撑的生命周期工作无处不在,但细节很重要。
TanStack Query
TanStack Query 的 React 层确实用了 useEffect。例如,useBaseQuery 会在 Effect 里应用 observer 选项,HydrationBoundary 也用了 useEffect。
这并不意味着「React Query 和 Activity 不搭」。
它的意思是:查询 observer 和其他一切一样,都参与 React 的生命周期语义。隐藏 Activity 边界时,可能拆掉该子树里由 effect 支撑的工作;再次显示时,又可能把它们重建。
在一个数据密集的大屏幕上,这很重要,因为查询订阅重建并不便宜。
Jotai
Jotai 的 useAtomValue 用 useEffect 向 store 订阅和退订。
再次说明,这不是在批评 Jotai。它只是生命周期成本的真实组成部分。如果隐藏的子树里有很多 atom 消费者,再次显示时可能意味着大量重新订阅的工作。
Zustand
这一点需要说清楚,避免把不同状态库混为一谈。
Zustand 的核心 React 绑定基于 useSyncExternalStore,而不是 useEffect,所以我不建议把 Zustand 核心和 Jotai 归到同一桶里。
话虽如此,许多使用 Zustand 的应用仍会用自定义 hook、订阅,或由 effect 驱动的组件逻辑来包装 store 访问。因此 Activity 在 Zustand 应用里仍可能很贵,只是成本未必来自 Zustand 本身。
密集屏幕问题
Activity 可能悄悄把工作量挪到最糟糕的地方:交互路径上。
说清楚一点:我觉得可怕的情况通常不是「150 个列表项同时都挂着」。在一个做得好的 React Native 应用里,那份列表多半会用 FlatList、FlashList 或 Legend List 做虚拟化。
更现实的问题是:一个嵌了大量设计系统组件的屏幕。比如设置页、结账流程、资料编辑页,或由主题化 wrapper、栈、文本组件、分隔线、图标、表单字段、焦点处理与响应式 helper 拼出来的 bottom sheet。
每个可见区块都可能混合着下面几类工作,而且往往同时存在:
- 一个 query 消费者
- 一个 atom 或 store 消费者
- 测量逻辑
- 可见性或分析 hook
- 一个派生状态的本地 effect
- 会建立观察者或订阅的设计系统 hook
这些单独看未必有错,难的是它们会在「再次可见」时一起复活。
但当你用 Activity 隐藏整屏时,你是在为「再次可见时」签下一笔大型协同再激活事件。
这意味着你会同时看到下面几类成本叠在一起:
- 本地状态被保留
- 每个子 Effect 被重新创建
- 订阅回来了
- 观察者接线回来了
- 由 effect 驱动的
setState可能再次运行 - 屏幕很大一块可能渲染不止一次
这就是隐性成本,也是本文标题里「hidden cost」真正指向的地方。
危险之处不在于 Activity 按定义就很慢。
危险在于:它可能看起来像免费的性能优化,直到你把它用在一个本来就过度依赖 Effects 的屏幕上。
更贴近现实的 React Native 示例
想象某个标签页屏幕或 bottom sheet 藏在 Activity 后面:
<Activity mode={open ? "visible" : "hidden"}>
<EditProfileSheet />
</Activity>
里面又有大量来自设计系统的嵌套 UI 原语。如果你在用 Tamagui,或任何类似的组件系统,那通常意味着在你触及产品逻辑之前,已经有一棵很深的主题化 wrapper 与 hook 树。
某个叶子组件可能大致长这样,而且往往一页里会有很多个:
function ProfileField({ user }) {
const { width } = useWindowDimensions()
const [layout, setLayout] = useState(null)
const query = useQuery({
queryKey: ["presence", user.id],
queryFn: () => fetchPresence(user.id),
})
useEffect(() => {
setLayout(computeLayout(width))
}, [width])
useEffect(() => {
const sub = AppState.addEventListener("change", () => {
trackProfileField(user.id)
})
return () => sub.remove()
}, [user.id])
return <Field user={user} layout={layout} presence={query.data} />
}
这段代码并不离谱。我在很多真实应用里都见过类似版本。
现在把它乘以一个嵌套 wrapper 很多、组件库里还藏着大量 hook 的密集屏幕,再用 Activity 把整棵子树藏起来。
当屏幕回来时,状态也许还在,但 effect 工作会一并卷土重来。
什么时候 Activity 是笔好买卖
我仍然认为 Activity 有用,只是需要选对场景。
它适合下面这些情况,而不是拿来包一整屏复杂业务:
- 子树有值得保留的有意义本地 UI 状态
- 子树不算巨大
- 里面的 Effects 重建成本低廉
- 用户很可能很快回来
- 整棵 remount 会更糟
表单、较小的详情面板,以及部分标签内容,都是合理候选。
什么时候要放慢脚步
如果子树具备以下特征,我会放慢脚步、先做审计:
- 大量嵌套的设计系统组件
- 整棵组件树各处都挂着 effect
- 叶子组件里重复的订阅
- 会挂载观察者的设计系统 hook
- 大量由 effect 驱动的本地状态
- mount 时昂贵的初始化工作
这正是 Activity 可能保住你想要的部分,同时又重建你本想避开的大量工作的那种组合。
实用结论
在把一整块 React Native 屏幕包进 Activity 之前,先审计那棵子树。
审计时建议重点留意下面这些模式:
- 一运行就立刻调用
setState的useEffect - 写在叶子组件里的仅 mount 逻辑
- 可以上移到树更高处的重复订阅
- 重复的测量或观察者初始化
- 藏在设计系统组件里的昂贵 hook
如果那块屏幕本来就 effect 很重,Activity 未必是在减少工作。它可能只是把成本从初次 mount,挪到用户回来的那一刻。
而那通常是更糟的支付时机,因为用户正等着界面立刻响应。
Activity 保留状态。这是每个人都会注意到的特性。
它真正考验的是你的 effect 卫生状况。
如果显示一棵隐藏子树感觉像一场 remount 风暴,那是因为在某一重要意义上,它确实如此。