返回博客

给父组件加了 '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 透明帧被"锁住"的时间变长,严重时永久停在起点。

坑:加了 'use client' 导致扫光文字永久透明

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 接管。任何祖先边界变动都不影响。

正解:别碰 reveal 库本身,修水合边界

根因在边界,不在动画库。Fix A/B 是最安全的:要么去掉装饰性动画,要么把状态下沉到更小的客户端子树。只有在 reveal 组件是自己维护的情况下,才考虑 Fix C 把初始 motionValue 改为终点值。

排查技巧

  1. 禁用 JS 重载(DevTools → Cmd-Shift-P → "Disable JavaScript"):如果文字仍不可见,说明 SSR 帧本身就是透明的,Fix A/C/D 可解。
  2. View Page Source(不是 Elements 面板,那是水合后的):找文字节点,看内联 stylebackgroundImage,检查所有彩色色标是否都在负百分比区。
  3. CPU 降速 6× 重载:把 SSR 透明帧"撑大",更容易截图捕捉。

一句话外卖

framer-motion 扫光文字的 SSR 起点天生透明——靠"客户端孤岛快速水合"来隐藏这个问题;一旦把祖先升为 'use client' 合并水合边界,透明帧就被暴露,且无任何报错。修边界,不修库。