一、为什么首屏体验总是不尽如人意?
你一定遇到过这样的场景:用户打开一个小程序或H5页面,看到的是短暂的白屏,然后突然冒出一堆已经排好版的内容。或者,列表里的图片一张接一张地加载,页面不断跳动。这两种情况都会让用户觉得“慢”甚至“卡”。其实,很多时候接口请求并不慢,真正让体验变差的是内容加载过程中的视觉空白和布局抖动。
解决这个问题最直接的手法就是两样东西:骨架屏和图片懒加载。骨架屏在真实内容出来之前用灰色区块撑住位置,告诉用户“这里即将有东西”;懒加载则让那些屏幕外的图片先不请求,等滚到它面前时再加载,把带宽和内存留给真正看得见的区域。Uniapp本身并没有内置骨架屏组件,但我们可以用自定义组件和条件渲染轻松造一个出来。懒加载则有现成的底层API可以调用,不需要第三方库。这篇文章就以一个新闻列表为例,从头到尾把这两个方案串起来。
二、骨架屏组件的设计思路
骨架屏要模拟真实内容的布局轮廓,一般用灰色或浅色块表示标题、图片、文本行。在Uniapp里,我们可以把它封装成一个独立组件,接收一些参数来控制“骨架”的形态,比如是否有头像、图片是横图还是竖图、文本的行数等。这样就做到了可复用:列表页、详情页、卡片组件都能用同一个骨架屏,只是变化几个参数。
骨架屏的实现原理并不复杂:在数据未回来时,用 v-if 显示一组占位块,数据就绪后再切到真实内容。为了保证切换时不引起高度突变,骨架屏的占位块尺寸要和最终内容大致一致。
我们从一个简单实例开始:一个常见的文章卡片,左侧有一张小图,右侧是标题和两行摘要。骨架屏就对应生成:左边一个正方形的灰色块,右边上面一个长条代表标题,下面两个短条代表摘要。下面直接看代码。
<!-- components/SkeletonCard.vue -->
<template>
<view class="skeleton-card">
<view class="skeleton-image"></view>
<view class="skeleton-info">
<view class="skeleton-title"></view>
<view class="skeleton-line short"></view>
<view class="skeleton-line medium"></view>
</view>
</view>
</template>
<script>
export default {
name: 'SkeletonCard'
}
</script>
对应的样式(这里只描述规则,实际写在组件的 <style> 块里):这三个骨架元素都用灰色的圆角矩形,加上一个从左到右移动的高光亮片动画,模仿数据正在加载的感觉。骨架图片固定宽高与真实图片一致,标题条高度约等于实际标题的行高,两个摘要条略短。循环动画用 CSS 的 @keyframes 实现,具体细节下节展示。
三、给骨架加上“呼吸”效果
为了让骨架屏看起来不那么死板,一条常见的做法是让它有一层流动的光感,就像Facebook的骨架屏那样。这可以通过一个半透明的渐变在灰色块上水平滑动来实现。在Uniapp中,我们同样可以用CSS动画。在骨架屏组件的样式里写:
.skeleton-image, .skeleton-title, .skeleton-line {
background: linear-gradient(
90deg,
#e8e8e8 25%,
#f5f5f5 50%,
#e8e8e8 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
这样,灰色块上就有一道高光从左扫到右,看起来像是数据正在被一点点拉取。这个动画对性能影响很小,Chrome 和 App 端都跑得很流畅。
四、在新闻列表中集成骨架屏
有了骨架屏组件,我们只需要在列表页的数据请求阶段用它来占位。假设我们有一个 NewsList 页面,在 onLoad 或 mounted 时发起请求,在请求完成前显示骨架屏卡片。
<template>
<view class="news-list">
<block v-if="loading">
<SkeletonCard v-for="n in 5" :key="n" />
</block>
<block v-else>
<view class="news-item" v-for="item in list" :key="item.id">
<image class="news-img" :src="item.thumb" mode="aspectFill" lazy-load="true"></image>
<view class="news-content">
<text class="news-title">{{ item.title }}</text>
<text class="news-desc">{{ item.summary }}</text>
</view>
</view>
</block>
</view>
</template>
<script>
import SkeletonCard from '@/components/SkeletonCard.vue';
export default {
components: { SkeletonCard },
data() {
return {
loading: true,
list: []
}
},
onLoad() {
this.fetchNews();
},
methods: {
async fetchNews() {
const [err, res] = await uni.request({
url: 'https://api.example.com/news'
});
if (!err && res.data) {
this.list = res.data;
}
this.loading = false;
}
}
}
</script>
这里用 v-if="loading" 控制骨架屏的显示,用 v-for 生成5个骨架卡片(模拟一屏的数量)。当数据返回后,loading 置为 false,真实列表立刻替换骨架。因为骨架的布局和真实卡片几乎一样,切换时页面不会抖动。
五、图片懒加载的几种实现方式
Uniapp的 image 组件自带一个 lazy-load 属性,设为true 后,在小程序端会自动启用懒加载,只在图片接近可视区域时才开始加载。这个属性在微信、支付宝、百度等小程序中都有效,在H5端则需要配合Intersection Observer自行处理。
如果 lazy-load 无法满足需求(比如想自定义加载占位图或控制加载时机),可以手写一个懒加载逻辑。核心是利用 uni.createIntersectionObserver 或者原生的 IntersectionObserver 来监听图片是否进入视口。我们选择跨端更友好的 uni.createIntersectionObserver,它在微信小程序和App-Vue中都能用。
下面展示一个可复用的懒加载指令(基于Vue的自定义指令):
// directives/lazy-image.js
export default {
mounted(el, binding) {
const src = binding.value;
// 设置默认占位图
el.src = '/static/placeholder.png';
const observer = uni.createIntersectionObserver(this);
observer.relativeToViewport({ bottom: 100 }); // 提前100px开始加载
observer.observe(el, (res) => {
if (res.intersectionRatio > 0) {
el.src = src;
observer.disconnect();
}
});
}
}
使用时在模板里给 image 标签加上 v-lazy-image="item.thumb" 即可。不过需要注意的是,自定义指令在非Vue环境(如App-NVue)中可能不支持,这时还是用 lazy-load 属性最稳妥。
六、解决图片加载带来的高度跳动
即使有了懒加载,当图片最终加载完成时,如果它的高度未知,仍可能挤开后面的内容。解决办法是在图片外包一层固定高度的容器,或者给 image 设置 mode=”aspectFill” 并指定宽高。在新闻列表中,我们给每个 .news-img 设置 width: 100rpx; height: 100rpx,就保证了无论图片是否加载,卡片高度都固定。
另一个细节:在图片加载前显示一张灰色占位图或使用骨架块。上面的懒加载指令中我们先把 src 指向一张本地的 placeholder 图片,它会被立即渲染,而无需等待网络。这样用户体验会更连贯。
七、组合起来的效果检验
把骨架屏和图片懒加载结合起来后,用户进入列表页看到的流程是这样的:首先页面立刻出现5个闪烁的骨架卡片,没有白屏;骨架之下,网络请求在后台进行。数据返回后,骨架消失,真实卡片瞬间呈现。此时每张卡片的图片区域一开始是占位图,当用户往下滑动,快到某张图片的位置时,真实图片才开始加载,无缝替换掉占位图。整个过程没有突然的跳动,也没有多余的服务器请求。
如果你想在开发工具里验证懒加载是否生效,可以打开控制台的Network面板,筛选图片请求,然后慢慢滚动页面。你会发现只有滚动到接近底部时,新的图片请求才会发出,而不是一开始就全部请求。
八、可能遇到的坑和注意事项
- 同一页面多个骨架屏列表:如果你在一个页面内有多个不同样式的列表,可以为每种卡片设计对应的骨架屏组件,千万不要试图用一个通用骨架去覆盖所有场景,否则对不齐的布局会让抖动更明显。
- 骨架屏的数量不宜过多:一般预渲染首屏可见的数量即可,比如5条。如果数据量很大,用户滑到下面时骨架屏已经替换为真实内容了,不需要再出现。
- 图片懒加载和骨架屏的拍照冲突:骨架屏里通常没有真实图片,所以不用懒加载指令。只在真实内容中启用懒加载。
- H5端的IntersectionObserver:在H5中,
uni.createIntersectionObserver会降级使用原生的 IntersectionObserver,但需要注意 polyfill 问题(现代浏览器都已支持)。 - 性能:骨架屏动画不宜过于复杂,简单的 shimmer 就足够,避免使用
box-shadow或大量transform卡顿低端机型。
九、总结
骨架屏和图片懒加载都不是什么新奇的技术,但把它们在Uniapp里用组件化的思路重新封装一遍,能极大提升项目的可维护性。一旦你有了 SkeletonCard 和 lazy-image 指令这两个小工具,以后任何列表页面都能在几分钟内添加“丝般顺滑”的加载体验,而不是每次都要重新写一套占位逻辑。
这套方案的核心就三点:用v-if配合请求状态控制骨架显示,用固定的宽高消除图片加载抖动,用交叉观察器把图片请求推迟到真正需要的时候。如果你正在为一个首屏加载慢的小程序头疼,不妨动手试试这两招。

