Astro 是多页面架构,点击链接原本会加载新文档;View Transitions 可以让两个页面在视觉上连续,但视觉连续不等于运行时连续。加入 <ClientRouter /> 后,模块脚本通常不会像完整刷新那样再次执行,旧页面创建的 RAF、Observer 和事件监听也不会自动知道自己应当退出。只处理快照动画,很快就会得到“页面越切越卡”的站点。

本文面向已经有 Astro 页面与客户端交互、准备加入跨路由动效的开发者。当前项目使用 Astro 7.0.7,Astro 的 ClientRouter 与生命周期事件按稳定文档实现;W3C View Transitions Level 1 与 Level 2 仍分别处于 Candidate Recommendation Draft 与 Working Draft,MDN API 总览 也要求按具体能力查看兼容性。四段精确代码均已进入 examples:Astro/HTML 结构和 CSS 通过静态契约测试,生命周期 TypeScript 通过类型检查。浏览器原生跨文档能力仍必须在目标环境核验;跨浏览器视觉和性能结论保持 source-reviewed

先选原生 MPA 还是 ClientRouter

浏览器原生跨文档 View Transition 在两个同源文档都通过 @view-transition { navigation: auto; } 选择加入时,可以为用户发起的合格导航生成快照过渡。CSS View Transitions Level 2 定义跨文档生命周期,同时明确它仍是 Working Draft。它不把 MPA 改造成客户端路由,也不需要为了切页加载一套 Astro 路由脚本;原有脚本仍按新文档加载。

Astro 的 <ClientRouter /> 指南 说明另一条路径:组件拦截内部链接与前进后退,在浏览器内准备和交换新页面,提供 transition 指令、持久元素、共享状态与不支持原生 API 时的 fallback。代价是导航行为变成 SPA-like,页面脚本需要适配重挂载,额外客户端代码也进入预算。

选择标准不是“哪个更新”。只需要简单同源 crossfade、希望保留纯 MPA 语义时,优先评估原生跨文档能力并接受无动画回退;需要岛屿状态持久、精确 swap 生命周期或当前浏览器覆盖下的统一 fallback 时,再使用 ClientRouter。不要同时搭建两套互相竞争的路由运行时。

最小 ClientRouter 合同

在所有参与客户端导航的页面共享布局中加入组件:

---
import { ClientRouter } from 'astro:transitions';
---

<html lang="zh-CN">
  <head>
    <ClientRouter fallback="swap" />
  </head>
  <body>
    <slot />
  </body>
</html>

fallback="swap" 表示不支持原生过渡时仍使用客户端交换,但不模拟动画;animate 是默认模拟策略,none 则回到完整页面导航。Astro 文档的 fallback 说明 是产品决策,不应只因默认值存在就忽略 Firefox、WebKit 与 Reduced Motion 实测。

HTML 链接仍然是基础接口。脚本失败、ClientRouter 未加载或链接带 data-astro-reload 时,目标页面必须能独立打开。不要用 div click handler 替代站内 <a>,也不要让 loader 覆盖内容等待动画模块;静态标题、导航与主要内容先可用,过渡只是增强层。

客户端切页也不会消除网络与解析成本。下一页 HTML、阻塞样式和必要资源尚未准备时,快照不能凭空生成完成状态。预取可以降低某些导航等待,但会消耗带宽并可能读取用户从未访问的页面;应按链接意图、网络条件与隐私要求选择,而不是全站强制。重型 WebGL、Lab 运行时和非首屏图片继续按路由延迟加载,不能为了路由动画提前塞进入口包。

导航反馈必须反映真实准备状态。若下一页很快完成,直接进入 transition;若网络变慢,保留浏览器或站点的可理解 pending 信号,但不要先隐藏旧页再显示空白。取消或快速改点另一个链接时,前一次准备任务不能在后台完成后抢占当前路由。

快照匹配要少而稳定

Astro 会尝试按元素类型和 DOM 位置自动匹配旧页与新页;需要稳定身份时,用 transition:name 显式关联。一个名称在同一页面只能出现一次,否则浏览器无法得到唯一的 view-transition-name。名称应来自页面结构,例如 article-cover,不要从数组 index 或每次渲染的新 UUID 生成。

---
interface Props {
  post: { slug: string; cover: { src: string }; title: string };
}

const { post } = Astro.props;
---

<a href={`/blog/${post.slug}/`}>
  <img src={post.cover.src} alt="" transition:name={`cover-${post.slug}`} />
  <span>{post.title}</span>
</a>

命名越多不代表效果越精致。每个参与元素都会产生快照伪元素与动画工作;正文段落、按钮和图标全部独立飞行,会让阅读顺序难以理解。通常只让封面、标题或一个明确容器承担空间连续性,其余内容使用根级 crossfade。真实焦点仍在 DOM 中,快照不应承担交互。

