尧图精选

Vue 骨架屏实战指南:四种方案与性能优化细节

🕒 发布时间:2026/10/2 10:34:23 📁 来源:尧图网络
1. 骨架屏的核心逻辑与四种方案的选型思路先说个真实场景。我之前接手过一个后台管理项目技术栈是 Vue 2 Element UI Webpack首屏加载时间在弱网环境下能飙到 6-8 秒。用户点开链接后页面白花花一片没有任何反馈那种感觉就像你打开一个软件却不知道它是卡死了还是在工作用户流失率肉眼可见地涨。后来我在首屏渲染阶段做了骨架屏白屏时间从 6 秒降到了 1.5 秒的视觉感知——注意我说的是“视觉感知”。骨架屏并不会让你的接口变快也不会让你的 JS 包变小它的本质是把等待时间变成可感知的过渡动画让用户觉得“页面在加载了马上就出来”而不是“这破网站是不是挂了”。那骨架屏到底是什么简单说就是用灰色或浅色的占位块模拟最终页面的布局结构在真实数据到达之前先渲染出来。用户看到的是几个灰色的矩形条排列方式和最终页面的标题、卡片、列表完全一致加载完成后真实内容淡入替换掉灰色占位块。这一步优化在移动端尤其重要。弱网环境下首屏 HTML 内容和静态资源的分发速度不可控用户可能盯着空白页好几秒。骨架屏刚好填补了这个空档期。我整理了目前 Vue 项目里最常用的四种骨架屏实现方案方案实现难度侵入性推荐场景纯 CSS 手写骨架屏组件低低小型项目、页面结构简单路由级独立骨架页低中中后台系统、结构相对统一组件内细粒度骨架屏中中高内容复杂、数据加载频繁的页面构建阶段自动注入骨架屏高极低大型项目、多页面需要统一效果这四种方案不是互斥的实际项目里经常是混合使用。你以为骨架屏就是写个灰色组件没那么简单。骨架屏最大的敌人是布局抖动——骨架屏结构和真实页面结构不一致数据一来整个布局跳一下观感反而更差。所以选型时要考虑的第一个问题不是“哪种方案更炫”而是“哪种方案能最精确地还原真实页面布局”。我每次做技术选型前的自问清单页面有多少种布局形态数据加载是在路由级别还是组件级别团队成员能不能维护一套独立的骨架组件库构建配置能不能动考虑完这些问题再做选型才不会出现“做完了发现骨架屏比白屏还难看”的尴尬情况。2. 方案一纯 CSS 手写骨架屏组件5 分钟接入这是最基础、也最常见的方案。不需要额外依赖不侵入构建流程效果还行。核心逻辑是写一个通用骨架组件然后按需在业务页面里引用。先上代码我直接给一个可用的组件版本template div classskeleton-wrapper div classskeleton-block v-fori in count :keyi :styleblockStyle(i)/div /div /template script export default { name: BaseSkeleton, props: { count: { type: Number, default: 3 }, type: { type: String, default: list // list | card | text } }, methods: { blockStyle(index) { const BASE_STYLES { list: { height: 32px, marginBottom: 16px }, card: { height: 120px, marginBottom: 20px }, text: { height: 16px, marginBottom: 12px, width: 60% } } const item BASE_STYLES[this.type] || BASE_STYLES.list // 让文字类骨架块宽度随机一点模拟真实文本的长度变化 if (this.type text) { const widths [60%, 80%, 40%, 70%, 50%] item.width widths[index % widths.length] } return item } } } /script style scoped .skeleton-wrapper { width: 100%; padding: 16px; box-sizing: border-box; } .skeleton-block { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; border-radius: 4px; } keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } } /style这个组件的核心就三件事生成占位块、模拟闪光动画、按预设类型排列。闪光动画用的是背景渐变色移动技巧linear-gradient定义了浅灰-中灰-浅灰的渐变色带background-size放大到 400%然后通过background-position的循环变化制造出光带扫过去的效果。这个比设置透明度闪烁好看得多也更接近 iOS 系统的加载风格。接入方式很简单在真实数据请求前渲染请求完成后切换template div BaseSkeleton v-ifloading :count3 typecard / div v-else !-- 真实内容区域 -- /div /div /template script export default { data() { return { loading: true } }, created() { setTimeout(() { this.loading false }, 1000) } } /script这套方案的优点是零依赖、零构建配置、组件复用性高。缺点是每个页面都要手动引入和切换页面多了以后业务代码里全是骨架屏的 v-if/v-else看着很啰嗦。而且手写结构毕竟精确度有限遇到复杂布局很容易出现骨架屏和真实页面“对不上”的情况。做个对比你就明白我在说什么。一个商品列表页真实布局是左侧图片、右侧标题加两行描述。如果你手写的骨架只是三个等宽灰色条加载完换成真实商品卡片时整块内容的位置和尺寸都变了用户会感觉页面“跳”了一下。所以在手写这类骨架时最好把骨架组件做成和真实卡片相同布局的结构只是用灰色块填充图片区和文字区。这套方案的关键注意事项我认为有三条第一骨架屏宽度不要设死。文字类骨架宽度尽量用百分比因为真实数据来的时候文字长度是变化的如果骨架屏宽度固定用户视觉上会看到文字从很窄“弹”到很宽。第二动画别太花哨。骨架屏动画的职责是告诉用户“还在加载”不是舞台表演。闪烁频率控制在 1.2-1.6 秒一轮比较舒服太快的动画反而让人焦虑。第三记得关注移动端的prefers-reduced-motion设置。有些系统开了弱动画模式骨架屏的闪烁动画应该自动停掉否则对晕动症用户不友好。加一行 CSS 就行media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } }大多时候我们写前端关注的是功能上线但这种细节往往决定了真实用户的使用体验。3. 方案二路由级骨架页把 loading 移出业务代码方案一最大的问题就是骨架屏代码和业务代码纠缠在一起——每个页面都要写一遍 v-if/v-else。那能不能在路由层面统一处理完全可以。思路是把骨架屏做成一个独立的页面级组件在动态 import 的组件加载完成前展示。核心代码如下// router/index.js import Vue from vue import Router from vue-router Vue.use(Router) // 独立骨架屏页面组件 const routeSkeleton (component, skeleton) () ({ // 真正要渲染的页面组件 component: component(), // 该组件加载完成前的占位组件 loading: skeleton(), // 加载失败时的兜底 error: () import(/components/ErrorMessage.vue), // 加载完成的延迟时长配合过渡效果使用 delay: 200, timeout: 10000 }) const router new Router({ routes: [ { path: /dashboard, name: Dashboard, component: routeSkeleton( () import(/views/Dashboard.vue), () import(/components/skeletons/DashboardSkeleton.vue) ) } ] })注意这里的delay: 200。这个参数很关键它的作用是如果异步组件在 200ms 内加载完成就不展示骨架屏。因为加载速度足够快时展示骨架屏反而是视觉闪烁——用户还没来得及看清一下就跳成真实页面了。加上 delay 之后加载时间短的场景会直接跳过骨架屏体验更平滑。这个方案的技术基础是 Vue 2.3.0 版本支持的异步组件高级选项。component、loading、error、delay这些字段都是官方支持的。如果你用的 Vue 版本比较旧要先把版本升上去。再说一种变体思路如果你不想用异步组件的高级选项也可以用路由 meta 字段配合 watchtemplate div classpage-with-skeleton component :iscurrentView / /div /template script export default { name: SkeletonRouterView, data() { return { currentView: null } }, watch: { $route: { immediate: true, async handler(route) { this.currentView null // 根据路由 meta 加载对应的骨架屏 const skeletonMap { /dashboard: DashboardSkeleton, /order/list: OrderListSkeleton } const SkeletonComp skeletonMap[route.path] if (SkeletonComp) { this.currentView SkeletonComp } try { const realComp await this.loadViewComponent(route) // 这里加一个小延迟避免骨架屏一闪而过 await this.$nextTick() setTimeout(() { this.currentView realComp }, 300) } catch (e) { console.error(页面加载失败, e) } } } } } /script路由级的方案和组件级方案的核心区别在于骨架屏的粒度和加载时机的掌控。路由级方案天然覆盖了“JS chunk 下载”这段时间而组件级骨架屏只能覆盖“数据请求”这段时间。在很多场景下首屏白屏的最大头就是 JS chunk 下载尤其是首次打开页面时浏览器要拉取几 MB 的 bundle。所以路由级方案是我个人认为性价比最高的侵入少、覆盖面广、代码也好维护。在实际项目中我还试用过一个组合拳路由级骨架屏 Webpack 的 magic comment。给动态 import 加webpackChunkName注释让有依赖关系的页面 chunk 合并加载减少 HTTP 请求数component: () import(/* webpackChunkName: dashboard-group */ /views/Dashboard.vue)不过这是在构建层面的优化骨架屏本身无法影响 chunk 大小和请求数是需要配合使用的手段。4. 方案三组件内细粒度骨架屏数据分段加载的体验天花板路由级骨架屏解决了“页面框架加载”的问题但真实场景往往更复杂一个页面有多个数据区域各自加载速度不一样。比如一个内容页顶部用户信息接口 200ms 返回中间列表接口 1.5 秒返回底部推荐内容接口 3 秒才返回。这种情况下如果你用一个统一的骨架屏等到全部接口都返回再展示真实页面那前面 1 秒多的时间里骨架屏就一直在“空转”。更聪明的做法是把页面拆成多个区域每个区域独立维护自己的骨架屏状态。我分享一个我常用的状态管理模式用 Vuex 比较省事也可以用 composable!-- 组件内的细粒度骨架屏示例 -- template div classuser-profile !-- 头部区域 -- div v-ifuserStatus loading classskeleton-avatar div classcircle-skeleton/div div classtext-skeleton w-40/div /div UserCard v-else-ifuserStatus success :useruserData / !-- 列表区域加载状态单独控制 -- ListSkeleton v-iflistStatus loading :rows5 / OrderList v-else-iflistStatus success :ordersorderData / !-- 推荐区域 -- RecommendSkeleton v-ifrecommendStatus loading / RecommendPanel v-else-ifrecommendStatus success :itemsrecommendData / /div /template script import { mapState } from vuex export default { name: UserProfilePage, computed: { ...mapState({ userStatus: state state.user.status, listStatus: state state.order.status, recommendStatus: state state.recommend.status }) }, created() { this.$store.dispatch(user/fetchUserInfo) this.$store.dispatch(order/fetchOrderList) this.$store.dispatch(recommend/fetchRecommendData) } } /script这种方案的体验优势很明显先出来的区域先显示真实内容后出来的区域继续展示局部骨架屏整个页面的感知加载时间被大幅压缩。对用户来说这是一种“渐进式揭晓”的体验——页面不是整体从灰变彩而是一块一块地“亮”起来。但是这里有个隐藏的成本你需要维护多套骨架组件而且每一块区域的数据状态都需要单独管理。如果项目小、页面少这个方案有点“杀鸡用牛刀”。细粒度方案值得单独说的一点是布局稳定性的意义。真实项目中如果骨架屏和真实内容的宽高不一致就会导致页面布局不断跳动用户刚看完第一块内容准备点按钮第二块骨架加载完又把整个布局往下推了 100px——这种体验其实是骨架屏方案的减分项。所以细粒度骨架屏的组件设计我建议遵循三个原则一是骨架结构的 DOM 层级要和真实内容保持对齐。最稳的办法是直接参考真实组件的样式结构只把颜色替换成灰色系。很多团队偷懒用几个 div 拼拼出来的结构和真实组件差了十万八千里那还不如不做。二是骨架区域的尺寸要预留误差空间。真实数据加载后文字长度、图片尺寸都会变化所以骨架区域尽量使用弹性布局让骨架与真实界面之间留 4-8px 的缓冲避免像素级错位。三是在骨架状态和真实状态切换时增加轻量过渡。Vue 内置的transition组件 fade模式就够了不要上复杂动画库。transition namefade modeout-in component :iscurrentComponent :keycomponentKey / /transitionmodeout-in很重要它的作用是先让旧组件消失、再让新组件出现避免两个组件同时在 DOM 里争抢视觉焦点。这套方案的实现细节非常多但核心不必复杂化先把基础的结构对齐和状态划分做好体验就会有非常明显的提升。5. 方案四构建阶段自动注入骨架屏SSR 之外的另一条工程化路线如果你负责的项目页面很多比如 30-50 个路由页面每个页面都手写骨架组件和维护加载状态工作量会非常大而且很容易出现 A 页面做了、B 页面忘做的情况。那有没有一种方案可以在构建阶段自动给所有页面注入骨架屏不需要在每个页面写任何代码答案是有的。这个方案的原理是利用 Webpack 的 HtmlWebpackPlugin 钩子在 HTML 生成阶段把骨架屏的代码直接内联进 index.html。这样浏览器在 JS bundle 加载完成前就已经能看到页面骨架了。我实际用过比较成熟的库是vue-skeleton-webpack-plugin下面给出一个可运行的配置// vue.config.js (Vue CLI 项目) const SkeletonWebpackPlugin require(vue-skeleton-webpack-plugin) module.exports { configureWebpack: (config) { config.plugins.push( new SkeletonWebpackPlugin({ webpackConfig: { entry: { app: path.join(__dirname, ./src/skeleton.js) } }, minimize: true, quiet: true, // 支持多页面给指定路由注入骨架屏 router: { mode: hash, routes: [ { path: /, skeletonId: homeSkeleton }, { path: /about, skeletonId: aboutSkeleton }, { path: /list/:id, skeletonId: listSkeleton } ] } }) ) } }对应的src/skeleton.js其实是一个获取骨架屏 DOM 的入口文件// src/skeleton.js import Vue from vue import HomeSkeleton from ./components/skeletons/HomeSkeleton.vue export default new Vue({ render: h h(HomeSkeleton) })这个方案的核心逻辑是插件在构建时把 Vue 组件渲染成字符串然后用正则替换掉 HtmlWebpackPlugin 生成的 HTML 中对应的div idapp/div内容把骨架屏的静态 HTML 和样式内联进去。为什么这个方案很适合大型项目因为它的侵入性几乎为零。业务组件里不需要写任何 v-if 判断不需要引入任何骨架组件构建完成后每个页面自动就有了对应的骨架屏。业务代码根本不需要感知骨架屏的存在骨架屏是纯构建期产物。但它有两个很明确的限制我在实际使用中感受很深第一清洗静态问题。因为骨架屏是构建期注入 HTML 的它不是 Vue 的运行时代码所以它只能展示静态结构不能响应数据变化。对于完全依赖接口数据的动态页面注入的骨架屏只能是一个“大致的壳”无法做到细粒度控制。第二多页面继承问题。如果你用的是 webpack 多入口配置MPA每个入口的 HTML 都需要单独配置路由映射配置会变得比较繁琐。Vue CLI 项目里pages配置和SkeletonWebpackPlugin的路由配置要一一对应维护成本会随着页面数量上升。第三也是最隐蔽的一个坑——骨架屏样式会被提取成独立 CSS 文件还是内联。如果构建配置里 CSS 是单独提取那么骨架屏的样式会出现在 CSS bundle 中而这个 CSS bundle 加载前HTML 里已经渲染出骨架 DOM 了没有样式的话就是一堆无样式的裸 div比白屏还难看。解决方案是插件的styleChunk配置或者在html-webpack-plugin的inlineSource配置里把骨架屏的 chunk 做内联处理。这一步很多人会踩坑我记得第一次接入的时候本地产物显示正常发到测试环境就出现了骨架屏 DOM 在样式加载前一次性暴露的问题排查了半天才发现是 CSS 提取顺序和加载顺序的问题。我后来对齐这套方案做了一次更完整的构建配置建议在vue.config.js里这样处理const SkeletonWebpackPlugin require(vue-skeleton-webpack-plugin) module.exports { chainWebpack: config { config.plugin(skeleton).use(SkeletonWebpackPlugin, [ { webpackConfig: { entry: { app: path.join(__dirname, ./src/skeleton.js) } }, minimize: true, quiet: true, router: { mode: hash, routes: [ { path: /, skeletonId: homeSkeleton } ] } } ]) // 关键确保骨架屏的 style 被内联而不是外链 config.plugin(html).tap(args { args[0].chunksSortMode none return args }) } }要说明的是这个方案和 SSR服务端渲染的思路有点相似——都是让用户更早知道页面结构——但 SSR 需要整个服务端架构配合而骨架屏注入是纯构建期操作根本不用起 Node 服务。对于纯前端静态部署的 Vue 项目来说这个方案确实像“平民版 SSR”。哪种场景我推荐用构建期注入我个人体会是项目规模大到人手一份手写骨架不现实且页面结构相对规整不追求细分场景。如果你的页面千奇百怪、动态组件特别多构建期注入的骨架屏会显得比较笨拙。6. 实操中遇到的坑与排查思路骨架屏做多了总有一些反复踩的坑。我把这些经验整理成常见问题速查表问题原因解法骨架屏展示了但一闪而过组件加载时间太短设置 delay 参数200ms 左右或用 transition 延迟切换骨架屏与真实内容布局跳动骨架 DOM 结构和真实结构不一致直接复用真实组件的模板结构与样式只替换内容区域骨架屏不显示异步组件 loading 配置没有触发检查component字段是否返回 Promise检查 delay 和 timeout 的配置检查异步组件的加载是否太快被 skip 了骨架屏样式在首屏加载时闪露构建期注入的骨架屏 CSS 被提取成外链把骨架屏相关 chunk 配置为 inlineSource 或内联处理骨架屏动画卡顿掉帧动画使用left/top属性只使用background-position和transform等合成器属性减少重排产品一口回绝不要骨架屏骨架屏动画效果太夸张降低动画频率、减小色彩对比度、给骨架屏增加淡出过渡其中“骨架屏与真实内容布局跳动”这个问题在项目里最严重几乎每个有复杂列表的页面都遇到过。我建议的处理方式想把骨架组件做成“半个真实页面”那不如直接复用真实页面的类名与结构。依然以订单列表为例真实订单卡片结构是“左侧时间轴 右侧信息”。那骨架组件的 DOM 就应该长这样div classorder-item div classorder-time skeleton-bg/div div classorder-info div classorder-title skeleton-bg/div div classorder-desc skeleton-bg/div div classorder-amount skeleton-bg/div /div /div这里.skeleton-bg只负责背景色和动画其余尺寸、布局、间距都由.order-item、.order-time、.order-info这些类名决定。每个生产项目我都会专门整理一个skeleton-bg的工具类一行代码就搞定骨架背景各个骨架组件统一复用。关于“骨架屏闪一下”的细节我再多说几句。前面提到设置 delay 可以避免加载太快的组件闪烁但实际还有一层体验优化骨架屏从显示到消失之间应该有个淡出过渡。如果 V-if 直接切走用户会看到灰色块瞬间消失视觉上也有点突兀。我当时在处理列表筛选操作时做了一个局部优化async loadData(filterParams) { this.listStatus loading const timer setTimeout(() { this.listShowSkeleton true }, 150) // 150ms 还没返回才显示骨架 try { const res await fetchList(filterParams) this.listData res this.listStatus success } catch(e) { this.listStatus error } finally { clearTimeout(timer) // 给骨架屏一点淡出时间 await new Promise(resolve setTimeout(resolve, 200)) this.listShowSkeleton false } }这相当于给“加载很快”的数据路径增加了一道保险150ms 内返回就不出现骨架屏出现骨架屏后至少要存在 200ms 再淡出。加入这两个时间阈值之后用户感知到的卡顿感和闪烁感会少很多。还有一个容易被忽视的点骨架屏的颜色。绝大多数商品项目的默认灰是#f2f2f2但不同主题、不同环境的背景色可能差异很大。在暗色模式下骨架屏的灰色块和浅色背景会非常突兀。骨架屏组件的颜色最好跟随 CSS 变量.skeleton-block { background: var(--skeleton-bg, linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%)); }在使用时单独覆盖--skeleton-bg变量即可不用大改组件代码。最后补充一个我在真实项目里常做的性能排查骨架屏做得再好也只是优化了“感知体验”真正的首屏优化还是要配合代码分割、懒加载、CDN 加速等手段。我通常会用 Lighthouse 跑一次 performance重点关注FCPFirst Contentful Paint和LCPLargest Contentful Paint两个指标。引入了骨架屏之后FCP 会明显提前——因为 HTML 里已经有骨架 DOM但 LCP 取决于最大内容出现的时间这个还是拼真实加载速度。做了一年多的骨架屏优化我的体感是骨架屏是“性价比”极高的一种前端体验优化手段它花不了多少开发成本也不会给项目引入复杂依赖却能让用户等待的体感舒适度上升一个台阶。骨架屏的核心从来不是技术有多炫而是你用没用心去对待用户等待的那几秒钟。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →