技技术学习庭院建造日志2026.08.06

用 View Transitions 让页面像花瓣一样切换

从 ClientRouter 到生命周期清理:把多页应用做成一场连续的动画,同时不把代码写成意大利面。

4 MIN READ1044 字

第一次打开一个使用 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.

    还没有留言。做第一个在这里留下脚印的人吧。

    登录后会把留言记在你的名字下;不注册也可以直接留言。
    支持换行 · 最多 2000 字0 / 2000