uniapp 从入门到精通(第33章)

第7章 7.3 性能优化:分包 + 懒加载 + 图片压缩


🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了用 Pinia 管理全局状态,终于告别了「这个变量到底从哪来的」灵魂拷问。但问题来了——项目越做越大,首屏加载越来越慢,用户点开就转圈圈,等得不耐烦直接跑了。

你有没有遇到过这些情况:

  • 微信小程序包体积超限,被迫删功能
  • 页面一多就卡顿,滑动像在看幻灯片
  • 图片一多就白屏,用户以为你 app 挂了

这一章我们来解决这些问题。学完你就能掌握三把利剑:分包加载让首屏飞起来、懒加载让内存不爆炸、图片压缩让流量不心疼。


🧱 基础 25 分钟:核心概念

什么是分包?

生活类比:想象你去超市买东西,如果你一口气把整个超市的货都搬回家(搬运车爆炸警告),不如先把今天要用的放篮子里,其他的等需要再拿。

为什么要用:小程序有包体积限制(主包不能超过 2MB),全塞一起会超限。而且加载一堆用不到的东西,纯粹浪费用户流量和\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n时间。

怎么用

在 uniapp 项目中,修改 pages.json

{
"pages": [
{
  "path": "pages/index/index",
  "style": { "navigationBarTitleText": "首页" }
}
],
"subPackages": [
{
  "root": "pages-sub/order",
  "pages": [
    {
      "path": "list",
      "style": { "navigationBarTitleText": "订单列表" }
    },
    {
      "path": "detail",
      "style": { "navigationBarTitleText": "订单详情" }
    }
  ]
}
]
}

这段配置的意思是:把 pages-sub/order 目录下的页面打包成独立分包,用户访问时才下载。

什么是懒加载?

生活类比:你刷抖音,不会一口气把 1000 条视频全部加载完,而是看到哪条才加载哪条。这叫「用时再取」,不占地方。

为什么要用:一个页面可能有 100 张商品图,如果一次全加载,内存直接爆掉。懒加载只加载用户看得见的部分,省内存又省流量。

怎么用

方式一:uniapp 原生的懒加载图片

<template>
<view>
<image
  v-for="item in productList"
  :key="item.id"
  :src="item.image"
  lazy-load
  mode="widthFix"
  style="width: 750rpx;"
/>
</view>
</template>

<script>
export default {
data() {
return {
  productList: [
    { id: 1, image: 'https://picsum.photos/400/300?random=1' },
    { id: 2, image: 'https://picsum.photos/400/300?random=2' },
    { id: 3, image: 'https://picsum.photos/400/300?random=3' },
    { id: 4, image: 'https://picsum.photos/400/300?random=4' },
    { id: 5, image: 'https://picsum.photos/400/300?random=5' }
  ]
}
}
}
</script>

lazy-load 属性一加,图片就变成「看到了才加载」模式。

方式二:列表数据懒加载(触底加载更多)

export default {
data() {
return {
  productList: [],
  page: 1,
  pageSize: 10,
  hasMore: true
}
},
onReachBottom() {
if (this.hasMore) {
  this.loadMore()
}
},
methods: {
async loadMore() {
  const res = await uni.request({
    url: `https://api.example.com/products?page=${this.page}&size=${this.pageSize}`
  })

  if (res.data.length < this.pageSize) {
    this.hasMore = false
  }

  this.productList = [...this.productList, ...res.data]
  this.page++
}
}
}

onReachBottom 是 uniapp 的生命周期钩子,等用户滑到底了才加载下一页。

什么是图片压缩?

生活类比:你拍了一张 10MB 的照片发朋友圈,如果不压缩,传上去又慢又耗流量。压缩一下变成 200KB,既清晰又飞快。

为什么要用:原图动不动几 MB,加载慢、耗流量、占内存。压缩后体积骤降,体验飞起。

怎么用

uniapp 提供了 uni.compressImage API:

uni.compressImage({
src: '/static/photos/original.jpg',
quality: 80,
success: (res) => {
console.log('压缩后路径:', res.tempFilePath)

// 上传到服务器
uni.uploadFile({
  url: 'https://api.example.com/upload',
  filePath: res.tempFilePath,
  name: 'image',
  success: (uploadRes) => {
    console.log('上传成功:', uploadRes.data)
  }
})
},
fail: (err) => {
console.error('压缩失败:', err)
uni.showToast({ title: '图片处理失败', icon: 'none' })
}
})

quality: 80 表示压缩到原图的 80% 清晰度(数值越小压缩越狠)。


🔥 实战 35 分钟:3 个递进的小项目

