一、背景
-
背景:
-

小程序在加载的时候容易出现图片加载缓慢的问题
项目图片使用现状分析
1. 图片类型与来源
|--------|--------------------|---------------------------|
| 类型 | 来源 | 处理方式 |
| 静态资源图片 | baseImgUrl + 相对路径 | 服务器静态资源 |
| OSS图片 | 后端返回的阿里云OSS地址 | 已添加processOssImage自动转webp |
| 服务器图片 | BASE_FILEURL + 文件名 | editFormatFileUrl方法拼接 |
二、性能瓶颈识别
1. 主要问题
|-------------|-------------|--------------------|-------------------------------------|
| 问题 | 影响 | 现状 | 修复后 |
| DNS解析耗时 | 100-200ms延迟 | 图片域名与页面域名不一致 | 考虑费用,上级决策 |
| 静态资源无CDN | 服务器带宽瓶颈 | baseImgUrl直接指向源站 | 考虑费用,上级决策 |
| 无HTTP缓存策略 | 重复下载 | 依赖浏览器默认缓存 | 不适用我们的项目(我们需要实时更新) |
| 首屏图片无预加载 | 白屏时间长 | 关键图片未优先加载 | 首页图片可预先加载,但是我们的首页是后台配置图片,无法写固定值提前加载 |
| 瀑布流图片无懒加载 | 并发请求过多 | 部分页面未使用u-lazy-load | 组件全部开启懒加载 |
| oss图片webp格式 | 100-200ms延迟 | 为增加webp后缀 | 所有oss图片已增加webp后缀 |
| 图片尺寸未适配 | 浪费带宽 | 未根据设备像素比裁剪 | 已修改为读取机型适配图片 |
可能存在的问题:
-
没有全局懒加载 :大部分 <u-image> 组件没有启用 lazy-load 属性
-
缺少图片尺寸控制 :没有使用OSS图片处理参数(如缩略图、压缩)
-
重复加载 :列表滚动时可能重复加载相同图片
-
首屏加载过多 :页面初始化时加载所有可见图片
-
缺少缓存策略 :没有利用小程序图片缓存机制
-
大图直接加载 :原图直接展示,没有渐进式加载
-
网络层 :OSS跨域请求、HTTPS握手、DNS解析
-
图片体积 :原图加载,没有压缩或裁剪
-
并发限制 :小程序同时请求数量有限(10个)
-
渲染层 :大量图片同时解码导致卡顿
-
缺少预加载 :没有提前加载即将展示的图片
当前已实现的优化:
-
使用 u-loading slot 实现加载占位
-
u-parse组件支持 lazyLoad 懒加载
-
图片格式化处理统一封装
-
u-image组件默认已开启懒加载
二、调研方案
方案一: 资源存储策略(最核心)
-
痛点: 小程序代码包限制在 2MB,过多的本地图片会挤占体积并减慢首屏解析速度。
-
CDN 加速: 所有的业务图片、大图、banner 必须上传至 OSS/COS 等云存储,利用 CDN 进行分发,提升加载速度。
开启 CDN 的费用与影响
- 是否收费? CDN 是独立计费的。
-
流量费: 开启后,产生的下行流量按 CDN 资费计算(通常比 OSS 直接外网流出的流量费更便宜)。
-
回源费: 当 CDN 节点没缓存时,会去 OSS 取数据,这会产生 OSS 的回源流量费。
-
结论: 对于图片较多、访问量大的小程序,CDN + OSS 的组合通常比单纯用 OSS 更省钱,因为 CDN 流量单价更低且有缓存机制。
- 有什么影响?
-
正面影响: 加载速度从"秒级"提升到"毫秒级",减轻 OSS 服务器压力。
-
负面影响(缓存同步): 如果你替换了 OSS 上的某张图但文件名没变,CDN 节点可能还缓存着旧图,用户看到的还是旧的。这时需要手动在后台"刷新 URL"。
预期效果: 开启后图片加载可缩短至 200ms 左右,显著提升用户体验,且 CDN 流量单价低于 OSS 外网流量,可降低运营成本
阿里云 CDN 计费调研报告
核心计费模式:按流量计费(最常用)
CDN 主要是为了替代 OSS 直接流出流量。对比发现,开启 CDN 后,单价通常更低。
|------------|------------|---------------------|
| 计费项 | 单价 (参考) | 说明 |
| OSS 外网流出流量 | 约 0.50元/GB | 目前小程序直接访问 OSS 产生的费用 |
| CDN 下行流量 | 约 0.24元/GB | 开启 CDN 后,用户访问产生的费用 |
| 节省幅度 | 约 50% | 开启 CDN 反而能节省一半的流量费 |
注: 阿里云经常有流量包促销,例如 1TB/1年的流量包可能只需 100-200 元,合 0.1元/ GB 左右,成本更低。
回源流量费(新增费用)
当 CDN 节点上没有缓存某张图片时,它会去 OSS 下载,这叫"回源"。
计费: 约 0.15元/ GB。
影响: 只有第一次访问或缓存过期时产生。一旦缓存命中,之后成千上万次访问都不再产生此费用。
静态资源预热(可选)
如果图片更新非常频繁,可能涉及刷新缓存的接口调用费,但对于普通小程序(图片固定),这部分费用通常在免费额度内。
调研结果:方案一考虑到费用问题,不做决策,上述内容向上反馈由管理层做决策
方案二:阿里云 OSS 的域名管理里开启 HTTPS 证书并勾选 HTTP/2 选项
1. 开启 HTTP/2
效果:解决图片排队等待问题。
-
原理: HTTP/1.1 下,浏览器对同一个域名同时只能建立 6-8 个连接,图片多了就会排队。HTTP/2 支持多路复用,几十张图可以同时发送请求。
-
操作: 即使没开 CDN,在阿里云 OSS 的域名管理里,通常也可以免费 开启 HTTPS 证书并勾选 HTTP/2 选项。
2. 阿里云 OSS 开启 HTTP/2 具体步骤
-
登录控制台: 登录 阿里云管理控制台。
-
进入存储桶: 在左侧菜单栏点击 Bucket 列表,找到存放图片资源的那个 Bucket(存储空间)。
-
进入域名管理: 在左侧导航栏中,选择 传输管理 -> 域名管理。
-
找到目标域名: 在域名列表中找到自定义域名
-
配置证书/ HTTPS :
-
点击该域名右侧的 证书托管 或 配置。
-
关键点: 必须确保"状态"为 已开启 HTTPS。如果没开启,需要先上传证书。
-
-
开启 HTTP/2:
-
在 HTTPS 配置界面中,找到 HTTP/2 设置 选项。
-
将开关切换为 开启 状态。
-
-
保存生效: 点击确定或保存。配置通常在 1-5 分钟内 全网生效
3.特别注意(避坑指南)
-
必须是 HTTPS : HTTP/2 协议强制要求在加密连接(HTTPS)下运行。如果你们目前是 HTTP 访问,开启后也不会生效 [1, 2]。
-
浏览器 兼容性 : 现代浏览器和微信小程序底层均完美支持 HTTP/2。如果用户手机系统版本极低,会自动降级回 HTTP/1.1,不会影响访问 [2]。
4.如何验证是否成功?
配置完成后,回到微信开发者工具:
-
清空缓存,刷新页面。
-
在 Network 面板找到图片请求。
-
查看 Protocol 列:如果显示为
h2,说明已经成功开启
现状:


