在普通多页面站点中,离开页面会销毁整个文档;加入 Astro ClientRouter 后,页面看起来换了,JavaScript 世界却不一定从零开始。旧页面创建的 ScrollTrigger、RAF、Observer 与 window 监听可能继续存在,新页面的模块脚本又可能因为浏览器认为它已经执行过而不再重跑。第一次访问正常、切换两次后事件重复、返回时触发器指向旧节点,通常不是 GSAP 本身失灵,而是页面没有清楚的资源所有权。

本文面向已在 Astro 页面使用 GSAP、需要保留客户端切页的开发者。前置条件是理解模块脚本、DOM 事件与清理函数。仓库版本为 Astro 7.0.7、GSAP 3.15.0;三段精确 TypeScript 代码已映射到 examples/ai-web/src/web-gsap-*.ts,并通过 examples 工作区类型检查。当前证据不包含真实 Astro 路由上的 GSAP 运行时与设备帧率测试,因此这些结论仍是 source-reviewed

视觉资产记录:封面(1600 × 900)与文中生命周期图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。

先把路由事件翻译成所有权边界

Astro View Transitions 指南给出的顺序是 astro:before-preparationastro:after-preparationastro:before-swapastro:after-swapastro:page-load。其中 preparation 负责获取并准备下一页,before-swap 仍面对当前 DOM,after-swap 已完成 body 替换,而 page-load 会在初次载入以及每次客户端导航结束后触发。

这个顺序可以压缩成一条运行时合同:每次 page-load 只创建一个当前页面作用域;下一次 before-swap 精确销毁它;新的 page-load 再基于新 DOM 创建新作用域。 after-swap 更适合恢复滚动、主题或必须在新 body 出现后立刻完成的同步操作,不应同时再初始化一套同样的页面动画,否则会和 page-load 双重挂载。

Astro 的路由 API 参考还暴露这些事件的类型以及 supportsViewTransitions。它们描述的是导航阶段,不会自动知道第三方库创建了什么。GSAP 动画、插件、媒体查询和原生监听依然要由应用登记并回收。

Astro 切页中的动效初始化与清理顺序

图 1(1200 × 680):一个路由只拥有一个作用域,旧作用域在 body 交换前完成精确清理,新 DOM 到达后重新初始化。原创程序化 SVG,WEB/SUN,2026-07-16。

初始化函数必须返回 cleanup

不要把 gsap.to() 散落在顶层脚本。把一个页面能产生的副作用放进初始化函数,并让它返回幂等 cleanup。入口先确认页面标记,避免一个打包后的模块在所有路由上运行:

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

function initWritingMotion(): () => void {
  const root = document.querySelector<HTMLElement>('[data-writing-page]');
  if (!root) return () => {};

  const controller = new AbortController();
  const context = gsap.context(() => {
    gsap.from('[data-writing-row]', {
      yPercent: 18,
      opacity: 0,
      stagger: 0.035,
      duration: 0.5,
    });
  }, root);

  root.addEventListener('pointermove', handlePointer, {
    signal: controller.signal,
    passive: true,
  });

  let cleaned = false;
  return () => {
    if (cleaned) return;
    cleaned = true;
    controller.abort();
    context.revert();
  };
}

GSAP context() 文档说明,回调期间创建的动画与 ScrollTrigger 会被记录,选择器也可以限制在给定根节点。context.revert() 不只是暂停:它会回退记录的动画、移除其内联样式并释放关联资源。这正适合“页面离开后不应留下痕迹”的场景。原生事件不属于 context,所以示例用 AbortController 同时移除。

这里仍有两条边界。第一,只有在 context 回调或通过 context.add() 登记的延迟创建动画才会自动收集;一次点击之后才生成的 timeline 如果逃出 context,仍会泄漏。第二,revert() 会恢复样式,因此持久化跨页元素要由自己的长生命周期控制器管理,不能归属某个即将销毁的页面。

用一个切页协调器,不要重复注册全局事件

模块脚本可能只执行一次,所以顶层适合安装一个协调器;页面内部效果则每次重建。关键是把当前 cleanup 放在闭包中:

let disposePage: (() => void) | undefined;

function mountCurrentPage() {
  disposePage?.();
  disposePage = initWritingMotion();
}

function unmountCurrentPage() {
  disposePage?.();
  disposePage = undefined;
}

document.addEventListener('astro:page-load', mountCurrentPage);
document.addEventListener('astro:before-swap', unmountCurrentPage);

// 兼容脚本晚于首次 page-load 执行的情况。
mountCurrentPage();

实际项目应确保协调器文件只被共享布局引入一次,或在 globalThis 上放一个明确的安装标志。否则每次页面组件出现都注册新的 astro:page-load,即使内部 cleanup 正确,协调器本身仍会叠加。初始化开头主动调用前一个 cleanup,可以抵抗热更新、脚本重复执行和异常导航带来的重复挂载。