项目 1:5 分钟 - 主页 + 订单分包的完整配置

目标:学会配置一个完整的主包 + 分包结构。

完整代码

pages.json

{
"pages": [
{
  "path": "pages/index/index",
  "style": {
    "navigationBarTitleText": "首页",
    "enablePullDownRefresh": true
  }
},
{
  "path": "pages/cart/cart",
  "style": {
    "navigationBarTitleText": "购物车"
  }
}
],
"subPackages": [
{
  "root": "pages-sub/order",
  "pages": [
    {
      "path": "list",
      "style": { "navigationBarTitleText": "我的订单" }
    },
    {
      "path": "detail",
      "style": { "navigationBarTitleText": "订单详情" }
    }
  ]
},
{
  "root": "pages-sub/user",
  "pages": [
    {
      "path": "profile",
      "style": { "navigationBarTitleText": "个人中心" }
    },
    {
      "path": "settings",
      "style": { "navigationBarTitleText": "设置" }
    }
  ]
}
],
"preloadRule": {
"pages-sub/order/list": {
  "network": "all",
  "packages": ["pages-sub/order"]
}
}
}

预期输出:控制台无报错,分包页面能正常访问。

解释preloadRule 是预加载规则,提前把订单分包下载好,用户点进去时就不用等了。


项目 2:15 分钟 - 带懒加载的商品列表页

目标:从 JSON 文件读取商品数据,实现触底加载更多 + 图片懒加载。

商品数据文件 static/data/products.json

[
{ "id": 1, "name": "iPhone 15", "price": 5999, "image": "https://picsum.photos/200/200?random=1" },
{ "id": 2, "name": "MacBook Pro", "price": 9999, "image": "https://picsum.photos/200/200?random=2" },
{ "id": 3, "name": "AirPods Pro", "price": 1899, "image": "https://picsum.photos/200/200?random=3" },
{ "id": 4, "name": "iPad Air", "price": 4399, "image": "https://picsum.photos/200/200?random=4" },
{ "id": 5, "name": "Apple Watch", "price": 2999, "image": "https://picsum.photos/200/200?random=5" }
]

商品列表页面 pages/product/list.vue

<template>
<view class="container">
<view class="product-grid">
  <view
    v-for="item in productList"
    :key="item.id"
    class="product-card"
  >
    <image
      :src="item.image"
      lazy-load
      mode="aspectFill"
      class="product-image"
    />
    <view class="product-info">
      <text class="product-name">{{ item.name }}</text>
      <text class="product-price">¥{{ item.price }}</text>
    </view>
  </view>
</view>

<view v-if="!hasMore" class="no-more">
  <text>— 没有更多了 —</text>
</view>
</view>
</template>

<script>
export default {
data() {
return {
  productList: [],
  allProducts: [],
  page: 0,
  pageSize: 5,
  hasMore: true,
  loading: false
}
},
onLoad() {
this.loadAllProducts()
},
onReachBottom() {
if (!this.loading && this.hasMore) {
  this.loadMore()
}
},
methods: {
loadAllProducts() {
  // 模拟从本地 JSON 加载数据
  const products = require('@/static/data/products.json')
  // 模拟后端返回多条数据(实际项目从 API 获取)
  this.allProducts = [...products, ...products, ...products]
  this.loadMore()
},
loadMore() {
  this.loading = true

  setTimeout(() => {
    const start = this.page * this.pageSize
    const end = start + this.pageSize
    const newItems = this.allProducts.slice(start, end)

    if (newItems.length === 0) {
      this.hasMore = false
    } else {
      this.productList = [...this.productList, ...newItems]
      this.page++

      if (newItems.length < this.pageSize) {
        this.hasMore = false
      }
    }

    this.loading = false
  }, 500)
}
}
}
</script>

<style>
.container {
padding: 20rpx;
background-color: #f5f5f5;
min-height: 100vh;
}

.product-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}

.product-card {
width: 49%;
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}

.product-image {
width: 100%;
height: 340rpx;
}

.product-info {
padding: 20rpx;
}

.product-name {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
}

.product-price {
display: block;
font-size: 32rpx;
color: #ff6034;
font-weight: bold;
}

.no-more {
text-align: center;
padding: 30rpx;
color: #999;
font-size: 24rpx;
}
</style>

预期输出:首次加载 5 个商品,向下滑动加载更多,直到全部加载完显示「没有更多了」。

解释:用 lazy-load 让图片「看到了再加载」,用 onReachBottom 实现触底加载更多,逻辑和之前讲的完全一样。


项目 3:15 分钟 - 图片压缩上传小工具

目标:做一个简单的「选图 → 压缩 → 上传」工具,组合使用分包 + 图片压缩。