transition:persist 会在两个页面间保留元素或 island,而不是用新节点替换旧节点,适合持续播放的媒体或必须保留的客户端状态。它有明确限制:iframe 与 CSS animation 等状态不能保证完全无重启;持久 island 默认还可能用新 props 重渲染,只有额外的 transition:persist-props 才保留现有 props。持久化之前先定义所有权,不能把整个 header 和它的所有监听都永久留下只为省一次初始化。

Astro ClientRouter 从准备、交换到新页面挂载的事件轨道

图 1:旧页面运行时在 swap 前清理,新页面只在 page-load 后挂载;本站原创 1400×800 程序化 SVG。

模块脚本只执行一次,页面初始化可执行多次

Astro 文档指出,打包后的 module script 在一次访问会话中通常只执行一次,即使后来导航到同样包含该脚本的页面;inline script 则可能再次执行。因此,顶层代码负责注册一次全局生命周期监听,真正的页面初始化放进 astro:page-load。该事件在初始预渲染页与后续导航完成后都会触发。

type Cleanup = () => void;

let cleanup: Cleanup = () => {};

function mountPage(): Cleanup {
  const page = document.querySelector<HTMLElement>('[data-page-runtime]');
  if (!page) return () => {};

  const controller = new AbortController();
  const stopMotion = initPageMotion(page, controller.signal);

  return () => {
    controller.abort();
    stopMotion();
  };
}

document.addEventListener('astro:before-swap', () => {
  cleanup();
  cleanup = () => {};
});

document.addEventListener('astro:page-load', () => {
  cleanup();
  cleanup = mountPage();
});

astro:before-swap 在下一页已经准备、但 DOM 尚未交换时触发,适合销毁旧页面拥有的 RAF、GSAP context、Observer、timer 和 listener;astro:after-swap 在 body 已交换、过渡尚未结束时触发,适合必须紧贴新 DOM 的轻量同步;astro:page-load 在新页可见且阻塞样式与脚本完成后触发。Astro transitions API reference 给出了这些事件的顺序与类型。

cleanup 必须幂等,因为错误恢复、快速导航或手工重挂载可能多调用一次。只关闭本页面创建的资源,不调用全局 ScrollTrigger.killAll() 或移除其他页面共用监听。异步 import() 和 fetch 也要有取消或“当前导航 token”,否则旧页面结果可能在新页面挂载后回来写错 DOM。

动画 CSS 与文档结构分离

View Transition 动画作用在 ::view-transition-* 伪元素树上,不是原 DOM。可以给根快照定义短 crossfade,给少量命名元素定义几何过渡:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 240ms;
  animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Astro ClientRouter 自身包含针对 Reduced Motion 关闭过渡动画的媒体查询,但站点自己的 GSAP、Canvas、平滑滚动和页面入场仍需遵守同一偏好。不要因为路由快照已停,就继续让新页面每个区块逐个弹入。

快照期间大图片、视频和带滤镜的区域会增加像素工作。过渡时长也会直接延迟用户看到最终清晰状态;不要把路由动画做成不可跳过的片头。首次访问、前进后退、哈希链接、查询参数、外链、下载链接与表单提交都要验证,不应一律被相同动画拦截。

焦点、滚动与历史不是视觉细节

ClientRouter 会处理历史与滚动恢复,astro:before-swap 的默认 swap 还包含 focus 保存等步骤。除非产品有明确需求,不要自定义 swap 后再忘记这些行为。若确实覆盖 event.swap(),应使用 Astro 提供的 swap functions 按合同组合,而不是只替换 body.innerHTML

页面标题、canonical、meta 与 landmark 必须来自新文档。导航完成后键盘用户应位于合理位置:浏览器后退通常恢复上下文,主动打开新文章则可让主标题成为跳转目标,但不要每次 page-load 都强制 focus body。Hash 目标和 skip link 仍需工作。

分析统计和页面曝光也要绑定正确事件。顶层模块只加载一次意味着传统“脚本执行即 pageview”可能漏记后续页面;应在 astro:page-load 读取当前 URL,并确保初始页与后续导航各记一次。反过来,不要在 before-swap 与 page-load 同时记录,造成重复。任何外部分析仍需服从用户同意和站点隐私策略。

回归矩阵与失败模式

至少测试原生支持与不支持 View Transition 的浏览器、脚本禁用、Reduced Motion、慢网络、快速连点、前进后退、字体与图片延迟。观察是否出现重复监听、离屏 RAF、旧页异步回写、重复 transition name、持久 island props 过期、焦点丢失、历史条目异常和页面内容被 loader 遮挡。

常见失败包括:把 ClientRouter 当零成本 CSS;在每页脚本顶层重复注册;没有 cleanup;所有元素都命名快照;用 persist 掩盖状态设计;全局杀死动画实例;自定义 swap 丢失 focus 与 head;无 JS 时链接不可用;Reduced Motion 只关掉根 crossfade。

Astro 的优势不是把 MPA 伪装成 SPA,而是允许你选择连续性的范围。快照只负责解释页面关系,ClientRouter 生命周期负责旧页退出与新页挂载,HTML 链接负责最终可达性。三者边界清楚,页面切换才能既炫又不会累积运行时债务。

Sources