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

283 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模式与指南
## 使用 `useDeferredValue` 的可搜索网格
`useDeferredValue` 将筛选更新转变为一次过渡,从而激活 `<ViewTransition>`
```tsx
'use client';
import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
export default function SearchableGrid({ itemsPromise }) {
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
return (
<>
<input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<ViewTransition>
<Suspense fallback={<GridSkeleton />}>
<ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
</Suspense>
</ViewTransition>
</>
);
}
```
在延迟列表内部,为每个条目添加 `<ViewTransition name={...}>` 会在每次按键时触发交叉淡入淡出。使用 `default="none"` 修复:
```tsx
{filteredItems.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`} share="morph" default="none">
<ItemCard item={item} />
</ViewTransition>
))}
```
## 使用 `startTransition` 的卡片展开/折叠
在网格与详情视图之间切换,使用共享元素变形:
```tsx
'use client';
import { useState, useRef, startTransition, ViewTransition } from 'react';
export default function ItemGrid({ items }) {
const [expandedId, setExpandedId] = useState(null);
const scrollRef = useRef(0);
return expandedId ? (
<ViewTransition enter="slide-in" name={`item-${expandedId}`}>
<ItemDetail
item={items.find(i => i.id === expandedId)}
onClose={() => {
startTransition(() => {
setExpandedId(null);
setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
});
}}
/>
</ViewTransition>
) : (
<div className="grid grid-cols-3 gap-4">
{items.map(item => (
<ViewTransition key={item.id} name={`item-${item.id}`}>
<ItemCard
item={item}
onSelect={() => {
scrollRef.current = window.scrollY;
startTransition(() => setExpandedId(item.id));
}}
/>
</ViewTransition>
))}
</div>
);
}
```
## 类型安全的过渡辅助函数
使用 `as const` 数组和派生类型防止 ID 冲突:
```tsx
const transitionTypes = ['default', 'transition-to-detail', 'transition-to-list'] as const;
const animationTypes = ['auto', 'none', 'animate-slide-from-left', 'animate-slide-from-right'] as const;
type TransitionType = (typeof transitionTypes)[number];
type AnimationType = (typeof animationTypes)[number];
type TransitionMap = { default: AnimationType } & Partial<Record<Exclude<TransitionType, 'default'>, AnimationType>>;
export function HorizontalTransition({ children, enter, exit }: {
children: React.ReactNode;
enter: TransitionMap;
exit: TransitionMap;
}) {
return <ViewTransition enter={enter} exit={exit}>{children}</ViewTransition>;
}
```
## 无重挂载的交叉淡入淡出
省略 `key` 可触发更新(交叉淡入淡出),而非退出+进入。避免 Suspense 重挂载/重新请求:
```jsx
<ViewTransition>
<TabPanel tab={activeTab} />
</ViewTransition>
```
当内容身份发生变化(状态重置)时使用 `key`;对于交叉淡入淡出(标签页、面板、轮播)则省略。
## 将元素与父级动画隔离
### 持久布局元素
持久元素(头部、导航栏、侧边栏)会被捕获到页面的过渡快照中。使用 `viewTransitionName` 修复:
```jsx
<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
```
然后从 `css-recipes.md` 添加持久元素隔离 CSS。对于 `backdrop-blur`/`backdrop-filter`,使用 `css-recipes.md` 中的 backdrop-blur 变通方案。
### 浮动元素
为弹出框/工具提示赋予它们自己的 `viewTransitionName`
```jsx
<SelectPopover style={{ viewTransitionName: 'popover' }}>{options}</SelectPopover>
```
全局修复方案:请参见 `css-recipes.md` 中的持久元素隔离。
## 骨架屏与内容之间的共享控件
为后备内容与正式内容中相匹配的控件赋予相同的 `viewTransitionName`
```jsx
// 后备内容
<input disabled placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
// 正式内容
<input placeholder="Search..." style={{ viewTransitionName: 'search-input' }} />
```
不要在 `<ViewTransition>` 内部的根 DOM 节点上设置手动的 `viewTransitionName`——React 自动生成的名称会覆盖它。
## 可复用的带动画折叠
```jsx
function AnimatedCollapse({ open, children }) {
if (!open) return null;
return (
<ViewTransition enter="expand-in" exit="collapse-out">
{children}
</ViewTransition>
);
}
// 使用方式:通过 startTransition 切换
<button onClick={() => startTransition(() => setOpen(o => !o))}>Toggle</button>
<AnimatedCollapse open={open}><SectionContent /></AnimatedCollapse>
```
## 使用 Activity 保留状态
```jsx
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<ViewTransition enter="slide-in" exit="slide-out">
<Sidebar />
</ViewTransition>
</Activity>
```
## 使用 `useOptimistic` 排除元素
`useOptimistic` 的值在过渡快照之前更新,从而将其排除在动画之外。适用于控件(标签);动画内容应使用已提交的状态:
```tsx
const [sort, setSort] = useState('newest');
const [optimisticSort, setOptimisticSort] = useOptimistic(sort);
function cycleSort() {
const nextSort = getNextSort(optimisticSort);
startTransition(() => {
setOptimisticSort(nextSort); // 在快照之前——无动画
setSort(nextSort); // 在快照之间——有动画
});
}
<button>Sort: {LABELS[optimisticSort]}</button>
{items.sort(comparators[sort]).map(item => (
<ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
))}
```
---
## 视图过渡事件
通过 `onEnter``onExit``onUpdate``onShare` 进行命令式控制。始终返回清理函数。`onShare` 优先级高于 `onEnter`/`onExit`
```jsx
<ViewTransition
onEnter={(instance, types) => {
const anim = instance.new.animate(
[{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 300, easing: 'ease-out' }
);
return () => anim.cancel();
}}
>
<Component />
</ViewTransition>
```
`instance` 对象:`instance.old``instance.new``instance.group``instance.imagePair``instance.name`
`types` 数组(第二个参数)允许你根据过渡类型变化动画效果。
---
## 动画时长
| 交互类型 | 时长 |
|---------|------|
| 直接切换(展开/折叠) | 100200ms |
| 路由过渡(滑动) | 150250ms |
| Suspense 揭示(骨架屏 → 内容) | 200400ms |
| 共享元素变形 | 300500ms |
---
## 故障排查
**VT 未激活:** 确保 `<ViewTransition>` 出现在任何 DOM 节点之前。确保状态更新位于 `startTransition` 内部。
**"Two ViewTransition components with the same name"** 名称必须在全局唯一。使用 ID:`name={`hero-${item.id}`}`
**`router.back()` 及浏览器后退/前进跳过动画:** 改用带显式 URL 的 `router.push()`。请参见 SKILL.md 中的 "router.back() and Browser Back Button"。
**`flushSync` 跳过动画:** 改用 `startTransition`
**只有更新有动画(没有进入/退出):** 如果没有 `<Suspense>`,React 会将切换视为更新。改为条件渲染 VT 本身,或将其包裹在 `<Suspense>` 中。
**布局 VT 阻止页面 VT 动画:** 嵌套的 VT 在父级 VT 内部永远不会触发进入/退出。如果布局中有一个包裹 `{children}` 的 VT,页面级别的进入/退出将静默失效。移除布局 VT。
**使用 `useOptimistic` 时列表重新排序没有动画:** 乐观值在快照之前解析。列表顺序应使用已提交的状态。
**TS 错误 "Property 'default' is missing"** 以类型为键的对象需要一个 `default` 键。
**哈希片段导致滚动跳跃:** 不使用哈希进行导航;导航后通过编程方式滚动。
**Backdrop-blur 闪烁:** 使用 `css-recipes.md` 中的 backdrop-blur 变通方案。
**`border-radius` 在过渡期间丢失:** 直接将 `border-radius` 应用于被捕获的元素。
**骨架屏控件滑走:** 为相匹配的控件赋予相同的 `viewTransitionName`
**批处理:** 动画期间的多次更新会被批处理。A→B→C→D 变为 B→D。
<details>
<summary>可用工具列表(点击展开)</summary>
- **Agent** – 启动子代理处理复杂/多步任务
- **Bash** 执行 bash 命令
- **CronCreate/CronDelete/CronList** 管理定时任务
- **Edit** – 精确字符串替换编辑文件
- **EnterWorktree/ExitWorktree** 管理 git worktree
- **NotebookEdit** 编辑 Jupyter notebook 单元格
- **Read** 读取文件内容
- **ReportFindings** 报告代码审查发现
- **ScheduleWakeup** 调度 /loop 唤醒
- **SendMessage** 向其他代理发送消息
- **Skill** 调用技能
- **TaskCreate/TaskGet/TaskList/TaskOutput/TaskStop/TaskUpdate** 任务管理
- **WebFetch/WebSearch** 获取 URL 内容 / 搜索网络
- **Workflow** 执行多代理工作流脚本
- **Write** 写入文件
</details>