Repository Wiki
LyraVoid/Mizuki

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 替换 → 新页初始化"的完整编排

关键概念:

术语含义
visitSwup 中一次页面导航的完整过程,携带 to.url 等元数据
hookSwup 生命周期事件,通过 window.swup.hooks.on() 订阅,replace() 覆盖默认行为
持久化元素不参与内容替换、跨导航存活的 DOM 节点
首屏优化路径window.swup 未就绪时的降级路径:监听 swup:enable 事件

架构

Loading diagram...

架构说明:

  • 入口层: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.tsinitLinkPreloading() 链接预加载SwupManager
src/scripts/utils/url-utils.tspathsEqual() / url() 路径比较SwupHooksManager

SwupManager:生命周期与初始化

单例与构造

SwupManager 通过模块级变量实现懒加载单例。构造函数做两件事:探测 Banner 是否启用(以 DOM 中是否存在 #banner-wrapper 为准),并提前获取三个 handler 实例:

typescript
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

typescript
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() 的幂等初始化序列

typescript
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

初始化顺序的设计考量:

  1. initialized 守卫保证幂等 —— 多次调用 initSwupManager() 不会重复注册钩子
  2. transitionEffect.applyConfig() 先行 —— 把 TRANSITION_CONFIG 中的动画参数应用到 CSS 变量/DOM,确保后续任何过渡发生时参数已生效
  3. initPanelHandler() 是唯一异步步骤且被 try/catch 包裹 —— 面板处理器可能依赖外部模块加载,失败时仅记录错误不阻断整个管理器启动(见"故障模式")
  4. 钩子注册早于 Banner/预加载 —— 保证首次导航发生时回调链已就绪

DOM 就绪的双分支处理

initSwupHooks() 是管理器中最关键的双路径分支,处理"Swup 是否已就绪"的竞态:

typescript
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() 采用完全相同的双分支模式:

typescript
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():轮播与单图的职责切分

typescript
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():对称销毁

typescript
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 接口定义了钩子管理器对外的全部依赖,全部为可选方法:

typescript
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 缓存,并提供统一的访问入口:

typescript
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():统一注册入口

typescript
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 的默认滚动复位逻辑:

typescript
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 —— 进入过渡态

typescript
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

逐步解析:

  1. 从钩子参数第二位取出被点击的 <a> 元素,读取 href
  2. 用 new URL(href, base) 解析出目标 pathname,try/catch 兜底非法 URL(此时退回原始 href 字符串比较)
  3. pathsEqual() 判断是否站内同页导航:同页则移除 is-page-transitioning 根类(避免无意义的离开动画),跨页则添加该类触发离开动画
  4. 将 CSS 变量 --content-delay 置为 0ms,消除首次加载的内容延迟,让点击后的反馈立即可见
  5. Banner 启用时额外处理 navbar 隐藏,保证视觉层叠正确

content:replace —— 新内容接管点(核心分发)

typescript
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

这是整个子系统最关键的回调点。执行顺序本身就是一个"新页面引导协议":

顺序操作目的
1clearCache()旧元素引用已失效,必须最先执行
2syncMainContentPosition()依据是否首页同步正文容器位置(首页布局不同)
3ensureNavbarVisibleForFullscreen()全屏模式下保证导航栏可见性
4updatePageOverlay()更新页面遮罩层状态
5initFancybox / checkKatex / initCustomScrollbar对新 DOM 重新初始化第三方增强组件
6handleTOCReinit()目录组件随内容变化需重建
7reinitSemifullScrollDetection()semifull 壁纸模式的滚动监听需重新绑定

由于 Swup 替换的只是 #content-wrapper,内嵌其中的增强组件(灯箱、公式、滚动条)的绑定随旧 DOM 一并销毁,必须在替换完成后全部重新初始化 —— 这正是该钩子承担的核心职责。回调全部通过可选链 ?.() 调用,任何一项未注入时静默跳过,保证接口的宽松耦合。

visit:start —— 清理上一页

typescript
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> 类名(首页/非首页布局差异),让离开动画与布局切换在时间上重叠,缩短感知延迟。

核心流程:一次完整导航的时序

Loading diagram...

该时序体现两个关键设计决策:

  1. 清理/初始化严格分离:cleanupFancybox 在 visit:start(替换前)执行,initFancybox 在 content:replace(替换后)执行,两者不会作用于同一份 DOM,从根源上避免"在新 DOM 上重复绑定旧监听"的经典 SPA 缺陷
  2. 滚动行为可覆盖:通过 replace 而非 on 处理 scroll:top,使主题能根据壁纸模式自定义滚动锚点,同时保留默认回退路径

配置选项

所有常量集中在 swup-config.ts,均为 as const 只读导出。

选择器配置 SWUP_SELECTORS

键值说明
contentContainer#content-wrapperSwup 替换的内容容器
animationScope#main-grid过渡动画作用域
persistElements#navbar-wrapper, #sidebar, .music-player跨导航持久化元素
bannerWrapper#banner-wrapperBanner 启用探测目标
banner#banner单图 Banner 元素
bannerTextOverlay.banner-text-overlayBanner 文字遮罩
navbar / navbarWrapper#navbar / #navbar-wrapper导航栏
tocWrapper / tableOfContents#toc-wrapper / table-of-contents目录组件
pageHeightExtend#page-height-extend页面高度扩展占位
backToTopBtn#back-to-top-btn返回顶部按钮
typescript
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

选项类型默认值说明
durationnumber120页面进入动画时长(ms)
easingstring"cubic-bezier(0.25, 0.46, 0.45, 0.94)"进入缓动曲线
easingOutstring"cubic-bezier(0.55, 0.055, 0.675, 0.19)"离开缓动曲线
translateDistancestring"1.5rem"位移动画距离
staggerDelaynumber35子元素错落动画间隔(ms)
typescript
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)页面进入时长
pageLeaveDuration150页面离开时长
heightExtendDelay150页面高度扩展延迟
tocReadyDelay80TOC 就绪延迟
commentInitDelay250评论系统初始化延迟
mobileBannerDelay80移动端 Banner 动画延迟
mobileContentDelay120移动端内容动画延迟

