给 Lyra 加一段“星轨校准”
这次给 Lyra 加了一个首次加载界面。
打开页面时,三圈蓝色星轨会围着中心的星光缓慢转动。下面是一条细细的进度轨道,还有一句随进度变化的提示。等页面准备得差不多,星光亮一下,遮罩退场,正文露出来。
很早以前,博客还在用 Hexo 时,我就想给网站加一段这样的开场过渡。等待不再只是一块空白,更像进站前的一小段序章。这个念头一直留到 Lyra:它本来就有蓝白玻璃、星空和安静的微动效,直接塞进一个通用转圈图标,怎么看都像临时贴上去的零件。
在“星轨校准”动起来之前
完整打开网站或刷新页面时,它可以出现;站内跳转就别再演一遍了。它只等页面结构完成并真正绘制一轮,不把字体、图片等网络资源变成放行门槛。资源慢了、坏了,都得按时放人,不能让一层漂亮的遮罩把正文永远关在后面。
交互也一样。加载时,鼠标、键盘快捷键和焦点都不该钻进页面底下。退场以后,这些状态还要原样还回去。
换成一张清单,大概是这样:
- 只在完整打开或刷新时出现;
- 等页面壳完成首轮绘制,资源状态只推动可见进度;
- 百分比要有可以说清楚的含义;
- 慢速或失败资源不能一直挡住正文;
- 加载期间隔离点击、快捷键和键盘焦点;
- 结束后恢复滚动、焦点和无障碍状态;
- Astro 站内导航不重复播放全屏动画;
- 手机、平板、桌面、深色模式和减少动态效果都能正常工作;
- JavaScript 没运行起来,正文照样进得去。
它也有几件明确不做的事:不假装提高网速,不统计所有文件的下载字节,不拿字体、图片、评论或音频当作全屏遮罩的放行门槛,更不能拿动画盖住真正的页面错误。站内跳转已经有独立的轻量场景过渡,这里也不抢它的工作。
这些“不做什么”其实比效果图更重要。加载器再好看,也不该变成网站的新负担。
为什么叫“星轨校准”
名字是从星铁里借来的
“星轨校准”这几个字,来自我对《崩坏:星穹铁道》一段台词的印象:小三月提到,列车出发前,姬子还在校准轨道。原话我已经记不清了,但“校准轨道”一直留在脑子里,刚好和加载时那种等待出发的感觉对上。
视觉上,它是一枚小星盘:三层轨道和星点围着中心的八角星光缓慢转动,完成时星光会短暂亮起。整套图形都用 CSS 绘制,并沿用站点现有配色,不额外增加图片或动画依赖,也能让遮罩退场时自然接回正文。
让进度条继续讲“星轨”这件事
普通的粗矩形进度条放在这里有点重。我把它压成一条细轨道,填充末端跟着一颗发光的小星点。轨道和星点共用同一个 CSS 变量:
.site-loader-progress-fill { transform: scaleX(var(--site-loader-progress)); transform-origin: left center;}
.site-loader-progress-star { left: calc(var(--site-loader-progress) * 100%);}两者读同一个数,进度走到哪里,星点就跟到哪里,不会一前一后各跑各的。
比数字更重要的是“现在在等什么”
只显示 67%,其实没有多少信息。旁边那句状态文案,反而更像人在解释页面此刻做到哪了:
| 就绪度 | 文案 |
|---|---|
| 0% | 正在校准星轨 |
| 28% | 正在展开星图 |
| 50% | 正在点亮文字 |
| 70% | 正在接收第一束星光 |
| 86% | 正在稳定轨道 |
| 94% | 正在完成校准 |
| 100% | 星轨校准完成 |
它们不是按时间轮播,而是看着屏幕上真正显示的进度换句子。数字还在 16%,文案就不能抢跑到“接收第一束星光”——这件事后来真的出过问题,后面再说。
三个文件,各管一件事
实现落下来,加载器拆成了三块:
| 文件 | 职责 |
|---|---|
src/components/SiteLoader.astro |
语义结构、进度条 ARIA 属性、无脚本兜底 |
src/styles/site-loader.css |
星盘、进度条、完成动画、明暗模式和响应式样式 |
public/js/site-loader.js |
页面就绪度、计时器、交互隔离、清理和 View Transition 生命周期 |
BaseLayout.astro 不碰具体业务,只把组件和脚本接进来。加载器放在 <body> 最前面:
<body> <SiteLoader /> <slot name="banner" /> <main>...</main></body>客户端逻辑也没有塞进 Astro 组件的内联脚本。它继续放在 public/js/,跟站内其他交互脚本走同一套外部加载规则。
拆开以后,改图形时不用碰状态机,调整等待逻辑也不必在一大段样式里翻来翻去。更现实的一点是:出问题时比较好找。
这个百分比,到底算的是什么
做进度条时绕不开一个问题:它是真实下载进度吗?
不是。
浏览器没有一个简单又可靠的数字,能告诉静态页面“所有资源已经下载了百分之多少”。页面里有缓存资源、懒加载图片、字体、第三方脚本,还有稍后滚动到附近才会发出的请求。硬把它们拼成一个百分比,看起来很精确,实际更像在编数字。
Lyra 展示的是页面就绪度。它记录一个放行门槛和几组跟首屏体验有关的视觉信号:
| 检查点 | 目标进度 | 角色 | 代表什么 |
|---|---|---|---|
| 初始状态 | 12% | 初始状态 | 脚本已经接管加载界面 |
| DOM 可用 | 34% | 硬门槛的一部分 | 页面结构解析完成 |
| 字体结束等待 | 54% | 视觉信号 | 字体可用;失败就回退到系统字体 |
| 当前断点的首张 Banner | 74% | 视觉信号 | 首页首屏图已加载或解码;非首页直接推进 |
| 布局稳定 | 90% | 视觉信号 | 布局稳定回调或双帧回退已经结束 |
window.load |
96% | 视觉信号 | 浏览器触发了完整页面加载事件 |
| 页面壳就绪 | 触发收尾 | 硬门槛 | DOM 完成后再经过两帧绘制,标准页与 404 都可以放行 |
| 收尾动画 | 100% | 视觉过渡 | 进入完成态,准备释放页面 |
资源信号有时会挤在很短的时间里一起完成。要是数字从 12% 突然蹦到 96%,眼睛看起来会很僵。脚本会把已到达的信号当作目标,再用 requestAnimationFrame 慢慢追上去:
const distance = target - progress;
if (distance > 0.02) { progress += Math.max(0.04, distance * 0.045); progress = Math.min(progress, target); render(progress);}如果页面壳先就绪,240ms 的完成动画会从当前值补到 100%,不再等迟到的资源信号。资源先到时,节点是真实状态;完成动画跨过的剩余百分比则只表示页面已经可以进入。整条进度从来不代表网速,也不冒充下载字节。
为什么只观察一张 Banner
首页的图片不少。除了 Banner,还有文章卡片、音乐封面和画廊。后面的内容本来就该等用户滚动接近时再加载,如果一进站就要求加载器等完所有图片,懒加载等于白做。
更糟的是,首屏明明已经能看,访客还得盯着遮罩等页面深处的照片。
真正值得作为视觉信号观察的只有当前屏幕会显示的第一张 Banner:
const isDesktop = window.matchMedia('(min-width: 768px)').matches;const selector = isDesktop ? '.banner-image-slot-desktop img' : '.banner-image-slot-mobile img';桌面和平板观察桌面图,手机观察移动图。文章页、关于页没有 Banner,这一步就直接推进到 74%。图片没有及时完成时,页面壳仍会正常触发收尾并中止监听,不会守着它耗到超时。慢网下只给活动槽显示 32w 预览,完整图片解码完成或 400ms 解码预算耗尽后再整体淡入,避免增量解码区域与低清背景之间出现移动横缝;切换断点时会取消旧槽的等待,并按新的 currentSrc 重新开始。
不管哪里出错,都要留条退路
加载动画最怕变成单点故障。为了不让它堵住网站,我留了三条退路。
正常走完后
页面壳就绪后,进度用 240ms 补到 100%,文案换成“星轨校准完成”,停留 120ms 后再用 280ms 淡出。
缓存命中时,所有事件可能一眨眼就结束。完全跟着事件走,遮罩会像闪屏一样出现又消失。所以正常状态下保留 420ms 的最短展示时间。能看清,又不至于让人觉得在故意拖延。
6 秒到了,先让人看正文
从导航开始计算的总等待达到 6 秒 时,脚本都会停下来,显示“部分星光稍后抵达”,然后把页面放出来。外部脚本本身花掉的下载时间也算在这 6 秒里,不会等脚本执行后重新计时。
这不代表资源突然全好了,只是优先级变了:正文已经比一场完整的入场动画更重要。正常页面壳就绪或总预算耗尽,都会通过同一个 AbortController 中止还没结束的图片监听、字体 Promise 和布局回调:
const maxWaitRemaining = Math.max(0, MAX_WAIT_MS - performance.now());maxWaitTimer = window.setTimeout( () => complete(isNotFound ? 'not-found' : 'timeout'), maxWaitRemaining,);
waitForPageShell().then((ready) => { if (ready) complete(isNotFound ? 'not-found' : 'success');});脚本没接住,CSS 再兜一次
JavaScript 被完全禁用时,组件里的 <noscript> 会立刻隐藏加载层。
还有一种更尴尬的情况:浏览器允许 JavaScript,但外部脚本因为网络或部署问题没跑起来。此时 CSS 会等 8 秒,再让没有被脚本接管的遮罩消失,同时撤掉点击拦截:
.site-loader:not([data-loader-managed]) { animation: site-loader-failsafe 1ms linear 8s forwards;}脚本正常启动后会写入 data-loader-managed,CSS 就知道:“这里有人管,不用提前放行。”
Astro 页面切换里,其实有两套加载反馈
Lyra 的完整打开和站内跳转,不是一回事。
完整打开或刷新时,用星轨校准;Astro 客户端站内导航时,保留旧页面直到新文档准备完成,只有等待超过短暂阈值才在导航栏显示进度。两个效果如果叠起来,只会显得页面很忙。
星轨脚本是全局一次性的。它用 window.lyra.siteLoader.bound 避免重复建立状态机,并对外留了一个很小的查询入口:
state.isActive = () => state.active;页面场景过渡准备反馈前会先问一句:
if (window.lyra?.siteLoader?.isActive?.()) return;星轨还带着:
transition:persist="site-loader"首次加载结束后,这个隐藏状态会跟着 Astro 的客户端导航保留下来。页面来回切换,不会把目标页新生成的加载器节点又翻出来。
还有一个比较偏门的场景:星轨没退完,程序却发起了页面导航。astro:before-swap 会立刻释放旧页面,避免整块遮罩被拍进下一张 View Transition 快照。
遮住页面,不等于锁住页面
早期版本只有视觉遮罩。鼠标点不到后面的按钮,看上去似乎够了,可按 Tab 键时,焦点还是能跑进导航、搜索按钮和链接。
这才意识到,z-index 很高,只能说明它盖得住,并不代表页面真的进入了加载状态。
现在星轨出现时会做几件事:
- 给
<html>和<body>同时加上滚动锁; - 给
<body>设置aria-busy="true"; - 给加载器之外的
<body>子元素设置inert; - 记住每个元素原来的
inert状态; - 退出时按原值恢复,不粗暴地全部改成
false; - 页面原本已有的
aria-busy和锁定类,也照原样还回去。
“记住原值”不能省。假设搜索弹窗或抽屉菜单本来就在维护某个锁,加载器退场时一股脑执行 removeAttribute(),就会顺手拆掉别人的状态。
动画也要照顾不想看动画的人
可见的百分比会不断变化,但屏幕阅读器没必要从 12、13、14 一路念到 100。进度条本身使用标准的 progressbar 语义:
<div role="progressbar" aria-label="页面就绪度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="正在校准星轨"></div>页面里还有一个 aria-live="polite"、aria-atomic="true" 的隐藏状态区。资源信号正常跨过阶段阈值时,它才播报一句;进入完成扫尾后,240ms 内跨过的中间阶段只更新可见文案和 aria-valuetext,不会让屏幕阅读器连续朗读。百分比本身仍对屏幕阅读器隐藏。
系统开启 prefers-reduced-motion: reduce 后,三层轨道和中心脉冲不再循环。它们会停在一个仍然看得出结构的角度,完成和淡出也更快,不为了凑够 420ms 继续等。
减少动态效果不是删掉全部信息。状态还在,只是少转几圈。
那些看起来问题的地方
这部分反而是整次实现里最值得留下的。很多问题构建时不会报错,第一次打开页面也未必看得出来。
只锁了 <body>,滚动条还在动
第一版只给 <body> 加了 overflow: hidden。桌面浏览器里,页面宽度和滚动条状态偶尔还是会晃一下。原因很简单:真正承担根滚动的也可能是 <html>。
后来两个一起锁:
html.site-loader-lock,body.site-loader-lock { overflow: hidden;}退场时也成对恢复,不留半截状态。
文案比百分比先跑了一站
早期的阶段文案跟着“目标进度”更新。某个资源一完成,目标值可能已经从 34% 提到 74%,可平滑动画还停在 16%。于是屏幕上出现过这种怪场面:数字写着 16%,文字却说“正在接收第一束星光”。
进度算法没算错,错的是数字和文案各看了一份状态。
后来把文案更新挪进 render()。目标值只告诉动画往哪里走,访客看到的文字只认屏幕上那个真实数字。
超时退场了,监听器却没下班
最初的 6 秒超时只管开始退场。图片的 load / error、window.load、字体 Promise 和布局回调还留在后台。它们晚一点回来,仍可能修改进度,或继续抓着旧页面节点不放。
界面上不一定马上露馅,生命周期却已经脏了。
现在所有等待共用一个 AbortController。正常完成、超时、页面切换,都会走同一条中止路径。计时器和动画帧也在 cleanup 里一起收走。
有全屏遮罩,键盘照样能钻到后面
全屏层会挡鼠标,却不会自动阻止键盘焦点。只靠 z-index 和 pointer-events,交互隔离是不完整的。
补上 inert 以后,焦点才真正留在加载状态之外。退场时不是统一撤掉,而是恢复每个元素之前的值,免得覆盖其他组件。
搜索框和点击爱心穿过了星轨
全站搜索监听 Ctrl/Command + K,点击爱心监听整个 document。它们根本不在乎用户有没有点到正文。
于是加载期间按快捷键,搜索框可能先在遮罩后面打开;点击星轨,爱心也可能飘到上面。画面挺浪漫,逻辑完全不对。
我没有让加载器粗暴吞掉所有全局事件,而是让这些功能在自己的入口处检查同一个状态:
if (window.lyra?.siteLoader?.isActive?.()) return;搜索、爱心、页面场景过渡都先看星轨是否还在工作。加载层的层级也提到 1100,高于搜索弹窗的 1000。
深色模式把三圈轨道抹成了一样
每条轨道先有一圈基础边框,再各自高亮某一侧,用来制造不同的亮面。第一版深色模式在样式文件后面又写了一次:
.site-loader-ring { border-color: ...;}这条简写把四个方向全覆盖了。前面辛苦调出来的方向高亮在深色模式里悄悄失效,三圈轨道看起来几乎一模一样。
现在深色模式只换变量:
.site-loader { --site-loader-ring-color: ...;}颜色由主题决定,哪一边更亮仍然归每条轨道自己的规则管。
完成类加上了,中心星光却没变亮
校准结束时,中心星光应该放大并加深光晕。类名确实加上了,画面变化却很弱。
问题藏在循环动画里。中心元素原本有一段持续修改 transform 和 filter 的脉冲动画,它的计算结果压过了普通的完成态样式。两个状态同时抢同一组属性,循环动画赢了。
完成时要先让它停下:
.site-loader.is-completing .site-loader-core { animation: none; filter: drop-shadow(...); transform: rotate(45deg) scale(1.18);}同一个元素如果既有循环动画,又有一次性状态变化,得明确什么时候由谁接管。
我差点把“就绪度”写成“真实加载进度”
界面只写“加载 70%”,大家自然会理解成文件已经下载了七成。可这套逻辑没统计字节,也没等全部资源。
后来文档、界面语义和 ARIA 标签都统一改成“页面就绪度”。这不是文字洁癖,而是一条很朴素的底线:动画可以做得顺滑,含义不能含糊。
404 页面也在说“校准完成”
文章发布后,又冒出一个语义上很别扭的问题:打开不存在的地址,404 页面也会播放星轨,结束时还认真地告诉我“星轨校准完成”。
从脚本的角度看,它确实完成了。404 同样经过 BaseLayout,页面壳在 DOM 完成并绘制两帧后就可以放行;字体、布局、window.load 和不存在的 Banner 都只是视觉信号,不会阻塞。页面壳一就绪,加载器便进入 not-found 完成序列。
可访客看到的是“页面不存在”。这里的资源加载成功了,访问目的却失败了。两个事实并不矛盾,放在同一个画面里却很荒唐。
第一反应是把 404 的星轨关掉。这很干净,却也浪费了一个挺适合讲故事的机会:既然普通页面是在校准一条可以抵达的星轨,那 404 就应该是一条怎么也找不到的航线。
现在 BaseLayout 不再使用开或关的布尔值,而是给加载器传入 standard 或 not-found。404 冷启动时,星轨会先读取坐标、验证信号、检索星图。进度越过 76% 后,蓝色开始转红;接近终点时三层轨道加速,最后错位停住,中心星光闪成红色,留下醒目的 ERROR 404 和“目标星轨不存在”。
错误状态停留片刻再退场,后面的 404 故障动画才开始工作。两段效果通过 lyra:site-loader-released 衔接,避免红色星轨还没结束,页面撕裂层就在遮罩后面提前创建。系统开启减少动态效果时,轨道不会错位抖动,只保留变红、错误文案和更短的退场。
底层仍然只有一个带 transition:persist 的加载器节点。直接打开 404 会看到完整错误星轨;站内往返则交给视口级场景过渡和导航栏进度,不会每次遇到 404 都重演一遍全屏动画。
这次修改提醒了我一件事:技术上的“加载完成”,不等于用户眼里的“访问成功”。全局反馈除了看资源状态,也得尊重当前页面正在说什么。
第一轮测试,偏偏漏了平板
第一轮我测了 1440×900 的桌面和 390×844 的手机。两个极端都正常,我差点就把验证写成“桌面、移动端通过”。
问题是,项目规范里明明单独划出了 768px–1280px 的平板区间,我却没测。
被提醒后补了三组尺寸:
768×1024,平板竖屏;1024×768,平板横屏;1280×800,靠近平板断点上限。
星盘、文字和进度条都保持居中,没有横向溢出。退场后,滚动锁、inert 和 aria-busy 也都恢复正常。
这次平板没有冒出新问题,但这不替最初的漏测开脱。响应式检查应该从一开始就覆盖手机、平板和桌面,而不是只测最小和最大两个端点。
我是怎么确认它真的能用的
“浏览器打开了”离完成还差得远。
代码层面,我给 public/js/ 下的全部脚本跑了 node --check,再做生产构建。实现完成时的那次构建生成了 43 个页面和 271 张优化图片。生产 HTML 里的脚本顺序也逐个核对过:布局稳定工具在前,加载器接着接管,全局交互随后进入。
生产 CSS 里,深色模式、减少动态效果和 8 秒兜底都还在。中文文件也扫过一遍,没有 Unicode 替换字符或常见乱码。
响应式用了五个尺寸:
| 设备场景 | 尺寸 | 看到的结果 |
|---|---|---|
| 手机 | 390×844 |
没有横向溢出,星盘自动缩小 |
| 平板竖屏 | 768×1024 |
进度区完整,内容居中 |
| 平板横屏 | 1024×768 |
星轨和文字都留在视口内 |
| 平板上限附近 | 1280×800 |
加载层没有水平或垂直溢出 |
| 桌面 | 1440×900 |
星轨比例和四周留白正常 |
浏览器里还专门折腾了几轮:
- 星轨出现时,正文子元素是否全部进入
inert; Ctrl/Command + K会不会偷偷打开搜索;- 点击加载层会不会飘出爱心;
- 进度到 100% 后,遮罩是否真的隐藏;
aria-busy、inert和两处滚动锁是否恢复;- 不存在的地址冷启动时,是否依次出现红色错误星轨与 404 页面;
- 错误星轨退场前,404 故障克隆是否保持未初始化;
- 两轮“404 → 首页 → 404”之后,是否仍只有一个持久加载器;
- 两轮“首页 → 文章 → 首页”之后,页面里是不是仍然只有一个加载器;
- 页面过渡状态和导航进度有没有残留;
- 控制台是否出现警告或错误。
构建只能证明 Astro 把文件生成出来了。快捷键穿层、焦点跑偏、监听器不清理、View Transition 重复初始化,这些问题还是得在真实浏览器里抓。
这套方案也不是哪儿都能用
星轨校准已经够 Lyra 当前的首屏使用,但边界仍然摆在那儿。
它显示的是几个就绪阶段,不适合拿去表示文件上传或大体积下载。加载器正常释放或 6 秒预算耗尽后,未完成的网络资源都可能继续按浏览器生命周期加载,这是刻意做的取舍。CSS 的 8 秒兜底也只负责把门打开,不负责修好脚本。
以后如果首页不再用 Banner,这个视觉信号要么移除,要么改由新的首屏媒介提供。站点再增加全局快捷键、弹窗或文档点击效果,也要记得接入 siteLoader.isActive()。至于阈值、等待项目和生命周期,只要动了,README 与 AGENTS 里的说明就要一起更新。
这些约束看着琐碎,却能防止半年后有人只改了一个数字,留下另一半已经过期的事实。
结语
开始时,我以为这只是“加一个转圈圈,再放条百分比”。
真正做下去,碰到的却是视觉语言、资源加载、键盘焦点、无障碍、全局事件、响应式、超时清理,还有 Astro View Transitions。圆环转起来只花了很少一部分时间,后面的大多数工作都在回答更实际的问题:
- 这个进度到底代表什么?
- 最多让人等多久?
- 资源失败,谁来开门?
- 遮罩后面的页面还能不能操作?
- 跳到下一页,旧状态会不会跟过去?
- 脚本彻底没运行,正文还进得去吗?
做完以后,“星轨校准”不再只是一张盖住页面的动画。它有开始,有检查点,也知道什么时候完成、什么时候认输、什么时候把现场收干净。
我还是喜欢好看的等待反馈。它能让第一次进入网站更完整,甚至给人留下一点点期待。
但它得克制。别假装网速变快,别为了展示自己拖长等待,也别抢正文的优先级。更不能因为自己出了错,反而把访客关在网站外面。