骨架屏技术在前端性能优化中的实践与思考
骨架屏又搞我心态了
今天上线前最后检查,发现首页加载时骨架屏闪一下就没了,用户根本没感觉到它的存在。本来是想优化首屏体验的,结果搞得像是出了bug——白屏都没这么尴尬。
一开始以为是接口太快,但本地跑了一下,把网络限速到Slow 3G,还是闪现。这就离谱了,按理说这时候应该稳稳地挂着骨架屏才对。折腾了快两个小时,中间还被产品叫去改了个按钮颜色,回来更懵了。
第一反应:是不是 mounted 里请求太猛?
我第一个怀疑的就是数据请求时机。我们用的是 Vue 3 + Pinia,首页的数据都在 store 里统一 fetch。之前为了“快”,我把所有请求都塞进了 onMounted,然后用 Promise.all 并发拉取。
代码长这样:
onMounted(async () => {
await Promise.all([
useHomeStore().fetchBanner(),
useHomeStore().fetchRecommendList(),
useHomeStore().fetchHotCategories()
])
// 数据到了,隐藏骨架屏
showSkeleton.value = false
})
问题就出在这儿。Promise.all 是等所有请求都完成才 resolve。但实际测试中发现,有些接口特别快(比如 banner 只有3条数据),不到100ms就回来了;而推荐列表要查数据库+缓存,平均600ms起。
所以出现了这种情况:骨架屏刚渲染出来,第一条数据回来了,整个 Promise.all 还没结束,等最后一个接口回来,一下子全更新,视觉上就是“闪一下”。
这里我踩了个坑:我以为骨架屏只要“数据没到就不显示内容”就行,但实际上,UI 更新是异步的,骨架屏自己也有渲染时间。你得确保它至少存在个200ms以上,否则人眼根本感知不到,还不如不加。
试过几种方案,都不太理想
最开始我想简单粗暴点,在 Promise.all 后面加个 setTimeout 强制延迟隐藏:
onMounted(async () => {
await Promise.all([...])
setTimeout(() => {
showSkeleton.value = false
}, 300)
})
结果更诡异了——网络慢的时候没问题,但网络快的时候,用户看到数据都出来了,还得再看300ms的骨架屏,像是卡住了。产品经理直接找上门:“你们这加载动画是不是卡了?”
后来试了下用计时器控制最小展示时间,这个思路靠谱点:
onMounted(async () => {
const startTime = Date.now()
await Promise.all([...])
const endTime = Date.now()
const duration = endTime - startTime
if (duration < 300) {
setTimeout(() => {
showSkeleton.value = false
}, 300 - duration)
} else {
showSkeleton.value = false
}
})
这样能保证骨架屏最少展示300ms。但问题来了:如果页面有多个模块异步加载(比如下拉刷新、懒加载组件),这种全局计时就不够用了。
而且这个逻辑写在每个页面里太重复了,维护起来头疼。
最终方案:组件级骨架 + 最小展示时间 Hook
后来我决定拆开看:不是所有地方都需要等全部数据回来才收骨架屏。比如 banner 加载完了,那部分就可以换成真实内容;推荐列表还在 loading,那就让它继续挂着骨架。
于是我把骨架屏从“全页一个开关”改成“按模块独立控制”,同时抽了个简单的 useSkeleton Hook 来统一处理最小展示时间。
核心代码就这几行:
// composables/useSkeleton.js
export function useSkeleton(minDuration = 300) {
let timer = null
let resolved = false
let finished = false
const show = ref(true)
function finish() {
resolved = true
if (finished) {
clearTimeout(timer)
show.value = false
}
}
// 模拟数据加载结束
function onLoadComplete() {
finished = true
if (!resolved) {
const elapsed = Date.now() - startTime
if (elapsed >= minDuration) {
show.value = false
} else {
timer = setTimeout(() => {
show.value = false
}, minDuration - elapsed)
}
}
}
const startTime = Date.now()
return {
show,
onLoadComplete
}
}
然后在组件里这样用:
// HomeBanner.vue
const { show: showSkeleton, onLoadComplete } = useSkeleton(300)
onMounted(async () => {
try {
await useHomeStore().fetchBanner()
} finally {
onLoadComplete()
}
})
<template>
<div v-if="showSkeleton">
<!-- 骨架屏 -->
<div class="skeleton-banner"></div>
</div>
<div v-else>
<!-- 真实内容 -->
<BannerItem v-for="item in banners" :data="item" />
</div>
</template>
CSS 部分很简单,就一个淡入动画:
.skeleton-banner {
height: 160px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading-shimmer 1.5s infinite;
}
@keyframes loading-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
这套组合拳打完,效果明显好多了。每个模块的骨架屏都能稳定展示至少300ms,也不会出现“数据早到了还硬撑”的情况。用户体验上感觉更“顺”,没有卡顿也没有闪瞎眼。
还有个小问题没解决
目前唯一还不太满意的是,如果用户快速切换页面(比如连点两次 tab),可能会出现骨架屏没来得及清除就被销毁的情况,偶尔报个 Cannot assign to 'value' of a readonly or non-writable property 的警告。应该是定时器在组件卸载后还试图修改响应式变量。
我知道该用 onUnmounted 清理定时器,但试了几次没完全压住,暂时先加了个 isAlive 标记位挡一下:
let isAlive = true
onMounted(() => {
isAlive = true
})
onUnmounted(() => {
isAlive = false
clearTimeout(timer)
})
function onLoadComplete() {
finished = true
if (!resolved && isAlive) {
// ...
}
}
虽然有点糙,但至少不报错了。后续再看看能不能用 AbortController 或者更好的生命周期管理来彻底解决。
额外踩坑提醒:别让骨架屏影响 LCP
顺便提一嘴,昨天跑性能检测的时候发现 LCP(最大内容绘制)分数很低,排查半天才发现是我们给骨架屏设置了 position: absolute 和 z-index,导致浏览器把它当成“主要元素”去计算绘制时间。
后来改成用透明背景占位,或者干脆让真实内容容器先渲染,通过 v-if 切换内部结构,LCP 直接提升了200ms。这个细节很多人忽略,但真会影响 SEO 和性能评分。
总结一下
骨架屏看着简单,真做起来全是坑。关键点就两个:
- 不要一次性控制整个页面的骨架屏显隐,按模块拆分会更自然
- 一定要加最小展示时间,否则等于没加
我现在这套方案虽然不算完美,但好在稳定、可复用,每个新页面复制粘贴一下 Hook 就能用。比之前那种“要么太快闪,要么太慢卡”强多了。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。特别是那个定时器清理的问题,我总觉得还能更优雅点。

暂无评论