调研结果:该方案二基于方案1,需要开启cdn,才能修改为http/2方案,目前未开启cdn,方案无效
方案三:采用 "前端动态资源拦截优化" 策略
1、 核心原理:利用阿里云 OSS 免费自带的"图片处理(Image Processing)"功能,在代码中通过"全局混入"强制将所有图片转为极小的 WebP 格式。
阿里云 OSS 支持在 URL 后直接拼接处理指令。对于小程序,我们最需要的组合是:
-
format,webp:将图片转为 WebP 格式(体积减小约 70%)。 -
resize,w_300:按需缩放。如果是一个 100px 的头像,加载 2000px 的原图就是浪费带宽。 -
quality,q_75:画质压缩。75% 是人眼几乎看不出区别、但体积缩减明显的平衡点。
-
兼容性:需要对 GIF、视频、PDF 等非图片资源做自动过滤。
-
预期效果:在不增加 CDN 费用的情况下,全站图片加载速度提升 200%-300%,流量成本降低 60%。
-
降低质量: q_60 或 q_50
-
调整尺寸: w_800 或 w_640
-
移除压缩: 'image/format,webp'
?x-oss-process=image/resize,w_200,h_200,m_fill // 缩略图
?x-oss-process=image/quality,q_80 // 质量压缩
?x-oss-process=image/format,jpg // 格式转换
?x-oss-process=image/interlace,1 // 渐进显示
调研结果:已经按照oss图片增加webp处理,增加上了后缀,图片有明显变化
再次优化: 图片尺寸自适应(响应式图片)
问题 : 当前OSS处理参数固定为 w_1080 ,未根据设备屏幕适配
优化措施 : 根据设备像素比(DPR)动态调整图片尺寸
优化前:

