
初识 View Transition API
前阵子在写 hana-img-viewer 的时候偶然了解到了 View Transition API,感觉挺有意思的,稍微氵一篇文章吧。
简单来说,这就是一个 提供在两个 DOM 之前平滑过渡动画的 API 。一个 DOM 和另一个 DOM 之间,通过 view-transition-name
这个 CSS 属性建立联系,它们之间存在 新 和 旧 的关系。
比如,我用 Vue 写一个 SPA,用了 vue-router 这个路由库。有两个页面:
- ListPage.vue,文章列表页。
- DetailPage.vue,文章详情页。
然后,ListPage 里面有很多的文章 Item,用 Grid 的形式,v-for 循环出来。然后,每个 Item 有 文章封面元素 和 文章标题元素。这两个元素是我们想要去过渡到 DetailPage 的元素。我们需要给这两个元素分别加上 view-transition-name
。
先看看完整的代码吧:
<!-- src/pages/ListPage.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
// 模拟数据
const articles = [
{ id: 1, title: '探索宇宙的奥秘', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
{ id: 2, title: '深入海底两万里', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
{ id: 3, title: '编程语言的演变', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp' },
]
const router = useRouter()
// 自定义导航函数,包裹在 startViewTransition 中
function navigate(to: string) {
// 特性检测
if (!document.startViewTransition) {
router.push(to)
return
}
// 使用 View Transition API
document.startViewTransition(() => {
// 返回的 Promise 会在 DOM 更新后 resolve
return router.push(to)
})
}
</script>
<template>
<div class="list-page">
<h1>文章列表</h1>
<div class="article-list">
<div v-for="article in articles" :key="article.id" class="article-card" @click="navigate(`/item/${article.id}`)">
<img
:src="article.imgSrc"
:alt="article.title"
:style="{ viewTransitionName: `article-image-${article.id}` }"
>
<h2
class="article-title"
:style="{ viewTransitionName: `article-title-${article.id}` }"
>
{{ article.title }}
</h2>
</div>
</div>
</div>
</template>
<style scoped>
.list-page {
padding: 2rem;
max-width: 900px;
margin: auto;
}
.article-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.article-card {
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.article-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.article-card img {
width: 100%;
height: 150px;
object-fit: cover;
display: block;
}
.article-title {
padding: 1rem;
font-size: 1.2rem;
margin: 0;
}
</style>
然后我们在 DetailPage 里面,给 想要进行过渡的元素 加上 相同的 view-transition-name
,即使 old 元素和 new 元素两者之间完全不同(指的是内容不同、标签不同)。不过最好还是除了样式以外完全相同,这样子在视觉层面上会更舒适。
<!-- src/pages/DetailPage.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
id: string // 从路由接收 id
}>()
const router = useRouter()
// 模拟数据源,实际项目中会从 store 或 API 获取
const articles = [
{ id: 1, title: '探索宇宙的奥秘', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '宇宙是广阔无垠的,充满了恒星、星系和未知的现象...' },
{ id: 2, title: '深入海底两万里', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '海洋覆盖了地球表面的70%以上,其深处隐藏着许多秘密...' },
{ id: 3, title: '编程语言的演变', imgSrc: 'https://static-r2.caelum.moe/greyflowers-ogimg.webp', content: '从机器码到高级语言,编程语言的发展极大地推动了技术革命...' },
]
const article = computed(() => articles.find(a => a.id === Number.parseInt(props.id)))
// 自定义导航函数,包裹在 startViewTransition 中
function navigate(to: string) {
// 特性检测
if (!document.startViewTransition) {
router.push(to)
return
}
// 使用 View Transition API
document.startViewTransition(() => {
// 返回的 Promise 会在 DOM 更新后 resolve
return router.push(to)
})
}
</script>
<template>
<div class="detail-page">
<div v-if="article" class="article-content">
<img
:src="article.imgSrc"
:alt="article.title"
class="detail-image"
:style="{ viewTransitionName: `article-image-${article.id}` }"
>
<h1
class="detail-title"
:style="{ viewTransitionName: `article-title-${article.id}` }"
>
{{ article.title }}
</h1>
<p class="detail-text">
{{ article.content }}
</p>
<span class="back-link" @click="navigate('/')">
返回列表
</span>
</div>
<div v-else>
<p>文章未找到!</p>
</div>
</div>
</template>
<style scoped>
.detail-page {
max-width: 800px;
margin: 2rem auto;
padding: 0 2rem;
}
.detail-image {
width: 100%;
height: 400px;
object-fit: cover;
border-radius: 12px;
}
.detail-title {
font-size: 2.5rem;
margin: 1.5rem 0;
}
.detail-text {
font-size: 1.1rem;
line-height: 1.8;
color: #333;
}
.back-link {
display: inline-block;
margin-top: 2rem;
color: #007bff;
text-decoration: none;
cursor: pointer;
}
</style>
别忘记了一些 infra 的配置:
// src/router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import DetailPage from '../pages/DetailPage.vue'
import ListPage from '../pages/ListPage.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'List',
component: ListPage,
},
{
path: '/item/:id',
name: 'Detail',
component: DetailPage,
props: true,
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
export default router
在 main.ts
里面注册一下:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
挂载一下 <RouterView />
:
<!-- src/App.vue -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<main>
<RouterView />
</main>
</template>
好,这样子 两个页面之间的目标过渡元素之间的关系就建立好了。我们可以看到,即使两个关联元素之间的标签、内容都不一样也是可以建立联系的。
那么,接下来就是去 触发。
view-transition
的触发是由专门的浏览器 API 提供的:document.startViewTransition
。这个 API 是一个函数,接收一个回调函数。这个回调函数不能乱传,它必须是 会导致当前视图状态发生改变 的函数。换个简单点的说法,就是 这个回调函数,可以把页面从 old 的 DOM 变到 new 的 DOM 。
当然,从 old 变到 new 的方式根据不同的页面情境有很多种情况,一般有这么几种:
- 路由变化:SPA 的页面切换。
- 数据驱动的列表重排:排序、筛选。
- 内容替换:分页、加载不同标签页的内容。
- UI 状态的重大改变:切换视图布局(如列表视图/网格视图)、主题模式切换。
- 元素的增删:在列表、购物车或任何集合中添加或移除元素。
我们现在在写的就是第一种。我们点 ListPage 中的 Item 之后就会触发 vue-router 的编程式导航:
function navigate(to: string) {
if (!document.startViewTransition) {
router.push(to)
return
}
// 使用 View Transition API
document.startViewTransition(() => {
return router.push(to)
})
}
当然,如果想要返回的时候也同样丝滑地过渡,那么返回的时候也得开一个 ViewTransition。
我们都知道,router.push
的返回是 Promise,我们把它从这个回调中返回出去,它会在 DOM 更新之后被 resolve。
然后,我们就能看到这样的效果:
是不是挺丝滑的?
以上是 ViewTransition API 的默认过渡效果,我们可以通过其提供的伪类来详细地控制这段过渡的效果:
- ::view-transition: 整个过渡的根容器。
- ::view-transition-group(name): 每个被命名的共享元素的容器。
- ::view-transition-image-pair(name): 包含共享元素的旧图和新图。
- ::view-transition-old(name): 旧视图或旧共享元素的“截图”。
- ::view-transition-new(name): 新视图或新共享元素的“截图”。
我们可以改改 App.vue
里的动画效果:
<style>
/* 全局样式,用于自定义过渡动画 */
/* 当我们从详情页返回列表页时,标题会有一个轻微的延迟和滑动效果 */
@keyframes slide-from-right {
from {
transform: translateX(30px);
opacity: 0;
}
}
@keyframes slide-to-left {
to {
transform: translateX(-30px);
opacity: 0;
}
}
/* ::view-transition-old/new(root) 代表整个页面的旧/新视图 */
::view-transition-old(root) {
animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
::view-transition-new(root) {
animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}
/* 针对共享元素的标题进行更精细的控制 */
/* * 选择器可以匹配所有以 'article-title-' 开头的 name */
::view-transition-old(article-title-*) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(article-title-*) {
animation: none;
mix-blend-mode: normal;
}
/* 针对共享元素的图片进行控制,可以给它添加一个不一样的缓动函数 */
::view-transition-group(article-image-*) {
animation-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
</style>
这样子我们就可以控制整个 view-transition 动效的全流程了。