主包页面 pages/index/index.vue

<template>
<view class="container">
<view class="title">图片压缩上传工具</view>

<view class="upload-area" @click="chooseImage">
  <image
    v-if="selectedImage"
    :src="compressedPath || selectedImage"
    mode="aspectFit"
    class="preview-image"
  />
  <view v-else class="placeholder">
    <text class="icon">+</text>
    <text class="text">点击选择图片</text>
  </view>
</view>

<view v-if="selectedImage" class="info-panel">
  <view class="info-row">
    <text>原图大小:</text>
    <text>{{ originalSize }} KB</text>
  </view>
  <view v-if="compressedSize" class="info-row">
    <text>压缩后:</text>
    <text class="highlight">{{ compressedSize }} KB</text>
  </view>
  <view v-if="compressedSize" class="info-row">
    <text>节省:</text>
    <text class="highlight">{{ savePercent }}%</text>
  </view>
</view>

<button
  v-if="selectedImage && !compressedPath"
  type="primary"
  @click="compressImage"
  :loading="compressing"
>
  {{ compressing ? '压缩中...' : '压缩图片' }}
</button>

<button
  v-if="compressedPath"
  type="warn"
  @click="uploadImage"
  :loading="uploading"
>
  {{ uploading ? '上传中...' : '上传到服务器' }}
</button>
</view>
</template>

<script>
export default {
data() {
return {
  selectedImage: '',
  compressedPath: '',
  originalSize: 0,
  compressedSize: 0,
  compressing: false,
  uploading: false
}
},
computed: {
savePercent() {
  if (!this.originalSize || !this.compressedSize) return 0
  return Math.round((1 - this.compressedSize / this.originalSize) * 100)
}
},
methods: {
chooseImage() {
  uni.chooseImage({
    count: 1,
    sizeType: ['original'],
    sourceType: ['album', 'camera'],
    success: (res) => {
      this.selectedImage = res.tempFilePaths[0]
      this.compressedPath = ''
      this.compressedSize = 0

      // 获取原图大小
      uni.getFileInfo({
        filePath: res.tempFilePaths[0],
        success: (info) => {
          this.originalSize = Math.round(info.size / 1024)
        }
      })
    }
  })
},
compressImage() {
  this.compressing = true

  uni.compressImage({
    src: this.selectedImage,
    quality: 60,
    success: (res) => {
      this.compressedPath = res.tempFilePath
      this.compressing = false

      // 获取压缩后大小
      uni.getFileInfo({
        filePath: res.tempFilePath,
        success: (info) => {
          this.compressedSize = Math.round(info.size / 1024)
        }
      })
    },
    fail: (err) => {
      this.compressing = false
      uni.showToast({
        title: '压缩失败',
        icon: 'none'
      })
      console.error('压缩失败:', err)
    }
  })
},
uploadImage() {
  this.uploading = true

  // 模拟上传(实际项目替换为真实接口)
  setTimeout(() => {
    this.uploading = false
    uni.showToast({
      title: '上传成功',
      icon: 'success'
    })
    console.log('上传成功,远程路径:https://cdn.example.com/image.jpg')
  }, 1500)
}
}
}
</script>

<style>
.container {
padding: 40rpx;
min-height: 100vh;
background: #f8f8f8;
}

.title {
font-size: 40rpx;
font-weight: bold;
text-align: center;
margin-bottom: 60rpx;
color: #333;
}

.upload-area {
width: 100%;
height: 500rpx;
background: #fff;
border: 2rpx dashed #ddd;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
}

.preview-image {
width: 100%;
height: 100%;
}

.placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: #999;
}

.placeholder .icon {
font-size: 80rpx;
line-height: 1;
margin-bottom: 20rpx;
}

.placeholder .text {
font-size: 28rpx;
}

.info-panel {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 40rpx;
}

.info-row {
display: flex;
justify-content: space-between;
padding: 15rpx 0;
font-size: 28rpx;
color: #666;
border-bottom: 1rpx solid #f0f0f0;
}

.info-row:last-child {
border-bottom: none;
}

.highlight {
color: #ff6034;
font-weight: bold;
}

button {
margin-top: 20rpx;
}
</style>

预期输出:点击选择图片 → 显示原图和大小 → 点击压缩 → 显示压缩后大小和节省比例 → 点击上传 → 提示上传成功。

解释:这个小工具把「选图」「压缩」「上传」三步串起来,压缩后的图片体积小很多,上传又快又省流量。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:分包路径写错了

❌ 错误写法:

"root": "/pages-sub/order"

路径多了个 /,会导致分包加载失败。

