跳至正文
全部文章

静态站真的需要 loading 吗

我先断定组件文档页首屏是空的,打算把示例改成服务端渲染。原型实测推翻了这个判断,真正的问题出在站内跳转、图表测量和 iframe 上。

约 7 分钟#工程#Next.js#性能

本站是 Next.js 16 的纯静态导出,所有页面在构建时就生成了 HTML,没有服务端等待,也没有接口请求。按理说不需要 loading。

但我在组件页上还是看到了「正在加载示例…」。这篇讲我怎么判断、怎么被实测推翻,最后加了哪些占位,以及验收时发现第一版有三处是坏的。

先下的判断:首屏是空的

起因是组件文档页的首屏。示例组件是客户端组件,按需加载,页面 HTML 里我看到的是 fallback 文字「正在加载示例…」。我据此判断:用户在 JS 下载完之前,首屏上没有示例,只有一行字。

对应的做法也很自然:把示例改成服务端渲染,让 HTML 里直接有内容。

原型实测推翻了它

动手之前我先做了个原型,用来确认这件事到底有多严重。方法是拦住所有 JS 请求,3 秒不放行,在 0.8 秒时截图。

截图里示例已经显示出来了。

原因在 React 的流式输出。服务端先输出 Suspense 的 fallback,示例的真实内容紧跟着放在一个隐藏的节点里:

<div hidden id="S:0">…示例的真实内容…</div>
<script>$RC("B:0", "S:0")</script>

(示意,省略了属性。)HTML 解析到内联的 $RC 脚本时,用隐藏节点里的内容替换 fallback。这一步不依赖任何外部 JS,所以静态 HTML 里其实早就有示例,我之前看到的「正在加载示例…」只是它被替换之前的中间状态。

原型还顺带测了我原本打算做的方案:在服务端 await import() 示例。结果每个组件页的 JS 从约 1.27MB 涨到约 2.4MB,因为同一个路由下所有示例被打进了同一个包。为了解决一个不存在的问题,让所有页面多下一倍的代码,不值得,我放弃了这个方案。

真正的四个问题

首屏没问题,不代表没有别的问题。我换成「点链接跳转」「慢网」「水合之前」几个场景逐个看,找到四处:

  1. 站内跳转时,示例代码块还在下载。 页面已经切过去了,但示例区的 chunk 没到,这段时间示例区闪出纯文字。
  2. recharts 图表在水合前整块空白。 ResponsiveContainer 要在浏览器里测量容器尺寸,服务端输出的 HTML 里没有图。
  3. 慢网下点了链接,页面没有任何反应。 静态导出没有服务端等待,但预取没命中的跳转还是要等页面载荷,这段时间用户不知道点上了没有。
  4. 预览 iframe 加载之前是空白。 区块和模板的预览用 iframe,加载前那一块什么也没有。

这四处的共同点是:都发生在页面已经出现、内容还没到的那一小段时间里。

方案

统一的骨架

示例区、整页预览和组件缩略图原来各有各的 fallback。我换成了同一个 PreviewSkeleton:撑满调用方给的尺寸,淡色底,轻微脉冲。

有两条约束:

  • 150ms 之后才显示。 内容在 150ms 内到了就不需要骨架,否则快的网络上会看到骨架一闪而过。这个延迟是纯 CSS,不依赖 JS:
.preview-skeleton {
  opacity: 0;
  animation:
    preview-skeleton-appear 1ms 150ms forwards,
    preview-skeleton-pulse 2s 151ms infinite;
}
@media (prefers-reduced-motion: reduce) {
  .preview-skeleton {
    animation: preview-skeleton-appear 1ms 150ms forwards;
  }
}
  • 减少动态效果时不脉冲。 系统设置了 prefers-reduced-motion,骨架只保留 150ms 后出现这一步。

图表:同尺寸占位,就绪后淡入

图表是四个问题里最难看的一个,因为它不是「晚一点出现」,而是在水合之前根本不存在,页面高度也会跟着变。

我给 ChartSeries、ChartRanking、ChartGauge、MiniChart、MiniDonut 都加了同一套处理:

  • 服务端输出一个与最终图表同尺寸的占位,用主题变量上色(bg-muted/30 加几条虚线网格);
  • 容器上有 data-ready,用 useSyncExternalStore 判断是否已经水合;
  • 真实图表在就绪前 opacity-0,就绪后 200ms 淡入,占位同时淡出。

占位和真实图表是叠在一起的,所以占位必须绝对定位,不参与布局。这条后来被验收抓出一个问题,下面讲。

跳转指示

站内跳转的指示,我在捕获阶段监听点击,判断是不是站内、同一路径之外的链接;点击后 200ms 页面还没切过去,才让指示出现;路径变了就熄灭,加一个 8 秒的兜底,防止链接被别的处理器拦下后一直亮着。

预取命中的跳转几乎是瞬间完成,用户根本不会看到它。只有慢网下预取没有命中的跳转才会触发。

