在普通多页面站点中,离开页面会销毁整个文档;加入 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-preparation、astro:after-preparation、astro:before-swap、astro:after-swap、astro: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 动画、插件、媒体查询和原生监听依然要由应用登记并回收。
图 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
- Astro View Transitions guide — Astro Documentation,访问于 2026-07-16。
- Astro View Transitions Router API — Astro Documentation,访问于 2026-07-16。
- gsap.context() — GSAP,访问于 2026-07-16。
- gsap.matchMedia() — GSAP,访问于 2026-07-16。
- GSAP ticker — GSAP,访问于 2026-07-16。
- ScrollTrigger — GSAP,访问于 2026-07-16。