原文:https://dev.to/jyotipathak__/how-to-keep-client-side-animation-from-taking-over-your-nextjs-architecture-26bj(作者 @jyotipathak__)
Next.js 客户端动画边界:把 use client 压到最小
一个带动画的区块,就能悄无声息地改变一个原本服务端优先的 Next.js 页面的架构。
首页 Hero 区需要跟踪指针位置,于是文件顶部出现了 "use client"。
接着,某个功能区块需要滚动时间线。
再后来,有人又加了一个依赖浏览器 API 的 WebGL 背景。
没过多久,一个原本只需要几块交互的页面,大部分组件树都跑到了客户端边界之内。
动画依然正常工作。
架构却已经跑偏了。
在 App Router 里,"use client" 定义的是客户端模块边界。一旦跨过这条边界,被引入这个客户端依赖图的模块就都成了浏览器端应用的一部分。
所以真正有用的原则不是:
别用客户端动画。
而是:
把客户端边界套在真正需要浏览器的那一小块交互上。
其余部分都可以保持服务端优先。
真正的问题是边界蔓延
大多数动画需求,确实都有正当理由要在浏览器里跑代码。
你可能会用到:
window- 指针位置
- 布局测量
- 滚动进度
ResizeObserverIntersectionObserver- 动画时间线
- Canvas
- WebGL
- 组件 state
但这些都不意味着整个区块或整个页面就该变成客户端组件(Client Component)。
想象一个产品 Hero 区,里面有:
- 一个标题
- 一段补充文案
- 一个 CTA
- 价格或产品数据
- 一个动画视觉元素
只有其中一部分需要浏览器端行为。
如果整个 Hero 都加上 "use client",那这条渲染边界反映的就是动画的需求,而不是界面的需求。
而这种做法很容易扩散。
后来的开发者看到某个区块归客户端所有,就会继续往里加只能在客户端用的 state。本来可以服务端渲染的内容,就这样和浏览器代码绑在了一起。动画相关的依赖开始沿着组件树一路往上蔓延。
更该问的问题是:
这个交互到底归哪个组件所有?
通常来说,这个组件也就该是客户端边界的所在。
让 "use client" 尽量下沉
看这段代码:
"use client"
export default function Hero() {
return (
<section>
<h1>Build faster</h1>
<p>Server-renderable marketing content.</p>
<AnimatedProduct />
</section>
)
}
它能跑。
但就因为一个子组件需要动画,整个 Hero 都进了客户端模块图。
更干净的做法是:
export default function Hero() {
return (
<section>
<h1>Build faster</h1>
<p>Server-renderable marketing content.</p>
<AnimatedProduct />
</section>
)
}
然后把带动画的那一块单独隔离出来:
"use client"
export function AnimatedProduct() {
return <div>{/* animation logic */}</div>
}
这样一来,浏览器专属的行为就待在了它该待的地方。
周围的内容仍然可以作为服务端组件(Server Components)编写和渲染,只有带动画的那个叶子节点进入客户端依赖图。
当这个动画还同时负责下面这些事时,这种做法就更有价值:
- 事件监听器
- 各种 observer
- GSAP 时间线
- 滚动触发
- 指针 state
- resize 处理
- 清理逻辑
一个好的动画边界,往往会把这几项职责聚在一起。
服务端渲染的内容也可以穿过客户端包装组件
还有一种容易被忽略的模式。
有时候动画需要把内容包起来,但并不需要“拥有”这些内容。
比如:
export default function Page() {
return (
<Reveal>
<article>
<h2>Server-rendered content</h2>
<p>This content does not need client-side state.</p>
</article>
</Reveal>
)
}
然后是:
"use client"
export function Reveal({
children,
}: {
children: React.ReactNode
}) {
return <div>{children}</div>
}
关键细节在于组合的方向。
由服务端组件负责组合内容,再把它作为 children 传给客户端组件。客户端模块并没有反过来直接 import 一个服务端组件。
这种模式适用于:
- 入场显示(reveal)包装组件
- 进入视口才触发的区块
- 带壳的动画容器
- 承载服务端提供标记的轮播
- 交互式容器
- 过渡包装组件
它让交互留在客户端,而不必把有实质内容的东西一起搬过去。
跨边界的值在适用时依然要遵守 React 与 Next.js 的序列化规则。仅客户端可用的函数和浏览器状态,应该留在客户端一侧。
引入动画库之前,先问一句 CSS 够不够
有相当多的动效,其实根本不需要客户端 JavaScript。
比如:
.card {
transform: translateY(0);
opacity: 1;
transition:
transform 220ms ease,
opacity 220ms ease;
}
.card:hover {
transform: translateY(-4px);
}
没有 hook。没有动画运行时。没有生命周期。也不需要 "use client"。
这并不是说要用 CSS 全面取代 Motion 或 GSAP。
hover 状态和固定滚动的连续动画,本来就是两类问题。
有用的原则是:选那个能解决当前交互的最小机制。
下面这些场景,CSS 通常就够了:
- hover 反馈
- 颜色过渡
- 简单的透明度变化
- transform 变换
- 下划线
- 状态本来在 CSS 里就有的基础进出场效果
一段文字入场效果,不该动用 3D 管线。
Motion 不需要整个 section 都归客户端所有
Motion 与 React 配合得很自然,所以很容易让客户端边界围着它一路扩张。
但这依然没有必要。
如果某一张卡片需要手势状态或动画 Hook,那让这张卡片自己持有客户端行为就好。
包含十二张卡片的那个 section,并不因此就必须变成客户端组件。
这里还有一个当下 Next.js 的重要细节。
Motion 提供了 motion/react-client,用于在 React Server Component 环境里渲染 Motion 组件。
这并不意味着所有 Motion 行为突然都跑在服务端。
Hook、手势逻辑、依赖浏览器的测量、带状态的交互,以及其他只能在客户端运行的行为,依然要放在合适的 Client Component 边界之后。
所以要想想组件真正需要的是什么。
一个简单的动画元素和一次拖拽交互,对客户端的要求差别很大,即便两者都用了 Motion。
命令式动画必须有明确的所有者
一旦引入命令式系统,事情就变得更偏架构了,比如:
- GSAP 时间线
- ScrollTrigger
- 自定义 observer
- 布局测量
- 手动事件监听
- resize 逻辑
这些都会产生生命周期责任。
持有受影响 DOM 的组件,通常也应该持有这些责任。
避免做一个横跨整页、伸手插进互不相关 section 的动画控制器。
像下面这样的结构更容易理清:
FeatureStory ├── owns its DOM ├── creates its timeline ├── creates its ScrollTriggers ├── handles its resize logic └── cleans everything up on unmount
如果另一个 section 有自己的动画系统,就让那个 section 单独管理它。
为什么?
因为页面级动画脚本一旦布局变化就容易变脆。
某个 section 变成条件渲染。
另一个被移进嵌套布局。
移动端断点改变了 DOM。
一条路由保留了部分组件树,另一条却把它卸载了。
这时「谁来清理这个动画?」就没有显而易见的答案了。
局部所有权能让动画架构保持可组合。
不要让动画时间线变成你的页面结构
滚动动画是最常出现这个错误的地方。
假设桌面端有一个 section 会固定(pin)住产品图,随着用户滚动依次出现五个文字状态。
到了移动端,你可能根本不想要这种固定体验。
内容应该依然能这样成立:
Feature 1 Visual 1 Feature 2 Visual 2 Feature 3 Visual 3
动画是叠加在有意义内容之上的一层表现层。
它不应是唯一把内容撑在一起的东西。
这样在以下情况你会有健康得多的降级方案:
- 视口变化
- 开启了减弱动态效果(reduced motion)
- JS 失效
- 动画库更换
- 之后创意方向被简化
WebGL 与 Canvas 值得拥有自己的一座孤岛
WebGL 是更需要隔离的更强案例。
一个 3D 主视觉可能依赖:
- Canvas
- WebGL
- Three.js
- React Three Fiber
- shader
- 纹理
- 指针输入
requestAnimationFrame- 视口尺寸
- 设备像素比
这些需求没有一条意味着标题或 CTA 也应该归客户端所有。
更好的架构长这样:
Server-rendered Hero ├── Heading ├── Copy ├── CTA └── Client WebGL Island
有意义的内容依然是普通 HTML。
渲染器保持隔离。
这也让下面这些事更容易:
- 提供静态降级
- 在移动端简化效果
- 延后加载渲染器
- 减少 GPU 开销
- 在不重写内容结构的前提下移除整个场景
如果 WebGL 场景开销很大或位于首屏之下,它可能也很适合做懒加载。
ssr: false 有用,但它不是一种架构
当某个包直接依赖浏览器 API 时,最快的反应往往是:
dynamic(() => import("./Scene"), {
ssr: false,
})
有时候这确实就是对的。
但在 App Router 里有一个重要限制:
`ssr: false` 必须从 Client Component 中配置。
所以对于只能在浏览器里跑的渲染器,结构可能是这样:
// SceneClient.tsx
"use client"
import dynamic from "next/dynamic"
const Scene = dynamic(() => import("./Scene"), {
ssr: false,
})
export function SceneClient() {
return <Scene />
}
你的 Server Component 随后可以渲染 SceneClient。
这是一个有用的「仅浏览器」边界。
但它依然没有回答架构的其余问题。
ssr: false 只是告诉 Next.js 不要对这个组件做服务端渲染。
它并不决定:
- 客户端孤岛应该多大
- 渲染器是否应该立即加载
- 有意义的内容应该放在哪
- 谁来负责清理
- 移动端是否需要降级
- 减弱动态效果是否改变效果
- 动画在离屏时是否应该暂停
这些都是彼此独立的决策。
因为一个组件碰了 window 就把整页变成客户端渲染,和让一个确实没有服务端表示的小渲染器单独隔离,是两回事。
懒加载与禁用 SSR 解决的是不同问题
这两个决定常常被捆绑在一起讨论,但它们回答的其实是不同的问题。
懒加载问的是:
这段代码应该在什么时候被下载?
禁用 SSR 问的是:
这个组件到底还需不需要在服务端渲染?
首屏之外的重型场景,可能适合用懒加载。
页面顶部一个很小的客户端交互,大概不需要精心设计的延迟处理。
一个只能在浏览器里跑的 WebGL 渲染器,同时使用动态加载和 ssr: false 是合理的。
而一个简单的 Motion 元素,可能两者都不需要。
把这两个决定分开看,可以避免 dynamic() 变成给每个动画组件都套上的通用包裹层。
全局动画 Provider 要比你想象的更小
当一个站点的动画越堆越多,人很容易产生"干脆做一个全局动画层"的冲动。
共享一部分配置是有用的。
例如:
- 减弱动效(reduced-motion)策略
- 过渡的默认值
- 常用的缓动值
- 功能开关
- 交互偏好
但共享策略不等于共享实现。
你大概不需要一个全局控制器去掌管下面这些东西:
- 每一个 ScrollTrigger
- 每一个 observer
- 每一条 timeline
- 光标
- 所有页面动画
- 每一个动画状态
在 Next.js 里,把 Provider 放在实际可行的最深位置通常更有利。
同样的做法用在动画上也成立。
如果只有营销区块需要动画 Provider,就把它放在那里。
如果只有一条路由需要自定义光标,就不要让根布局去管它。
全局策略留在全局,局部动画留在局部。
暂停用户看不见的工作
客户端边界只是问题的第一部分。
动画完成 hydration 之后,你还要决定它应该在什么时候运行。
一个在隐藏标签页里仍在渲染的 Canvas 场景,照样在消耗资源。
屏幕外的粒子效果可能仍在播放。
如果清理不完整,光标拖尾可能在相关组件已经卸载之后,仍然订阅着指针移动事件。
根据效果类型的不同,可以考虑:
- 页面隐藏时暂停渲染循环
- 处于屏幕外时挂起开销大的工作
- 断开 observer
- 移除事件监听器
- 卸载时销毁(kill)timeline
- 在移动端降低视觉复杂度
- 降低 WebGL 渲染开销
- 提供静态降级方案
一个动画做了多余的工作,可能没人察觉。
但五六个各自独立做动画的区块,会让这些被忽略的开销迅速累积起来。
一份实用的架构自查清单
在给 App Router 项目再加一个动画之前,先问自己:
- 能不能用 CSS 解决?→ 别让它进入客户端运行时
- 需要 hooks、state 或浏览器 API 吗?→ 用一个小型 Client Component
- 需要命令式 timeline 吗?→ 给这条 timeline 一个局部归属
- 会创建 listener 或 observer 吗?→ 让初始化和清理写在一起
- 里面包含实质内容吗?→ 尽量把这些内容留在 HTML 里
- 需要 Canvas 或 WebGL 吗?→ 把渲染器隔离出来
- 渲染器开销大,或者位于首屏之外吗?→ 考虑懒加载
- 它只能在浏览器里存在吗?→ 使用合适的 client-only 边界
- 需要共享的动画策略吗?→ 把 Provider 放在实际可行的最深位置
- 隐藏时它能停下来吗?→ 暂停它
- 减弱动效时会怎样?→ 有意识地简化交互
- 在触摸设备上会怎样?→ 设计一个真正的降级方案
正是这样一份清单,能阻止动画慢慢变成应用架构本身。
源码优先的效果为什么能帮上忙
这也正是源码优先的交互库能派上用场的地方。
例如,Hyperiux Vault 以可编辑源码的形式提供 React 和 Next.js 交互模式。
从架构角度看,有价值的地方并不只是"这个效果存在"。
而是你可以查看:
- 客户端边界从哪里开始
- 这个效果用了哪些依赖
- 生命周期和清理是怎么处理的
- 面向移动端或纯浏览器渲染时该改什么
这样一来,你就能更容易地把一个效果嵌进现有的架构,而不是为了一个黑盒包去重构整个应用。
Vault 的安装文档还区分了依赖滚动状态、指针移动、布局测量、timeline、Canvas 或 WebGL 的效果,与那些本身并不需要同样客户端占用的组件。
Vault 不会替你解决 Next.js 的架构问题。
渲染模型仍然由 Next.js 定义。Motion、GSAP、Three.js、React Three Fiber 和浏览器 API 也仍然带着各自的约束。
可编辑源码只是让这些约束更容易被查看和适配。
目标不是零客户端 JavaScript
一个有创意的 Next.js 站点,是被允许使用浏览器的。
有些交互确实非它不可。
滚动联动的叙事需要滚动状态。
拖拽需要指针输入。
页面过渡需要导航状态。
自定义光标需要指针事件。
WebGL 需要渲染器。
想把客户端 JavaScript 彻底清零,只会把有用的交互一起清零。
真正的目标是架构上的比例感。
一个测量视口的 Hero 区块,不代表整个页面就该变成客户端接管。
一个 Canvas 组件,不代表这条路由就该关掉 SSR。
一个带动画的包裹层,不代表它里面的内容就得搬进客户端模块。
让动画只持有它真正需要的那部分浏览器代码。
然后把边界就停在这里。
如果你想看看更复杂的 React 与 Next.js 交互模式如何在可编辑源码中处理这类边界,可以浏览 Hyperiux Vault 特效库。
好的交互可以很有野心,但它的客户端边界不必跟着一起大。
原文:https://dev.to/jyotipathak__/how-to-keep-client-side-animation-from-taking-over-your-nextjs-architecture-26bj(作者 @jyotipathak__)