其他常量组

常量组关键项说明
BANNER_HEIGHT / BANNER_HEIGHT_EXTEND / BANNER_HEIGHT_HOME35 / 30 / 65Banner 高度(vh 派生常量)
THEME_CONFIGthemeStorageKey: "theme",lightMode: "light"主题存储与取值
SCROLL_CONFIGthrottleInterval: 16(约 60fps),backToTopOffset: 100,navbarHideOffset: 88滚动节流与阈值
PERFORMANCE_CONFIGsakuraEffect.maxParticles: 60,waveAnimation.layers: 4性能模式粒子/层数上限
PerformanceMode"high" | "medium" | "low" | "auto"性能模式类型
typescript
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

typescript
1constructor(bannerEnabled: boolean, handlers: SwupHookHandlers = {}) { 2 this.bannerEnabled = bannerEnabled; 3 this.handlers = handlers; 4}

参数:

参数类型必填说明
bannerEnabledboolean是Banner 是否启用(影响 navbar 隐藏处理)
handlersSwupHookHandlers否回调集合,默认 {},全部字段可选

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 APItypeof hooks.replace !== "function" 能力检测后跳过registerScrollTopHook()
重复初始化initialized 布尔守卫SwupManager.init()
回调未注入全链路可选链 ?.() 调用SwupHooksManager 各钩子
非法 hrefnew URL() 外层 try/catch,失败退回原始字符串registerLinkClickHook()
选择器缓存返回 undefined?? null 兜底getCachedElement()
hooksManager 为 nullif (this.hooksManager) 判空swup:enable 监听回调
typescript
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 协同降低导航延迟

扩展点

  1. 新增过渡期组件:实现一个新的 handler 模块(遵循 get*Handler() 单例模式),在 SwupManager.init() 中获取实例,并在 initSwupHooks() 的回调对象里追加对应的 initXxx / cleanupXxx 方法 —— SwupHookHandlers 接口的全部字段可选,扩展无需修改钩子管理器内部逻辑
  2. 新增钩子:在 SwupHooksManager.registerHooks() 中追加 register*Hook() 私有方法,复用 getCachedElement 缓存与 this.handlers 回调分发
  3. 调整动画手感:修改 TRANSITION_CONFIG 中的 duration / easing / translateDistance / staggerDelay,init() 会通过 transitionEffect.applyConfig() 将其应用到 DOM
  4. 调整持久化边界:修改 SWUP_SELECTORS.persistElements 数组即可改变哪些组件跨导航存活 —— 这同时决定了"哪些组件必须在 content:replace 中重初始化"
  5. 覆盖滚动行为:scroll:top 的 replace 模式是接入自定义滚动锚点逻辑的官方切口

相关链接

Sources

(3 files)