跳到主内容

LYRA

Lyra 正在加载

给 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 / errorwindow.load、字体 Promise 和布局回调还留在后台。它们晚一点回来,仍可能修改进度,或继续抓着旧页面节点不放。

界面上不一定马上露馅,生命周期却已经脏了。

现在所有等待共用一个 AbortController。正常完成、超时、页面切换,都会走同一条中止路径。计时器和动画帧也在 cleanup 里一起收走。

有全屏遮罩,键盘照样能钻到后面

全屏层会挡鼠标,却不会自动阻止键盘焦点。只靠 z-indexpointer-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: ...;
}

颜色由主题决定,哪一边更亮仍然归每条轨道自己的规则管。

完成类加上了,中心星光却没变亮

校准结束时,中心星光应该放大并加深光晕。类名确实加上了,画面变化却很弱。

问题藏在循环动画里。中心元素原本有一段持续修改 transformfilter 的脉冲动画,它的计算结果压过了普通的完成态样式。两个状态同时抢同一组属性,循环动画赢了。

完成时要先让它停下:

.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 不再使用开或关的布尔值,而是给加载器传入 standardnot-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,靠近平板断点上限。

星盘、文字和进度条都保持居中,没有横向溢出。退场后,滚动锁、inertaria-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-busyinert 和两处滚动锁是否恢复;
  • 不存在的地址冷启动时,是否依次出现红色错误星轨与 404 页面;
  • 错误星轨退场前,404 故障克隆是否保持未初始化;
  • 两轮“404 → 首页 → 404”之后,是否仍只有一个持久加载器;
  • 两轮“首页 → 文章 → 首页”之后,页面里是不是仍然只有一个加载器;
  • 页面过渡状态和导航进度有没有残留;
  • 控制台是否出现警告或错误。

构建只能证明 Astro 把文件生成出来了。快捷键穿层、焦点跑偏、监听器不清理、View Transition 重复初始化,这些问题还是得在真实浏览器里抓。

这套方案也不是哪儿都能用

星轨校准已经够 Lyra 当前的首屏使用,但边界仍然摆在那儿。

它显示的是几个就绪阶段,不适合拿去表示文件上传或大体积下载。加载器正常释放或 6 秒预算耗尽后,未完成的网络资源都可能继续按浏览器生命周期加载,这是刻意做的取舍。CSS 的 8 秒兜底也只负责把门打开,不负责修好脚本。

以后如果首页不再用 Banner,这个视觉信号要么移除,要么改由新的首屏媒介提供。站点再增加全局快捷键、弹窗或文档点击效果,也要记得接入 siteLoader.isActive()。至于阈值、等待项目和生命周期,只要动了,README 与 AGENTS 里的说明就要一起更新。

这些约束看着琐碎,却能防止半年后有人只改了一个数字,留下另一半已经过期的事实。

结语

开始时,我以为这只是“加一个转圈圈,再放条百分比”。

真正做下去,碰到的却是视觉语言、资源加载、键盘焦点、无障碍、全局事件、响应式、超时清理,还有 Astro View Transitions。圆环转起来只花了很少一部分时间,后面的大多数工作都在回答更实际的问题:

  • 这个进度到底代表什么?
  • 最多让人等多久?
  • 资源失败,谁来开门?
  • 遮罩后面的页面还能不能操作?
  • 跳到下一页,旧状态会不会跟过去?
  • 脚本彻底没运行,正文还进得去吗?

做完以后,“星轨校准”不再只是一张盖住页面的动画。它有开始,有检查点,也知道什么时候完成、什么时候认输、什么时候把现场收干净。

我还是喜欢好看的等待反馈。它能让第一次进入网站更完整,甚至给人留下一点点期待。

但它得克制。别假装网速变快,别为了展示自己拖长等待,也别抢正文的优先级。更不能因为自己出了错,反而把访客关在网站外面。

版权许可

本文采用 CC BY-SA 4.0 协议授权

保留署名与相同方式共享即可转载、引用或再创作。转载时请注明出处并附上本文链接。

评论

留言系统基于 Disqus,发言前可查看 评论规则。部分网络环境可能无法正常加载。