条件空状态 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 只能在组件保持挂载的前提下保留状态;条件渲染(不渲染)= 卸载,和隐藏完全是两回事。
只要容器对「空视图」提前 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>
)关键三点:
- 列表无条件渲染——用
display:none隐藏,不用条件渲染卸载 - 稳定 key——用业务 ID 而非 index,避免 React 因 key 变化触发重建
- 空状态是兄弟节点——不替换列表,而是在列表上方平铺
把「空状态」从「替换整棵列表」改为「列表的兄弟/overlay」,列表容器始终存在于 React 树中,所有子组件的 ref、内部状态、imperative 资源全部完好无损。
验证方式
在子组件的 cleanup 里加一行日志:
useEffect(() => {
return () => console.log('[unmount]', id)
}, [])切到空视图再切回来:日志不应该出现。只有真正删除条目或关闭应用时,才允许触发 unmount。
Suspense fallback、route-level guard、error boundary——任何「替换整棵子树」的机制都会触发同样的卸载。如果子树里有需要保活的组件,一律改为「隐藏而非替换」。
一句话外卖
display:none和「不渲染」在 React 里是两个完全不同的生命周期结果——前者保命,后者杀进程。