骨架屏(Skeleton Screen)设计与实现
引言
你可能已经注意到,当打开淘宝、知乎等主流应用时,页面并非一片空白,而是立即展现出内容的轮廓。
这种让用户感知"页面正在加载"的设计技巧,正是今天要介绍的主角 —— 骨架屏。
一、骨架屏基础认知
1.1 什么是骨架屏
骨架屏(Skeleton Screen)是在页面数据加载完成前,先呈现出页面的大致结构框架。这种加载占位图以简单的线条和色块勾勒出页面的大致轮廓,当真实内容加载完成后,再无缝替换掉占位图。
想象一下,当你打开淘宝App时,页面并不是一片空白或转圈的loading,而是立即展现出类似下面这样的界面:商品图片区域显示灰色方块,标题显示几条灰色线条,这就是典型的骨架屏应用。
相比传统的加载方式,骨架屏有以下特点:
- 渐进式加载:不是等所有内容都准备好才显示,而是先展示框架,再填充内容
- 结构预知性:用户可以预先了解页面的布局结构
- 视觉连续性:避免了页面从空白到内容的突然跳变
- 减少焦虑感:给用户"正在加载"的明确反馈
1.2 与传统loading的区别
特性 | 传统Loading | 骨架屏 |
---|---|---|
视觉反馈 | 局部动画图标 | 整体页面轮廓 |
空间占用 | 通常居中显示,不占用实际内容空间 | 与实际内容结构一致 |
用户体验 | 完全等待状态,不知道等待什么 | 可预知内容结构,减少等待焦虑 |
过渡效果 | 内容加载完成时会有明显跳变 | 可以实现平滑过渡 |
开发成本 | 相对简单 | 需要额外的开发工作 |
二、骨架屏实现方案对比
在前端开发中,骨架屏的实现方案主要有三大类:手写代码、自动生成和预渲染。每种方案都有其特点和适用场景,让我们深入分析各个方案。
2.1 手写 HTML + CSS 方案
这是最基础且直观的实现方式,通过手动编写骨架屏的结构和样式。
我们来提供是个小小的示例,大家可以复制到
https://play.vuejs.org/
这个网站上自己试试。
以下是使用骨架屏的一个效果。可以看到过渡的非常丝滑。
以下是源代码,大家可以复制下来看看,到上面提到的网站上看看效果。
<!-- App.vue -->
<template>
<div class="container">
<button class="toggle-btn" @click="toggleLoading">
{{ isLoading ? '显示内容' : '显示骨架屏' }}
</button>
<!-- 文章列表 -->
<div v-if="!isLoading" class="article-list">
<article v-for="article in articles" :key="article.id" class="article-card">
<div class="article-header">
<img :src="article.avatar" :alt="article.author" class="avatar">
<div class="author-info">
<h3 class="author-name">{{ article.author }}</h3>
<time class="post-time">{{ article.date }}</time>
</div>
</div>
<h2 class="article-title">{{ article.title }}</h2>
<p class="article-content">{{ article.content }}</p>
<div class="article-stats">
<span>{{ article.views }} 阅读</span>
<span>{{ article.likes }} 点赞</span>
<span>{{ article.comments }} 评论</span>
</div>
</article>
</div>
<!-- 骨架屏 -->
<div v-else class="article-list">
<div v-for="n in 3" :key="n" class="article-card">
<div class="article-header">
<div class="skeleton avatar-skeleton"></div>
<div class="author-info">
<div class="skeleton title-skeleton"></div>
<div class="skeleton date-skeleton"></div>
</div>
</div>
<div class="skeleton-content">
<div class="skeleton heading-skeleton"></div>
<div class="skeleton text-skeleton"></div>
<div class="skeleton text-skeleton"></div>
<div class="skeleton text-skeleton"></div>
</div>
<div class="article-stats">
<div class="skeleton stats-skeleton"></div>
<div class="skeleton stats-skeleton"></div>
<div class="skeleton stats-skeleton"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isLoading = ref(true)
const articles = ref([
{
id: 1,
author: '张三',
avatar: 'https://placekitten.com/100/100',
date: '2024-01-16',
title: 'Vue3 组件开发最佳实践',
content: 'Vue3的组件开发带来了全新的可能性。Composition API不仅提供了更好的代码组织方式,还能够提供更好的类型推导...',
views: '1.2k',
likes: 328,
comments: 46
},
{
id: 2,
author: '李四',
avatar: 'https://placekitten.com/101/101',
date: '2024-01-15',
title: '深入理解响应式原理',
content: '响应式系统是Vue的核心特性之一。通过Proxy的实现,Vue3的响应式系统变得更加强大和高效...',
views: '2.3k',
likes: 892,
comments: 125
},
{
id: 3,
author: '王五',
avatar: 'https://placekitten.com/102/102',
date: '2024-01-14',
title: 'Vite构建工具的实践分享',
content: 'Vite作为新一代的前端构建工具,其基于ES modules的开发服务器让开发体验得到极大提升...',
views: '1.8k',
likes: 567,
comments: 89
}
])
const toggleLoading = () => {
isLoading.value = !isLoading.value
if (isLoading.value) {
setTimeout(() => {
isLoading.value = false
}, 2000)
}
}
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.toggle-btn {
background-color: #4a90e2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
margin-bottom: 20px;
cursor: pointer;
transition: background-color 0.3s;
}
.toggle-btn:hover {
background-color: #357abd;
}
.article-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.article-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.article-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.author-info {
margin-left: 12px;
}
.author-name {
font-weight: 600;
color: #333;
margin: 0;
}
.post-time {
font-size: 14px;
color: #666;
}
.article-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
}
.article-content {
color: #666;
line-height: 1.6;
margin-bottom: 16px;
}
.article-stats {
display: flex;
justify-content: space-between;
color: #666;
font-size: 14px;
}
/* 骨架屏样式 */
.skeleton {
background: #f0f0f0;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.6) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.avatar-skeleton {
width: 48px;
height: 48px;
border-radius: 50%;
}
.title-skeleton {
height: 16px;
width: 120px;
margin-bottom: 8px;
}
.date-skeleton {
height: 14px;
width: 80px;
}
.heading-skeleton {
height: 24px;
width: 80%;
margin-bottom: 16px;
}
.text-skeleton {
height: 16px;
width: 100%;
margin-bottom: 8px;
}
.text-skeleton:last-child {
width: 60%;
}
.stats-skeleton {
height: 14px;
width: 60px;
}
.skeleton-content {
margin-bottom: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 16px;
}
.article-card {
padding: 16px;
}
.article-title {
font-size: 18px;
}
.avatar {
width: 40px;
height: 40px;
}
.author-info {
margin-left: 8px;
}
}
</style>
在这里我们发现想要做一个骨架屏,太累了,重复工作多,代码量大,那有没有更加简便的方法也能够实现效果,或者说我能够去复用呢,当然有。
2.2 组件化方案
具体思路:写一个包含多种类型骨架的基础组件,然后使用该基础组件去构建我们的业务组件。
基础组件: 封装常用骨架类型(例如,头像,文本,标题,按钮等等)
业务组件:使用用基础组件来构建业务组件(比如商品列表,文章列表之类的去复用基础组件)
这样的话大幅度缩减了我们的代码量。
接下来我们同样使用组件化的思想来实现上述骨架屏的效果。
这里的话由于代码量过多,大家可以直接前往下方地址查看源码。
https://play.vuejs.org/#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
这里我给大家提供基础骨架组件的代码。
<template>
<div
:class="[
'skeleton-item',
`skeleton-${type}`,
animated && 'skeleton-animated'
]"
:style="computedStyle"
>
<slot></slot>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
type: {
type: String,
default: 'text', // text, avatar, image, button,这里是这个组件包含的几种类型,如果还需要添加的话,只需在css部分添加样式就好了。
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: null
},
animated: {
type: Boolean,
default: true
},
round: {
type: Boolean,
default: false
}
});
const computedStyle = computed(() => {
const style = {};
if (props.width) {
style.width = isNaN(props.width) ? props.width : `${props.width}px`;
}
if (props.height) {
style.height = isNaN(props.height) ? props.height : `${props.height}px`;
}
if (props.round) {
style.borderRadius = '50%';
}
return style;
});
</script>
<style scoped>
.skeleton-item {
background: #f2f2f2;
overflow: hidden;
}
.skeleton-text {
height: 16px;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton-image {
border-radius: 4px;
}
.skeleton-button {
height: 36px;
border-radius: 4px;
}
.skeleton-animated {
position: relative;
overflow: hidden;
z-index: 1;
}
.skeleton-animated::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.5) 50%,
rgba(255, 255, 255, 0) 100%);
animation: skeleton-loading 1.4s infinite;
}
@keyframes skeleton-loading {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>
2.3 现成的组件库
像element-plus,t-design等骨架屏也可以使用。以下是t-design的骨架屏的效果。
2.4 骨架屏预渲染方案
- page-skeleton-webpack-plugin
npm install --save-dev page-skeleton-webpack-plugin
webpack 配置示例:
const PageSkeletonWebpackPlugin = require('page-skeleton-webpack-plugin')
module.exports = {
plugins: [
new PageSkeletonWebpackPlugin({
pathname: path.resolve(__dirname, `./shell`), // 生成骨架屏文件存放地址
staticDir: path.resolve(__dirname, './dist'), // 静态资源路径
routes: ['/', '/about'], // 需要生成骨架屏的路由
excludes: ['.van-nav-bar'], // 需要忽略的元素
defer: 5000, // 渲染延迟时间
})
]
}
- vue-skeleton-webpack-plugin
这是 Vue 官方维护的骨架屏 webpack 插件。
npm install vue-skeleton-webpack-plugin
配置示例:
const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin')
module.exports = {
plugins: [
new SkeletonWebpackPlugin({
webpackConfig: {
entry: {
app: resolve('./src/skeleton.js')
}
},
quiet: true,
minimize: true,
router: {
mode: 'history',
routes: [
{ path: '/', skeletonId: 'skeleton1' },
{ path: '/about', skeletonId: 'skeleton2' }
]
}
})
]
}
总结
手写骨架屏:看似多余实则重要的实践
现在各大组件库都提供了骨架屏组件,为什么我们还要手写?本文会告诉你,从0到1实现骨架屏不仅能提升技术功底,更能帮你建立完整的组件化思维。
坦白说,在现在这个组件库横行的年代,手写骨架屏确实显得有点"多此一举"。但就像练武要从最基础的马步开始,手写骨架屏的过程,能让我们学到很多:
-
真正理解组件设计
- 知道为什么要这么设计
- 明白参数该怎么定义
- 学会如何做到通用性
-
应对特殊需求
- 组件库不够用?自己造
- 需求有变化?随时改
- 性能有问题?直接优化
-
提升开发功力
- CSS功底更扎实
- 组件化思维更清晰
- 代码更有掌控感
实践中的收获
从最简单的开始:
<div class="skeleton-line"></div>
到封装成组件:
<skeleton-base width="200px" height="20px" />
最后变成业务组件:
<article-skeleton :loading="true" />
这个过程会让你:
- 明白组件是怎么一步步抽象的
- 知道什么时候该复用,什么时候该重写
- 学会如何设计一个好用的组件
值得还是不值得?
如果你问我值不值得花时间去手写,我的建议是:
- 新手必须写:打好基础很重要
- 老手可以写:温故知新也不错
- 实在没时间:至少要理解原理
记住,会用组件库很简单,但理解背后的原理才是进阶的关键。就像开车,会开很简单,但懂原理的司机才能应对各种路况。
最后的建议
-
循序渐进
- 先用原生写写看
- 再试试组件封装
- 最后做业务整合
-
学以致用
- 理解了就要实践
- 可以在项目中尝试
- 逐步形成自己的组件库
-
持续改进
- 多看看主流组件库的实现
- 思考还有什么可以优化
- 保持学习的心态
与其纠结要不要手写,不如动手试试看。毕竟,"会用"和"懂得"是有很大区别的。当你真正理解了骨架屏的实现原理,再去用组件库时,就能更得心应手,遇到问题也能迎刃而解。