若站点没有 ClientRouter,这些自定义事件不会构成完整生命周期,但常规文档卸载会回收资源;代码仍应让静态 HTML 独立可用。不要为了动画把导航改成只有 JavaScript 能点击的元素。

媒体查询也属于生命周期

桌面滚动编排、移动触摸反馈与 prefers-reduced-motion 不应由三组永久监听拼接。gsap.matchMedia()会在查询匹配时运行回调,在条件不再匹配时回退其中收集的动画;整个页面离开时再调用 mm.revert()

const mm = gsap.matchMedia();

mm.add(
  {
    desktop: '(min-width: 64rem) and (pointer: fine)',
    reduce: '(prefers-reduced-motion: reduce)',
  },
  (context) => {
    const { desktop, reduce } = context.conditions!;
    if (reduce) return;
    if (desktop) buildPinnedPreview();
    else buildTapFeedback();
  },
);

return () => mm.revert();

减少动态不是把 duration 乘以 0.2 后继续滚动钉住,而是移除非必要位移、视差、自动播放与长弹性,让内容顺序和操作结果不依赖动画。媒体条件改变时 GSAP 会重新执行对应回调,因此回调内也不能创建无法回收的原生监听。

为什么不能全局 killAll()

ScrollTrigger 文档提供单个实例的 kill(),也存在 ScrollTrigger.killAll()。后者在独立页面调试时很方便,在共享站点却会越过所有权边界:导航条、持久光标、另一个岛屿或下一页刚创建的触发器都可能被误杀。正确做法是让页面创建的触发器进入页面 context,或保存确切实例数组逐个销毁。

同理,GSAP ticker是全局 RAF 心跳。只有确实需要接入该时钟时才 add(),cleanup 必须以同一函数引用 remove();不要在页面离开时休眠整个 ticker,因为其他 GSAP 动画也使用它。若只是普通 tween,GSAP 已负责调度,不需要再包一层自己的 RAF。

防住异步初始化越过切页边界

按路由动态加载 GSAP 插件能缩小首屏包,却引入另一种竞态:用户在 import()、图片解码或字体 Promise 完成前已经离开,旧 Promise 随后仍可能在新 body 上查询节点并创建动画。页面 scope 需要一个 disposed 标志,任何 await 返回后先检查它;若资源对象已经创建,则立即释放而不是继续 mount。

ScrollTrigger 的尺寸计算也要等布局稳定。图片有明确宽高可以降低重排,字体或异步内容真正改变几何后,才对当前页面拥有的触发器安排一次 refresh()。不要把 refresh() 放进 resize 的每次原始事件,也不要用全局刷新掩盖组件没有尺寸合同的问题。异步任务、触发器与 refresh 请求都应归属同一页面 scope,cleanup 后不得再次执行。

常见失败模式

症状 根因 验证与修复
返回页面后动画执行两次 全局路由监听重复安装 记录 mount/cleanup 序号,保证同一时刻仅一个页面作用域
ScrollTrigger 指向旧节点 body 交换前没有 revert astro:before-swap 调用当前页面 cleanup
移动端仍下载整包 GSAP 插件 静态顶层 import 在能力、路由和媒体条件通过后动态 import 页面模块
新页面闪出旧内联 transform kill() 未恢复样式 对页面动画使用 context.revert(),核查持久元素归属
切页后导航动效停止 使用 killAll() 仅清理当前 context 或保存的实例
reduced-motion 切换无效 只在首次加载读取媒体查询 使用 matchMedia 生命周期并提供静态状态

用生命周期测试,而不是只看首屏

最小回归不是刷新一次,而是 A → B → A → B,期间改变视口宽度与 Reduce Motion,再检查:每次页面恰好 mount 一次;离开后旧节点没有事件响应;ScrollTrigger 数量回到预期;控制台没有访问已断开节点;页面隐藏时没有自建 RAF;键盘与触摸仍能取得 hover 等价信息。开发环境可给每个 scope 分配递增 ID,在创建与清理时输出,生产构建移除日志。

性能结论必须来自录制。本文没有为某个页面采集设备、刷新率和 trace,因此不写“清理后快了多少”。可以证明的是结构不变量:同一时刻页面作用域数量为一,离开后登记资源归零,路由外持久组件不被误杀。之后再以固定导航脚本、相同设备和 Performance trace 比较长任务、事件数量与帧时序。

结论

Astro 负责告诉应用“页面将换”和“新页已完成”,GSAP 负责描述动画,却没有任何一方能替你定义资源归属。把页面动效收束为 init() → cleanup(),用 gsap.context()matchMedia()管理 GSAP 资源,用 AbortController、Observer disconnect 与 RAF cancel 管理原生副作用,并在 before-swap 精确回收,就能让高水准动效经得住反复导航,而不是只在第一次打开时成立。

Sources