← 返回文章

JavaScript / 浏览器 API / CSS

View Transitions API 实战:给单页应用加上可控的页面过渡

单页应用切换列表和详情时,DOM 往往在一瞬间全部替换。功能没有问题,但用户很难理解“刚才点击的卡片去了哪里”。过去要做共享元素过渡,需要克隆节点、读取位置、计算 transform,还要处理滚动与窗口变化,维护成本很高。

View Transitions API 把页面更新前后的视觉快照和动画阶段交给浏览器管理。开发者只需说明“什么时候更新 DOM”以及“哪些元素应该建立视觉对应关系”,就能得到渐进增强的过渡效果。

一、它解决的是视图连续性,不是路由本身

View Transitions API 不负责数据请求,也不是路由库。它包裹一次已有的 DOM 更新:浏览器先捕获旧状态,执行更新回调,再捕获新状态,最后在两组快照之间创建动画。

最小示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function renderView(view) {
app.replaceChildren(createView(view));
}

function navigate(view) {
if (!document.startViewTransition) {
renderView(view);
return;
}

document.startViewTransition(() => {
renderView(view);
});
}

能力检测非常重要。不支持 API 的浏览器仍然执行同一份 DOM 更新,只是没有动画。过渡属于增强层,不应该成为导航成功的前提。

二、浏览器在调用期间做了什么

一次同文档过渡可以理解为四步:

  1. 捕获当前页面的旧快照。
  2. 调用传给 startViewTransition() 的更新回调。
  3. 等待回调返回的 Promise 完成,然后捕获新快照。
  4. 使用专用伪元素在旧快照和新快照之间动画。

因此异步渲染可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function openProduct(productId) {
const product = await loadProduct(productId);

if (!document.startViewTransition) {
renderProduct(product);
return;
}

const transition = document.startViewTransition(async () => {
renderProduct(product);
await waitForCriticalImage();
});

await transition.finished;
announcePageChange(product.name);
}

不要把慢网络请求全部放进更新回调。旧快照已经捕获后,长时间等待会让页面停在过渡中间。通常先拿到页面必须的数据,再启动过渡;次要内容可以在新视图出现后继续加载。

返回的 ViewTransition 对象提供 readyupdateCallbackDonefinished 等 Promise,分别适合在动画准备好、DOM 更新结束和整个过渡结束时安排后续工作。业务流程不应为了动画而依赖脆弱的固定时长。

三、从根视图淡入淡出开始

如果不指定共享元素,浏览器会对页面根视图建立过渡。可以通过专用伪元素调整动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
::view-transition-old(root) {
animation: 160ms ease-out both fade-out;
}

::view-transition-new(root) {
animation: 220ms ease-out both fade-in;
}

@keyframes fade-out {
to { opacity: 0; }
}

@keyframes fade-in {
from { opacity: 0; }
}

根视图动画适合简单内容切换。时长要短,避免每次点击都让用户等待一场演出。信息工具、后台系统和高频操作通常比营销页更需要克制。

四、用 view-transition-name 建立共享元素

列表中的商品图片和详情页大图可以使用相同的过渡名称:

1
2
3
4
.product-card[data-active="true"] .product-card__image,
.product-detail__image {
view-transition-name: product-hero;
}
1
2
3
4
::view-transition-group(product-hero) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(.2, .8, .2, 1);
}

浏览器会匹配更新前后的 product-hero,并处理尺寸与位置之间的变化。名称在同一时刻必须唯一;如果列表中每张卡片都设置成同一个名字,过渡会失败或表现异常。

可以只给被点击的卡片添加状态:

1
2
3
4
5
6
function selectCard(card) {
document.querySelectorAll('[data-active="true"]')
.forEach((item) => item.removeAttribute('data-active'));

card.dataset.active = 'true';
}

动态名称也可以基于稳定 ID 生成,但不要把未经处理的任意文本直接拼进 CSS 标识符。名称的职责是建立视觉身份,应该稳定、唯一且可预测。

五、与路由和历史记录配合

单页应用仍要正确更新 URL、标题和历史记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function goToProduct(productId) {
const product = await loadProduct(productId);

const update = () => {
history.pushState({ productId }, '', `/products/${productId}`);
document.title = `${product.name} · 商品详情`;
renderProduct(product);
document.querySelector('main')?.focus();
};

if (document.startViewTransition) {
document.startViewTransition(update);
} else {
update();
}
}

动画不能替代可访问的导航语义。视图变化后仍要维护标题、焦点位置、返回键行为和读屏提示。浏览器视觉上连续,不代表辅助技术已经知道页面发生了变化。

popstate 返回时也可以使用过渡,但方向可能与前进导航不同。可以在根元素加 data-navigation="back",为前进和返回使用不同动画,而不是假设所有切换都从右向左。

六、尊重减少动态效果偏好

部分用户会因为大幅缩放和移动感到不适。必须为 prefers-reduced-motion 提供弱化或取消方案:

1
2
3
4
5
6
7
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root),
::view-transition-group(*) {
animation-duration: 1ms;
}
}

也可以在 JavaScript 中跳过过渡:

1
2
3
4
5
6
7
8
9
10
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');

function updateWithTransition(update) {
if (!document.startViewTransition || reduceMotion.matches) {
update();
return;
}

document.startViewTransition(update);
}

七、性能和交互陷阱

View Transitions API 不是性能优化器。更新回调里如果执行 300ms 的同步计算,主线程仍会卡住。大面积滤镜、超大共享元素和同时运行太多命名过渡,也会增加合成压力。

还要检查这些边界:

  • 用户快速连续导航时,新的过渡如何处理旧过渡?
  • 动画期间按钮是否仍能重复触发请求?
  • 共享元素在新旧视图中是否都存在?
  • 滚动位置变化是否符合预期?
  • 固定定位导航是否应该参与根视图快照?
  • 更新回调抛错时,页面是否仍能呈现可用状态?

动画只能解释状态变化,不能掩盖状态错误。先保证无动画导航完全正确,再加过渡。

八、推荐落地顺序

  1. 为现有渲染函数添加能力检测和根视图淡入淡出。
  2. 检查前进、返回、刷新和深链接是否仍然正确。
  3. 选择一个最有价值的共享元素,不要一次给所有节点命名。
  4. 加入减少动态效果策略。
  5. 在低端设备录制性能,确认没有长任务和明显掉帧。
  6. 用键盘与读屏流程检查焦点、标题和状态通知。

好的页面过渡应该帮助用户理解空间关系,而不是提醒用户“这里用了动画 API”。从短暂的根视图过渡开始,逐步加入少量共享元素,并把无动画路径始终保持为完整功能,才能让体验提升而不是增加新的依赖。

参考资料