这个指示最初是页头底边的一条 1px 线。做完发现它会和导航当前项下面的指示线叠在一起,看不出哪一条在表示什么。我把它挪到了页头标志的右侧,改成三道依次点亮的小斜杠:3×12px,倾斜 30°,每道延迟 160ms,一个周期 960ms,形状上呼应「易」标志的斜杠。减少动态效果时它保持静止。

iframe 接近视口才加载

区块查看器里的 iframe 改成接近视口才挂载,加载中显示同一个骨架。首页的「区块与模板」一栏有四个缩小的实时预览,不用一开始就把四个页面全拉下来。

第一版自动检查全过了,验收却不通过

这套方案我配了一个脚本 pnpm qa:loading:在本地起一个静态服务,用 Playwright 驱动 Chrome,把所有 JS 请求挂起,检查图表是不是留了占位、占位尺寸对不对,再放行后对比前后的尺寸。第一版脚本全绿。

然后我让另一个子任务独立验收,不看实现者的汇报,自己去实测。它判了「不通过」,抓到三个阻塞问题:

  1. 骨架根本没有延迟。 PreviewSkeleton 的样式最初写成了 CSS module,而项目的构建配置把这个文件当成了全局样式,类名被丢掉,动画根本没挂上。150ms 延迟没有生效,骨架一出现就是满的。
  2. 图表占位把排名图撑高了 433px。 占位里的 svg 带着 1:1 的 viewBox,是作为静态子元素放进去的,在排名图里按宽度撑出了一个正方形。更糟的是,就绪之后这个占位只是变透明,还留在文档流里,所以页面一直多出一大块空白。
  3. 列表缩略图的骨架高度是 0。 为了让骨架不受缩放影响,缩略图容器在加载态被改成了 static,骨架的 absolute 失去了定位参照,高度塌成 0;页面上 25 个看不见的骨架却还在向读屏播报「正在加载…」。

这三个问题,第一版的自动检查一个也没发现。第一个和第三个,脚本里没有对应的断言:没人检查骨架的动画是否生效,也没人检查缩略图里的骨架有没有撑满画框。第二个更隐蔽:CLS(累计布局偏移)测不出它,布局偏移只记录元素「跳了」,而这块空白从一开始就在,之后也没有动过。

补上的两条断言

修复很直接:骨架改成全局类,放进 docs.css;图表占位改成 absolute inset-0 脱离布局,里面的 svg 用 preserveAspectRatio="none" 铺满容器;缩略图去掉那组 static,骨架照常用 absolute inset-0 铺满画框。

更重要的是,每一类问题都要有一条会失败的断言,不然它还会再来。我加了两条。

骨架动画确实生效,并且初始是透明的。 在脚本里克隆一个骨架到固定尺寸的容器里,读 getComputedStyle:

const style = getComputedStyle(clone)
const result = { name: style.animationName, opacity: style.opacity }
// animationName 为 none,或初始 opacity 不是 "0",都说明延迟没有生效
if (animation.name === "none" || animation.opacity !== "0")
  issues.push(`骨架动画或延迟失效:${JSON.stringify(animation)}`)

摘掉占位前后,图表的高度差必须是 0。 把占位节点临时摘下来,量一次容器高度,再放回去:

const placeholder = el.querySelector('[data-slot$="-placeholder"]')
const next = placeholder?.nextSibling
placeholder?.remove()
const without = el.getBoundingClientRect().height
if (placeholder) el.insertBefore(placeholder, next ?? null)
// item.height 是带着占位时的高度
if (Math.abs(item.height - without) > 0) issues.push(`移除占位造成高度变化`)

这条断言只在占位还在的时候量一次,就能抓到「占位在撑开布局」。同一个脚本还检查放行 JS 之后,占位的尺寸和正常图表的尺寸相差不超过 2px,所以占位和真实图表的尺寸一致也是被验证过的。

脚本覆盖所有组件页,再加上首页、主题页、区块列表、模板列表、组件列表和 /view/app-shell-01/ 这 6 个页面。

现在的做法

整套加载方案写进了组件书写规范的「加载与占位」一节,写 registry 代码的人都要遵守:

  • 依赖尺寸测量、canvas、WebGL 或当前时间的内容,必须有水合前占位;
  • 占位与真实内容同尺寸,颜色用主题变量,不能让布局跳;
  • 占位层绝对定位,不参与布局,淡出之后保留的节点也不能占尺寸;
  • 文本类内容的服务端输出必须可见,不能为了等挂载把整块文字藏起来;
  • 站内跳转的指示只覆盖页面载荷没到的慢跳转。

在这个站里,样板是点阵地球:球体出来之前,先放一个与球同大的径向渐变圆。下面这个排名图就是当时被占位撑高的那一类,现在占位在它上面是盖着的,摘掉也不会改变它的高度。

正在加载…

这件事最后改的代码并不多,时间都花在两次判断上:一次是动手之前的原型,一次是验收时的实测。两次都推翻了我原本以为对的东西,第一次省下了翻倍的 JS,第二次挡住了一块多出来 433px 的空白。