给父组件加了 'use client',framer-motion 扫光文字就消失了
一个用 framer-motion 做的"扫光显字"组件,在某个侧边栏里稳定跑了很久。某天为了接入 SWR 数据刷新,在父组件顶部加了一行 'use client'——部署上线,发现所有"正在执行"卡片里的扫光文字变成了空白。
DevTools 打开看:元素在,文字节点在,就是看不见。控制台:干净,连水合警告都没有。
现象
<motion.span>在 DOM 里正常存在,文字内容也在- Computed Styles 显示
color: transparent+background-clip: text+ 一个backgroundImage: linear-gradient(…) - 把那条渐变展开一看:所有带颜色的色标都在负百分比位置(
-34%、-25.5%、-17%...到0%截止),0%之后全是transparent - 可见区域
0%–100%里没有任何颜色 → 文字背景全透明 → 文字透明 = 完全不可见 - 同一个组件在另一个地方(祖先仍是 Server Component)正常工作
根因
三件事叠加导致这个无声的坑:
第一层:SSR 把 motionValue 冻结在动画起点。
useMotionValue(SWEEP_START) 在服务端只执行一次,产出一张 HTML 快照,此时 sweepPos = SWEEP_START(通常是一个负数,比如 -17)。buildGradient(-17) 构造的渐变,恰好把所有彩色色标塞进负百分比区——目的是让动画从"完全透明"扫向"完全显色"。所以 SSR 吐出的 HTML 里,这段文字天生就是透明的。
第二层:客户端孤岛模式本来藏住了这个问题。
在"祖先是 Server Component、reveal 是独立 client island"的结构里,客户端孤岛水合极快,useEffect → animate() 几乎立即执行,用户根本看不到 SSR 的起点帧。
第三层:把父组件升为 'use client' 合并了水合边界。
现在父组件和 reveal 子组件同属一棵客户端树。React 把整棵树当一个单元调度水合。如果父组件还有 useSWR / useState 触发的重渲染,motion 的 useEffect 就可能被推迟——SSR 透明帧被"锁住"的时间变长,严重时永久停在起点。
framer-motion 的扫光文字依赖 useEffect 启动动画。SSR 产出的 HTML 在动画起点,该起点的渐变色带在可见区域之外(全透明)。原本在客户端孤岛结构里,水合很快、几乎感知不到。但一旦把祖先升级为 'use client',整棵树的水合时序打乱,useEffect 延后,透明帧就被永久"冻住"了。
没有任何报错、没有水合 warning——唯一的信号就是"我加了 'use client',文字消失了"。
正解
按改动成本从轻到重选一种:
Fix A(最轻)— 换成静态文字
如果扫光只是装饰,直接用带颜色的 <em> 替代:
// before
<em className="running">
<DiaTextReveal text={label} repeat startOnView={false} />
</em>
// after
<em className="running">{label}</em>CSS 的 color + font-weight 同样能做视觉强调,且完全没有 JS 动画、天然支持 prefers-reduced-motion。
Fix B(推荐)— 父组件维持 Server,把状态下沉到薄客户端子组件
// sidebar.tsx — 仍是 Server Component
export async function Sidebar({ data }) {
return (
<aside>
<SidebarBody initialData={data} /> {/* 'use client',内部跑 useSWR */}
</aside>
)
}reveal 组件重新回到"客户端孤岛套在服务端树里"的结构,水合速度恢复,闪烁消失。
Fix C — 修改 reveal 组件的初始 motionValue 为动画终点
// 改前
const sweepPos = useMotionValue(SWEEP_START)
// 改后:SSR 渲染终点帧(文字可见),客户端水合后 useEffect 重置到起点再播
const sweepPos = useMotionValue(SWEEP_END)
useEffect(() => {
sweepPos.set(SWEEP_START) // 重置到起点
animate(sweepPos, SWEEP_END, { duration, ease })
}, [])SSR 输出"文字可见"的终点帧,用户看到的是"文字先出现 → 再扫一遍",而不是"空白 → 文字出现"。
Fix D(最重)— 水合完成前不挂载 motion
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return <span>{text}</span>
return <motion.span style={…}>{text}</motion.span>SSR / 首屏静态文字可见,水合后 motion 接管。任何祖先边界变动都不影响。
根因在边界,不在动画库。Fix A/B 是最安全的:要么去掉装饰性动画,要么把状态下沉到更小的客户端子树。只有在 reveal 组件是自己维护的情况下,才考虑 Fix C 把初始 motionValue 改为终点值。
排查技巧
- 禁用 JS 重载(DevTools → Cmd-Shift-P → "Disable JavaScript"):如果文字仍不可见,说明 SSR 帧本身就是透明的,Fix A/C/D 可解。
- View Page Source(不是 Elements 面板,那是水合后的):找文字节点,看内联
style的backgroundImage,检查所有彩色色标是否都在负百分比区。 - CPU 降速 6× 重载:把 SSR 透明帧"撑大",更容易截图捕捉。
一句话外卖
framer-motion扫光文字的 SSR 起点天生透明——靠"客户端孤岛快速水合"来隐藏这个问题;一旦把祖先升为'use client'合并水合边界,透明帧就被暴露,且无任何报错。修边界,不修库。