在前端开发中,页面切换时的视觉体验一直是个痛点。传统的单页应用(SPA)通过路由切换内容,但往往缺少流畅的过渡效果,页面变化显得突兀生硬;而多页应用(MPA)则只能依赖浏览器原生的页面加载行为,更谈不上什么过渡动画。为了解决这个问题,开发者不得不引入 Framer Motion、GSAP、Vue Transition 等第三方库,写大量的状态管理代码来实现元素进出动画。
View Transitions API 的出现彻底改变了这一局面。它是浏览器原生提供的页面过渡方案,无需任何第三方依赖,就能实现堪比原生 App 的丝滑过渡效果。从 Chrome 111 开始支持,到如今已覆盖 Chromium 全系列浏览器,Safari 18 也已跟进支持。本文将从原理到实战,带你全面掌握这项令人兴奋的 Web 新能力。
一、View Transitions API 的核心原理
View Transitions API 的核心思想非常巧妙——它并不直接操作 DOM,而是利用浏览器的合成层(Compositing)能力,在旧状态和新状态之间创建视觉过渡。整个流程分为四个关键步骤:
- 捕获旧状态:浏览器对当前页面的可见元素进行截图,生成旧状态的视觉快照
- DOM 更新:开发者正常修改 DOM(添加、删除、移动元素),浏览器不受限制
- 捕获新状态:浏览器对新 DOM 状态进行截图,生成新状态的视觉快照
- 动画过渡:浏览器在旧快照和新快照之间执行交叉淡入淡出(cross-fade)动画,或根据匹配的 view-transition-name 执行元素级别的过渡动画
这个设计的精妙之处在于:过渡动画完全由浏览器的合成线程处理,不会阻塞主线程,因此即使 DOM 更新较为复杂,动画依然能保持 60fps 的流畅度。
伪元素层级结构
当 View Transition 激活时,浏览器会创建以下伪元素树:
1
2
3
4
5
6
7
8
9
10 ::view-transition
├── ::view-transition-group(root)
│ ├── ::view-transition-old(root)
│ └── ::view-transition-new(root)
├── ::view-transition-group(header)
│ ├── ::view-transition-old(header)
│ └── ::view-transition-new(header)
└── ::view-transition-group(card-1)
├── ::view-transition-old(card-1)
└── ::view-transition-new(card-1)
其中 ::view-transition-old() 是旧状态的截图,::view-transition-new() 是新状态的截图。默认情况下,浏览器对 root 组执行交叉淡入淡出动画,而自定义的命名过渡组则可以配置独立的动画效果。
二、SPA 中的基础用法
在单页应用中使用 View Transitions API 非常简单,核心就是 document.startViewTransition() 方法。它接受一个回调函数,在回调中执行 DOM 更新操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // 基础用法:点击切换照片
async function switchPhoto(newIndex) {
// 检测浏览器是否支持
if (!document.startViewTransition) {
updatePhotoDOM(newIndex);
return;
}
// 启动 View Transition
const transition = document.startViewTransition(() => {
updatePhotoDOM(newIndex);
});
// 可选:等待过渡动画完成
await transition.finished;
console.log('过渡动画已完成');
}
function updatePhotoDOM(index) {
const img = document.querySelector('.photo');
img.src = photos[index].url;
img.alt = photos[index].title;
document.querySelector('.photo-title').textContent = photos[index].title;
}
仅此而已!无需配置任何动画参数,浏览器默认就会为新旧状态添加一个 250ms 的交叉淡入淡出效果。虽然效果简单,但已经比生硬的瞬间切换好太多了。
配合 Vue / React 使用
在框架中使用时,关键是在 DOM 更新前后正确调用 API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 // Vue 3 组合式 API 示例
import { nextTick } from 'vue';
async function navigateWithTransition(route) {
if (!document.startViewTransition) {
router.push(route);
return;
}
const transition = document.startViewTransition(async () => {
router.push(route);
await nextTick(); // 等待 Vue 完成 DOM 更新
});
await transition.finished;
}
// React 示例
function handleNavigation(path) {
if (!document.startViewTransition) {
navigate(path);
return;
}
document.startViewTransition(async () => {
navigate(path);
// React 18 自动批处理,flushSync 确保立即更新
flushSync(() => {});
});
}
注意 React 中需要使用 flushSync 强制同步更新,否则 React 的批处理机制会导致 DOM 更新延迟,从而让新状态截图为空。
三、自定义过渡动画:view-transition-name
默认的交叉淡入淡出虽然不错,但真正的威力在于 view-transition-name。通过给元素指定唯一的过渡名称,浏览器可以自动匹配新旧状态中的同一元素,并为其创建独立的过渡动画——包括位置变化时的平滑移动和尺寸变化时的平滑缩放。
1
2
3
4
5
6
7
8
9
10
11
12
13 /* 为卡片元素分配唯一的过渡名称 */
.photo-card {
view-transition-name: none; /* 默认不参与过渡 */
}
.photo-card.active {
view-transition-name: active-photo;
}
/* 标题的过渡名称 */
.page-title {
view-transition-name: page-title;
}
关键规则:每个 view-transition-name 在同一时刻必须是唯一的。如果两个可见元素拥有相同的名称,浏览器会跳过该名称的过渡。这就是为什么上面的例子中只有 .active 的卡片才分配名称。
用 JavaScript 动态分配名称
在列表场景中,我们通常需要动态分配过渡名称:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 // 给每个列表项分配基于 ID 的唯一过渡名称
function setupTransitionNames() {
document.querySelectorAll('.list-item').forEach(item => {
const id = item.dataset.id;
item.style.viewTransitionName = `item-${id}`;
});
}
// 切换详情页时,给目标元素分配固定名称
async function openDetail(itemId) {
// 先给即将进入详情的元素分配 'detail-hero' 名称
const card = document.querySelector(`[data-id="${itemId}"]`);
card.style.viewTransitionName = 'detail-hero';
const transition = document.startViewTransition(() => {
// 更新 DOM 到详情视图
renderDetail(itemId);
// 详情页的大图也用 'detail-hero' 名称
document.querySelector('.detail-hero').style.viewTransitionName = 'detail-hero';
});
await transition.finished;
// 过渡完成后清理,避免后续冲突
card.style.viewTransitionName = '';
}
这种”动态分配、匹配过渡、清理名称”的模式是实现列表到详情页 Hero 动画的核心技巧。
四、CSS 自定义动画效果
通过伪元素,我们可以完全控制过渡动画的时长、缓动函数和关键帧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39 /* 自定义根级别的过渡效果 */
::view-transition-old(root) {
animation: fade-out 300ms ease-out both;
}
::view-transition-new(root) {
animation: fade-in 300ms ease-in both;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
/* 为命名过渡组自定义动画 */
::view-transition-old(active-photo) {
animation: shrink-out 400ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(active-photo) {
animation: grow-in 400ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes shrink-out {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes grow-in {
from {
opacity: 0;
transform: scale(1.2);
}
}
方向感知动画
一个常见需求是根据导航方向(前进/后退)播放不同动画。我们可以利用 :active-view-transition-type() 伪类或 JavaScript 设置类型来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 // 在 startViewTransition 中设置类型
const transition = document.startViewTransition({
update: () => {
updateDOM();
},
types: ['slide-forward'], // 自定义过渡类型
});
// 后退时使用不同类型
const backTransition = document.startViewTransition({
update: () => {
updateDOM();
},
types: ['slide-back'],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 /* 根据过渡类型应用不同动画 */
:active-view-transition-type(slide-forward) {
::view-transition-old(root) {
animation: slide-out-left 300ms ease both;
}
::view-transition-new(root) {
animation: slide-in-right 300ms ease both;
}
}
:active-view-transition-type(slide-back) {
::view-transition-old(root) {
animation: slide-out-right 300ms ease both;
}
::view-transition-new(root) {
animation: slide-in-left 300ms ease both;
}
}
@keyframes slide-out-left {
to { transform: translateX(-30%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(30%); opacity: 0; }
}
@keyframes slide-out-right {
to { transform: translateX(30%); opacity: 0; }
}
@keyframes slide-in-left {
from { transform: translateX(-30%); opacity: 0; }
}
五、MPA 跨页面过渡
这是 View Transitions API 最令人兴奋的扩展——传统的多页应用也能享受丝滑过渡!Chrome 126+ 支持了跨文档的 View Transition,通过一个简单的 meta 标签即可启用:
1
2 <!-- 在 <head> 中添加 -->
<meta name="view-transition" content="same-origin">
仅此一行,同源页面之间的导航就会自动获得交叉淡入淡出效果。浏览器会在旧页面卸载前截图,在新页面渲染后截图,然后在两者之间执行过渡动画。
跨页面动画的 CSS 配置
由于涉及两个不同的文档,CSS 需要在两个页面中保持一致:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 /* 两个页面中都要定义 */
.site-header {
view-transition-name: site-header;
}
.sidebar {
view-transition-name: sidebar;
}
/* 排除不需要过渡的元素 */
.ad-banner,
.cookie-notice {
view-transition-name: none;
}
浏览器会自动匹配两个页面中相同 view-transition-name 的元素,实现跨页面的平滑过渡。比如导航栏在页面切换时不会闪动,而是平滑地保持在原位。
阻止自动过渡
并非所有导航都需要过渡动画。通过 pageswap 和 pagereveal 事件可以精确控制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 // 在页面中监听 pageswap 事件
window.addEventListener('pageswap', event => {
// 如果是跨域导航,不执行过渡
if (!event.activation.navigationType.includes('cross-origin')) {
return;
}
// 如果用户按了 Ctrl 点击(新标签页打开),跳过过渡
if (event.activation.navigationType.includes('new-tab')) {
event.skipTransition();
}
});
// 在新页面监听 pagereveal 事件
window.addEventListener('pagereveal', event => {
// 如果是从外站跳转来的,跳过过渡
if (!event.activation.fromExternal) {
return;
}
event.skipTransition();
});
六、实战案例:图片画廊 Hero 动画
下面我们用一个完整的图片画廊案例来串联所有知识点。效果是:点击缩略图,图片平滑飞入到详情页大图位置,配合背景淡入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 <!-- 缩略图列表 -->
<div class="gallery">
<img src="photo1.jpg" class="thumb" data-id="1" alt="风景">
<img src="photo2.jpg" class="thumb" data-id="2" alt="人像">
<img src="photo3.jpg" class="thumb" data-id="3" alt="建筑">
</div>
<!-- 详情视图 -->
<div class="detail-view" hidden>
<img class="detail-image" src="" alt="">
<h2 class="detail-title"></h2>
<p class="detail-desc"></p>
<button class="back-btn">&larr; 返回</button>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49 /* CSS 关键样式 */
.thumb {
cursor: pointer;
border-radius: 8px;
transition: transform 0.2s;
}
.thumb:hover {
transform: scale(1.05);
}
.detail-image {
view-transition-name: hero-image;
border-radius: 12px;
}
/* 过渡动画自定义 */
::view-transition-old(hero-image) {
animation: 350ms ease-out both fade-scale-out;
}
::view-transition-new(hero-image) {
animation: 350ms ease-in both fade-scale-in;
}
@keyframes fade-scale-out {
to {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes fade-scale-in {
from {
opacity: 0;
transform: scale(1.1);
}
}
/* 标题过渡 */
.detail-title {
view-transition-name: detail-title;
}
::view-transition-group(hero-image) {
/* 位置和尺寸变化使用更平滑的缓动 */
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 // JavaScript 交互逻辑
const gallery = document.querySelector('.gallery');
const detailView = document.querySelector('.detail-view');
const detailImage = document.querySelector('.detail-image');
const detailTitle = document.querySelector('.detail-title');
const backBtn = document.querySelector('.back-btn');
const photos = [
{ id: '1', src: 'photo1.jpg', title: '山间晨雾', desc: '清晨的山谷笼罩在薄雾中...' },
{ id: '2', src: 'photo2.jpg', title: '城市人像', desc: '街头的一瞬间...' },
{ id: '3', src: 'photo3.jpg', title: '古典建筑', desc: '百年历史的建筑...' },
];
// 点击缩略图 → 打开详情
gallery.addEventListener('click', async (e) => {
const thumb = e.target.closest('.thumb');
if (!thumb) return;
const photoId = thumb.dataset.id;
const photo = photos.find(p => p.id === photoId);
// 关键:给缩略图分配与详情图相同的过渡名称
thumb.style.viewTransitionName = 'hero-image';
const transition = document.startViewTransition(() => {
// 更新 DOM
gallery.hidden = true;
detailView.hidden = false;
detailImage.src = photo.src;
detailImage.alt = photo.title;
detailTitle.textContent = photo.title;
});
await transition.finished;
// 过渡完成后清理缩略图的过渡名称
thumb.style.viewTransitionName = '';
});
// 点击返回 → 回到画廊
backBtn.addEventListener('click', async () => {
const currentPhotoId = detailImage.src.match(/photo(\d)/)?.[1];
const thumb = document.querySelector(`[data-id="${currentPhotoId}"]`);
// 再次分配过渡名称以实现返回动画
if (thumb) {
thumb.style.viewTransitionName = 'hero-image';
}
const transition = document.startViewTransition(() => {
detailView.hidden = true;
gallery.hidden = false;
});
await transition.finished;
if (thumb) {
thumb.style.viewTransitionName = '';
}
});
七、性能优化与注意事项
View Transitions API 虽然由浏览器合成线程驱动,但仍需注意性能问题:
| 优化项 | 建议 | 原因 |
|---|---|---|
| 减少参与过渡的元素 | 只给关键元素设置 view-transition-name | 每个命名过渡组都需要截图和合成,过多会影响性能 |
| 避免大面积元素参与 | 大尺寸容器不要设置过渡名称 | 截图像素量与元素尺寸成正比,大图会消耗大量 GPU 内存 |
| 缩短动画时长 | 控制在 200-400ms | 过渡期间旧状态截图常驻内存,过长的动画增加内存压力 |
| 使用 will-change | 在过渡前设置 | 提前创建合成层,减少首帧延迟 |
| 及时清理名称 | 过渡完成后移除动态名称 | 避免名称冲突导致后续过渡失败 |
内存管理
过渡期间,浏览器需要同时持有旧状态和新状态的截图。对于包含大量图片或复杂布局的页面,这意味着 GPU 内存消耗会短暂翻倍。在移动设备上,这可能导致页面被系统回收。建议:
1
2
3
4
5
6
7
8
9
10 // 在低端设备上禁用或简化过渡
if (navigator.hardwareConcurrency <= 4 || navigator.deviceMemory <= 2) {
// 禁用 view-transition-name
document.documentElement.classList.add('reduce-transitions');
}
/* 配合 CSS */
.reduce-transitions * {
view-transition-name: none !important;
}
八、浏览器兼容性与渐进增强
截至目前(2025年底),View Transitions API 的浏览器支持情况如下:
- Chrome 111+:完整支持 SPA 的 startViewTransition()
- Chrome 126+:支持 MPA 跨文档过渡
- Safari 18+:支持 SPA 过渡,MPA 过渡仍在开发中
- Firefox:仍在实现中(Nightly 版本可用)
渐进增强策略非常简单:
1
2
3
4
5
6
7
8
9
10
11 // 标准的渐进增强模式
function updateView() {
if (document.startViewTransition) {
document.startViewTransition(() => {
updateTheDOMSomehow();
});
} else {
// 不支持时直接更新,无过渡效果
updateTheDOMSomehow();
}
}
这种写法确保在不支持的浏览器中功能完全正常,只是缺少动画效果。也可以使用 CSS @supports 查询:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 /* 仅在支持 View Transitions 时应用特定样式 */
@supports (view-transition-name: auto) {
.card {
view-transition-name: var(--card-name);
}
}
/* 不支持时的降级方案 */
@supports not (view-transition-name: auto) {
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.02);
}
}
九、与第三方动画库的对比
很多开发者会问:我已经在用 Framer Motion / Vue Transition / GSAP 了,还需要 View Transitions API 吗?答案是:两者并非替代关系,而是互补关系。
| 特性 | View Transitions API | Framer Motion | GSAP + Flip |
|---|---|---|---|
| 依赖 | 无(浏览器原生) | React 生态 | 需引入库 |
| 跨页面过渡 | MPA 原生支持 | 仅 SPA 内 | 需手动处理 |
| DOM 退出动画 | 自动处理 | 需 AnimatePresence | 需手动处理 |
| 弹簧物理动画 | 仅 CSS 缓动 | 内置弹簧系统 | 可自定义 |
| 手势交互动画 | 不支持 | 拖拽/滑动 | 可自定义 |
| 性能 | 合成线程 | 主线程 | 主线程 |
| 学习成本 | 低 | 中 | 高 |
最佳实践是:页面级别的过渡用 View Transitions API(简单、高性能、零依赖),组件级别的交互动画继续使用你熟悉的库。两者可以完美共存。
十、总结与展望
View Transitions API 是近年来 Web 平台最令人兴奋的新能力之一。它用极低的 API 表面积解决了长期困扰前端开发的页面过渡问题,让原生 Web 应用的视觉体验终于可以与原生 App 一较高下。
核心要点回顾:
- SPA 中使用 document.startViewTransition() 即可启用,无需任何配置
- view-transition-name 是实现元素级过渡动画的关键,名称必须唯一
- 通过伪元素可以完全自定义动画效果和时长
- MPA 跨文档过渡只需一行 meta 标签
- 渐进增强策略简单直接,不支持时自动降级为无动画
- 注意性能优化,减少参与过渡的元素数量和尺寸
随着 Firefox 的实现推进和 Safari 持续完善 MPA 支持,View Transitions API 将很快成为所有现代浏览器的标准能力。现在就是最佳的学习和接入时机——零成本升级,巨大体验提升,没有理由不开始使用它。
汤不热吧