【翻译】React.Activity 的隐性成本

7 阅读9分钟

React.Activity 的隐性成本

2026 年 5 月 7 日

React.Activity 乍一听很美好。

你可以让 UI 继续留在树上,让状态继续留着,把某个屏幕或子树藏起来,又不必付出「整棵拆掉、之后再全部重建」的代价。

这一点确实成立,也是大多数人第一次理解 Activity 时的直觉。

但人们常常忽略的是:Effects 会发生什么。

当某个 Activity 边界变为 hidden(隐藏)时,React 会保留子树的状态,同时也会清理该子树下 Effects 与活跃订阅。当边界再次变为 visible(可见)时,React 会恢复旧状态,并重新创建那些 Effects。

这和「保持挂载,只是不可见」是非常不同的取舍。

它更像是下面这三件事的组合,而不是单纯的「隐藏 DOM」:

  • 保留状态
  • 停掉副作用
  • 之后再全部重新启动

如果你的应用几乎不用 useEffect,这种取舍也许还能接受。

但如果你的 React Native 屏幕里塞满了嵌套的设计系统组件、订阅、测量、观察者、查询消费者、atom 消费者,以及大量 useEffectsetState 的组合,这笔账就会贵得多。

Activity 实际在做什么

React 官方文档在这里写得很清楚,至少包含下面四点:

  • 隐藏的内容会保留其状态
  • 隐藏的内容在 props 变化时仍会重新渲染,只是优先级更低
  • 隐藏内容的 Effects 会被销毁
  • 当边界再次可见时,这些 Effects 会被重新创建

所以,是的,Activity 有助于保留短暂的 UI 状态。某个标签页回来时,可以不丢表单输入、展开区块,或与滚动相关的本地状态。

但它不会保留已挂载的 effect 生命周期。

这个细节比听起来重要得多,因为它决定了你对「回来有多快」的预期。

为什么在 React Native 里会变得诡异

React Native 应用往往会自然累积大量「长得像 effect」的工作。

并不是因为大家都在写烂代码,而是因为很多移动端行为本来就像这样:

  • 订阅应用状态
  • 订阅键盘事件
  • 响应布局变化
  • 响应屏幕尺寸
  • 接入分析埋点
  • 通过设计系统 hook 挂载观察者
  • 在 mount 之后派生一些本地状态

这都是正常的 React Native 代码。

问题在于:当一个密集子树从 Activity 边界里回来时会发生什么。

如果某个屏幕由大量嵌套的主题组件构成,而每一层都会挂载若干 Effects,那么再次显示该屏幕时,可能触发一连串:

  • 订阅建立
  • 观察者建立
  • effect 清理的「反向回放」
  • 后续的 setState
  • 额外渲染

当你把 Activity 解释成「状态被保留了」时,这笔成本不会出现。

它会在用户点回该屏幕、却感觉比预期更卡时出现。

隐蔽的放大器:useEffectsetState

事情在这里开始真正叠加,因为 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 的 useAtomValueuseEffect 向 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 之前,先审计那棵子树。

审计时建议重点留意下面这些模式:

  • 一运行就立刻调用 setStateuseEffect
  • 写在叶子组件里的仅 mount 逻辑
  • 可以上移到树更高处的重复订阅
  • 重复的测量或观察者初始化
  • 藏在设计系统组件里的昂贵 hook

如果那块屏幕本来就 effect 很重,Activity 未必是在减少工作。它可能只是把成本从初次 mount,挪到用户回来的那一刻。

而那通常是更糟的支付时机,因为用户正等着界面立刻响应。

Activity 保留状态。这是每个人都会注意到的特性。

它真正考验的是你的 effect 卫生状况。

如果显示一棵隐藏子树感觉像一场 remount 风暴,那是因为在某一重要意义上,它确实如此。