用 View Transitions 让页面像花瓣一样切换
从 ClientRouter 到生命周期清理:把多页应用做成一场连续的动画,同时不把代码写成意大利面。
第一次打开一个使用 View Transitions 的网站时,我反复点了好几次导航。
页面没有闪白,没有重新测量布局,旧内容淡出、新内容从下方轻轻升起,像翻过一页带有呼吸感的书。对于一座强调「氛围」的庭院来说,这种连续性几乎是必须的。
Astro 把这项能力封装成了 ClientRouter。
打开方式
只需要在布局组件里引入一次:
---
import { ClientRouter } from 'astro:transitions';
---
<head>
<ClientRouter />
</head>
之后普通的 <a href="/posts"> 就会自动被拦截。浏览器不再重新加载整张页面,而是请求新文档、替换 DOM,并在支持的浏览器里播放 document.startViewTransition() 动画。
不支持 View Transitions API 的浏览器会退化成普通导航。渐进增强,这正是我喜欢它的地方。
它到底替换了什么
这是最重要的一段:ClientRouter 会替换 <body>,而不是只替换 <main>。
意味着:
- 所有绑定在旧
body上的事件监听器会随着节点一起消失。 - 之前启动的定时器、
requestAnimationFrame、IntersectionObserver不会自动清理。 - 你的初始化函数可能被再次调用,而旧实例仍然在后台呼吸。
如果只是写一点静态 HTML,这无关紧要。但只要页面上有樱花粒子、打字机、搜索弹窗和滚动观察器,就很容易出现「切换两次后花瓣变成两倍速」的幽灵 bug。
一个足够笨但可靠的守卫
我的做法是记录当前初始化过的 body 引用。只有 document.body 真的换了,才重新初始化。
let initializedBody = null;
function initPage() {
if (initializedBody === document.body) return;
initializedBody = document.body;
initTheme();
initSearchModal();
initPetals();
initReveal();
initTypewriter();
}
document.addEventListener('DOMContentLoaded', initPage);
document.addEventListener('astro:page-load', initPage);
astro:page-load 在首次加载和每次导航后都会触发。两次调用之间用 body 身份做幂等判断,比维护一堆 isInitialized 布尔值更不容易漏。
在离开之前收拾房间
初始化时创建的资源,需要绑定到一次导航的生命周期上。
let petalController = null;
let petalFrame = 0;
function initPetals() {
petalController?.abort();
cancelAnimationFrame(petalFrame);
petalController = new AbortController();
const { signal } = petalController;
const loop = () => {
drawPetals();
petalFrame = requestAnimationFrame(loop);
};
loop();
window.addEventListener('resize', resizeCanvas, { signal });
}
document.addEventListener('astro:before-swap', () => {
petalController?.abort();
cancelAnimationFrame(petalFrame);
closeSearch(true);
closeMenu();
});
AbortController 在这里像一把收拢花园的工具:一个 abort(),所有通过 signal 注册的监听器一起失效。定时器与动画帧不接收 signal,所以要单独取消。
给转场一点性格
默认的淡入淡出已经很耐看,但既然是一座有樱花的庭院,转场也可以更像「呼吸」。
::view-transition-old(root) {
animation: fade-out 0.28s cubic-bezier(0.65, 0, 0.35, 1) both;
}
::view-transition-new(root) {
animation: rise-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.995);
}
}
注意不要同时给 html 设置 scroll-behavior: smooth 和很长的转场动画。滚动位置恢复、锚点跳转与动画叠在一起时,页面会像喝醉了一样。
尊重「不想动」的人
prefers-reduced-motion 不是可选项。
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
我还会在 JS 里查询同一个媒体条件,让粒子、打字机和计数器直接呈现最终状态。动效是礼物,不是门槛。
幽灵弹窗与焦点
还有一个容易忽略的细节:导航发生时,如果搜索弹窗还开着,旧的弹窗节点会被替换,但 html 上的 search-open 类可能残留,导致新页面无法滚动。
所以 astro:before-swap 里需要主动收起所有覆盖层,astro:after-swap 里清理 body 上的状态类:
document.addEventListener('astro:after-swap', () => {
document.body.classList.remove('menu-open', 'search-open');
});
值得吗
为了几百毫秒的连贯感写这么多代码,值得吗?
我的答案是:值得,但前提是你把它当作体验的一部分,而不是装饰。读者不会注意到转场本身,他们只会觉得这个网站「很顺」。而为了让这份「顺」不变成 bug,初始化逻辑必须比传统多页应用更克制、更可清理。
花园里的每一朵花都会谢。代码也是。重要的是下一次打开时,它还能安静地开。
MARGINALIA
留言0
路过的话,留下一枚脚印吧。
BE KIND, BLOOM SOFTLY.
还没有留言。做第一个在这里留下脚印的人吧。