Skip to content

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 中:

vue
<UserCard v-skeleton="loading" :user="user" />

当 loading 为 true 时,每一行文字变成灰条,图片和按钮变成实心块,而边框与间距保持原样;加载结束后真实内容回归。作者在在线演示的 Skeletonize 页面提供了可勾选的 loading 开关。

核心规则:绝不改动 DOM ​

最直观的做法是测量真实元素,再在其上渲染占位盒子。但这需要在每次渲染和每次 resize 时执行 JavaScript,还会与布局产生冲突。

作者选择了相反的路线:指令只在元素上切换一个 class、几个 CSS 变量和两个属性:

html
<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 的卡片会直接失去边框。

修复方案用到一个多数人从未接触过的属性:

css
.vslk-skeletonize * {
  -webkit-text-fill-color: transparent !important;
}

text-fill-color 只绘制字形本身,color 以及所有通过 currentColor 继承它的属性都保留真实值。

每行文字对应一根灰条 ​

为了在文字行位置绘制灰条,作者用按 lh 单位(一个行高)设定尺寸的重复渐变来遮罩元素背景:

css
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 测试。主测试记录每个元素的包围盒,开启骨架屏,再记录一次:

typescript
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,避免闪烁:

vue
<!-- 刷新真实数据:保留延迟 -->
<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。

试用 ​

shell
npm install vue-smart-loading-kit
typescript
import VueSmartLoadingKit from 'vue-smart-loading-kit'
import 'vue-smart-loading-kit/style.css'

app.use(VueSmartLoadingKit)

该工具包还包含带错误状态与重试的 SmartLoader 组件、13 种骨架屏变体、9 种 spinner 与进度条。

作者表示欢迎反馈,尤其是骨架屏显示效果不佳的布局场景。

原文链接 ​

https://dev.to/navidjaberi/i-built-a-vue-directive-that-turns-your-real-component-into-its-own-skeleton-3853