以下内容为可公开验证的通用工程方法,示例均为“假设 / 演示”场景,不涉及真实生产数据。
一个容易被忽略的事实
Astro 的 <ClientRouter /> 开启后,页面切换不再触发完整的浏览器加载,而是走 View Transitions API(不支持的浏览器自动回退为 MPA 行为)。这带来一个经典问题:
同一个 <script> 不会因为“看起来重新渲染了”而重新执行。
- 内联脚本(
is:inline):随新页面 DOM 一起被换入,会再次执行。 - 打包模块脚本(普通
<script>):ES Module 只执行一次,第二次进入页面只有事件不监听、DOM 是新的。
所以多模板博客的动效代码必须先回答一个问题:这段代码应该在哪一层运行、由谁清理?
三层分层模型
推荐把全站动效代码拆成三层,每层有明确的所有权:
| 层 | 归属 | 生命周期 | 典型内容 |
|---|---|---|---|
| 全局底座 | 基础布局 | 首次加载执行一次,跨页面存活 | 滚动揭示、涟漪反馈、命令面板 |
| 页面专属 | 单个页面 | 进入页面执行,离开时清理 | 星图物理模拟、文章专注模式 |
| 组件内部 | 单个组件 | 组件挂载后自管 | 计时器、Observer、浮层 |
全局底座:用“清理指针”覆盖旧实例
全局脚本必须能在 DOM 被替换后重建与当前 DOM 的联系。一个可靠的模式是把清理函数挂在 window 上,每次执行先调用旧的:
window.__dsScrollRevealCleanup?.();
const observer = new IntersectionObserver((entries) => {
// ...
});
document.addEventListener('astro:before-swap', cleanup);
window.__dsScrollRevealCleanup = cleanup;
关键点有三个:
- 脚本开头先执行上一次留下的
cleanup,保证旧 Observer、旧计时器全部断开。 astro:before-swap是最后的清理窗口,此时旧 DOM 还在,移除文档级监听器最安全。- 清理函数里既要
disconnect()Observer,也要removeEventListener,缺一都会在长会话中累积。
页面专属:is:inline + 清理指针的组合
页面专属动效(比如力导向图)建议使用 is:inline 脚本:它随页面换入而重新执行,等于每次进入都得到全新实例。配合清理指针,可以覆盖掉上一次的循环:
window.__graphExplorerCleanup?.();
// ... 初始化画布、物理循环、监听器 ...
window.__graphExplorerCleanup = () => {
window.cancelAnimationFrame(frame);
observer.disconnect();
document.removeEventListener('astro:before-swap', cleanup);
};
document.addEventListener('astro:before-swap', cleanup);
不这样做的话,一个正在 requestAnimationFrame 循环里跑的物理模拟,会在离开页面后继续烧 CPU——画面没了,计算还在。
组件内部:谁注册,谁注销
组件脚本最常见的泄漏是把匿名函数同时用作注册和注销:
// ❌ 这行什么都不做:箭头函数是新的引用
toggle.removeEventListener('click', () => setMode(false));
// ✅ 保存引用再注销
const handler = () => setMode(false);
toggle.addEventListener('click', handler);
// ...
toggle.removeEventListener('click', handler);
reduced-motion 不是开关,是分支
很多人把 prefers-reduced-motion 当成一个 CSS 媒体查询一刀切。对涉及 JS 驱动的动画,它是三条不同的代码路径:
- CSS 部分:用 token 化的 duration/easing,在媒体查询里归零。
- JS 循环部分:物理模拟、粒子、打字机这类逐帧逻辑,应该直接不启动 rAF,改为一次性计算终态(比如把力导向模拟同步跑 N 次迭代后画一帧)。
- 交互语义:拖拽、缩放、平移属于直接操作而非装饰动画,reduced-motion 用户仍然需要它们,只是不要“惯性滑动”式的持续运动。
第三点最容易被做错:把所有动画都关掉,等于把功能也关掉了。
自检清单
发布前可以按这个顺序过一遍:
- 每个
requestAnimationFrame循环都有停止条件(alpha 衰减、离开视口、页面隐藏)。 - 每个全局脚本都有
window.__xxxCleanup指针,并在开头调用。 - 页面隐藏(
document.hidden)时动画循环暂停。 -
astro:before-swap里注销了所有文档级监听器。 - Canvas 尺寸跟随容器(
ResizeObserver而不是只监听window.resize)。 - reduced-motion 下逐帧动画不启动,但拖拽/缩放/点击功能完整。
小结
View Transitions 时代的前端动效,核心工程问题不是“怎么让它动”,而是“它由谁拥有、什么时候停止”。三层分层 + 清理指针 + 三分支的 reduced-motion 处理,可以让全站动画在长会话里保持稳定,也让每个页面的专属效果都有明确的归属。