✅ 正确写法:

"root": "pages-sub/order"

root 不能以 / 开头,直接写目录名就行。


坑 2:懒加载在某些机型不生效

❌ 错误用法:

<image :src="item.image" lazy-load />

在某些小程序版本,lazy-load 可能不工作。

✅ 正确用法:
配合 v-if 控制显示:

<image v-if="item.visible" :src="item.image" lazy-load />

或者使用成熟的第三方懒加载组件(如 easy-loadimage)。


坑 3:图片压缩质量设太低

❌ 错误代码:

uni.compressImage({
src: path,
quality: 10  // 太低导致图片糊成一团
})

✅ 正确代码:

uni.compressImage({
src: path,
quality: 70  // 70-80 是平衡清晰度和体积的最佳区间
})

坑 4:onReachBottom 在某些页面不触发

❌ 错误:在 pages.json 给页面加了 scroll-view 又监听 onReachBottom

✅ 正确做法:

方案一:使用 scroll-view 的滚动事件

<scroll-view scroll-y @scrolltolower="loadMore">

方案二:确保页面没有阻止触底事件的全屏 scroll-view


坑 5:分包预加载规则写错

❌ 错误:

"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["pages-sub/order"]  // 路径写错了
}
}

✅ 正确:

"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["pages-sub/order"]  // 要和 subPackages 中的 root 一致
}
}

性能小贴士:使用骨架屏减少白屏等待

用户等待加载时看到一个白屏会觉得 app 卡死了。加点骨架屏(loading 占位),体验好很多:

<template>
<view>
<!-- 加载中显示骨架屏 -->
<view v-if="loading" class="skeleton">
  <view class="skeleton-img"></view>
  <view class="skeleton-text"></view>
  <view class="skeleton-text short"></view>
</view>

<!-- 加载完显示真实内容 -->
<view v-else>
  <image :src="product.image" lazy-load />
  <text>{{ product.name }}</text>
</view>
</view>
</template>

调试技巧:查看分包加载情况

打开微信开发者工具 → 详情 → 分包信息,可以查看每个分包的大小和加载状态。

// 打印当前加载的分包
console.log('当前分包信息:', getCurrentSubPackage())

✏️ 练习题 + 作业题

练习题(10 分钟)

练习 1(2 分钟):分包路径纠错
- 输入:以下 JSON 配置中,root 路径写错了吗?

"root": "/pages-sub/shop"
  • 预期输出:指出错误并给出正确答案
  • 提示:检查是否有不必要的字符在开头

练习 2(2 分钟):添加懒加载
- 输入:给这张图片添加懒加载属性

<image :src="item.url" />
  • 预期输出:<image :src="item.url" lazy-load />
  • 提示:只需要加一个属性

练习 3(3 分钟):触底加载更多
- 输入:在商品列表中加入「下拉加载更多」功能,已知 loadMore() 方法存在
- 预期输出:在 onReachBottom 中调用 loadMore
- 提示:找到 onReachBottom 生命周期钩子

练习 4(3 分钟):图片压缩质量调整
- 输入:把压缩质量从 50 调整到 80
- 预期输出:quality: 80
- 提示:只改一个数字

练习 5(挑战题,5 分钟):分析报错
- 输入:用户反馈「点击压缩没反应,控制台报错 compressImage:fail
- 预期输出:列出 3 个可能的原因
- 提示:考虑图片路径、格式、权限等


作业题(30 分钟 - 2 小时)

作业:做一个「图片工具箱」

  • 需求描述:整合本章所学的分包、懒加载、图片压缩知识,做一个小工具集合

  • 功能点
    1. 使用分包加载不同工具页面(压缩工具、查看 EXIF 信息)
    2. 图片列表使用懒加载
    3. 实现图片压缩功能,可调节压缩质量

  • 加分项
    1. 添加压缩前后对比
    2. 支持批量压缩多张图片

  • 验收标准

  • 分包配置正确,能正常访问
  • 图片懒加载正常工作
  • 压缩功能可用,体积明显减小
  • 代码有适当注释

  • 提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学了 3 件事
1. 分包:把不常用的页面拆出去,首屏加载飞起来
2. 懒加载:图片「看到了再加载」,省内存省流量
3. 图片压缩uni.compressImage 一行搞定,体积骤降体验飙升

延伸学习资源

互动钩子:你在项目中最头疼的性能问题是什么?首屏加载慢?图片太多卡顿?评论区聊聊,老粉优先回复!


下一章预告:学会了性能优化,但 app 总有崩溃的时候……下一章我们来做「错误监控与日志」,让你的 app 能「自查身体」,报错了第一时间知道原因。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。