主题切换后数字归零:React 的 reconciler 悄悄覆盖了你的命令式写入
从 Magic UI 吸收了一个数字滚动组件(NumberTicker),用 useMotionValue 驱动动画、useMotionValueEvent 把格式化结果写到 ref.current.textContent。首屏动画丝滑,单测全绿,部署上线——完美。
直到 QA 切换深色模式截了一张图,发现页面上所有滚动数字都变回了 0,且不会再动。
现象
- 首屏加载:数字从
0滚动到目标值(如12,345),正常 - 点击主题切换按钮(React 状态变更,非页面刷新):所有数字瞬间跳回
0,且不再动画 - 刷新页面:数字恢复正常,首屏动画重新播放一次
- DOM 检查、unit test、
console.log全部正常——只有截图能抓到这个状态
根因
组件同时做了两件事,埋下冲突:
// 1. JSX child:为 SSR / 首屏给定初始值
return <span ref={ref}>{format(startValue)}</span>;
// 2. 动画订阅:命令式覆盖 textContent
useMotionValueEvent(mv, "change", (latest) => {
if (ref.current) ref.current.textContent = format(latest);
});动画播完后,DOM 节点里的文字是命令式写入的 12,345。但 React 并不知道——它只认 JSX 树,里面记的是 {format(startValue)},即 0。
主题切换触发父组件 re-render,React reconciler 对比 fiber 树,发现这个文本节点「应该是 0」,于是 把 12,345 覆盖回 0。而动画是用 useInView({ once: true }) 触发的,依赖数组没变,不会重新执行——数字就永远卡在 0。
React 以 JSX 树为真相来源。你用 ref.current.textContent = x 写进去的值在 React 看来根本不存在,下一次渲染时它会把该节点的文本还原成 JSX child 的值。如果动画触发是一次性的(once: true),不会重播,数字就永久停在初始值。这个问题在单元测试和刷新截图中完全不可见——只有在触发 re-render 之后截图才能发现。
正解
加一个无依赖 useEffect(每次渲染后都执行),在 React 刚把文本覆盖回初始值之后,立即把当前 motion value 重新写回去:
// 每次渲染后重新同步:React reconcile 覆盖 → 本 effect 立即纠正
useEffect(() => {
if (ref.current) {
ref.current.textContent = format(mv.get(), decimalPlaces);
}
});注意是空依赖数组都不传——这样每次渲染都会执行,正好把 reconciler 的覆盖纠正过来。
完整的三件套缺一不可:
| 角色 | 代码 | 职责 |
|------|------|------|
| JSX child | <span ref={ref}>{format(startValue)}</span> | SSR / 首屏 / 无 JS 时的确定值,避免 hydration mismatch |
| useMotionValueEvent | 订阅 mv 变化写 textContent | 动画播放中实时更新 |
| 无依赖 useEffect | re-sync mv.get() 到节点 | re-render 后纠正 reconciler 的覆盖 |
只要组件同时存在「JSX child 提供初始值」和「命令式 textContent 写入动态值」两条路径,就必须加无依赖 useEffect 做 re-sync,否则任何父组件的 re-render(主题切换、context 变更、locale 切换……)都能把你的值打回原形。
排查提示
错误的验证方式:刷新页面截图——刷新会重跑动画,结果永远正确。
正确的验证方式:首屏动画播完后,在页面内(不刷新)触发一次状态变更(切主题、切语言、点任何会导致父组件 re-render 的按钮),再截图。数字必须保持最终值,而不是跳回 0。
不只是 textContent——任何通过 ref 命令式写入的 DOM 属性(innerHTML、自定义 attribute、style 直接赋值),只要该节点同时有 React 管理的 JSX 内容,都有被 reconciler 覆盖的风险。防御方法相同:无依赖 useEffect 在每次渲染后重申。
一句话外卖
命令式 DOM 写入和 React JSX child 不能同时存在于同一节点——如果非要并存,必须用无依赖
useEffect在每次 re-render 后重申命令式写入,否则 reconciler 会悄悄把你的值覆盖回去。