View Transitions API:状态与导航
用 document.startViewTransition 协调 DOM 状态、ready 与 finished、历史记录、终止处理和减少动效降级。
BotBrowser Team
View Transitions API 可以让同文档路由切换更连贯,但动画只是契约的一部分。BotBrowser 能在授权场景中重放并记录受限的 DOM、生命周期和偏好状态;它不能让不支持的浏览器实现该 API,也不能证明辅助技术会播报什么。当过渡同时涉及路由或动效偏好时,请结合Navigation API 历史与隐私和媒体查询偏好与用户控制阅读。
TL;DR
- 用一次同步 DOM 更新包住
document.startViewTransition(),让更新回调具备幂等性。 - 把
transition.ready视为捕获伪元素可以开始动画的节点,把transition.finished视为视觉状态稳定的节点。 - 更新被拒绝或过渡终止时,应进入可控降级,而不是把它当作路由失败;新的 DOM 仍要可用。
- 先提交应用状态再测量新布局,不要在路由遥测和过渡遥测中重复记录同一次导航或同一个矩形。
- 尊重
prefers-reduced-motion: reduce:保留状态变化和历史记录,只关闭或缩短动画。
内容
建模生命周期
CSS View Transitions 规范描述了旧渲染快照、更新回调以及可通过伪元素动画化的新渲染。JavaScript 入口是 document.startViewTransition(updateCallback)。它会立即返回 ViewTransition 对象,但回调和捕获会稍后发生。这个对象是生命周期句柄,不代表路由已经完成。
为状态明确命名:
| 状态 | 含义 | 可记录字段 |
|---|---|---|
requested | 应用接受了一次过渡请求 | navigationId、路由、动效偏好 |
updating | 回调正在修改所属 DOM | updateStarted: true |
ready | 新伪元素可用于动画 | ready: true,不要声称绘制已完成 |
animating | 动画或即时降级路径正在运行 | animationMode |
finished | 过渡稳定或被跳过 | finished: true |
aborted | 捕获、更新或动画无法继续 | abortReason、降级状态 |
这样就不会把 requested、ready、finished 算作三次导航,它们只是同一事件的阶段。整个序列保持一个 navigationId,并只写一个终态。浏览器可以跳过动画但仍提交 DOM,所以 finished 不等于用户看到了非零时长动画。
在边界处检查该 API。缺少 document.startViewTransition 时,仍执行相同的路由更新,只跳过视觉捕获。不要因为增强能力缺失而改变历史记录语义。
只提交一次 DOM 状态
更新回调是新 DOM 的唯一负责人。保持它同步且短小;在请求过渡前准备好数据,或先提交加载状态。第二次点击要么忽略,要么分配明确的新 navigationId,不能悄悄增加两条历史记录。
let navigationId = 0;
function showRoute(route, render) {
const id = ++navigationId;
const commit = () => {
render(route);
document.documentElement.dataset.route = route;
};
if (!document.startViewTransition) {
commit();
return Promise.resolve({ id, mode: 'fallback', outcome: 'committed' });
}
const transition = document.startViewTransition(commit);
return transition.finished.then(
() => ({ id, mode: 'view-transition', outcome: 'finished' }),
error => ({ id, mode: 'view-transition', outcome: 'aborted', error: error.name })
);
}
使用冻结的路由记录渲染,并只更新一次状态仓库。如果观察者在捕获期间修改另一个元素,旧快照和新路由会混在一起;这是应用竞态,不是盲目重试的理由。只把 view-transition-name 用于跨状态代表同一语义对象的元素。重复名称可能导致捕获失败;未命名元素也必须保持可读。
处理 ready、finished 与 abort
transition.ready 在新的伪元素树可动画化时解决,适合添加类名,但不等于用户已经看到新页面。transition.finished 在动画结束或无动画路径完成后解决。用它清理临时类和释放锁,不要把它当作第二次路由事件。
const transition = document.startViewTransition(updateRoute);
const record = { navigationId, ready: false, outcome: 'updating' };
transition.ready.then(
() => {
record.ready = true;
record.outcome = 'animating';
},
error => {
record.outcome = 'aborted';
record.abortReason = error.name;
}
);
transition.finished.then(
() => {
record.outcome = 'finished';
releaseRouteLock();
},
error => {
record.outcome = 'aborted';
record.abortReason = error.name;
releaseRouteLock();
}
);
如果更新回调抛出异常,保留可用文档并显示现有错误或重试入口。分开记录 domCommitted 与 animationOutcome;DOM 已提交时不应因为动画失败就无条件回滚。abortReason 可限制为 unsupported、duplicate-name、update-error、reduced-motion、superseded。
新导航取代正在运行的过渡后,旧记录的延迟回调不能再写入结果。新路由应从当前状态渲染,而不是恢复过时快照。
避免重复计算历史和测量
pushState() 或 Navigation API 决定路由是否创建历史条目;startViewTransition() 只负责视觉增强。历史操作执行一次,再把结果路由交给过渡回调。popstate 应沿用路由器既定的前进后退语义,不能因为动画启动而再次 push。
Navigation API 与历史指南说明了条目归属。直接沿用其中的 navigationId,不要再创造一个访问计数器。一个路由事件只能有一个过渡尝试和一个终态视觉结果。API 不存在时也保留同一历史调用,并设置 mode: "fallback"。
只有当问题是“新状态捕获后的布局”时才在 ready 后测量;只有当问题是“动画结束后的稳定布局”时才在 finished 后测量。不要把两次矩形相加。
function measureOnce(phase, navigationId, element) {
const rect = element.getBoundingClientRect();
return {
navigationId,
phase,
rect: {
top: Math.round(rect.top),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
};
}
坐标会受滚动、缩放、字体和路由影响。几何是验收条件时,连同 viewport 和 visualViewport 偏移一起记录;不要把矩形变成身份信号。前进后退由历史事件选择目标条目;更晚请求在渲染前标记为 superseded。
构建减少动效的降级路径
MDN 的 prefers-reduced-motion 参考定义了用户偏好。在选择较长的位移效果前先读取它。状态、焦点、路由和历史照常变化,只改变动画。
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.18s;
animation-timing-function: ease-out;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s;
animation-iteration-count: 1;
}
}
每次导航都读取当前偏好。JavaScript 跳过 API 的分支必须提交相同的 DOM。记录 animationMode: "instant" 和 motionPreference: "reduce",但不要把偏好持久化为身份。
视觉过渡不会自动创建焦点陷阱、可访问名称或读屏播报。按路由契约移动焦点,并把键盘和辅助技术检查与动画分开。新的标题应在声明路由就绪前进入 DOM,避免内容闪烁。
运行受限状态矩阵
使用一个 same-origin 场景、稳定数据,并为每行准备新 profile。
| 行 | 条件 | 预期结果 | 验证目标 |
|---|---|---|---|
| A | API 可用,普通动效 | DOM、ready、finished 各一次 | 首选生命周期 |
| B | API 不可用 | DOM 与历史相同,mode: fallback | 兼容路径 |
| C | 重复过渡名称 | DOM 可恢复并记录终止原因 | 捕获失败处理 |
| D | prefers-reduced-motion: reduce | 路由和焦点相同,动画即时完成 | 用户控制 |
| E | 前进或后退条目 | 一个历史事件,没有额外 pushState | 历史归属 |
| F | 过渡中发起新请求 | 旧记录变为 superseded | 过期回调保护 |
{
"fixtureId": "vt-nav-01",
"navigationId": "route-17",
"mode": "view-transition",
"motionPreference": "no-preference",
"domCommitted": true,
"ready": true,
"outcome": "finished",
"historyAction": "push",
"measurementPhase": "ready",
"browserVersion": "155.0"
}
只保留一个 historyAction、一个 navigationId 和至多一个测量阶段。不要把查询参数、页面文字或焦点转录复制到可复用产物中。若 E 产生两个条目,先修路由器再调 CSS;若 D 改变焦点,即使截图正确也属于无障碍回归。
BotBrowser 能力边界
BotBrowser 可以在授权 profile 中加载场景、保持路由和动效偏好并记录可见生命周期标签。它不能强制暴露 startViewTransition,不能保证合成器时序,也不能认证辅助技术播报。重放结果只描述所用浏览器、viewport、profile 和样式表。
来源
- CSS View Transitions Module Level 1 (CSSWG)
- MDN:
Document.startViewTransition() - Chrome for Developers:View Transitions
- MDN:
prefers-reduced-motion - BotBrowser 平台能力
实践结论
让路由决策、DOM 提交和终态过渡结果各有一个负责人。用 ready 表示捕获阶段,用 finished 做清理,对不支持或失败路径明确记录 abort。减少或关闭动效时仍保留历史和焦点语义,在一个命名阶段测量几何。BotBrowser 可以重放这份受限矩阵,最终体验仍由应用和无障碍复核负责。
本文属于指南: Web 平台能力面:一致性复核地图