返回博客

条件空状态 early-return 静默卸载持久子组件

在一个多标签终端面板里,我把所有 xterm 实例同时挂载、用 display:none 隐藏非当前标签——这是保留终端滚动缓冲和 WebGL 渲染器最常用的手法。切标签时一切正常,直到我切到一个「当前视图没有任何条目」的项目时,再切回来,所有终端的历史输出全部消失了,连其他项目的终端也不例外。

现象

切换到某个"空项目"(该项目下暂无条目),再切回有内容的标签:

  • xterm 滚动缓冲清零,历史输出不见了
  • WebGL renderer 重新初始化,之前绘制的内容丢失
  • 所有标签的终端都受影响,不只是当前切到的那个

根因

问题藏在容器组件的早返回逻辑里:

const visible = items.filter(inCurrentView)
 
// 只要当前视图为空就整体替换
if (visible.length === 0) return <Empty />
 
return (
  <div className="grid">
    {allItems.map(renderChild)}  {/* 包括所有标签的终端 */}
  </div>
)

visible.length === 0 时,React reconciler 看到的整棵树变成了 <Empty />——它会把原来的 <div className="grid"> 及其下所有子节点一起卸载,xterm 的 dispose()、WebGL context 的 loseContext() 全部触发。回来时重新挂载,是崭新的实例,历史状态清零。

display:none 只能在组件保持挂载的前提下保留状态;条件渲染(不渲染)= 卸载,和隐藏完全是两回事。

坑:early-return 替换了整棵持久子树

只要容器对「空视图」提前 return,React 就会卸载 return 之后本该渲染的那整棵子树——包括你用 display:none 精心保活的所有子组件。cleanup 函数会无条件触发,不管子组件是否在当前视图可见。

正解

不要用 early-return 控制「是否渲染列表」,改用 display 控制「是否展示列表」,空状态作为兄弟节点或 overlay 并存:

const visible = items.filter(inCurrentView)
const hasVisible = visible.length > 0
 
return (
  <div className="container">
    {/* 空状态和工具栏互斥,但不影响下面的 grid */}
    {hasVisible ? <Toolbar /> : <Empty />}
 
    {/* grid 永远渲染,子组件保持挂载;仅用 display 控制可见性 */}
    <div
      className="grid"
      style={hasVisible ? undefined : { display: 'none' }}
    >
      {allItems.map((item) => (
        <TerminalPanel
          key={item.id}                          {/* 稳定 key,永不重建 */}
          style={isInCurrentView(item) ? undefined : { display: 'none' }}
        />
      ))}
    </div>
  </div>
)

关键三点:

  1. 列表无条件渲染——用 display:none 隐藏,不用条件渲染卸载
  2. 稳定 key——用业务 ID 而非 index,避免 React 因 key 变化触发重建
  3. 空状态是兄弟节点——不替换列表,而是在列表上方平铺
正解:display:none 保活,空状态做兄弟

把「空状态」从「替换整棵列表」改为「列表的兄弟/overlay」,列表容器始终存在于 React 树中,所有子组件的 ref、内部状态、imperative 资源全部完好无损。

验证方式

在子组件的 cleanup 里加一行日志:

useEffect(() => {
  return () => console.log('[unmount]', id)
}, [])

切到空视图再切回来:日志不应该出现。只有真正删除条目或关闭应用时,才允许触发 unmount。

同一陷阱的其他形态

Suspense fallback、route-level guard、error boundary——任何「替换整棵子树」的机制都会触发同样的卸载。如果子树里有需要保活的组件,一律改为「隐藏而非替换」。

一句话外卖

display:none 和「不渲染」在 React 里是两个完全不同的生命周期结果——前者保命,后者杀进程。