骨架屏怎么落地:手写 CSS、自动生成方案取舍与内容跳动问题
先把这个月我在组内评审时说服大家的判断标准写下来:一个页面值不值得上骨架屏,取决于三件事——首屏结构是否稳定可预测、白屏等待是否集中且超过大约一秒、这个页面的访问频次配不配得上维护成本。三条都占的页面才值得做,只占一两条的,一个居中的 loading 转圈可能是更诚实的方案。
这个标准不是拍脑袋来的。六月底产品提了个需求,说竞品 App 的页面加载"看起来快很多",让我们全站铺骨架屏。我抓包看了竞品,接口耗时跟我们差不多,差别确实在等待期的呈现上:我们是白屏加转圈,它是一副灰色的页面轮廓。但"全站铺"这个说法我不同意,拉着资深前端过了一遍我们所有页面之后,最后立项的范围只有四个页面。这篇把判断过程和落地细节都记下来。
转圈和骨架屏的感知差异到底在哪
转圈传递的信息是"系统在忙,请等待",它把用户的注意力吸到"等待"这个行为本身上,转的每一圈都在提醒你时间在流逝。骨架屏传递的是"内容马上就来,它长这样",用一个和真实页面结构相似的占位轮廓,把等待偽装成"页面正在逐步呈现"的过程。两者的实际耗时一样,但骨架屏把用户对时间的感知拆碎了——这是感知性能(perceived performance)的范畴,优化的不是秒数,是体感。
但这个体感优势有前提:骨架的形状要和即将出现的内容大体吻合。商品列表页,每个 item 都是"左图右文"的固定卡片,骨架画出来八九不离十,内容一到,灰块原位替换成实物,衔接非常顺。反过来,一个由运营配置驱动的活动页,今天三个楼层明天五个楼层,骨架画成什么样都是错的,内容一到整个页面结构大变,骨架反而放大了落差。这就是判断标准第一条"结构稳定可预测"的来源。
第二条是等待时长。接口三百毫秒就回来的页面,骨架屏刚渲染出来就被替换,一闪而过,用户看到的是"灰块闪烁"而不是"平滑过渡",纯属画蛇添足。我们中后台有些页面走了本地缓存,几乎秒开,这类页面上骨架屏是负优化。粗略的线是一秒:等待普遍超过一秒的页面才有做的意义,五百毫秒以内的別碰。
第三条是维护成本,这条最容易被忽略。骨架屏本质上是把页面结构复制了一份,页面一改版,骨架就是一份必须同步修改的副本,忘了改就会出现"骨架和内容长得完全不一样"的尴尬。访问频次低的页面,为它维护一份结构副本不划算。
有一类页面在评审时争议最大,值得单独说:中后台的纯表格页。它结构极其稳定(表头固定、行高固定),等待也常超一秒,按前两条标准似乎该做。但我最后把它们全划出去了,理由是 Element UI 的 Table 自带 v-loading,表头、分页器、筛选栏这些"页面骨架"本来就是同步渲染的静态结构,等待期用户看到的不是白屏,是一个结构完整、只有数据区蒙着 loading 的页面——表格页的"骨架"天然就在那里,再画一遍灰色表格行属于重复劳动。真要抠体验,把 loading 蒙层从整表缩小到只罩数据行区域,比上骨架屏性价比高得多。这个判断也让立项范围少了六七个页面,是整场评审里省掉工作量最多的一条结论。
按这三条筛下来:商品列表、订单列表、数据看板、移动端 H5 的店铺首页,四个页面立项。其余的,该转圈转圈。
顺带把"转圈"本身也修了一下,因为它同样有感知问题,只是方向相反。我们的全局 loading 是 axios 拦截器触发的,请求一发出就显示——结果是那些两三百毫秒就返回的快接口,用户看到的是一次毫无必要的闪烁。业界的常见做法是给 loading 加一个出场延迟:三百毫秒内返回的请求,转圈根本不出现,用户感受到的是"直接出结果";超过三百毫秒才显示,此时用户确实在等,提示才有意义。
1var loadingTimer = null; 2 3function showLoadingDelayed() { 4 loadingTimer = setTimeout(function () { 5 Loading.show(); 6 }, 300); 7} 8 9function hideLoading() { 10 clearTimeout(loadingTimer); 11 Loading.hide(); 12}
一个 setTimeout 的事,但"等待提示本身不该制造新的视觉噪音"这个原则,对转圈和骨架屏是同样生效的——后面会看到,骨架屏也需要类似的防闪烁处理。
手写 CSS 骨架:最笨也最可控的方案
先做商品列表页,方案选了手写。骨架就是一组灰色的占位块,按真实卡片的布局摆好,核心只有两块:占位元素和扫光动画。
1<div class="skeleton-card" v-if="loading"> 2 <div class="skeleton-block skeleton-img"></div> 3 <div class="skeleton-lines"> 4 <div class="skeleton-block skeleton-line" style="width: 80%"></div> 5 <div class="skeleton-block skeleton-line" style="width: 60%"></div> 6 <div class="skeleton-block skeleton-line" style="width: 40%"></div> 7 </div> 8</div>
1.skeleton-block { 2 background: linear-gradient( 3 90deg, 4 #f0f2f5 25%, 5 #e6e8eb 37%, 6 #f0f2f5 63% 7 ); 8 background-size: 400% 100%; 9 animation: skeleton-loading 1.4s ease infinite; 10} 11 12@keyframes skeleton-loading { 13 0% { background-position: 100% 50%; } 14 100% { background-position: 0 50%; } 15} 16 17.skeleton-img { width: 88px; height: 88px; border-radius: 4px; } 18.skeleton-line { height: 14px; margin-bottom: 12px; border-radius: 2px; }
扫光用背景渐变加 background-position 位移实现,只动 background-position 虽然不如 transform 便宜,但占位块数量有限,实测没有性能问题。动画节奏是调过的:一开始照抄某个组件库用了 1s 的周期,QA 看了一眼说"这页面怎么在闪",放慢到 1.4s、把渐变的对比度压低(两个灰色只差一档),观感就从"闪烁"变成了"呼吸"。骨架动画的作用是传递"活着、在加载",不是吸引注意力,越低调越对。另外扫光和"脉冲"(整体透明度呼吸)两种风格我们统一选了扫光,四个页面用同一种,不同页面动画风格不一致会显得系统很杂。
Element UI 到现在也没有官方的 Skeleton 组件,我把占位块封装成了一个内部通用组件,四个页面复用:
1<template> 2 <div class="skeleton-card"> 3 <div v-if="avatar" class="skeleton-block skeleton-img"></div> 4 <div class="skeleton-lines"> 5 <div 6 v-for="(w, i) in widths" 7 :key="i" 8 class="skeleton-block skeleton-line" 9 :style="{ width: w }" 10 ></div> 11 </div> 12 </div> 13</template> 14 15<script> 16export default { 17 name: 'SkeletonCard', 18 props: { 19 avatar: { type: Boolean, default: false }, 20 widths: { 21 type: Array, 22 default: function () { return ['80%', '60%', '40%']; }, 23 }, 24 }, 25}; 26</script>
实现选型时还看过 vue-content-loader,它用 SVG 画骨架,占位形状是 SVG 图形、扫光是 SVG 渐变动画,形状表达能力比 div 加圆角强(能画不规则轮廓),社区用得也不少。没选它的原因有两个:一是我们的骨架全是矩形和圆,div 完全够表达,为这点需求引入一个依赖和一套 SVG 的写法不值;二是 SVG 骨架的尺寸是 viewBox 坐标系里写死的,对不同宽度容器做自适应要费额外的心思,div 用百分比宽度天然自适应。它适合形状复杂、尺寸固定的场景,我们两条都不占。
灰色的取值也不是随手写的。骨架的底灰和扫光灰从设计规范的中性色板里取(我们用的是 Element UI 那套色板的 #f0f2f5 和 #e6e8eb 附近),和页面的分割线、禁用态背景同源,骨架出现时和页面整体的灰阶是连续的。之前见过别的系统骨架用了偏蓝的灰,和页面暖灰的背景放在一起,占位块像是贴上去的补丁——这种细节说不上是 bug,但感知优化做的就是这种说不上是 bug 的事。
列表页用的时候外面套一层 v-for 渲染出一屏的量。props 故意只留两个,行数由 widths 数组的长度隐含表达。之前讨论时有同事想把它做成"传入配置对象自动生成任意布局"的万能骨架组件,被资深前端拦了:配置得越万能,描述配置的成本越接近直接写 HTML,通用组件负责最常见的"图加几行文字",特殊布局的页面(比如看板)直接手写骨架模板,不硬塞进抽象里。
有个细节值得记录:占位行的宽度要错开(80%、60%、40%),全等宽的灰条看起来像表格而不像文字段落,错落的宽度更接近真实文本的形态。另外骨架卡片的数量按"撑满一屏"来渲染就够,我们按视口高度估算了一个条数,多渲染的反正在首屏外,没人看见还费渲染。
Vue 里的切换就是最普通的 v-if/v-else,loading 期渲染骨架组件,数据到了换真实列表。这里我特意没用 v-show,骨架组件在数据到达后就彻底销毁,CSS 动画不留在不可见的节点上空转。
切换时机上也套用了前面转圈的防闪烁思路,但方向反过来。转圈是"太快就别出现",骨架屏是"出现了就别立刻消失":如果数据两百毫秒就到了,骨架刚画出来就被替换,那一下灰块闪烁比白屏还难受。所以骨架一旦渲染,至少停留够一个最短时长(我们定的三百毫秒)再切换,数据早到就等一等:
1export default { 2 data: function () { 3 return { loading: true, list: [] }; 4 }, 5 created: function () { 6 var start = Date.now(); 7 var vm = this; 8 fetchList().then(function (list) { 9 var elapsed = Date.now() - start; 10 var wait = Math.max(0, 300 - elapsed); 11 setTimeout(function () { 12 vm.list = list; 13 vm.loading = false; 14 }, wait); 15 }); 16 }, 17};
故意让用户多等最多三百毫秒,听起来违反直觉,但平滑的三百毫秒好过突兀的一百毫秒,感知优化本来就不是在优化秒表。这段逻辑抽成了一个通用工具函数,四个页面共用:
1// utils/withMinDuration.js 2function withMinDuration(promise, ms) { 3 var timer = new Promise(function (resolve) { 4 setTimeout(resolve, ms); 5 }); 6 // 数据和最短时长都满足才落定,Promise.all 天然表达这个语义 7 return Promise.all([promise, timer]).then(function (results) { 8 return results[0]; 9 }); 10}
用 Promise.all 把"数据到达"和"最短展示时长"并成一个条件,比手算 elapsed 再 setTimeout 的写法干净,错误也能顺着 Promise 链正常抛出去。
分块骨架:数据看板的做法不一样
数据看板页和列表页有个本质区别:它不是一个接口喂饱整页,而是六七个卡片各自请求各自的聚合接口,返回时间从三百毫秒到四五秒不等。如果整页共用一个 loading 状态、等最慢的接口到了才一起切换,那最快的数据要陪最慢的接口白等四秒,太亏。
所以看板的骨架是按卡片为单位拆的:每个卡片组件自己管理 loading 态,自己决定什么时候从骨架切到图表,先到先渲染。页面加载的过程变成"骨架逐块点亮",比整页等待的体感好非常多。实现上没有新东西,就是把 loading 状态从页面级下放到卡片级,骨架模板跟着卡片组件走。唯一要处理的是 ECharts 的初始化时机:图表容器在骨架显示期间是不存在的(v-if 切换),echarts.init 必须等切换后的 $nextTick 里再执行,不然拿到的容器宽高是 0,图表画出来是空的。这个坑前年做 ECharts 大屏时踩过一次,这次绕开了。
分块骨架还有一个隐性好处:某个聚合接口挂了,只有那一块卡片切换到错误占位,其他卡片照常展示。loading 状态的粒度决定了错误状态的粒度,这是拆分时没预料到、上线后才体会到的收益。
构建期自动生成:page-skeleton-webpack-plugin 类方案的取舍
手写方案落地后,组里有同事提出:饿了么开源过 page-skeleton-webpack-plugin,构建时用 Puppeteer 把页面跑起来,自动分析 DOM 结构生成骨架页,注入到 html 里,不用手写也不用维护。听起来很美,我花了一天时间试了试。
原理确实聪明:无头浏览器打开页面,把文本块、图片、按钮按规则替换成灰色占位元素,抽出这份"骨架 DOM"的 HTML 和 CSS,打进 index.html 的挂载点里,JS 没加载完之前用户看到的就是骨架。但试下来问题也具体:一是我们的页面全在登录态后面,Puppeteer 得先模拟登录才能截到真实页面,构建脚本里塞一份测试账号的登录流程,CI 环境还得装 Chromium,构建时间和构建环境复杂度都上去了;二是生成的骨架质量不可控,数据看板页里 ECharts 的 canvas 被替换成一整块巨大的灰色矩形,效果生硬,还得写自定义规则去修;三是这个插件的维护活跃度肉眼可见地下降了,issue 里不少没人回。
我的结论是:自动生成方案省掉的是"画骨架"的半天工作量,换来的是一条需要长期伺候的构建链路,对页面数量不多的团队来说是笔亏本买卖。如果有几十上百个页面要铺骨架,这个天平才会倒向自动化。我们四个页面,手写半天一个,成本清清楚楚。
试用期间还顺便验证了另一个更省事的野路子:把页面的设计稿截图整体压暗、模糊,当一张背景图铺在加载期。做出来的效果一眼假——截图里的文字虽然模糊但仍能辨认出是"另一份内容",数据到达后截图里的假信息被真数据替换,用户会有被骗的感觉。骨架屏的灰块之所以成立,恰恰因为它诚实地表达了"这里将有内容,但我不知道内容是什么";截图方案僭越了这条线,假装知道。这个对比让我更确信骨架的形态应该是抽象的结构示意,而不是内容的低清预览。
不过这个插件有个思路值得偷师:它把骨架放在 html 里而不是 Vue 组件里,骨架的出现时机提前到了"HTML 解析完成",比"JS 加载并执行完、Vue 挂载、渲染骨架组件"早了一大截。我们移动端 H5 首页的白屏大头恰恰在 JS 加载期,于是店铺首页借用了这个思路——不用插件,手写一段骨架 HTML 和内联 CSS,直接放在 index.html 的 <div id="app"> 里面:
1<div id="app"> 2 <!-- Vue 挂载后这里会被整体替换掉 --> 3 <div class="skl-page"> 4 <div class="skl-banner"></div> 5 <div class="skl-grid"> 6 <div class="skl-cell"></div> 7 <div class="skl-cell"></div> 8 <div class="skl-cell"></div> 9 <div class="skl-cell"></div> 10 </div> 11 </div> 12</div>
Vue 实例挂载时会替换掉挂载点的内容,骨架自然消失,一行 JS 都不用写。样式必须内联在 html 里(我们用 html-webpack-plugin 的模板插了一小段 <style>),如果放在外链 CSS 里,骨架本身也要等资源加载,意义就打折了。同理,这段骨架里不能出现任何图片资源,全部用纯色块和 CSS 渐变表达——它存在的意义就是在"什么资源都还没到"的窗口期撑住画面,自己再引入资源依赖就本末倒置了。
这个方案的效果可以量化验证。Performance API 里有 paint 时序记录,first-contentful-paint 表示页面第一次绘制出内容的时间点:
1var paints = performance.getEntriesByType('paint'); 2paints.forEach(function (entry) { 3 console.log(entry.name, Math.round(entry.startTime) + 'ms'); 4});
店铺首页改造前,FCP 基本等于"JS 执行完、Vue 渲染出第一屏"的时间,弱网下要两秒多;骨架进了 html 之后,HTML 一解析完骨架就是"有内容的绘制",FCP 直接提前到几百毫秒。当然这里要诚实:FCP 提前不代表可交互时间提前一毫秒,用户能点的时间没变,变的是"页面有响应"的信号来得早了。拿这个数字去汇报时我特意把两句话都说全了,只报前半句属于自欺欺人。
另外有个无障碍相关的细节是评审时资深前端提的:系统设置了"减弱动态效果"的用户(prefers-reduced-motion: reduce),扫光动画应该关掉,一直晃动的灰块对这部分用户是负担。CSS 里补一段媒体查询,动画置空、保留静态灰色就行,成本半分钟:
1@media (prefers-reduced-motion: reduce) { 2 .skeleton-block { animation: none; } 3}
SSR 场景:骨架直出的另一种用法
数据看板有个对外投屏的版本走了 SSR(去年用 Nuxt 搭的),这里骨架屏的用法又不一样。SSR 首屏 HTML 是服务端直出的,理论上不存在白屏,但我们的看板数据接口慢(要聚合好几个数据源),如果让服务端等数据齐了再吐 HTML,TTFB 直接爆炸。所以做法反过来:服务端只直出页面框架和骨架占位,数据获取全部放到客户端,也就是把 Nuxt 的 asyncData 拆掉,改成 mounted 里请求。用户很快看到带骨架的页面结构,数据到了逐块填充。
这其实是在 TTFB 和"内容完整呈现时间"之间做交换:HTML 来得快了,但内容要等第二轮请求。对看板这种"先看到布局再等数字"很自然的页面,交换划算;如果是内容型页面(比如文章详情),直出正文才是 SSR 的意义,套骨架反而是自废武功。SSR 场景下骨架屏不是默认答案,先问一句"这个页面的内容能不能直接直出",能直出就不该有骨架。
骨架屏在"加载更多"场景还有一个轻量用法。商品列表是上拉分页的,翻页等待期以前是列表底部一个小转圈,现在改成追加两三条骨架卡片——新数据到了原位替换,视觉上是"列表在往下生长"而不是"列表到底了、在等什么东西"。这个场景骨架条数要克制,两三条足够示意,渲染一整屏反而让用户误以为有很多内容马上要来,数据只回来五条时落差很难看。这里的骨架和首屏骨架用的是同一个组件,只是数量参数不同,边际成本几乎为零——复用成本低的地方,骨架屏的适用判断可以放宽;前面三条标准针对的是"从零专门做"的场景。
骨架和内容对不上:跳动问题
四个页面上线后,QA 提了一个很准的 bug:订单列表页从骨架切到真实内容的瞬间,页面会"跳"一下。录屏逐帧看,骨架卡片高度 100px,真实订单卡片因为有一行可选的优惠信息,高度在 100 到 124px 之间浮动,替换瞬间列表整体高度变了,滚动位置跟着抖。
这暴露了骨架屏最实际的一条纪律:骨架的尺寸必须以真实内容的尺寸为准绳,做不到完全一致,至少要保证外层容器的高度一致。修法有两种,我们都用了:一是给卡片定最小高度,把可选行的空间预留出来,真实内容不管有没有优惠信息,卡片高度恒定;二是图片这类尺寸已知的元素,骨架和真实元素用同一套宽高样式,直接复用同一个 class,改版时想不同步都难。
图片占位还有一个进阶问题:骨架切走之后,真实图片自身还要加载一阵,如果 img 元素没有预定高度,图片到达的瞬间会发生第二次跳动——骨架只解决了"数据到达前"的占位,没解决"图片到达前"的占位。列表缩略图是固定尺寸,写死宽高就行;店铺首页的 banner 是宽度自适应、比例固定的图,处理办法是老牌的 padding-top 撑比例:
1.banner-wrap { 2 position: relative; 3 width: 100%; 4 padding-top: 40%; /* 高宽比 2:5 的占位 */ 5 background: #f0f2f5; 6} 7.banner-wrap img { 8 position: absolute; 9 top: 0; 10 left: 0; 11 width: 100%; 12 height: 100%; 13}
容器先用比例把空间占住,图片加载完成只是往既有的洞里填内容,页面高度全程不变。这个手法和骨架屏是一体的:骨架管接口等待期,尺寸占位管资源等待期,两层都稳了页面才真的不跳。
顺带一提,Google 五月刚提出了一组新的性能指标,里面专门有一项在度量这种"布局偏移",看来这类跳动问题以后会被指标化地盯上。指标细节我还没研究,先把手头这种肉眼可见的跳动修掉再说。
跳动这类问题还有一个隐蔽的来源要提防:自定义字体加载完成引起的文字重排。好在我们中后台没用 web font,这一项排查一遍就排除了,但列在检查清单里不亏。
修跳动的过程中还发现一个验证手段挺好用:Chrome DevTools 的 Rendering 面板里有个 Layout Shift Regions 选项,勾上之后页面里发生布局偏移的区域会闪蓝色高亮。订单列表那个跳动我最初靠录屏逐帧找,后来用这个开关,哪一块在动一目了然,比录屏效率高得多。四个页面修完,我把"开着 Layout Shift Regions 过一遍页面加载"加进了自己上线前的检查习惯里。
修完跳动,这轮骨架屏的活就算收尾了。四个页面的转化数据运营还在观察,但客服那边"页面卡住不动"的反馈少了——页面其实没有变快一毫秒,只是等待不再是一片空白。而那些没立项的页面,转圈还在转,我依然认为它们就该转圈:骨架屏是给"结构稳定、等待较长、访问频繁"的页面准备的,不是给所有页面的等待期镀金的。