从页面过渡到持久外壳:个人网站的客户端生命周期治理
记录个人网站如何从分散的页面脚本演进为持久站点外壳,并治理浮层、导航、首帧状态、字体就绪、页面增强、标签筛选和 Pagefind 搜索中的生命周期与视觉连续性问题。
- 首次发布
- 最后更新
本文目录
我的个人网站仍然是一组由 Astro 构建的静态页面,但它已经不再只有“打开 HTML、阅读、离开”这一种运行方式。主题、景观壁纸、移动导航、文章目录、交互图、图片查看器和站内搜索都需要客户端状态;页面之间又通过局部导航切换。此时,网站虽然不是传统单页应用,却已经拥有一个长期运行的客户端运行时。
这轮重构最初由几个很小的现象触发:弹窗在断点切换后失去触发器,页面导航时全局控制器被重复初始化,正文在页面切换完成后又整体跳一次,鼠标停在导航链接上时指针会闪一下,搜索结果在每次输入后先消失再出现。它们表面上属于不同组件,背后却指向同一个问题:谁拥有一段 DOM,谁负责它的状态,它应该存活到什么时候,又由谁清理。
本文不按提交顺序罗列修改,而是记录最终留下的架构边界、曾经走错的方向,以及这些问题之间为什么会相互影响。景观壁纸自己的数据链路、随机队列和首帧图层实现仍放在个人网站轮换景观背景的实现与体验优化中。
浮层先暴露了所有权问题
壁纸设置、版本比较和移动导航都属于临时界面。早期实现把关闭、定位、焦点和页面切换清理分别写在组件里,单个场景通常能工作,但组合起来就会出现边界问题:
- 桌面壁纸菜单打开后缩窄窗口,触发按钮已经隐藏,浮层却仍然存在;
- 页面导航开始后,旧页面的浮层可能继续停留到内容交换阶段;
- 原生 Popover API 与兼容实现对
aria-expanded的同步时机不同; - 多次执行入口脚本后,旧监听器仍然响应事件;
- 一个浮层打开时,另一个浮层并不知道自己应该关闭。
最终没有继续为每个弹窗增加局部条件,而是建立统一的临时界面契约:
- 使用原生 Popover API 表达打开、轻触关闭和 Escape 语义,并为不支持的浏览器加载兼容层;
- 同一时刻只允许一个临时浮层处于活动状态;
- 导航开始时统一关闭;
- 视口尺寸或方向变化后,重新确认浮层是否仍有一个可见触发器;
- 触发器隐藏、断开连接或不再适合作为定位锚点时,关闭浮层而不是猜测新位置;
- 移动端直接把壁纸设置放进导航面板,不让一个桌面弹窗同时承担两种布局职责。
这里最重要的不是 Popover API 本身,而是把“临时界面”定义成了一个全站概念。组件只声明自己是临时浮层,公共控制器负责互斥、验证和清理。
全局脚本也必须有唯一实例
局部导航会重新执行新页面中的脚本,开发服务器和部署版本切换也可能让同一入口再次运行。仅依靠模块级变量或组件卸载回调,并不能保证旧实例一定已经退出。重复的滚动监听、媒体查询监听和计时器会造成非常难解释的偶发行为。
网站因此加入了一个很小的客户端运行时注册表。控制器以名称声明所有权;同名控制器再次启动时,注册表先释放旧实例,再允许新实例注册。监听器、计时器、观察器和其他清理函数都挂在同一个释放句柄上。
claimClientRuntime('navigation') ├─ 注册事件监听 ├─ 注册额外清理函数 └─ 再次 claim 时先 dispose 旧实例主题、导航、壁纸、Reveal、文章目录和临时浮层最终都遵守这一契约。它解决的不是某一次内存泄漏,而是长期运行的静态网站缺少明确运行时所有权这个结构性问题。
站点外壳不应该跟随正文一起重建
最初的网站使用文档级页面过渡。它适合快速获得整页切换效果,但导航栏、壁纸和全局控制器也会进入页面交换或过渡快照的作用范围。即使视觉位置没有变化,真实节点仍可能被迁移、重建或短暂失去命中关系。
最终改用 Swup,但关键并不是“换了一个路由库”,而是收紧替换边界:
body├─ SiteChrome 长期存活│ ├─ 导航栏与进度反馈│ ├─ 主题控制│ ├─ 壁纸图层、设置与摄影署名│ └─ 移动导航与无障碍状态│└─ #swup 页面出口 ├─ main 每次导航替换 └─ footer 每次导航替换Swup 的 containers 只包含 #swup,animationScope 也设置为 containers。因此内容淡入淡出只改变页面出口,动画类不再写到 <html>,站点外壳及其祖先不参与过渡状态变化。Swup 的选项说明也明确区分了“被替换的容器”和“接收动画状态的范围”;两者在这里被统一为同一个边界。
这个外壳本身也是这轮重构的产物。此前主题开关、语言切换、壁纸控制各自是一个独立的 Astro 组件,页面一换就整批重新执行;现在它们被合并成同一个持久的 Solid 组件,跨页面只存在一份实例。新的 HTML 仍然包含完整站点头部数据,但路由不会替换现有 SiteChrome:它从已解析的新文档中读取一段经过验证的上下文,只更新语言、链接、文案和 aria-current 等状态。导航栏、壁纸图层、定时器和全局控件始终是原来那个 Solid 实例。
动画对象也要按同样的边界切分。主题切换用的是根元素上的 View Transition,它必须覆盖整份文档——配色本来就是全站的;内容切换用的是页面出口内部的淡入淡出。早期两者共用一套过渡,结果是切换主题时整页被卷进内容动画,中途露出一帧旧的浅色配色。把它们拆成两个互不相干的动画对象之后,这类中间态才彻底消失。同一个视觉效果由谁拥有,取决于它作用的范围,而不是它触发的时机。
脚本执行范围也必须随之收紧。Swup Scripts Plugin 默认面向整份文档重新执行脚本,这会破坏“外壳永久存在”的前提。当前实现只让它扫描 #swup:页面专属的交互图和增强脚本可以重新运行,外壳旁边的启动脚本不会被替换。
持久状态和页面状态必须分层
采用持久外壳之后,页面生命周期可以明确分成两类:
| 层级 | 典型状态 | 生命周期 |
|---|---|---|
| 站点级 | 主题、壁纸、导航进度、移动菜单 | 跨页面持续存在 |
| 页面级 | 文章目录、图片查看器、交互图、Reveal、搜索实例 | 内容交换前释放,交换后重建 |
路由层统一发出交换前、交换后和页面就绪事件。页面级控制器不再分别猜测 Astro、Solid 或第三方库何时完成,而是围绕同一组边界清理和初始化。
文章目录是这个原则的典型例子。目录链接由服务端直接输出,客户端只负责观察滚动位置并原位更新 aria-current。它不再先输出空壳,再由目录库销毁并重建整棵 DOM。无 JavaScript 时目录仍然完整;启用 JavaScript 后只是获得当前章节追踪和刷新恢复。
首帧状态则比水合更早。<head> 中的同步启动脚本在绘制前读取已经持久化的主题、字体修订、滚动位置、文章章节和壁纸状态,形成唯一快照。Nanostores 和 Solid 从这份快照启动,而不是在水合后重新推断一次。
滚动恢复是这里一个容易做过头的细节。浏览器自己的 scrollRestoration 在多数情况下表现良好,只有刷新和前进/后退这两种情况下站点确实存有更准的位置记录,才把它切换成 manual 并自行恢复;其余导航一律交还 auto,页面卸载时也主动还原。接管一项浏览器默认行为的前提,是确实比浏览器知道得更多;否则最好的做法是不接管。
这形成了一条清晰的数据流:
持久化状态 ↓绘制前快照 ↓SSR 完整结构 ↓客户端接管并增强只有后一个阶段从前一个阶段接管,而不是重新创建另一份“真相”,首帧、刷新和客户端导航才会保持一致。
字体就绪是第三种生命周期
站点级和页面级两层分完之后,还剩下一类状态哪一层都装不下:字体。它不跟着页面走,也不完全属于站点——它是异步的、跨页面的,而且所有需要测量文字几何的代码都在等它。
这个问题在持久外壳下反而更刺眼。整站使用本地可变字体,正文、标题、代码和 CJK 各有一套,数学公式还要额外加载 KaTeX 自带的一批字形。如果内容先交换、字体后到达,用户会先看到回退字体排好的一版布局,再看着它整体跳一次。旧的整页过渡还能把这次跳动藏在动画里;换成只替换内容出口之后,周围的外壳纹丝不动,正文的重排就格外明显。
按页面实际内容推导字体需求
最直接的做法是等所有字体都加载完,但那等于为一篇纯英文短文也付出整套 CJK 字库的代价。当前实现改成从文档本身推导需求:扫描目标页面的文本,只有真正出现 CJK 字符时才要求 CJK 字族;出现 em/i 才要求斜体;出现代码块才要求等宽;出现 .katex 才加载数学字形,并进一步根据 mathcal、mathfrak、mathsf、mathtt 这些类名判断需要哪几族,而不是把 KaTeX 的字体一次全拉下来。
更关键的是,需求不止指定字族,还把页面上实际出现的那些字符一并交给 document.fonts.load(descriptor, text)。浏览器据此只确保这批字形可用。对 CJK 这种动辄上千字的字库,“这一页用到的字”和“整个字库”完全是两个量级。
在内容交换之前,为目标页面准备字体
推导需求的输入是 visit.to.document——Swup 已经解析好、但尚未替换上屏的新文档。字体准备挂在 content:replace 之前:等新页面真正需要的字形就绪,再执行交换。
这条顺序是整个设计的重点。等到交换完成再加载,无论加载多快,用户都必然先看到一版错误的布局;在交换之前完成,则布局从出现的第一帧起就是最终形态。持久外壳把“页面切换不重排”做到了外壳,字体准备把它补全到了正文。
就绪状态必须有一个失败终点
字体加载没有成功保证——网络可能失败,字体文件可能缺失,用户可能处于极慢的连接上。因此就绪被建模成一个显式的状态机:cold 是首次冷启动,warm 表示这套字体此前已经就绪过,revealing 是首访时那一小段揭示动画,ready 是正常终点,degraded 是失败终点。
degraded 的存在比其他四个都重要:超时之后不是继续等,而是明确宣布“就用回退字体”,让所有等待方立即放行。绘制前脚本还额外埋了一个兜底计时器——即使控制器脚本根本没有加载成功,根元素上的状态也会在超时后自己从 cold 翻到 degraded。一个所有下游都在等待的状态,必须保证它一定会到达某个终点。
用字体版本给“已经就绪”打指纹
首访需要揭示动画,回访不需要——否则每次打开网站都要看一遍同样的淡入。所以“这套字体已经就绪过”被记在本地存储里。
但这条记录一旦过期就会骗人:升级字体包之后,浏览器缓存里其实没有新字体,本地存储却还说“已就绪”,于是回访者会跳过揭示、直接看到一次真实的换字。解决办法是给这条记录一个指纹——由所有字体依赖包的版本号哈希而来,写在构建产物里。字体包一升级,指纹就变,所有访客的旧记录自动失效,同时顺手清掉存储中的历史键。
这条记录始终只是优化。存储被禁用或写入失败时,代码只是少一次跳过揭示的机会,字体加载契约本身不受影响。缓存可以让流程更快,但不能成为流程正确性的前提。
让下游等待,而不是让下游猜
文章目录要测量章节位置,Plotly 图表要按容器尺寸布局,代码对比面板要计算等宽字符宽度,Reveal 动画要在正确的几何上开始——它们无一例外依赖文字的最终尺寸。
在此之前,这些组件各自用超时或若干帧延迟“等一等”,本质上是在猜字体什么时候好。现在它们统一 await 同一个就绪 Promise:谁需要测量文字,谁就在这个 Promise 之后开始。这和前面浮层、控制器的处理方式是同一个思路——把散落在各处的隐式时序假设,收敛成一个显式的、可以被等待的契约。
一次鼠标指针闪烁带来的诊断教训
这次重构中最容易误判的问题,是点击持久导航链接后,系统手形指针会短暂变回箭头。先后怀疑过整页过渡、Swup 动画范围、HeadPlugin 的样式同步、Solid 节点更新和焦点迁移,其中一些确实值得治理,却都不能解释最后残留的极短脉冲。
普通网页测试始终显示:
elementFromPoint()命中同一个导航链接;- 链接 DOM 实例没有变化;
- 位置和尺寸没有变化;
getComputedStyle(...).cursor始终为pointer。
真实 Wayland cursor-shape 协议与 Chrome Performance trace 最后把残留现象定位到了浏览器进程:在我测试的 Linux/Wayland + Chromium(Ozone/Aura 后端)组合下,history.replaceState() 和 history.pushState() 会短暂进入同文档 loading 状态,原生界面层会把系统指针提交为默认箭头,再恢复页面请求的手形。整个窗口只有约一到两毫秒,通常落在两个 requestAnimationFrame 之间,因此既不会体现在 DOM 状态里,也抓不进 Playwright 截图。
这个结论划清了两个边界:
- 持久外壳、容器级动画和稳定命中区域仍然有独立价值,它们消除了应用自己造成的节点替换、全页覆盖和重复初始化;
- 只要客户端导航仍要正确更新 URL 和前进/后退记录,网站代码就无法承诺这一平台组合下的系统指针绝无原生脉冲——这是浏览器进程的行为,不在页面的管辖范围内。
排查途中一种看似合理的解释把问题归因于 HeadPlugin,并建议持久化全部样式表、把清理推迟到导航结束。受控实验表明,完整保留 HeadPlugin、内容交换和动画,只抑制 History API 时系统指针没有变化;单独调用 History API 则可以复现。错误方案不仅没有命中根因,还会让页面专属样式不断累积。
这段经历改变了测试方法:自动化测试继续守住 DOM 身份、命中区域、焦点和计算样式;涉及系统指针或合成器的问题,则必须使用有头浏览器、原生协议或性能轨迹。测试通过只能证明它实际观察到的那一层是对的,不能代替用户真正看到的现象。
等待不等于禁用
慢速导航需要反馈,但等待状态不能顺手破坏交互。当前导航控制器在开始访问时记录目标,超过短暂阈值才显示不确定进度条;完成后保证最短可见时间,再平滑退出。进度条覆盖在导航栏边缘,不改变其高度;主内容用 aria-busy 暴露状态。
重复点击同一个目标由逻辑层合并,而不是把原链接禁用或用加载节点替换它。壁纸刷新、下载等异步操作也遵守相同原则:只要动作仍然合理,触发器就保持原节点和命中区域;真正没有下一张照片或景观模式已经关闭时,控件才进入不可用状态。
这比全局添加 cursor: pointer 更重要。光标样式只能描述“这里可以操作”,却无法弥补节点被移除、命中区被覆盖层接管或控件被业务代码错误禁用这些真实缺陷。
筛选结果与筛选器不是同一个状态
博客标签最初直接使用 Pagefind 的 Filter Pane。这个组件提供的是动态分面:每次结果变化后,它都会根据剩余结果重新计算标签、顺序和数量。这个模型适合电商筛选,却不符合博客标签目录的语义——标签应当描述全站内容,用户选择标签只是在这份目录上缩小文章范围。
最终的数据流被改成单向关系:
全部逻辑文章 ──→ 固定标签目录、顺序与总数 │ └────────→ 当前选择 ──→ 文章过滤 ──→ 分页结果标签目录由 Astro 在构建时从逻辑文章生成。一篇文章无论有几种语言、多少个历史版本,都只计一次;统计只读取最新版本,同一篇文章中的重复标签也会先去重。运行期间,选择状态只影响文章结果、结果总数、页码和 URL,不会反向删除标签、改变标签数字或重新排序。Pagefind 因此退出博客筛选,只继续承担全站全文搜索。
数据稳定还不够,布局也必须稳定。筛选面板曾经是动态结果网格中的直接粘性子项,右侧文章高度跨过某个阈值时,面板会突然获得或失去一小段可滚动空间,表现为选择若干标签后整体跳动。最终由独立的 Sidebar 布局提供稳定的纵向边界,Sidebar 外壳负责粘性定位,标签面板只负责内容与内部滚动;结果列可以任意增减,而不再参与决定筛选器的位置。
对应的测试不再断言“第三次点击不跳”,而是连续选择和取消不同标签,并验证标签名称、顺序、数字与面板顶端坐标始终不变。这里的经验与持久外壳完全一致:会变化的结果和不应该变化的控制面,必须先在数据所有权和布局所有权上分开。
搜索页把所有边界同时考了一遍
持久外壳稳定后,Pagefind 搜索成了检验页面级生命周期的最佳场景。它同时包含外部组件注册、异步结果、URL 状态、浏览器历史、键盘行为、无 JavaScript 回退和客户端往返恢复。
保留搜索引擎,只重做产品层
Pagefind 继续负责索引、排序、分词、方向键和无障碍播报。网站只为可搜索页面补充“页面、文章、项目”类型元数据,并重做结果卡片、摘要、章节命中、空状态与加载状态。搜索页本身不进入索引,首页、项目列表、About、文章和项目详情仍然属于全站搜索范围。
夸张的大标题、技术说明和键盘教学最终都被删除。搜索页面打开后直接进入功能本身;Escape 只让输入框失去焦点,不清空查询,清空动作留给明确的清除按钮。
URL 必须同时属于浏览器和路由器
最初输入查询时只调用原生 history.replaceState()。地址栏已经变成 ?q=技术,但 Swup 自己记录的 history.state.url 和当前位置仍是无查询地址。点击结果再后退时,路由器据此恢复成 /search/,查询和结果都丢失。
现在查询更新使用 Swup 的 history helper,并同步它的当前位置。直接访问、客户端进入、点击结果后返回以及浏览器前进/后退,都从同一个 URL 恢复。Pagefind 的输入和结果组件完成注册之后才触发恢复搜索,避免事件发出得比新组件注册更早。
这里还遇到过一个很隐蔽的 HTML 解析差异:放在 <noscript> 中的 <style>,直载页面时只服务于无 JavaScript 状态;Swup 使用 DOMParser 解析返回 HTML 时,却可能把它作为普通样式节点处理,导致有 JavaScript 的搜索界面被隐藏。最终无 JavaScript 回退保留结构和文案,但不再依赖具有双重解析语义的内联样式。
结果切换需要缓冲,但不能叠加半透明图层
Pagefind Component UI 每次搜索会先清空旧列表,再插入骨架,最后逐项换成真实结果。搜索词从 gi 变成 git 时,用户看到的不是自然更新,而是整张候选列表闪空。
当前实现只接管“旧结果如何交给新结果”:
- 已有结果被复制成不可交互的前缓冲;
- 快照标记为
aria-hidden、inert和不可点击; - Pagefind 在原结果容器中生成新列表;
- 当前视口中的骨架消失后,同一帧显示新列表并删除快照。
这套缓冲最初根本没生效,而原因出在样式所有权上。快照是整段结果列表的克隆,因此连 Pagefind 的 pf-results 类名一起复制了过来——那正是让它保持列表外观的原因。但 Pagefind 自己的基础样式会对任何带 pf- 前缀类名的元素执行一次高优先级的 all: revert,把我们给快照设置的网格定位和不透明度一并抹掉。结果快照掉出叠放位置,落到实时列表下方的正常文档流里,看起来就像每次按键列表都先空一次。只有用 !important 重新声明这两个属性才压得住。复用第三方组件的类名,等于同时继承了它的样式重置规则;要么另起一套类名自己实现列表外观,要么就得准备好在优先级上和它正面对抗。
正确叠上去之后,第二个缺陷才显露出来:交叉淡化会让画面忽明忽暗。结果卡片是半透明的玻璃背景,透出后面的景观壁纸;淡化过程中两层半透明表面短暂重叠,透过去的壁纸比例就和静止时不一样——无论换哪条缓动曲线,或者在背后补一层什么底色,都改变不了这个事实。最终放弃 cross-fade,改成同步交换:快照在实时结果重新出现的同一帧被移除,任意时刻画面上都只有一层半透明表面。为配合淡化而写的那些 transitionend 监听、兜底移除计时器和中间状态类,也随之一起删掉了。
平滑并不等于所有东西都加动画。对于透明层的合成,原子交接往往比过渡更稳定——而且顺带省掉了一整套用来协调过渡的机器。
恢复查询后还有两个异步步骤会把光标折回文本开头:Pagefind 会重新赋值输入框,Chromium 聚焦 type="search" 时还会再次调整选区。修复只在首次聚焦及随后一次 selectionchange 中把光标放回末尾,之后的中间编辑不受影响。
释放实例之后还要保住缓存
离开搜索页时释放 Pagefind 实例,可以避免旧组件引用累积;返回时则创建新实例。Pagefind 组件默认会给 pagefind-entry.json 加入基于当前时间的查询参数,这意味着每次返回搜索页都得到一个从未见过的 URL,浏览器缓存无法复用。
网站为构建过程生成一个稳定的 meta-cache-tag:同一次部署中的所有页面共享它,下一次构建才变化。这个带版本的入口文件因此可以使用长期 immutable 缓存;JavaScript 模块、WASM 和实际索引片段仍按 Pagefind 原有机制加载。这里的原则与静态资源哈希相同:先让 URL 表达内容版本,再谈长期缓存。
视觉连续性必须写成不变量
这轮修复之后,测试不再只检查最终页面“看起来对了”,而是检查过渡过程中的不变量:
- 导航期间持久链接仍是同一个 DOM 节点,位置和命中区域不变;
- 页面出口之外不出现 Swup 动画类;
- 浮层在导航和断点变化后不会失去可见触发器;
- SSR 目录不会被客户端重建,只更新当前章节;
- 刷新首帧直接恢复滚动、字体、主题和壁纸状态;
- 内容交换前后正文几何不变,字体不在交换完成后才替换;
- 搜索返回时查询、URL、结果和输入光标一致;
- 搜索结果更新期间旧列表保持可见,但不会产生第二套可聚焦内容;
- 半透明结果层在任意一帧都只有一份。
有些不变量则必须在正确的环境里验证。搜索结果那次忽明忽暗,是逐帧采样亮度加录屏才定位的,而且必须跑在真实带壁纸的预览上——用本地静态回退的网格背景根本复现不出来,因为缺陷的成因正是“半透明层背后透出了什么”。同理,指针脉冲要靠有头浏览器和原生协议取证。验证环境选错,再严格的断言也只是在测另一个系统。
这也是 CI 任务上限从紧凑值放宽到 30 分钟的原因:字体准备、生产构建和真实浏览器回归已经不再是几个轻量断言,过短的上限会把正常验证直接误报成失败。放宽超时不是降低标准,而是让完整验证有机会跑到给出结论为止。
总结
静态生成与长期运行的客户端并不矛盾,但二者之间必须有清晰边界:
- 服务器输出完整、可阅读的结构;
- 绘制前脚本只恢复首帧必须知道的状态;
- 持久外壳拥有跨页面状态;
- 页面出口拥有可替换内容;
- 页面级控制器在交换边界清理和重建;
- 跨层的异步就绪状态(如字体)有显式契约和保证到达的失败终点;
- 第三方组件继续负责其擅长的功能,集成层只补生命周期和视觉交接;
- 浏览器原生行为、DOM 行为和用户感知分别取证,不能互相替代。
最初看到的是弹窗、鼠标和搜索列表的几个闪烁,最后治理的是整个网站的状态所有权。真正稳定的体验并不是把动画调得更快,而是尽量不制造用户本来就不该看到的中间状态。