Swup 页面过渡与运行时管理
Swup 页面过渡与运行时管理是 Mizuki 主题前端的核心子系统,它基于 Swup 实现无刷新(SPA 式)页面导航,并在页面切换的各个生命周期阶段协调内容初始化、特效播放、滚动复位与第三方组件(Fancybox、KaTeX、自定义滚动条、TOC)的重挂载。
目的与范围
本页覆盖 Swup 过渡子系统的完整运行时链路:
SwupManager的单例创建、初始化顺序与销毁流程(swup-manager.ts)SwupHooksManager对 Swup 生命周期钩子(link:click、visit:start、content:replace、page:view、visit:end、scroll:top)的注册与分发(swup-hooks.ts)- 过渡动画、Banner、滚动、主题等常量配置(swup-config.ts)
- 处理器(handlers)与特效(effects)子模块在此子系统中的接线方式
有意留给同级页面的内容:各 handler/effect 的内部实现细节(图片灯箱、返回顶部按钮、樱花粒子、过渡特效动画本身)属于独立子能力,本页只说明它们如何被 Swup 管理器编排与回调,不展开其内部算法。主题切换与 Expressive Code 主题映射的具体机制也属于主题管理页面。
概述
传统多页站点在站内跳转时会整页卸载并重建 DOM,导致音乐播放器、导航栏等"持久化"组件被打断,且每次导航都触发完整的资源重载。Mizuki 通过 Swup 解决这个问题:
- 局部替换:仅
#content-wrapper内的 HTML 被替换,#navbar-wrapper、#sidebar、.music-player被声明为持久化元素,跨页面存活 - 动画过渡:内容容器以 120ms 的位移动画淡入,配合
is-page-transitioning根类控制离开/进入两段动画 - 生命周期回调:每次导航在确定的时间点触发钩子,管理器借此完成"上一页清理 → DOM 替换 → 新页初始化"的完整编排
关键概念:
| 术语 | 含义 |
|---|---|
| visit | Swup 中一次页面导航的完整过程,携带 to.url 等元数据 |
| hook | Swup 生命周期事件,通过 window.swup.hooks.on() 订阅,replace() 覆盖默认行为 |
| 持久化元素 | 不参与内容替换、跨导航存活的 DOM 节点 |
| 首屏优化路径 | window.swup 未就绪时的降级路径:监听 swup:enable 事件 |
架构
架构说明:
- 入口层:
SwupManager是唯一的编排者,通过getSwupManager()提供模块级单例,避免重复初始化 - 核心层:
swup-config.ts是纯常量模块(无副作用),被管理器与钩子管理器共同依赖;SwupHooksManager持有一个Map<string, Element | null>作为选择器查询缓存 - 处理器层 / 特效层:四个 handler 与两个 effect 均为独立模块,管理器通过 getter 函数获取实例(
getFancyboxHandler()等),并以回调注入的方式把它们交给钩子管理器 —— 这是为了解耦:钩子管理器不直接 import handler 实现,只依赖SwupHookHandlers接口 - 外部运行时:Swup 实例由 Astro 构建侧挂载到
window.swup,本子系统通过window.swup.hooks与之交互,属于单向依赖(管理器依赖 window.swup,反向不感知)
这种"薄管理器 + 接口注入"的设计意图是:handler/effect 可以独立测试与替换,钩子管理器只关心"何时调用什么",而"具体怎么做"全部下沉到回调对象。
模块组成与职责
| 模块 | 职责 | 被谁使用 |
|---|---|---|
src/scripts/swup-manager.ts | 编排入口:单例、初始化顺序、销毁 | getSwupManager() / initSwupManager() 消费方 |
src/scripts/core/swup-hooks.ts | 注册 Swup 钩子并分发到回调 | SwupManager |
src/scripts/core/swup-config.ts | 选择器、动画、主题、滚动、性能常量 | SwupManager、SwupHooksManager |
src/scripts/handlers/* | Fancybox / 返回顶部 / 面板 / 滚动条与公式 | SwupManager、SwupHooksManager(经回调) |
src/scripts/effects/* | 过渡特效、樱花特效 | SwupManager |
src/scripts/utils/navigation-utils.ts | initLinkPreloading() 链接预加载 | SwupManager |
src/scripts/utils/url-utils.ts | pathsEqual() / url() 路径比较 | SwupHooksManager |
SwupManager:生命周期与初始化
单例与构造
SwupManager 通过模块级变量实现懒加载单例。构造函数做两件事:探测 Banner 是否启用(以 DOM 中是否存在 #banner-wrapper 为准),并提前获取三个 handler 实例:
1// 创建全局实例
2let globalSwupManager: SwupManager | null = null;
3
4/**
5 * 获取全局 Swup 管理器实例
6 */
7export function getSwupManager(): SwupManager {
8 if (!globalSwupManager) {
9 globalSwupManager = new SwupManager();
10 }
11 return globalSwupManager;
12}
13
14/**
15 * 初始化 Swup 管理器(便捷函数)
16 */
17export async function initSwupManager(): Promise<void> {
18 const manager = getSwupManager();
19 await manager.init();
20}Source: swup-manager.ts
1constructor() {
2 this.bannerEnabled = !!document.getElementById(
3 SWUP_SELECTORS.bannerWrapper.slice(1),
4 );
5
6 // 初始化各个处理器
7 this.fancyboxHandler = getFancyboxHandler();
8 this.backToTopHandler = getBackToTopHandler(this.bannerEnabled);
9 this.panelHandler = getPanelHandler();
10}Source: swup-manager.ts
设计意图:bannerEnabled 采用"DOM 探测"而非配置传参,是因为 Astro 组件树中 Banner 是否渲染由主题配置决定,脚本侧通过查询 #banner-wrapper 即可同步感知,避免了配置同步问题。注意 SWUP_SELECTORS.bannerWrapper.slice(1) 去掉了 # 前缀以适配 getElementById 的入参约定。
init() 的幂等初始化序列
1async init(): Promise<void> {
2 if (this.initialized) {
3 return;
4 }
5
6 const transitionEffect = getTransitionEffect();
7 transitionEffect.applyConfig();
8
9 await this.initPanelHandler();
10
11 // 设置 Sakura 特效
12 this.setupSakura();
13
14 // 初始化 Swup 钩子
15 this.initSwupHooks();
16
17 // 初始化返回顶部处理器
18 initBackToTopHandler(this.bannerEnabled);
19
20 // 初始化 Banner
21 this.initBanner();
22
23 // 初始化链接预加载
24 this.initPreloading();
25
26 this.initialized = true;
27 console.log("SwupManager: 初始化完成");
28}Source: swup-manager.ts
初始化顺序的设计考量:
initialized守卫保证幂等 —— 多次调用initSwupManager()不会重复注册钩子transitionEffect.applyConfig()先行 —— 把TRANSITION_CONFIG中的动画参数应用到 CSS 变量/DOM,确保后续任何过渡发生时参数已生效initPanelHandler()是唯一异步步骤且被 try/catch 包裹 —— 面板处理器可能依赖外部模块加载,失败时仅记录错误不阻断整个管理器启动(见"故障模式")- 钩子注册早于 Banner/预加载 —— 保证首次导航发生时回调链已就绪
DOM 就绪的双分支处理
initSwupHooks() 是管理器中最关键的双路径分支,处理"Swup 是否已就绪"的竞态:
1private initSwupHooks(): void {
2 // 创建钩子管理器
3 this.hooksManager = new SwupHooksManager(this.bannerEnabled, {
4 showBanner: this.showBanner.bind(this),
5 initFancybox: async () => {
6 await initFancybox();
7 },
8 cleanupFancybox: () => {
9 cleanupFancybox();
10 },
11 initCustomScrollbar: () => {
12 initCustomScrollbar();
13 },
14 checkKatex: () => {
15 checkKatex();
16 },
17 });
18
19 // 如果 Swup 已经就绪,直接设置钩子
20 if (window?.swup?.hooks) {
21 initFancybox();
22 checkKatex();
23 this.hooksManager.registerHooks();
24 } else {
25 // 监听 Swup 就绪事件
26 document.addEventListener("swup:enable", () => {
27 if (this.hooksManager) {
28 this.hooksManager.registerHooks();
29 }
30 });
31
32 // 监听 DOM 加载(确保首屏也能加载优化组件)
33 if (document.readyState === "loading") {
34 document.addEventListener("DOMContentLoaded", async () => {
35 await initFancybox();
36 checkKatex();
37 });
38 } else {
39 initFancybox();
40 checkKatex();
41 }
42 }
43}Source: swup-manager.ts
三个要点的 WHY:
- 回调注入而非直接调用:
showBanner用.bind(this)保持 this 指向;其余回调是薄包装函数,让SwupHooksManager与 handler 实现完全解耦 swup:enable事件兜底:当脚本在 Swup 实例化之前执行时,通过监听该事件在 Swup 可用后补注册钩子 —— 这是 Swup 官方推荐的就绪通知机制- 首屏路径单独处理:
document.readyState === "loading"时等待DOMContentLoaded再初始化 Fancybox/KaTeX,否则立即执行。这保证**首次硬加载(非 Swup 导航)**的页面也能获得图片灯箱与公式渲染
initBanner() 与 initPreloading() 采用完全相同的双分支模式:
1private initPreloading(): void {
2 if (document.readyState === "loading") {
3 document.addEventListener("DOMContentLoaded", () => {
4 initLinkPreloading();
5 });
6 } else {
7 initLinkPreloading();
8 }
9}Source: swup-manager.ts
showBanner():轮播与单图的职责切分
1/**
2 * 显示 Banner
3 * 轮播图由 Banner.astro 中的内联脚本自行初始化(data-swup-ignore-script),
4 * 此处仅处理单图模式的淡入效果。
5 */
6showBanner(): void {
7 requestAnimationFrame(() => {
8 // 处理单图 Banner (桌面端)
9 const banner = document.getElementById(
10 SWUP_SELECTORS.banner.slice(1),
11 );
12 if (banner) {
13 banner.classList.remove("opacity-0", "scale-105");
14 }
15
16 // 处理移动端单图 Banner
17 const mobileBanner = document.querySelector(
18 '.block.md\\:hidden[alt="Mobile banner image of the blog"]',
19 );
20 if (mobileBanner && !document.getElementById("banner-carousel")) {
21 mobileBanner.classList.remove("opacity-0", "scale-105");
22 mobileBanner.classList.add("opacity-100");
23 }
24 });
25}Source: swup-manager.ts
设计意图:轮播 Banner 的初始化脚本位于 Astro 组件内并标记 data-swup-ignore-script(Swup 会在内容替换时重新执行它),因此这里有意不接管轮播;本方法只负责单图模式的 opacity-0 → opacity-100 + scale-105 → scale-100 淡入。requestAnimationFrame 确保样式变更与浏览器渲染帧对齐,避免首帧闪烁。移动端选择器中 md\\:hidden 的转义是为了在 querySelector 中表示 Tailwind 类名中的冒号。
destroy():对称销毁
1destroy(): void {
2 this.hooksManager = null;
3 this.fancyboxHandler.destroy();
4 this.backToTopHandler.destroy();
5 this.panelHandler.destroy();
6 destroyTransitionEffect();
7 this.initialized = false;
8}Source: swup-manager.ts
销毁按依赖逆序进行:先释放钩子管理器引用,再逐一销毁三个 handler 与过渡特效,最后重置 initialized 使管理器可被再次 init() —— 支持热重载场景下的完整重建。
SwupHooksManager:钩子注册与回调分发
回调接口与选择器缓存
SwupHookHandlers 接口定义了钩子管理器对外的全部依赖,全部为可选方法:
1// 钩子处理器接口
2export interface SwupHookHandlers {
3 fancyboxHandler?: FancyboxHandler;
4 scrollHandler?: ScrollHandler;
5 showBanner?: () => void;
6 initFancybox?: () => void;
7 cleanupFancybox?: () => void;
8 initCustomScrollbar?: () => void;
9 checkKatex?: () => void;
10}Source: swup-hooks.ts
钩子管理器内部维护一个选择器 → 元素的 Map 缓存,并提供统一的访问入口:
1private cachedElements: Map<string, Element | null> = new Map();
2
3private getCachedElement(selector: string): Element | null {
4 if (!this.cachedElements.has(selector)) {
5 const id = selector.startsWith("#") ? selector.slice(1) : selector;
6 if (selector.startsWith("#")) {
7 this.cachedElements.set(selector, document.getElementById(id));
8 } else {
9 this.cachedElements.set(
10 selector,
11 document.querySelector(selector),
12 );
13 }
14 }
15 return this.cachedElements.get(selector) ?? null;
16}
17
18private clearCache(): void {
19 this.cachedElements.clear();
20}Source: swup-hooks.ts
设计意图(WHY 缓存):页面过渡钩子在一次导航内会被多次触发(link:click、visit:start、content:replace、page:view、visit:end),每个钩子都需要查询 #main-grid、#banner 等元素。缓存把重复的 DOM 查询合并为一次,把查询开销摊薄到整个 visit 生命周期。缓存与 DOM 生命周期强绑定:content:replace 钩子第一件事就是 clearCache() —— 内容替换会使旧元素引用失效,不清缓存会拿到已脱离文档的"僵尸元素"。?? null 的兜底处理了 Map 中存了 undefined 的边缘情况。
registerHooks():统一注册入口
1registerHooks(): void {
2 if (!window.swup) {
3 return;
4 }
5 this.registerScrollTopHook();
6 this.registerLinkClickHook();
7 this.registerContentReplaceHook();
8 this.registerVisitStartHook();
9 this.registerPageViewHook();
10 this.registerVisitEndHook();
11 this.updatePageOverlay();
12}Source: swup-hooks.ts
window.swup 缺失时静默返回(防御性编程),否则按"滚动 → 点击 → 内容替换 → 访问开始 → 视图更新 → 访问结束 → 遮罩更新"的固定顺序注册六个钩子。
scroll:top —— 覆盖默认滚动行为
这是唯一使用 hooks.replace()(而非 on())的钩子,用于改写 Swup 的默认滚动复位逻辑:
1hooks.replace(
2 "scroll:top",
3 (visit: VisitObject, args: { options?: ScrollIntoViewOptions }) => {
4 const isFullscreen = this.getCurrentWallpaperMode() === "fullscreen";
5 const isHomePage = pathsEqual(visit.to.url, url("/"));
6 if (isFullscreen && !isHomePage) {
7 const mainGrid = this.getCachedElement("#main-grid") as HTMLElement | null;
8 if (mainGrid) {
9 mainGrid.scrollIntoView({
10 behavior: args.options?.behavior ?? "auto",
11 });
12 return true;
13 }
14 }
15
16 window.scrollTo({
17 top: 0,
18 left: 0,
19 ...args.options,
20 });
21 return true;
22 },
23);Source: swup-hooks.ts
设计意图:全屏壁纸模式下页面顶部是全屏背景而非正文,直接滚到 0 会让用户看不到内容起点。该钩子在"全屏壁纸 + 非首页"场景下改为滚动到 #main-grid(正文网格容器),否则回退到标准的回到顶部。代码还包含能力检测:if (typeof hooks.replace !== "function") return; —— 当 Swup 版本不支持 replace API 时保持默认行为,避免运行时异常。返回 true 表示钩子已处理该滚动,阻止 Swup 默认行为重复执行。
link:click —— 进入过渡态
1private registerLinkClickHook(): void {
2 window.swup!.hooks.on("link:click", ((...args: unknown[]) => {
3 const hookArgs = args[1] as { el?: HTMLAnchorElement } | undefined;
4 const href = hookArgs?.el?.getAttribute("href") || "";
5 const targetPathname = (() => {
6 try {
7 return new URL(href, window.location.href).pathname;
8 } catch {
9 return href;
10 }
11 })();
12 const isSamePage = pathsEqual(targetPathname, window.location.pathname);
13
14 // 移除首次页面加载的延迟
15 document.documentElement.style.setProperty(
16 "--content-delay",
17 "0ms",
18 );
19
20 if (isSamePage) {
21 document.documentElement.classList.remove("is-page-transitioning");
22 } else {
23 document.documentElement.classList.add("is-page-transitioning");
24 }
25
26 // 处理 navbar 隐藏
27 if (this.bannerEnabled) {
28 this.handleNavbarHideOnLinkClick();
29 }
30 }) as (...args: unknown[]) => void);
31}Source: swup-hooks.ts
逐步解析:
- 从钩子参数第二位取出被点击的
<a>元素,读取href - 用
new URL(href, base)解析出目标 pathname,try/catch兜底非法 URL(此时退回原始 href 字符串比较) pathsEqual()判断是否站内同页导航:同页则移除is-page-transitioning根类(避免无意义的离开动画),跨页则添加该类触发离开动画- 将 CSS 变量
--content-delay置为0ms,消除首次加载的内容延迟,让点击后的反馈立即可见 - Banner 启用时额外处理 navbar 隐藏,保证视觉层叠正确
content:replace —— 新内容接管点(核心分发)
1private registerContentReplaceHook(): void {
2 window.swup!.hooks.on("content:replace", () => {
3 this.clearCache();
4 this.syncMainContentPosition(
5 pathsEqual(window.location.pathname, url("/")),
6 );
7 this.ensureNavbarVisibleForFullscreen();
8 this.updatePageOverlay();
9
10 // 初始化新页面的图片、公式、滚动条和 TOC
11 this.handlers.initFancybox?.();
12 this.handlers.checkKatex?.();
13 this.handlers.initCustomScrollbar?.();
14
15 // 处理 TOC 重新初始化
16 this.handleTOCReinit();
17
18 // 重新初始化 semifull 模式滚动检测
19 this.reinitSemifullScrollDetection();
20 });
21}Source: swup-hooks.ts
这是整个子系统最关键的回调点。执行顺序本身就是一个"新页面引导协议":
| 顺序 | 操作 | 目的 |
|---|---|---|
| 1 | clearCache() | 旧元素引用已失效,必须最先执行 |
| 2 | syncMainContentPosition() | 依据是否首页同步正文容器位置(首页布局不同) |
| 3 | ensureNavbarVisibleForFullscreen() | 全屏模式下保证导航栏可见性 |
| 4 | updatePageOverlay() | 更新页面遮罩层状态 |
| 5 | initFancybox / checkKatex / initCustomScrollbar | 对新 DOM 重新初始化第三方增强组件 |
| 6 | handleTOCReinit() | 目录组件随内容变化需重建 |
| 7 | reinitSemifullScrollDetection() | semifull 壁纸模式的滚动监听需重新绑定 |
由于 Swup 替换的只是 #content-wrapper,内嵌其中的增强组件(灯箱、公式、滚动条)的绑定随旧 DOM 一并销毁,必须在替换完成后全部重新初始化 —— 这正是该钩子承担的核心职责。回调全部通过可选链 ?.() 调用,任何一项未注入时静默跳过,保证接口的宽松耦合。
visit:start —— 清理上一页
1private registerVisitStartHook(): void {
2 window.swup!.hooks.on("visit:start", ((...args: unknown[]) => {
3 const visit = args[0] as VisitObject;
4 // 清理上一页的 Fancybox
5 this.handlers.cleanupFancybox?.();
6
7 // 处理页面状态
8 const isHomePage = pathsEqual(visit.to.url, url("/"));
9 this.handleBodyClass(isHomePage);Source: swup-hooks.ts
清理先于替换:在 DOM 被替换之前调用 cleanupFancybox() 释放上一页的灯箱实例(事件监听、动态创建的 DOM),防止内存泄漏与孤儿监听器。visit.to.url 在 visit 开始时即可用,因此可以提前根据目标页面切换 <body> 类名(首页/非首页布局差异),让离开动画与布局切换在时间上重叠,缩短感知延迟。
核心流程:一次完整导航的时序
该时序体现两个关键设计决策:
- 清理/初始化严格分离:
cleanupFancybox在visit:start(替换前)执行,initFancybox在content:replace(替换后)执行,两者不会作用于同一份 DOM,从根源上避免"在新 DOM 上重复绑定旧监听"的经典 SPA 缺陷 - 滚动行为可覆盖:通过
replace而非on处理scroll:top,使主题能根据壁纸模式自定义滚动锚点,同时保留默认回退路径
配置选项
所有常量集中在 swup-config.ts,均为 as const 只读导出。
选择器配置 SWUP_SELECTORS
| 键 | 值 | 说明 |
|---|---|---|
contentContainer | #content-wrapper | Swup 替换的内容容器 |
animationScope | #main-grid | 过渡动画作用域 |
persistElements | #navbar-wrapper, #sidebar, .music-player | 跨导航持久化元素 |
bannerWrapper | #banner-wrapper | Banner 启用探测目标 |
banner | #banner | 单图 Banner 元素 |
bannerTextOverlay | .banner-text-overlay | Banner 文字遮罩 |
navbar / navbarWrapper | #navbar / #navbar-wrapper | 导航栏 |
tocWrapper / tableOfContents | #toc-wrapper / table-of-contents | 目录组件 |
pageHeightExtend | #page-height-extend | 页面高度扩展占位 |
backToTopBtn | #back-to-top-btn | 返回顶部按钮 |
1// 选择器配置
2export const SWUP_SELECTORS = {
3 // 内容容器
4 contentContainer: "#content-wrapper",
5
6 // 动画元素
7 animationScope: "#main-grid",
8
9 // 需要持久化的元素
10 persistElements: [
11 "#navbar-wrapper",
12 "#sidebar",
13 ".music-player",
14 ],
15 // ... banner / navbar / toc 等选择器
16} as const;Source: swup-config.ts
设计意图:集中定义选择器避免了魔法字符串散落各处;persistElements 显式声明了 SPA 化的边界 —— 只有这三类元素(导航、侧栏、音乐播放器)跨页面存活,其余一律随内容替换重建,这个边界同时也是"哪些组件必须重初始化"的判断依据。
过渡动画配置 TRANSITION_CONFIG
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
duration | number | 120 | 页面进入动画时长(ms) |
easing | string | "cubic-bezier(0.25, 0.46, 0.45, 0.94)" | 进入缓动曲线 |
easingOut | string | "cubic-bezier(0.55, 0.055, 0.675, 0.19)" | 离开缓动曲线 |
translateDistance | string | "1.5rem" | 位移动画距离 |
staggerDelay | number | 35 | 子元素错落动画间隔(ms) |
1// 过渡动画默认配置 - 灵感来自 Firefly 主题的快速流畅体验
2export const TRANSITION_CONFIG: TransitionConfig = {
3 duration: 120,
4 easing: "cubic-bezier(0.25, 0.46, 0.45, 0.94)",
5 easingOut: "cubic-bezier(0.55, 0.055, 0.675, 0.19)",
6 translateDistance: "1.5rem",
7 staggerDelay: 35,
8} as const;Source: swup-config.ts
120ms 的极短时长配合小幅位移(1.5rem)刻意追求"快而不闪"的导航体感;进入与离开使用不同的缓动曲线(进入偏 ease-out 的自然减速,离开偏 ease-in 的加速离场),这是常见的动效分层手法。
动画时序配置 ANIMATION_CONFIG
| 选项 | 默认值 | 说明 |
|---|---|---|
pageEnterDuration | = TRANSITION_CONFIG.duration(120) | 页面进入时长 |
pageLeaveDuration | 150 | 页面离开时长 |
heightExtendDelay | 150 | 页面高度扩展延迟 |
tocReadyDelay | 80 | TOC 就绪延迟 |
commentInitDelay | 250 | 评论系统初始化延迟 |
mobileBannerDelay | 80 | 移动端 Banner 动画延迟 |
mobileContentDelay | 120 | 移动端内容动画延迟 |
其他常量组
| 常量组 | 关键项 | 说明 |
|---|---|---|
BANNER_HEIGHT / BANNER_HEIGHT_EXTEND / BANNER_HEIGHT_HOME | 35 / 30 / 65 | Banner 高度(vh 派生常量) |
THEME_CONFIG | themeStorageKey: "theme",lightMode: "light" | 主题存储与取值 |
SCROLL_CONFIG | throttleInterval: 16(约 60fps),backToTopOffset: 100,navbarHideOffset: 88 | 滚动节流与阈值 |
PERFORMANCE_CONFIG | sakuraEffect.maxParticles: 60,waveAnimation.layers: 4 | 性能模式粒子/层数上限 |
PerformanceMode | "high" | "medium" | "low" | "auto" | 性能模式类型 |
1// 滚动配置
2export const SCROLL_CONFIG = {
3 // 节流间隔 (ms)
4 throttleInterval: 16, // 约60fps
5
6 // 返回顶部显示阈值偏移量 (像素)
7 backToTopOffset: 100,
8
9 // Navbar 隐藏阈值偏移量 (像素)
10 navbarHideOffset: 88,
11} as const;Source: swup-config.ts
API 参考
getSwupManager(): SwupManager — swup-manager.ts
获取全局 SwupManager 单例,不存在时懒创建。
返回:SwupManager 全局唯一实例。
initSwupManager(): Promise<void> — swup-manager.ts
便捷初始化函数:内部调用 getSwupManager().init()。幂等,重复调用安全。
抛出:initPanelHandler() 内部已捕获面板初始化异常,正常情况下不抛出。
SwupManager.init(): Promise<void> — swup-manager.ts
按序执行:应用过渡配置 → 初始化面板 → 樱花特效 → 注册钩子 → 返回顶部 → Banner → 链接预加载。
参数:无。
返回:初始化完成的 Promise。重复调用立即返回(initialized 守卫)。
SwupManager.destroy(): void — swup-manager.ts
销毁钩子管理器引用、三个 handler、过渡特效,并重置 initialized。
SwupManager.showBanner(): void — swup-manager.ts
在 requestAnimationFrame 内对单图 Banner(桌面端与移动端)执行淡入。轮播模式由 Banner.astro 内联脚本负责,本方法不参与。
SwupManager.isBannerEnabled(): boolean — swup-manager.ts
返回构造时探测到的 Banner 启用状态(DOM 中是否存在 #banner-wrapper)。
SwupHooksManager 构造函数 — swup-hooks.ts
1constructor(bannerEnabled: boolean, handlers: SwupHookHandlers = {}) {
2 this.bannerEnabled = bannerEnabled;
3 this.handlers = handlers;
4}参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
bannerEnabled | boolean | 是 | Banner 是否启用(影响 navbar 隐藏处理) |
handlers | SwupHookHandlers | 否 | 回调集合,默认 {},全部字段可选 |
SwupHooksManager.registerHooks(): void — swup-hooks.ts
注册全部六个钩子。前置条件:window.swup 已存在;否则静默返回(调用方需自行保证时序,见 initSwupHooks 的 swup:enable 兜底)。
getCachedElement(selector: string): Element | null — swup-hooks.ts
带缓存的选择器查询。# 前缀走 getElementById,其余走 querySelector。缓存随 content:replace 清空。
返回:匹配元素;选择器不匹配时返回 null(而非 undefined)。
故障模式、边界情况与并发
已实现的故障防护
| 场景 | 防护机制 | 位置 |
|---|---|---|
| 面板处理器初始化失败 | try/catch 捕获并 console.error,不阻断管理器启动 | initPanelHandler() |
| Swup 实例未就绪 | 监听 swup:enable 事件延迟注册钩子 | initSwupHooks() |
| 首屏 DOM 未加载完成 | DOMContentLoaded 双分支降级 | initBanner() / initPreloading() / initSwupHooks() |
Swup 不支持 replace API | typeof hooks.replace !== "function" 能力检测后跳过 | registerScrollTopHook() |
| 重复初始化 | initialized 布尔守卫 | SwupManager.init() |
| 回调未注入 | 全链路可选链 ?.() 调用 | SwupHooksManager 各钩子 |
| 非法 href | new URL() 外层 try/catch,失败退回原始字符串 | registerLinkClickHook() |
| 选择器缓存返回 undefined | ?? null 兜底 | getCachedElement() |
hooksManager 为 null | if (this.hooksManager) 判空 | swup:enable 监听回调 |
1private async initPanelHandler(): Promise<void> {
2 try {
3 await initPanelHandler();
4 } catch (error) {
5 console.error("SwupManager: 面板处理器初始化失败", error);
6 }
7}Source: swup-manager.ts
设计意图:面板处理器(如 Live2D 看板娘)属于增强性功能,失败不应导致整个导航子系统不可用,因此采用"记录后继续"的宽松策略;而 Fancybox/KaTeX 初始化失败没有同等包装,由各自 handler 内部处理(其实现属于同级页面范围)。
并发与时序考量
- 缓存生命周期与 DOM 替换同步:
cachedElements的有效窗口是一次content:replace到下一次content:replace之间。钩子在content:replace中第一句即clearCache(),这是必须最先执行的操作,否则后续getCachedElement会返回已被 Swup 移除的旧节点 - 清理与初始化的时间隔离:
cleanupFancybox(visit:start)与initFancybox(content:replace)分别作用于旧/新 DOM,天然避免了在替换瞬间对同一节点重复绑定 - 同一选择器重复查询:
#main-grid在scroll:top、syncMainContentPosition等多处被访问,缓存保证了整个 visit 周期内只查询一次 - 快速连续点击:Swup 自身会对进行中的 visit 做合并/取消处理;本子系统的钩子均为无状态回调 + 缓存清空模式,不持有跨 visit 可变状态,天然可重入
已知边界情况
- 同页链接点击:
link:click中检测到目标与当前 pathname 相同会移除is-page-transitioning,跳过离开/进入动画 —— 避免点击锚点类链接时页面"闪一下" - 全屏壁纸模式滚动:
scroll:top被覆盖为滚动到#main-grid;若该元素不存在则回退到window.scrollTo(top: 0),不会中断导航 - 移动端 Banner:仅当页面不存在
#banner-carousel时才处理移动端单图淡入,防止对轮播模式误操作
性能与运维要点
- DOM 查询摊销:
cachedElements把每次导航内多次querySelector合并为首次一次,配合SCROLL_CONFIG.throttleInterval = 16(约 60fps)的滚动节流,共同控制高频路径开销 - 动画时长极短(120ms 进入 / 150ms 离开):导航感知延迟被压缩到接近瞬时,同时保留动效提示
- 性能模式分级:
PerformanceConfig提供桌面/移动端独立上限(如樱花粒子桌面 60 / 移动端由maxParticlesMobile控制,波浪层数 4/2),PerformanceMode支持high | medium | low | auto四档 - 首屏不等待 Swup:
DOMContentLoaded分支保证硬加载首屏的 Fancybox/KaTeX 照常初始化,Swup 就绪与否不影响首屏体验 - 链接预加载:
initLinkPreloading()(来自utils/navigation-utils)在 DOM 就绪后挂载,用于提前拉取即将访问的页面,与 Swup 协同降低导航延迟
扩展点
- 新增过渡期组件:实现一个新的 handler 模块(遵循
get*Handler()单例模式),在SwupManager.init()中获取实例,并在initSwupHooks()的回调对象里追加对应的initXxx/cleanupXxx方法 ——SwupHookHandlers接口的全部字段可选,扩展无需修改钩子管理器内部逻辑 - 新增钩子:在
SwupHooksManager.registerHooks()中追加register*Hook()私有方法,复用getCachedElement缓存与this.handlers回调分发 - 调整动画手感:修改
TRANSITION_CONFIG中的duration/easing/translateDistance/staggerDelay,init()会通过transitionEffect.applyConfig()将其应用到 DOM - 调整持久化边界:修改
SWUP_SELECTORS.persistElements数组即可改变哪些组件跨导航存活 —— 这同时决定了"哪些组件必须在content:replace中重初始化" - 覆盖滚动行为:
scroll:top的replace模式是接入自定义滚动锚点逻辑的官方切口
相关链接
- swup-manager.ts — 管理器入口与初始化编排
- swup-hooks.ts — 钩子注册与回调分发
- swup-config.ts — 选择器与动画常量
- Swup 官方文档 — hooks API 与
swup:enable事件参考