10 KiB
实施工作流
为应用添加视图过渡时,请按以下步骤顺序操作。每一步都建立在之前步骤的基础之上。
步骤 1:审查应用
在编写任何代码之前,彻底扫描代码库。搜索:
- 每一个
<Link>和router.push—— 这些是你的导航触发器。打开包含它们的每个文件。 - 每一个
<Suspense>边界 —— 每一个都是展示动画的候选对象。检查其 fallback 渲染了什么。 - 每一个页面/路由组件 —— 将它们全部列出。每个页面都需要一个 VT 放置决策。
- 持久化元素 —— 在导航间保持停留在屏幕上的 header、navbar、sidebar、粘性控件。这些需要
viewTransitionName隔离。 - 共享视觉元素 —— 在源视图和目标视图中都出现的图像、卡片或头像(例如列表中的缩略图和详情页上的同一张图片)。
- 骨架屏与内容控件对 —— 如果 Suspense fallback 渲染了一个控件(搜索输入框、标签栏),而实际内容中也有同样的控件,那么两者都需要一个匹配的
viewTransitionName。
然后对每个导航进行分类,并生成一份导航映射表:
| 路由 | 导航至 | 方向 | VT 模式 |
|---------------------|----------------------|-------------|------------------------|
| / | /detail/[id] | 前进 | 定向滑动 (directional slide) |
| /detail/[id] | / | 后退 | 定向滑动 (directional slide) |
| /detail/[id] | /detail/[other] | 顺序切换 | 定向滑动 (有序 prev/next) 或 key+share 交叉淡变 |
| /tab/[a] | /tab/[b] | 横向切换 | key+share 交叉淡变 |
| (Suspense) | (内容加载) | — | 上滑展示 (slide-up reveal) |
对于每个共享元素(name 属性),记录哪些导航会形成配对、哪些不会——这决定了你是否需要将 enter/exit 作为 share 的备用方案。
步骤 2:添加 CSS 配方
将 css-recipes.md 中的完整 CSS 配方集复制到你的全局样式表中。这包括计时变量、共享关键帧、淡入淡出、滑动(垂直)、定向导航(前进/后退)、共享元素变形、持久化元素隔离以及减弱动效。
不要自行编写动画 CSS——这些配方已经处理了错开计时、变形时的运动模糊以及容易出错的减弱动效。你可以在初始设置之后自定义计时变量(--duration-exit、--duration-enter、--duration-move)。
步骤 3:隔离持久化元素
对于步骤 1 中识别出的每个持久化元素,添加 viewTransitionName 样式以将其从页面内容的过渡快照中提取出来:
<header style={{ viewTransitionName: "site-header" }}>...</header>
然后添加 css-recipes.md 中的持久化元素隔离 CSS(防止该元素在页面过渡期间产生动画)。如果该元素使用了 backdrop-blur 或 backdrop-filter,请改用 css-recipes.md 中的 backdrop-blur 变通方案。
如果 Suspense fallback 镜像了一个持久化控件(例如骨架搜索输入框),请为实际控件和骨架屏设置相同的 viewTransitionName,使它们在原位变形。
步骤 4:添加定向页面过渡
对于步骤 1 中识别出的层级导航,使用 startTransition 内部的 addTransitionType 标记导航方向:
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
然后将每个页面组件(而非布局)包裹在类型键控的 <ViewTransition> 中:
<ViewTransition
enter={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
exit={{
"nav-forward": "nav-forward",
"nav-back": "nav-back",
default: "none",
}}
default="none"
>
<div>...页面内容...</div>
</ViewTransition>
css-recipes.md 中的 nav-forward 和 nav-back CSS 类会产生水平滑动效果。对于不需要定向动效的简单应用,一个空的 <ViewTransition default="none"> 包裹搭配 enter="fade-in" / exit="fade-out" 也同样可行。
将其提取为可复用组件,这样每个页面就不必重复冗长的类型映射:
export function DirectionalTransition({ children }: { children: React.ReactNode }) {
return (
<ViewTransition
enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
default="none"
>
{children}
</ViewTransition>
);
}
这也成为了将来添加新过渡类型时进行修改的唯一入口点。
规则:
- 始终将
enter与exit配对使用——没有退出动画,旧页面会瞬间消失,而新页面则动画进入。 - 始终在类型映射对象中包含
default: "none",并在组件上设置default="none"——否则它会在每次过渡时触发。 - 将定向的
<ViewTransition>放置在每个页面组件中,而不是布局中。布局在导航间持久存在,永远不会触发 enter/exit。 - 仅对层级导航或有序序列(prev/next)使用定向滑动。横向/同级导航(tab 到 tab)应使用空的
<ViewTransition>(交叉淡变)或default="none"。
步骤 5:添加 Suspense 展示
对于步骤 1 中识别出的每个 <Suspense> 边界,将 fallback 和内容分别包裹在各自的 <ViewTransition> 中:
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<AsyncContent />
</ViewTransition>
</Suspense>
此示例使用 slide-down / slide-up 实现定向垂直动效。对于更简单的展示,一个包裹 <Suspense> 的空 <ViewTransition> 即可实现零配置的交叉淡变。根据空间含义进行选择——请查阅主技能文件中的"选择正确的动画样式"表格。
规则:
- 始终在内容
<ViewTransition>上使用default="none",以防止在重新验证或不相关的过渡中重新触发动画。 - 在 Suspense
<ViewTransition>上使用简单的字符串属性(而非类型映射)——Suspense 的解析作为独立的、没有类型的过渡触发,因此类型键控属性无法匹配。
步骤 6:添加共享元素过渡
对于步骤 1 中识别出的每个共享视觉元素,在源视图和目标视图上添加匹配的命名 <ViewTransition> 包裹:
// 在源视图上(例如列表/网格页面)
<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
<Image src={photo.src} ... />
</ViewTransition>
// 在目标视图上(例如详情页)——相同的 name
<ViewTransition name={`photo-${photo.id}`} share="morph">
<Image src={photo.src} ... />
</ViewTransition>
share="morph" 类使用 css-recipes.md 中的变形配方(受控时长 + 运动模糊)。对于更简单的交叉淡变,使用 share="auto"(浏览器默认行为)。
当列表项包含共享元素时,使用两个嵌套的 <ViewTransition> 层组合两种模式——请参阅 SKILL.md 中的"将共享元素与列表标识组合使用"。
规则:
- 名称必须是全局唯一的——使用
photo-${id}等前缀。 - 在列表侧的共享元素上添加
default="none",以防止在过滤/搜索更新时每个项目都产生交叉淡变。
步骤 7:验证每个导航路径
对照步骤 1 中的导航映射表,逐行确认:
- VT 在此导航中是挂载/卸载,还是保持挂载状态(相同路由)?
- 对于命名 VT:是否形成了共享配对?如果没有,
enter/exit是否提供了备用方案? default="none"是否阻止了你实际想要的动画?- 持久化元素是否保持静态(不随页面内容滑动)?
- Suspense 展示动画是否独立于定向导航?
如果任何路径没有产生动画或产生了冲突的动画,请重新审视相关步骤。
常见错误
- 不带属性的空
<ViewTransition>——没有default="none",它会在每次过渡(每次导航、每次 Suspense 解析、每次重新验证)时触发浏览器的默认交叉淡变。始终设置default="none"并仅显式启用你想要的触发器。 - 在布局中使用定向
<ViewTransition>——布局在导航间持久存在,永远不会卸载/重新挂载。enter/exit属性不会在路由变化时触发。将外层的类型键控<ViewTransition>放置在每个页面组件中。 - 淡出退出与共享元素变形共存 ——页面溶解与变形冲突。请改用定向滑动退出。
- 自行编写自定义动画 CSS ——
css-recipes.md中的配方已经处理了错开计时、变形时的运动模糊以及减弱动效。直接复制它们,不要重新发明。 - 类型键控对象中缺少
default: "none"——TypeScript 要求有default键,没有它时回退值为"auto",会在每次过渡时触发。 - 在 Suspense 展示中使用类型映射 ——Suspense 的解析作为独立的、没有类型的过渡触发。类型键控属性无法匹配——请改用简单的字符串属性。
- 使用原生的
viewTransitionNameCSS 触发动画 ——只有当<ViewTransition>组件存在于渲染树中时,React 才会调用document.startViewTransition。单纯的viewTransitionName样式用于将元素从父级快照中隔离出来,而非用于触发动画。 - 同路由导航的
update触发器 ——内容内部嵌套的 VT 会劫持父级的突变,因此update永远不会在外层 VT 上触发。请改用key+name+share。 - 在可复用组件中使用命名 VT ——如果一个带有命名 VT 的组件同时在模态框/弹出层和页面中渲染,两者会同时挂载,破坏变形效果。请使 name 变为条件性,或将其移至具体的消费者处。
- 使用
router.back()进行后退导航 ——router.back()会触发同步的popstate,与视图过渡不兼容。请使用带显式 URL 的router.push()。
关于 Next.js 特定的实施步骤(配置标志、<Link> 上的 transitionTypes、同路由动态段),请参阅 nextjs.md。