前端知库

<Activity> 怎样在隐藏时保留状态?和条件渲染有什么区别?

React 19.2 起可用的 <Activity mode="hidden"> 会用 display: none 隐藏子树,同时销毁其中的 Effect,但保留 React 状态和 DOM;切回 visible 时状态还在,Effect 重新创建。条件渲染 {show && <A />} 会卸载组件,状态和 DOM 都没了。隐藏时 DOM 仍在,video 这类会继续播放,需要在清理函数里暂停。

参考答案

简答

做标签页时,用户在“联系”页的输入框里打了一半字,切到“首页”再切回来,内容没了,因为条件渲染把组件卸载了。<Activity> 解决的就是这个问题:<Activity mode="hidden"> 把子树藏起来,但状态和 DOM 都保留,切回 visible 时原样出现。

它隐藏时做三件事:用 CSS display: none 藏起子元素;销毁里面的 Effect,订阅之类会被清理;保留 React 状态和 DOM,包括 <textarea> 里输入的文字这类非受控状态。隐藏的子组件收到新 props 仍会重新渲染,只是优先级比可见内容低。

除了保留状态,它还能预渲染。一开始就是 hidden 的边界,React 会低优先级地先把子组件渲染出来,用 use 读取的数据也会提前请求,用户点开时出现得更快。

要注意,隐藏的 DOM 还在页面上,所以 <video>、<audio>、<iframe> 这类会继续播放或运行,要在 useLayoutEffect 的清理函数里暂停。另外它是 React 19.2 正式加入的,之前只在实验版里有。

和条件渲染比,差别在哪

React 的官方文档拿侧边栏举例,两种写法只差一行:

JSX
// 条件渲染:隐藏时卸载,状态和 DOM 都被销毁
{isShowingSidebar && <Sidebar />}

// Activity:隐藏时保留状态和 DOM
<Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
  <Sidebar />
</Activity>

用条件渲染时,isShowingSidebar 变成 false,<Sidebar /> 从树里消失,React 会丢掉它的全部状态。为什么会丢?因为 React 的状态挂在组件在树中的位置上,位置上的组件没了,状态也就没了,见《Hooks 在 React 内部是怎样保存状态的?为什么靠调用顺序区分?》。所以侧边栏里展开的分组,再显示时又收起来了。

换成 <Activity>,组件一直在树里,只是被标记为隐藏,状态就保住了。Sidebar 本身一行都不用改。

标签页是最典型的场景,每个标签各包一层:

JSX
<Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
  <Home />
</Activity>
<Activity mode={activeTab === 'contact' ? 'visible' : 'hidden'}>
  <Contact />
</Activity>

隐藏的时候,Effect 怎么处理

文档建议把隐藏的 Activity 理解成“被卸载了,只是 React 替你存着状态”。所以进入 hidden 时,里面的 Effect 会执行清理函数;回到 visible 时,Effect 重新执行。这样隐藏的标签页不会还在后台连着 WebSocket、监听窗口事件。

这就要求 Effect 的清理函数写完整。如果某个副作用只在 Effect 挂载时做,没有对应的清理,隐藏后它就会留在那里。开发环境的 <StrictMode> 会额外多做几次 Activity 的卸载和挂载,帮你暴露这类漏写的清理,见《React 严格模式为什么会让组件渲染两次、Effect 执行两次?》。

隐藏的视频为什么还在播放

display: none 只是不显示,<video> 元素还在 DOM 里,所以声音照样在放。文档给的改法是在清理函数里暂停:

JSX
import { useRef, useLayoutEffect } from 'react';

export default function Video() {
  const ref = useRef();

  useLayoutEffect(() => {
    const videoRef = ref.current;
    return () => {
      videoRef.pause();
    };
  }, []);

  return <video ref={ref} controls playsInline src="..." />;
}

为什么用 useLayoutEffect 而不是 useEffect?因为暂停这件事要和“界面被藏起来”同时发生。文档说明,普通 useEffect 的清理可能被重新挂起的 Suspense 边界或 View Transition 推迟。两种 Effect 的时机区别见《useEffect 和 useLayoutEffect 在什么时候执行?测量 DOM 为什么要用 useLayoutEffect?》。顺带的好处是,切回来时视频从原来的进度继续,也不用重新下载。

另一个小坑:如果子组件只返回一段文本,没有任何元素,隐藏时什么都不会输出,因为没有元素可以加 display: none。

怎样用它提前加载下一页

一开始就是 hidden 的 Activity,React 会在低优先级下先渲染里面的组件,但不挂载 Effect。配合 Suspense,就能在用户点开之前把代码和数据准备好:

JSX
<Suspense fallback={<h1>Loading...</h1>}>
  <Activity mode={activeTab === 'posts' ? 'visible' : 'hidden'}>
    <Posts />
  </Activity>
</Suspense>

这里有个限制。只有通过会触发 Suspense 的方式读取的数据才会提前请求,比如用 use 读取 Promise。在 Effect 里 fetch 的数据不会,因为隐藏时 Effect 根本不执行。use 的用法见《React use API 可以读取哪些值?》。

常见追问

Activity 和 Vue 的 KeepAlive 一样吗?

目的相近,都是切走时保留组件状态。但机制不同:Activity 在隐藏时会销毁 Effect、回来再重建,DOM 只是被 display: none 藏起来;KeepAlive 缓存组件实例,组件里的定时器不会自己停,要靠 onActivated、onDeactivated 钩子暂停和恢复,见《KeepAlive 与 Teleport 有哪些使用边界?》。

所有标签页都包 Activity,会不会很耗内存?

会占用更多内存,因为隐藏的组件状态和 DOM 都留着,而且收到新 props 时还会低优先级重新渲染。只给用户会频繁来回切换、丢状态体验差的部分用,不常访问的页面还是条件渲染更省。

服务端渲染时有什么用?

文档提到,Activity 和 Suspense 一样会把树分成可以独立水合的单元,React 可以先水合标签按钮这类部分。区别是 Activity 不显示 fallback,服务端输出的 HTML 不变。