View Transitions API 如何实现自然的页面切换?
View Transitions API 能在 DOM 更新前后保存页面快照并执行过渡,让列表到详情、筛选和局部刷新更连贯,但它不负责路由、数据请求或焦点管理。
先从一个常见的页面问题开始
假设页面左侧是商品列表,右侧是商品详情。用户点击某个商品后,应用需要请求详情数据、替换右侧内容、修改地址栏,再把焦点放到详情标题上。如果直接执行 innerHTML 或更新框架状态,旧内容会突然消失,新内容突然出现,列表中的图片也无法和详情页里的图片建立视觉联系。
过去通常会自己写淡入淡出、记录旧节点、处理动画结束和快速点击。这样做容易遇到几个问题:动画期间用户又点击了其他商品;旧请求比新请求晚返回;组件已经卸载但回调还在修改 DOM;用户开启了减少动画后仍然被强制播放动画。
View Transitions API 解决的是“DOM 更新前后的视觉衔接”。它会在更新前捕获旧页面快照,执行更新后再捕获新页面快照,然后让浏览器对这些快照做动画。它不会替你请求数据,也不会替你决定哪个路由应该显示。
最小的可运行流程
async function showProduct(productId) {
const response = await fetch(`/api/products/${encodeURIComponent(productId)}`);
if (!response.ok) throw new Error(`请求失败:${response.status}`);
const product = await response.json();
const updateDom = () => {
document.querySelector('#product-name').textContent = product.name;
document.querySelector('#product-description').textContent = product.description;
document.querySelector('#product-image').src = product.image;
document.title = `${product.name} | 商品详情`;
};
if (!document.startViewTransition) {
updateDom();
return;
}
const transition = document.startViewTransition(updateDom);
await transition.finished;
document.querySelector('#product-name')?.focus();
}
这里有一个重要的顺序:先完成数据请求,再把已经准备好的数据提交给过渡回调。这样旧页面不会因为网络慢而长时间停在“正在切换”的状态。对于需要立即反馈的界面,也可以先显示加载状态,再对加载状态到真实内容做一次短过渡。
startViewTransition 的回调只应该负责提交页面状态。不要在里面启动无法取消的长时间计算,也不要把用户确认框放进去。回调返回 Promise 时,浏览器会等待异步更新完成,但等待时间过长会让过渡体验变差。
快速点击时如何避免旧内容覆盖新内容
页面切换最容易出错的情况是:用户先点击商品 A,又马上点击商品 B。A 的请求可能后返回,如果没有保护,它会把页面覆盖成 A,但地址栏已经是 B。可以使用 AbortController 和请求编号:
let currentRequest;
let requestId = 0;
async function openProduct(productId) {
currentRequest?.abort();
const controller = new AbortController();
currentRequest = controller;
const id = ++requestId;
try {
const response = await fetch(`/api/products/${productId}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error('商品加载失败');
const product = await response.json();
if (id !== requestId) return;
const update = () => renderProduct(product);
if (document.startViewTransition) {
await document.startViewTransition(update).finished;
} else {
update();
}
} catch (error) {
if (error.name === 'AbortError') return;
if (id === requestId) showProductError(error);
}
}
取消请求和请求编号分别解决不同问题。AbortController 能节省网络和解析工作,但第三方库或已经开始的同步计算不一定真正响应取消;请求编号则保证过期结果即使返回,也不能提交到当前页面。
共享元素动画为什么需要唯一名称
默认情况下,浏览器可能对整个页面做淡入淡出。如果希望列表里的商品图片移动到详情页,可以给旧页面和新页面中代表同一个商品的元素设置相同的 view-transition-name:
.product-image {
view-transition-name: product-image;
}
::view-transition-old(product-image),
::view-transition-new(product-image) {
animation-duration: 240ms;
object-fit: cover;
border-radius: 12px;
}
名称必须唯一。不能给列表中所有卡片都设置同一个名字,否则浏览器无法判断哪张图片对应详情页。列表通常只给当前点击的卡片临时设置名称,并在过渡结束后清理。若旧元素或新元素不存在,浏览器会回退到根过渡,应用不应依赖动画一定成功来完成业务逻辑。
过渡完成后还要处理焦点和滚动
动画不会自动完成无障碍处理。详情页更新后,键盘用户仍应知道焦点在哪里;标题可以使用 tabindex="-1",更新后调用 focus()。新页面通常滚到顶部,返回列表时则可能要恢复之前的滚动位置,这些都属于路由层逻辑。
如果用户设置了 prefers-reduced-motion: reduce,应关闭或缩短动画:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 1ms;
}
}
还要防止动画期间重复提交、重复发起导航和重复写入表单。可以禁用正在提交的按钮,但不要把整个页面设置成不可聚焦,否则会损害键盘和读屏器使用体验。
哪些场景不适合使用
View Transitions 不能修复数据请求慢、图片没有尺寸导致的布局跳动,也不能替代骨架屏和缓存。如果页面内容变化很小,简单的 CSS 过渡可能更容易维护。大图、视频和很长的页面参与截图时,还可能增加内存和合成开销。
它也不是路由库。React、Vue 或其他框架仍然负责组件树、状态和生命周期,路由库负责地址和导航。View Transitions 只作为渲染更新外层的增强能力。浏览器不支持时,应直接完成同样的 DOM 更新,不能让核心功能依赖动画接口。
在正式项目中,应测试快速连续点击、后退前进、数据请求失败、页面卸载、减少动画设置、低端设备和不支持该 API 的浏览器。确认这些情况都能正常工作后,再为关键页面增加共享元素动画。