优化后:

代码思路:
直接在u-image组件接收的src直接对src进行处理
(以下是优化过后的u-image组件,可以参考oss增加webp的逻辑)
<template>
<view class="u-image" @tap="onClick" :style="[wrapStyle, backgroundStyle]">
<image
v-if="!isError"
:src="processedSrc || src"
:mode="mode"
@error="onErrorHandler"
@load="onLoadHandler"
:lazy-load="lazyLoad"
class="u-image__image"
:show-menu-by-longpress="showMenuByLongpress"
:style="{
borderRadius: shape == 'circle' ? '50%' : $u.addUnit(borderRadius)
}"
></image>
<view
v-if="showLoading && loading"
class="u-image__loading"
:style="{
borderRadius: shape == 'circle' ? '50%' : $u.addUnit(borderRadius),
backgroundColor: this.bgColor
}"
>
<slot v-if="$slots.loading" name="loading" />
<u-icon v-else :name="loadingIcon" :width="width" :height="height"></u-icon>
</view>
<view
v-if="showError && isError && !loading"
class="u-image__error"
:style="{
borderRadius: shape == 'circle' ? '50%' : $u.addUnit(borderRadius)
}"
>
<slot v-if="$slots.error" name="error" />
<u-icon v-else :name="errorIcon" :width="width" :height="height"></u-icon>
</view>
</view>
</template>
<script>
/**
* Image 图片
* @description 此组件为uni-app的image组件的加强版,在继承了原有功能外,还支持淡入动画、加载中、加载失败提示、圆角值和形状等。
* @tutorial https://uviewui.com/components/image.html
* @property {String} src 图片地址
* @property {String} mode 裁剪模式,见官网说明
* @property {String | Number} width 宽度,单位任意,如果为数值,则为rpx单位(默认100%)
* @property {String | Number} height 高度,单位任意,如果为数值,则为rpx单位(默认 auto)
* @property {String} shape 图片形状,circle-圆形,square-方形(默认square)
* @property {String | Number} border-radius 圆角值,单位任意,如果为数值,则为rpx单位(默认 0)
* @property {Boolean} lazy-load 是否懒加载,仅微信小程序、App、百度小程序、字节跳动小程序有效(默认 true)
* @property {Boolean} show-menu-by-longpress 是否开启长按图片显示识别小程序码菜单,仅微信小程序有效(默认 false)
* @property {String} loading-icon 加载中的图标,或者小图片(默认 photo)
* @property {String} error-icon 加载失败的图标,或者小图片(默认 error-circle)
* @property {Boolean} show-loading 是否显示加载中的图标或者自定义的slot(默认 true)
* @property {Boolean} show-error 是否显示加载错误的图标或者自定义的slot(默认 true)
* @property {Boolean} fade 是否需要淡入效果(默认 true)
* @property {String Number} width 传入图片路径时图片的宽度
* @property {String Number} height 传入图片路径时图片的高度
* @property {Boolean} webp 只支持网络资源,只对微信小程序有效(默认 false)
* @property {String | Number} duration 搭配fade参数的过渡时间,单位ms(默认 500)
* @event {Function} click 点击图片时触发
* @event {Function} error 图片加载失败时触发
* @event {Function} load 图片加载成功时触发
* @example <u-image width="100%" height="300rpx" :src="src"></u-image>
*/
export default {
name: 'u-image',
props: {
// 图片地址
src: {
type: String,
default: ''
},
// 裁剪模式
mode: {
type: String,
default: 'aspectFill'
},
// 宽度,单位任意
width: {
type: [String, Number],
default: '100%'
},
// 高度,单位任意
height: {
type: [String, Number],
default: 'auto'
},
// 图片形状,circle-圆形,square-方形
shape: {
type: String,
default: 'square'
},
// 圆角,单位任意
borderRadius: {
type: [String, Number],
default: 0
},
// 是否懒加载,微信小程序、App、百度小程序、字节跳动小程序
lazyLoad: {
type: Boolean,
default: true
},
// 开启长按图片显示识别微信小程序码菜单
showMenuByLongpress: {
type: Boolean,
default: true
},
// 加载中的图标,或者小图片
loadingIcon: {
type: String,
default: 'photo'
},
// 加载失败的图标,或者小图片
errorIcon: {
type: String,
default: 'error-circle'
},
// 是否显示加载中的图标或者自定义的slot
showLoading: {
type: Boolean,
default: true
},
// 是否显示加载错误的图标或者自定义的slot
showError: {
type: Boolean,
default: true
},
// 是否需要淡入效果
fade: {
type: Boolean,
default: true
},
// 只支持网络资源,只对微信小程序有效
webp: {
type: Boolean,
default: false
},
// 过渡时间,单位ms
duration: {
type: [String, Number],
default: 500
},
// 背景颜色,用于深色页面加载图片时,为了和背景色融合
bgColor: {
type: String,
default: '#f3f4f6'
}
},
data() {
return {
// 图片是否加载错误,如果是,则显示错误占位图
isError: false,
// 初始化组件时,默认为加载中状态
loading: true,
// 不透明度,为了实现淡入淡出的效果
opacity: 1,
// 过渡时间,因为props的值无法修改,故需要一个中间值
durationTime: this.duration,
// 图片加载完成时,去掉背景颜色,因为如果是png图片,就会显示灰色的背景
backgroundStyle: {},
// 处理后的图片地址(添加webp后缀)
processedSrc: ''
};
},
watch: {
src: {
immediate: true,
handler (n) {
if(!n) {
// 如果传入null或者'',或者false,或者undefined,标记为错误状态
this.isError = true;
this.loading = false;
this.processedSrc = '';
} else {
this.isError = false;
// 处理OSS图片,自动添加webp后缀
this.processedSrc = this.processOssImage(n);
}
}
}
},
computed: {
wrapStyle() {
let style = {};
// 通过调用addUnit()方法,如果有单位,如百分比,px单位等,直接返回,如果是纯粹的数值,则加上rpx单位
style.width = this.$u.addUnit(this.width);
style.height = this.$u.addUnit(this.height);
// 如果是配置了圆形,设置50%的圆角,否则按照默认的配置值
style.borderRadius = this.shape == 'circle' ? '50%' : this.$u.addUnit(this.borderRadius);
// 如果设置圆角,必须要有hidden,否则可能圆角无效
style.overflow = this.borderRadius > 0 ? 'hidden' : 'visible';
if (this.fade) {
style.opacity = this.opacity;
style.transition = `opacity ${Number(this.durationTime) / 1000}s ease-in-out`;
}
return style;
}
},
methods: {
// 点击图片
onClick() {
this.$emit('click');
},
// 图片加载失败
onErrorHandler(err) {
this.loading = false;
this.isError = true;
this.$emit('error', err);
},
// 图片加载完成,标记loading结束
onLoadHandler() {
this.loading = false;
this.isError = false;
this.$emit('load');
// 如果不需要动画效果,就不执行下方代码,同时移除加载时的背景颜色
// 否则无需fade效果时,png图片依然能看到下方的背景色
if (!this.fade) return this.removeBgColor();
// 原来opacity为1(不透明,是为了显示占位图),改成0(透明,意味着该元素显示的是背景颜色,默认的灰色),再改成1,是为了获得过渡效果
this.opacity = 0;
// 这里设置为0,是为了图片展示到背景全透明这个过程时间为0,延时之后延时之后重新设置为duration,是为了获得背景透明(灰色)
// 到图片展示的过程中的淡入效果
this.durationTime = 0;
// 延时50ms,否则在浏览器H5,过渡效果无效
setTimeout(() => {
this.durationTime = this.duration;
this.opacity = 1;
setTimeout(() => {
this.removeBgColor();
}, this.durationTime);
}, 50);
},
// 移除图片的背景色
removeBgColor() {
// 淡入动画过渡完成后,将背景设置为透明色,否则png图片会看到灰色的背景
this.backgroundStyle = {
backgroundColor: 'transparent'
};
},
// 处理OSS图片,自动添加webp格式后缀,并根据尺寸自适应压缩
processOssImage(url) {
if (!url || typeof url !== 'string') {
return url;
}
// 判断是否为OSS图片(包含oss关键词或阿里云OSS域名特征)
const isOssImage = url.includes('oss') ||
url.includes('aliyuncs.com') ||
url.includes('oss-cn-');
if (!isOssImage) {
return url;
}
// 如果已经包含x-oss-process参数,追加format,webp
if (url.includes('x-oss-process')) {
// 如果已经包含webp格式,不再处理
if (url.includes('format,webp')) {
return url;
}
// 追加webp格式转换
return url + '/format,webp';
}
// 根据组件尺寸计算合适的图片宽度
const targetWidth = this.getTargetWidth();
// 根据图片尺寸和使用场景选择质量参数
const quality = this.getQualityBySize(targetWidth);
// OSS图片处理参数:自适应尺寸、转换为webp格式、动态质量
const OSS_PROCESS_PARAMS = `image/resize,w_${targetWidth},m_lfit/format,webp/quality,q_${quality}`;
// 如果没有x-oss-process参数,添加完整的处理参数
const separator = url.includes('?') ? '&' : '?';
return url + separator + 'x-oss-process=' + OSS_PROCESS_PARAMS;
},
// 根据组件宽度计算目标图片宽度(考虑设备像素比)
getTargetWidth() {
try {
// 获取设备信息
const systemInfo = uni.getSystemInfoSync();
const dpr = systemInfo.pixelRatio || 1;
const screenWidth = systemInfo.windowWidth || 375;
// 解析组件宽度
let componentWidth = this.parseWidth(this.width, screenWidth);
// 根据设备像素比计算实际需要的图片宽度
let targetWidth = Math.ceil(componentWidth * dpr);
// 限制最大宽度,避免过大图片
const MAX_WIDTH = 1080;
const MIN_WIDTH = 100;
if (targetWidth > MAX_WIDTH) {
targetWidth = MAX_WIDTH;
} else if (targetWidth < MIN_WIDTH) {
targetWidth = MIN_WIDTH;
}
// 按50的倍数取整,增加缓存命中率
return Math.ceil(targetWidth / 50) * 50;
} catch (e) {
// 异常情况下返回默认值
return 800;
}
},
// 解析组件宽度,返回px数值
parseWidth(width, screenWidth) {
if (typeof width === 'number') {
// 数值类型,认为是rpx,转换为px
return width / 2;
}
if (typeof width === 'string') {
// 处理百分比
if (width.includes('%')) {
const percent = parseFloat(width) / 100;
return screenWidth * percent;
}
// 处理rpx
if (width.includes('rpx')) {
return parseFloat(width) / 2;
}
// 处理px
if (width.includes('px')) {
return parseFloat(width);
}
// 纯数字字符串
if (!isNaN(parseFloat(width))) {
return parseFloat(width) / 2;
}
}
// 默认返回屏幕宽度
return screenWidth;
},
// 根据图片尺寸选择质量参数
getQualityBySize(targetWidth) {
// 小图标/头像:高压缩率,质量60
if (targetWidth <= 150) {
return 60;
}
// 中等尺寸图片:质量70
if (targetWidth <= 400) {
return 70;
}
// 大图:质量75(平衡清晰度与体积)
if (targetWidth <= 800) {
return 75;
}
// 超大图:质量80,保证清晰度
return 80;
}
}
};
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-image {
position: relative;
transition: opacity 0.5s ease-in-out;
&__image {
width: 100%;
height: 100%;
}
&__loading,
&__error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include vue-flex;
align-items: center;
justify-content: center;
background-color: $u-bg-color;
color: $u-tips-color;
font-size: 46rpx;
}
}
</style>
方案四:后端 OSS 资源元数据强缓存优化
- 技术实现
在后端调用阿里云 OSS SDK 上传图片的代码逻辑中,通过设置 ObjectMetadata(对象元数据),统一为资源注入以下 HTTP 响应头:
-
配置参数 :
Cache-Control: max-age=31536000 -
实施方式 :在执行
putObject(上传操作)时,全局配置metadata.setCacheControl("max-age=31536000")
深度解析:什么是 max-age=31536000 ?
-
定义 :
Cache-Control是 HTTP 协议中控制缓存的核心指令。max-age代表资源在客户端(用户手机)中被视为"新鲜"的最大时间,单位为秒。 -
数值换算 :
31536000秒 = 3600秒 × 24小时 × 365天 = 1 年。 -
运行机制 :当小程序首次下载图片后,手机浏览器会将该图片存入本地磁盘或 内存 。在未来的一年内,只要图片 URL 不变,手机将直接从本地读取,不再向阿里云服务器发送任何网络请求。
为什么需要这样做?(核心痛点解决)
-
消除网络排队延迟:在 HTTP/1.1 协议下,浏览器对同一域名的并发请求有限制。如果不设缓存,每次打开页面图片都要"排队下载"。开启强缓存后,图片加载跳过了网络阶段,彻底解决"1-2秒才出图"的尴尬。
-
解决重复渲染闪烁:用户在切换页面或二次进入小程序时,由于资源已在本地,图片会随页面同步"瞬时弹出",消除先白屏、后出图的视觉闪烁感。
. 方案优势与商业价值
-
极致的加载性能(0ms 响应) : 二次访问时,图片的加载耗时将从"秒级"直接降至 0 毫秒 (显示为
from memory cache或from disk cache)。 -
大幅降低运营成本(省钱) : 阿里云 OSS 是按下行流量 计费的。配置强缓存后,大量重复的图片访问不再产生外网流出流量。根据行业测算,此举可为公司节省 30% - 60% 的 OSS 流量费用。
-
提高系统稳定性: 极大降低了高并发时期 OSS 服务器的并发压力,确保核心业务接口(如登录、支付)在高峰期拥有更多的带宽资源。
风险控制:如果图片需要更新怎么办?
-
文件名版本号化 :对于需要更新的图片(如活动 Banner),建议在上传时修改文件名,或在前端 URL 后拼接版本号(如
?v=20260311)。 -
结论:由于 URL 变动会被视为新资源,手机会自动重新下载并缓存。这确保了"静态图标永久缓存,动态资源受控更新"的完美平衡。
-
配置
max-age=3600(1小时):-
优势:时效性极高。
-
体验:用户在一个小时内点击小程序是秒开的。
-
代价:相比 1 天,会产生更多的 OSS 流量请求。
-
调研结果:不建议使用,虽然0 ms 秒开图片,但是因为365天意味着图片一直未更新,运营侧上传的图片无法实时更新,如果更新为1小时配置,可以看情况考虑,但是要基于我们的运营经常就该患教或者其他图片相关配置决定
方案五: HTTP缓存头优化
问题: 静态资源缓存策略不明确
优化措施: 在OSS/服务器端配置缓存头
nginx
Nginx配置示例
location ~* \.(png|jpg|jpeg|gif|webp|svg)$ {
expires 30d; # 图片缓存30天
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
带hash的文件长期缓存
location ~* \.[a-f0-9]{8,}\.(png|jpg|jpeg|gif|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
调研结果:缓存三十天的方案和上面的缓存也差不多,无法实时更新
方案六: 首屏关键图片预加载(使用首次获取低质量压缩图,获取到接口再展示清晰图)
问题: 首屏图片(如轮播图、logo)与页面同时加载,造成白屏
优化措施: 在App.vue中提前预加载关键图片
主要处理大尺寸的图片(封面图、头像等),小图标(20x20, 32x32, 36x36等)不需要渐进式加载(反而会更久)
首页:
IM头像
预加载组件:
<template>
<view class="progressive-image-wrapper" :style="{ width: imgWidth, height: imgHeight, borderRadius: imgRadius }">
<!-- 模糊缩略图 -->
<image
v-if="showThumb && thumbUrl"
class="progressive-image thumb"
:src="thumbUrl"
:mode="mode"
:style="{
width: imgWidth,
height: imgHeight,
borderRadius: imgRadius,
filter: 'blur(10px)',
transform: 'scale(1.1)'
}"
/>
<!-- 骨架屏 -->
<view
v-if="loading && !showThumb"
class="progressive-skeleton"
:style="{
width: imgWidth,
height: imgHeight,
borderRadius: imgRadius,
backgroundColor: bgColor
}"
>
<view class="progressive-shimmer"></view>
</view>
<!-- 高清原图 -->
<image
class="progressive-image original"
:src="originalUrl"
:mode="mode"
:style="{
width: imgWidth,
height: imgHeight,
borderRadius: imgRadius,
opacity: originalLoaded ? 1 : 0,
transition: `opacity ${fadeDuration}ms ease-in-out`
}"
@load="onOriginalLoad"
@error="onOriginalError"
/>
</view>
</template>
<script>
/**
* ProgressiveImage 渐进式图片加载组件
* @description 先加载模糊缩略图,再加载高清原图,实现渐进式加载效果
* @property {String} src 原图URL
* @property {String} thumb 缩略图URL(不传则自动生成)
@property {String | Number} width 宽度(默认100%)
* @property {String | Number} height 高度(默认200rpx)
* @property {String | Number} borderRadius 圆角(默认0)
* @property {String} mode 图片裁剪模式(默认aspectFill)
* @property {String} bgColor 骨架屏背景色(默认#f0f0f0)
* @property {Number} fadeDuration 淡入动画时长,单位ms(默认300)
* @property {Number} thumbQuality 缩略图质量 1-100(默认30)
* @property {Number} thumbWidth 缩略图宽度(默认200)
* @example
* <progressive-image
* src="https://example.com/image.jpg"
* width="300rpx"
* height="200rpx"
* borderRadius="12rpx"
* ></progressive-image>
*/
export default {
name: 'ProgressiveImage',
props: {
src: {
type: String,
default: ''
},
thumb: {
type: String,
default: ''
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: '200rpx'
},
borderRadius: {
type: [String, Number],
default: 0
},
mode: {
type: String,
default: 'aspectFill'
},
bgColor: {
type: String,
default: '#f0f0f0'
},
fadeDuration: {
type: Number,
default: 300
},
thumbQuality: {
type: Number,
default: 30
},
thumbWidth: {
type: Number,
default: 200
}
},
data() {
return {
loading: true,
originalLoaded: false,
thumbLoaded: false,
loadError: false
}
},
computed: {
imgWidth() {
return this.$u.addUnit(this.width);
},
imgHeight() {
return this.$u.addUnit(this.height);
},
imgRadius() {
if (this.borderRadius === 'circle' || this.borderRadius === '50%') {
return '50%';
}
return this.$u.addUnit(this.borderRadius);
},
// 生成缩略图URL
thumbUrl() {
if (this.thumb) return this.thumb;
if (!this.src) return '';
// 如果是OSS图片,添加压缩参数
if (this.src.includes('aliyuncs.com') || this.src.includes('oss-')) {
const params = [
`resize,w_${this.thumbWidth}`,
'format,webp',
`quality,q_${this.thumbQuality}`
];
return `${this.src}?x-oss-process=image/${params.join('/')}`;
}
return this.src;
},
originalUrl() {
return this.src;
},
showThumb() {
return this.thumbUrl && !this.originalLoaded && !this.loadError;
}
},
watch: {
src: {
immediate: true,
handler(newVal) {
if (newVal) {
this.loading = true;
this.originalLoaded = false;
this.loadError = false;
this.preloadThumb();
}
}
}
},
methods: {
// 预加载缩略图
preloadThumb() {
if (!this.thumbUrl) return;
uni.downloadFile({
url: this.thumbUrl,
success: () => {
this.thumbLoaded = true;
},
fail: () => {
// 缩略图加载失败,直接显示骨架屏等待原图
this.thumbLoaded = false;
}
});
},
// 原图加载完成
onOriginalLoad() {
this.originalLoaded = true;
this.loading = false;
this.$emit('load');
},
// 原图加载失败
onOriginalError() {
this.loadError = true;
this.loading = false;
this.$emit('error');
}
}
}
</script>
<style scoped lang="scss">
.progressive-image-wrapper {
position: relative;
overflow: hidden;
background-color: #f5f5f5;
}
.progressive-image {
position: absolute;
top: 0;
left: 0;
will-change: opacity, transform;
&.thumb {
z-index: 1;
}
&.original {
z-index: 2;
}
}
.progressive-skeleton {
position: absolute;
top: 0;
left: 0;
z-index: 0;
overflow: hidden;
}
.progressive-shimmer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>
方案7: 骨架屏优化(已部分实现)
现状: 已有Skeleton组件,但未全面应用
优化建议: 在图片加载区域使用骨架屏占位
太快截图不到闪烁


