骨架屏技术在前端性能优化中的实践与思考

一耀择 优化 阅读 2,885
赞 21 收藏
二维码
手机扫码查看
反馈

骨架屏又搞我心态了

今天上线前最后检查,发现首页加载时骨架屏闪一下就没了,用户根本没感觉到它的存在。本来是想优化首屏体验的,结果搞得像是出了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: absolutez-index,导致浏览器把它当成“主要元素”去计算绘制时间。

后来改成用透明背景占位,或者干脆让真实内容容器先渲染,通过 v-if 切换内部结构,LCP 直接提升了200ms。这个细节很多人忽略,但真会影响 SEO 和性能评分。

总结一下

骨架屏看着简单,真做起来全是坑。关键点就两个:

  • 不要一次性控制整个页面的骨架屏显隐,按模块拆分会更自然
  • 一定要加最小展示时间,否则等于没加

我现在这套方案虽然不算完美,但好在稳定、可复用,每个新页面复制粘贴一下 Hook 就能用。比之前那种“要么太快闪,要么太慢卡”强多了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。特别是那个定时器清理的问题,我总觉得还能更优雅点。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论