Vue 指令让组件自动生成骨架屏
译注:本文编译自 dev.to 文章《I built a Vue directive that turns your real component into its own skeleton》,作者 Navid Jaberi。原文链接见文末。
骨架屏(Skeleton Loader)一直存在维护难题:开发者需要为每个组件手写第二套版本——头像位置画个灰圆,文字位置摆三根灰条。一旦有人在真实卡片上加了徽章,骨架屏就悄悄对不上了。
作者为此构建了 v-skeleton 指令,收录在 vue-smart-loading-kit 中:
<UserCard v-skeleton="loading" :user="user" />当 loading 为 true 时,每一行文字变成灰条,图片和按钮变成实心块,而边框与间距保持原样;加载结束后真实内容回归。作者在在线演示的 Skeletonize 页面提供了可勾选的 loading 开关。
核心规则:绝不改动 DOM
最直观的做法是测量真实元素,再在其上渲染占位盒子。但这需要在每次渲染和每次 resize 时执行 JavaScript,还会与布局产生冲突。
作者选择了相反的路线:指令只在元素上切换一个 class、几个 CSS 变量和两个属性:
<article class="card vslk-skeletonize vslk-skeletonize--shimmer"
style="--vslk-sk-base: …; --vslk-sk-hi: …"
inert aria-hidden="true">其余全部交给 CSS。不新增、不删除、不改变任何元素尺寸,因此不会有任何位移。这成为整个设计中最关键、也最难坚守的决定。
隐藏文字而不破坏边框
第一版用 color: transparent 隐藏文字,看起来没问题,直到代码评审发现它同时抹掉了所有使用 currentColor 的边框、分隔线和图标——一个 1px solid currentColor 的卡片会直接失去边框。
修复方案用到一个多数人从未接触过的属性:
.vslk-skeletonize * {
-webkit-text-fill-color: transparent !important;
}text-fill-color 只绘制字形本身,color 以及所有通过 currentColor 继承它的属性都保留真实值。
每行文字对应一根灰条
为了在文字行位置绘制灰条,作者用按 lh 单位(一个行高)设定尺寸的重复渐变来遮罩元素背景:
mask-image: repeating-linear-gradient(
to bottom,
#000 0 calc(1lh - 0.3em),
transparent calc(1lh - 0.3em) 1lh
);每一行得到一根灰条,行与行之间留出间隙。一个折成三行的段落会得到三根灰条,宽度由浏览器决定。
遮罩整个元素会带来副作用:元素自身的边框也被隐藏,这对表格单元格影响很大。因此遮罩额外增加两层——border-box 层与 padding-box 层,配合 mask-composite: exclude,共同在边框位置留出一圈,使表格网格线得以保留。
图片使用 object-position: -99999px,把画面滑出自身盒子,而盒子保持原有尺寸与圆角。按钮和输入框则变成实心块。
证明「没有任何位移」
「没有任何位移」说起来容易,测试起来很难。多数 Vue 测试运行的 jsdom 没有布局引擎:所有元素都是 0×0,任何位置断言都会通过。
因此视觉规则在真实 Chromium 中通过 Vitest Browser Mode 与 Playwright 测试。主测试记录每个元素的包围盒,开启骨架屏,再记录一次:
it("does not move or resize a single element", async () => {
const before = rects();
await skeletonize();
expect(rects()).toEqual(before);
});该测试在每次 push 时于 CI 运行,任何让单个像素发生位移的 CSS 改动都会导致构建失败。
差点上线的 SSR Bug
在服务端渲染配合占位数据的场景下,你希望骨架屏出现在服务端 HTML 中,避免假内容闪现。指令不会在服务端运行,但 Vue 提供了 getSSRProps 钩子专门处理此事,Vue 3.5 的 data-allow-mismatch 则允许 hydration 接受客户端再次设置的属性。
Bug 出在属性恢复上。骨架屏结束时,指令会还原元素此前拥有的 inert 和 aria-hidden。第一版从 DOM 读取「此前值」。SSR 之后,DOM 中已经包含骨架屏自己的 inert,于是它把 inert 当作原始值保存下来。加载结束时再还原,整个区块就永远无法点击了。
修复方式是从 vnode 读取原始值——那才是模板真正绑定的内容,绝不从 DOM 读取。现在有一个测试会执行服务端渲染、hydration、结束加载,并检查没有属性被遗留。
时机:到底何时显示
一个在快速请求下闪现 80ms 的骨架屏,体验比没有骨架屏更糟。因此 v-skeleton 会等待 200ms 再显示,一旦显示则至少保持 500ms,避免闪烁:
<!-- 刷新真实数据:保留延迟 -->
<UserCard v-skeleton="refreshing" :user="user" />
<!-- 首次加载带占位数据:从第一帧就显示 -->
<ul v-skeleton="{ loading, delay: 0 }">…</ul>时机测试使用假定时器,每 10ms 记录一次值,因此任何一帧出错都会失败。只检查最终状态会漏掉中途的闪现。
100% 覆盖率遗漏了什么
该包的行覆盖率约为 99%,但作者仍不确定测试能否发现被破坏的默认值。于是他运行了 Stryker,它会在源码中植入小 bug(翻转条件、修改常量、删除调用),并针对每个变异重跑测试。
首次得分 80%。存活的变异体中,有些是真实缺口:改动表格骨架屏的默认行高,没有任何测试失败。有两个是真实 bug:
v-skeleton移除了模板绑定为false的aria-hidden,而 Vue 自身将其渲染为"false"。- 颜色辅助函数把
#12345z当作合法十六进制颜色,因为parseInt("5z", 16)会在非法字符处停止并返回 5。
补齐缺口后得分超过 90%。其余多为等价变异体——测试无法观察到的改动,这也是 100% 并非目标的原因。
局限
它是 CSS,因此也有 CSS 的盲区。折行段落的最后一行会得到一根全宽灰条,因为 CSS 无法知道文字在哪里结束。彩色圆圈上的几个字符(首字母头像)看起来像文字,需要标记 data-skeleton="block"。此外它需要 2023 年末之后的浏览器,以支持 :has()、lh 和 mask-composite。
试用
npm install vue-smart-loading-kitimport VueSmartLoadingKit from 'vue-smart-loading-kit'
import 'vue-smart-loading-kit/style.css'
app.use(VueSmartLoadingKit)该工具包还包含带错误状态与重试的 SmartLoader 组件、13 种骨架屏变体、9 种 spinner 与进度条。
- 在线演示:https://navidjaberi.github.io/vue-smart-loading-kit/
- GitHub:https://github.com/navidjaberi/vue-smart-loading-kit
作者表示欢迎反馈,尤其是骨架屏显示效果不佳的布局场景。