184 lines
6.1 KiB
Markdown
184 lines
6.1 KiB
Markdown
---
|
||
name: nextjs-view-transitions
|
||
description: Next.js 中 View Transitions API 的使用指南
|
||
metadata:
|
||
type: reference
|
||
---
|
||
|
||
# Next.js 中的 View Transitions
|
||
|
||
## 设置
|
||
|
||
`<ViewTransition>` 开箱即用,支持 `startTransition`/`Suspense` 更新。若要对 `<Link>` 导航也启用动画:
|
||
|
||
```js
|
||
// next.config.js
|
||
const nextConfig = {
|
||
experimental: { viewTransition: true },
|
||
};
|
||
module.exports = nextConfig;
|
||
```
|
||
|
||
这会为每个 `<Link>` 导航包裹 `document.startViewTransition`。任何 `default="auto"` 的 VT 会在**每次**链接点击时触发——请使用 `default="none"` 来防止动画冲突。
|
||
|
||
**不要**安装 `react@canary`——详见 SKILL.md 的"可用性"章节。
|
||
|
||
---
|
||
|
||
## Next.js 实现补充
|
||
|
||
在遵循 `implementation.md` 时,应用以下补充内容:
|
||
|
||
**步骤 2 之后:** 启用上述实验性标志。
|
||
|
||
**步骤 4:** 在 `<Link>` 上使用 `transitionTypes`——详见下文"`transitionTypes` Prop"章节的用法和可用性说明。
|
||
|
||
**步骤 6 之后:** 对于相同路由的动态段(例如 `/collection/[slug]`),使用 `key` + `name` + `share` 模式——详见下文"相同路由动态段过渡"章节。
|
||
|
||
---
|
||
|
||
## 布局级别的 ViewTransition
|
||
|
||
**如果页面有自己的 VT,不要在布局中添加包裹 `{children}` 的布局级 VT。** 嵌套 VT 在父级 VT 内部时,enter/exit 永远不会触发——页面级的 enter/exit 会静默失效。请完全移除布局级 VT。
|
||
|
||
一个裸露的 `<ViewTransition>` 在布局中只有在页面**没有**自己的 VT 时才能正常工作。
|
||
|
||
**布局在导航间持久存在**——`enter`/`exit` 仅在初始挂载时触发,路由变化时不触发。不要在布局中使用类型键映射。
|
||
|
||
---
|
||
|
||
## `next/link` 上的 `transitionTypes` Prop
|
||
|
||
无需包装组件,在服务端组件中即可工作:
|
||
|
||
```tsx
|
||
<Link href="/products/1" transitionTypes={['transition-to-detail']}>查看产品</Link>
|
||
```
|
||
|
||
这替代了手动 `onNavigate` + `startTransition` + `addTransitionType` + `router.push()` 的模式。仅在非链接交互(按钮、表单)时保留手动 `startTransition`。
|
||
|
||
**可用性:** `transitionTypes` 需要启用 `experimental.viewTransition: true`,在 Next.js 15+ canary 构建版本和 Next.js 16+ 中可用。如果不可用,请使用 `startTransition` + `addTransitionType` + `router.push()`(参见下面的"程序化导航"章节)。检查方法:`grep -r "transitionTypes" node_modules/next/dist/`——如果没有结果,则回退到程序化导航。
|
||
|
||
---
|
||
|
||
## 程序化导航
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
import { startTransition, addTransitionType } from 'react';
|
||
|
||
function handleNavigate(href: string) {
|
||
const router = useRouter();
|
||
startTransition(() => {
|
||
addTransitionType('nav-forward');
|
||
router.push(href);
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 使用 `router.replace` 实现服务端筛选
|
||
|
||
对于通过 URL 参数在服务端重新渲染的搜索/排序/筛选,使用 `startTransition` + `router.replace`。VT 会生效,因为状态更新位于 `startTransition` 内部:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
import { startTransition } from 'react';
|
||
|
||
function handleSort(sort: string) {
|
||
const router = useRouter();
|
||
startTransition(() => {
|
||
router.replace(`?sort=${sort}`);
|
||
});
|
||
}
|
||
```
|
||
|
||
包裹在 `<ViewTransition key={item.id}>` 中的列表项会以动画方式重新排序。这是 `patterns.md` 中客户端 `useDeferredValue` 模式的服务端组件替代方案。
|
||
|
||
---
|
||
|
||
## 双层模式(方向动画 + Suspense)
|
||
|
||
方向滑动动画与 Suspense 展现动画可以共存,因为它们在各自不同的时机触发。请将方向 VT 放在**页面组件**中(而非布局):
|
||
|
||
```tsx
|
||
<ViewTransition
|
||
enter={{ "nav-forward": "slide-from-right", default: "none" }}
|
||
exit={{ "nav-forward": "slide-to-left", default: "none" }}
|
||
default="none"
|
||
>
|
||
<div>
|
||
<Suspense fallback={<ViewTransition exit="slide-down"><骨架屏 /></ViewTransition>}>
|
||
<ViewTransition enter="slide-up" default="none"><内容 /></ViewTransition>
|
||
</Suspense>
|
||
</div>
|
||
</ViewTransition>
|
||
```
|
||
|
||
---
|
||
|
||
## 将 `loading.tsx` 作为 Suspense 边界
|
||
|
||
Next.js 的 `loading.tsx` 是一个隐式的 `<Suspense>` 边界。在 `loading.tsx` 中,将骨架屏包裹在 `<ViewTransition exit="...">` 中;在页面中,将内容包裹在 `<ViewTransition enter="..." default="none">` 中:
|
||
|
||
```tsx
|
||
// loading.tsx
|
||
<ViewTransition exit="slide-down"><PhotoGridSkeleton /></ViewTransition>
|
||
|
||
// page.tsx
|
||
<ViewTransition enter="slide-up" default="none"><PhotoGrid photos={photos} /></ViewTransition>
|
||
```
|
||
|
||
与显式 `<Suspense>` 规则相同:使用简单字符串 prop(而非类型映射),因为 Suspense 展现动画触发时不带过渡类型。
|
||
|
||
---
|
||
|
||
## 跨路由共享元素
|
||
|
||
```tsx
|
||
// 列表页
|
||
{products.map((product) => (
|
||
<Link key={product.id} href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
|
||
<ViewTransition name={`product-${product.id}`}>
|
||
<Image src={product.image} alt={product.name} width={400} height={300} />
|
||
</ViewTransition>
|
||
</Link>
|
||
))}
|
||
|
||
// 详情页——相同的 name
|
||
<ViewTransition name={`product-${product.id}`}>
|
||
<Image src={product.image} alt={product.name} width={800} height={600} />
|
||
</ViewTransition>
|
||
```
|
||
|
||
---
|
||
|
||
## 相同路由动态段过渡
|
||
|
||
当在同一路由的不同动态段之间导航时(例如 `/collection/[slug]`),页面保持挂载状态——enter/exit 永远不会触发。请使用 `key` + `name` + `share`:
|
||
|
||
```tsx
|
||
<Suspense fallback={<骨架屏 />}>
|
||
<ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
|
||
<Content slug={slug} />
|
||
</ViewTransition>
|
||
</Suspense>
|
||
```
|
||
|
||
- `key={slug}` 在变化时强制卸载/重新挂载
|
||
- `name` + `share="auto"` 创建共享元素的交叉淡入淡出效果
|
||
- 位于 `<Suspense>` 内部的 VT(不对 Suspense 设置 key)在加载期间保持旧内容可见
|
||
|
||
---
|
||
|
||
## 服务端组件
|
||
|
||
- `<ViewTransition>` 在服务端组件和客户端组件中均可使用
|
||
- `<Link transitionTypes>` 在服务端组件中可用——无需 `'use client'`
|
||
- 程序化导航的 `addTransitionType` 和 `startTransition` 需要客户端组件
|