骨架屏组件:
<template>
<!-- 图片骨架屏占位组件 -->
<view class="image-skeleton-wrapper" :style="{ width: skeletonWidth, height: skeletonHeight, borderRadius: skeletonRadius }">
<view
v-if="loading"
class="image-skeleton"
:style="{
width: '100%',
height: '100%',
borderRadius: skeletonRadius,
backgroundColor: bgColor
}"
>
<view class="image-skeleton__shimmer"></view>
</view>
<slot v-else></slot>
</view>
</template>
<script>
/**
* ImageSkeleton 图片骨架屏占位组件
* @description 在图片加载前显示骨架屏占位,提升用户体验
* @property {String | Number} width 宽度,支持rpx、px、%(默认100%)
* @property {String | Number} height 高度,支持rpx、px(默认200rpx)
* @property {String | Number} borderRadius 圆角(默认0)
* @property {Boolean} loading 是否显示骨架屏(默认true)
* @property {String} bgColor 骨架屏背景色(默认#f0f0f0)
* @example
* <image-skeleton width="200rpx" height="200rpx" borderRadius="50%" :loading="!imageLoaded">
* <u-image width="200" height="200" :src="imageUrl" @load="imageLoaded = true"></u-image>
* </image-skeleton>
*/
export default {
name: 'ImageSkeleton',
props: {
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: '200rpx'
},
borderRadius: {
type: [String, Number],
default: 0
},
loading: {
type: Boolean,
default: true
},
bgColor: {
type: String,
default: '#f0f0f0'
}
},
computed: {
skeletonWidth() {
return this.$u.addUnit(this.width);
},
skeletonHeight() {
return this.$u.addUnit(this.height);
},
skeletonRadius() {
if (this.borderRadius === 'circle' || this.borderRadius === '50%') {
return '50%';
}
return this.$u.addUnit(this.borderRadius);
}
}
}
</script>
<style scoped lang="scss">
.image-skeleton-wrapper {
position: relative;
overflow: hidden;
}
.image-skeleton {
position: relative;
overflow: hidden;
&__shimmer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>
方案5: 静态资源本地缓存策略
问题: 静态图标每次都需要网络请求
优化措施: 将常用小图标转为Base64或使用小程序本地资源
思考:我们的静态资源都是放在服务器上通过服务器路径拼接,如果常用小图标改为本地资源,是否会影响小 程序包 体积大小