Files
2026-07-13 21:35:52 +08:00

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-blurbackdrop-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-forwardnav-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>
  );
}

这也成为了将来添加新过渡类型时进行修改的唯一入口点。

规则:

  • 始终将 enterexit 配对使用——没有退出动画,旧页面会瞬间消失,而新页面则动画进入。
  • 始终在类型映射对象中包含 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 的解析作为独立的、没有类型的过渡触发。类型键控属性无法匹配——请改用简单的字符串属性。
  • 使用原生的 viewTransitionName CSS 触发动画 ——只有当 <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