返回博客

React Offscreen 重连时 ref.current 变游离节点,el.style 直接 undefined 崩溃

做 3D 卡片堆叠动效时,我在 useEffect 里对一组 ref 逐个写 el.style.zIndexel.style.transform。本地单元测试全绿,正式预览环境也没问题——直到用户切换标签页再切回来,控制台炸出一条:

TypeError: Cannot set properties of undefined (setting 'zIndex')

调用栈第一眼看不出所以然:reconnectPassiveEffectsrecursivelyTraverseReconnectPassiveEffectscommitHookPassiveMountEffects。这不是我的业务代码,是 React 内部的 Offscreen 重连路径。

现象

  • 组件首次挂载、正常操作全部正常,切换标签页或触发 <Activity> 隐藏再显示后必崩
  • 错误行就是 el.style.zIndex = String(...) 这种普通赋值。
  • 代码里明明有 if (!el) return 守卫,el 非空,但 el.style 却是 undefined
  • jsdom / Vitest 单测:永远不复现,因为 jsdom 根本不跑 Offscreen 重连路径,而且 jsdom 里游离节点也保留 .style

根因

React 的 Activity/Offscreen 机制在把子树从隐藏变为可见时,会对其中所有组件重新触发 passive effect(即 useEffect),这条路径叫 reconnectPassiveEffects

关键陷阱:重连时 ref.current 拿到的节点已被从 DOM 树中摘除,它是一个"游离节点"(detached node)。游离节点是真值(!!el === true),所以 if (!el) return 放行了它。但浏览器对游离节点的 .style 访问行为不统一——某些情况下 .style 会是 undefined,随后对它赋值就直接 TypeError。

坑:el 为真值不代表 el.style 可用

Offscreen 重连后,ref.current 可能指向一个游离节点。游离节点通过 if (!el) 检查,但 el.style 可能是 undefined。只守 null 是不够的,必须同时守 style

StrictMode 双重挂载(开发模式)会走相同路径,所以在 dev 环境切一次 tab 就能稳定复现——这反而是好事,让问题更早暴露。

正解

把所有 el.style.* 写入点的守卫从 !el 改为 !el?.style

- if (!el) return;
+ if (!el?.style) return;   // 游离节点:el 为真值但 .style 是 undefined,直接跳过
  el.style.zIndex = String(slot.zIndex);
  el.style.transform = `...`;

一个 effect 里往往有多处写 style 的位置(初始布局、动画循环、cleanup),每一处都要改,不能只改入口处。

正解:守 el?.style 而不只守 el

游离节点没有可操作的样式上下文,跳过它是完全正确的——正常挂载的节点永远拥有 .style,所以这个守卫对正常路径零影响。

延伸:同类写法都要守

同理,el.classListel.getBoundingClientRect() 在游离节点上同样危险。如果你需要精确判断"是否挂载到文档",可以用 el?.isConnected;但对 style 写入场景,el?.style 已经是最小必要守卫。

排查路径

  1. 看报错的属性名是不是 style prop(zIndextransformopacity……)。
  2. 搜调用栈,看有没有 reconnectPassiveEffects——有就是 Offscreen/Activity 重连路径。
  3. jsdom 跑不出来是正常的,切到真实浏览器(Playwright 打开 dev server,切一次 tab)验证。

一句话外卖

useEffect 里写 el.style,守卫要用 !el?.style,不能只守 !el——Offscreen 重连会给你一个游离节点,它是真值但没有活的样式上下文。