小程序离线包与本地缓存体系

系统讲解小程序离线包与本地缓存体系:Storage 本地缓存 API 与容量治理、分包与预下载机制、代码包与数据增量更新、弱网断网兜底、图片与静态资源缓存策略,以及缓存命中率监控与失效设计。

一、缓存能力全景

1.1 四层缓存

小程序的「离线可用」不是单一能力,而是四层机制的叠加:

层级载体容量生命周期典型用途
代码包微信客户端本地主包 2MB,整包 20MB随版本更新页面逻辑、组件、静态资源
数据缓存Storage单 key 1MB,总 10MB手动清理或卸载接口数据、用户偏好、草稿
文件缓存本地文件系统上限约 200MB手动清理或卸载图片、音视频、导出文件
网络缓存HTTP 缓存与 CDN取决于 CDN受 Cache-Control 控制静态资源、图片

设计离线能力时,先判断数据属于哪一层:页面结构放代码包,小结构数据放 Storage,大二进制放文件系统,可复用静态资源放 CDN 加 HTTP 缓存。

1.2 用户视角的离线分级

离线能力可以分成四级:L0 打开白屏(无任何缓存)、L1 能打开并展示上次数据(Storage 缓存加骨架屏)、L2 能浏览但不能提交(L1 加只读接口兜底)、L3 能提交且恢复后同步(L1 加本地队列加重放)。绝大多数业务做到 L2 就有明显体验收益,L3 只适合打卡、草稿、埋点这类可重放的场景。

二、本地缓存 API 与容量治理

2.1 同步与异步 API

wx.setStorageSync('token', 'abc123');            // 同步写入,适合小数据
const token = wx.getStorageSync('token');         // 同步读取

// 异步写入,适合大数据与不阻塞渲染的场景
wx.setStorage({ key: 'feed_cache', data: { list: [], ts: Date.now() } });

const info = wx.getStorageInfoSync();             // currentSize 与 limitSize 单位为 KB

选型原则:单个 key 小于 100KB 用同步写入,大于 100KB 或写入频繁时用异步;启动阶段读配置用同步,页面渲染中读大数据用异步;删除用 wx.removeStorageSync 精确清理,wx.clearStorageSync 会清掉登录态要慎用;wx.getStorageInfoSync 用于定期巡检容量。

2.2 容量限制与失败处理

硬限制:单个 key 最大 1MB,同一个用户在同一个小程序的数据缓存上限 10MB,超出后写入失败且不会自动淘汰,需要业务自己治理。

function safeSetStorage(key, data, maxRetry = 1) {
  try {
    wx.setStorageSync(key, data);
    return true;
  } catch (e) {
    // 容量超限,清理后重试一次;仍失败则上报并放弃
    if (maxRetry > 0) {
      evictByLRU();
      return safeSetStorage(key, data, maxRetry - 1);
    }
    wx.reportEvent('storage_write_fail', { key });
    return false;
  }
}

2.3 LRU 清理策略

不要等容量爆掉才被动清理,应该主动维护一张「缓存索引表」:

// utils/cache.js
const INDEX_KEY = '__cache_index__';
const readIndex = () => wx.getStorageSync(INDEX_KEY) || {};
const writeIndex = (index) => wx.setStorageSync(INDEX_KEY, index);

function set(key, data, ttlMs) {
  const payload = { data, expire: ttlMs ? Date.now() + ttlMs : 0 };
  if (!safeSetStorage(key, payload)) return false;
  const index = readIndex();
  index[key] = { size: JSON.stringify(payload).length, accessAt: Date.now() };
  writeIndex(index);
  return true;
}

function get(key) {
  const payload = wx.getStorageSync(key);
  if (!payload) return null;
  if (payload.expire && payload.expire < Date.now()) {
    remove(key);
    return null;
  }
  const index = readIndex();
  if (index[key]) {
    index[key].accessAt = Date.now();   // 更新访问时间
    writeIndex(index);
  }
  return payload.data;
}

function remove(key) {
  wx.removeStorageSync(key);
  const index = readIndex();
  delete index[key];
  writeIndex(index);
}

// 容量超限时按「最久未访问」淘汰 20%
function evictByLRU() {
  const entries = Object.entries(readIndex()).sort((a, b) => a[1].accessAt - b[1].accessAt);
  entries.slice(0, Math.ceil(entries.length * 0.2)).forEach(([key]) => remove(key));
}

淘汰完成后上报 cache_evict 事件便于观察容量压力。

关键点:索引表本身也占一个 key,所以索引里只存元信息(大小、访问时间、过期时间),不存数据,并且要防止它无限膨胀。

2.4 命名空间与版本前缀

缓存 key 必须带业务前缀与数据结构版本,方便整体失效:命名空间(mp:v2:feed)避免与其他 key 冲突,用户标识(mp:v2:u123:cart)支持多账号切换,业务域(mp:v2:order:list)便于批量清理,版本段(mp:v2:)在结构变更时整体切换前缀让旧数据自然淘汰。

三、分包与离线包加载

3.1 分包配置

主包只放启动必需的页面,其余按业务域分包:

{
  "pages": ["pages/index/index", "pages/login/login"],
  "subPackages": [
    { "root": "packageA", "name": "order", "pages": ["pages/list/index", "pages/detail/index"] },
    { "root": "packageB", "name": "marketing", "pages": ["pages/coupon/index"] }
  ],
  "preloadRule": {
    "pages/index/index": { "network": "all", "packages": ["order"] },
    "packageA/pages/list/index": { "network": "wifi", "packages": ["marketing"] }
  }
}

preloadRule 的键是触发预下载的页面路径,network 取 all 或 wifi 表示允许预下载的网络条件,packages 是分包 name 或 root(__APP__ 表示主包)。预下载是静默的,不阻塞当前页面,下载完成后进入分包无感知。

3.2 手动控制分包加载

preloadRule 不够灵活时,可以手动触发并展示进度:

function loadPackage(name) {
  return new Promise((resolve, reject) => {
    const task = wx.loadSubpackage({ name, success: () => resolve(name), fail: reject });
    task.onProgressUpdate((res) => updateProgressBar(res.progress));   // 0 到 100
  });
}

// 进入首页后空闲时预载营销包,失败不打扰用户
setTimeout(() => loadPackage('marketing').catch(() => {}), 3000);

3.3 分包策略要点

分包策略有四条要点:主包体积控制在 1.5MB 以内给迭代留余量;分包按「用户路径」而非「技术层次」划分,同一路径的页面放一起;independent: true 的独立分包不依赖主包,适合分享落地页,但无法使用主包公共资源;单个分包不超过 2MB,总包不超过 20MB。

分包与预下载的深入实践可以参考小程序分包与加载性能 中对加载时序与体积监控的讨论。

四、增量更新与版本对账

4.1 代码包更新

小程序代码包由微信客户端管理,更新是「下次启动生效」的模型:

// app.js
onLaunch() {
  const updateManager = wx.getUpdateManager();

  updateManager.onUpdateReady(() => {
    wx.showModal({
      title: '更新提示',
      content: '新版本已准备好,是否重启应用',
      success: (res) => { if (res.confirm) updateManager.applyUpdate(); }
    });
  });

  updateManager.onUpdateFailed(() => {
    wx.showModal({ title: '更新失败', content: '请删除当前小程序后重新打开', showCancel: false });
  });
}

4.2 数据结构版本对账

代码包更新了,但 Storage 里的数据结构可能还是旧的,必须有迁移机制:

const SCHEMA_VERSION = 3;

function migrateStorage() {
  const stored = wx.getStorageSync('__schema_version__') || 0;
  if (stored === SCHEMA_VERSION) return;

  const steps = {
    // v0 -> v1:把旧的 userInfo 拆成 user + token
    1: () => {
      const old = wx.getStorageSync('userInfo');
      if (!old) return;
      wx.setStorageSync('user', { nick: old.nickName });
      wx.setStorageSync('token', old.token);
      wx.removeStorageSync('userInfo');
    },
    2: () => wx.removeStorageSync('mp:v1:feed'),   // v1 -> v2:feed 结构变更,直接丢弃
    3: () => wx.removeStorageSync('mp:v2:cart')    // v2 -> v3:购物车改为按用户维度
  };

  for (let v = stored + 1; v <= SCHEMA_VERSION; v++) {
    try { if (steps[v]) steps[v](); }
    catch (e) { wx.reportEvent('storage_migrate_fail', { version: v }); }
  }
  wx.setStorageSync('__schema_version__', SCHEMA_VERSION);
}

4.3 服务端数据版本对账

接口数据缓存要带版本号与时间戳,避免「客户端缓存与服务端不一致」:

async function fetchWithCache(url, { ttl = 5 * 60 * 1000 } = {}) {
  const cacheKey = `mp:v2:api:${url}`;
  const cached = get(cacheKey);

  // 把本地版本号带给服务端,内容未变时服务端返回 304
  const res = await wx.request({
    url,
    header: { 'If-None-Match': cached ? cached.etag : '' }
  });

  if (res.statusCode === 304 && cached) return cached.data;
  if (res.statusCode === 200) {
    set(cacheKey, { data: res.data.data, etag: res.header.ETag }, ttl);
    return res.data.data;
  }
  if (cached) return cached.data;   // 网络失败但本地有缓存,降级返回旧数据
  throw new Error(`request failed: ${res.statusCode}`);
}
对账手段适用说明
ETag / If-None-Match详情类接口内容不变返回 304,省流量
version 字段配置类接口版本号一致直接读缓存
updated_at 时间戳列表类接口只拉增量

五、弱网与断网兜底

5.1 网络状态感知

// app.js
onLaunch() {
  wx.onNetworkStatusChange((res) => {
    this.globalData.isConnected = res.isConnected;
    if (res.isConnected) this.flushPendingQueue();
    else wx.showToast({ title: '网络已断开', icon: 'none' });
  });
}

5.2 请求层兜底

把「网络请求、缓存读取、超时、重试」收敛到一个请求层,业务侧无感知:

// utils/request.js
const TIMEOUT = 8000;
const MAX_RETRY = 2;

async function request(options) {
  const { url, data, method = 'GET', retry = MAX_RETRY } = options;

  // 完全离线时直接读缓存,读不到才抛错
  if (!getApp().globalData.isConnected) {
    const cached = get(`mp:v2:api:${url}`);
    if (cached) return cached.data;
    throw new Error('NETWORK_OFFLINE');
  }

  try {
    return await doRequest({ url, data, method, timeout: TIMEOUT });
  } catch (err) {
    if (retry > 0 && /timeout|fail|abort/.test(String(err.errMsg || err.message))) {
      await sleep(300 * (MAX_RETRY - retry + 1));   // 退避重试
      return request({ ...options, retry: retry - 1 });
    }
    const cached = get(`mp:v2:api:${url}`);
    if (cached) return cached.data;   // 兜底返回旧数据
    throw err;
  }
}

退避间隔按 300ms、600ms 递增,重试次数上限为 2 次,避免弱网下把请求堆满。

5.3 离线队列

对于「必须成功」的写操作,落本地队列,恢复网络后重放:

const QUEUE_KEY = 'mp:v2:pending';

function enqueue(payload) {
  const queue = wx.getStorageSync(QUEUE_KEY) || [];
  queue.push({
    id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    payload,
    createdAt: Date.now(),
    retry: 0
  });
  wx.setStorageSync(QUEUE_KEY, queue);
}

async function flush() {
  const rest = [];
  for (const item of wx.getStorageSync(QUEUE_KEY) || []) {
    try {
      await doRequest({ url: '/api/submit', data: item.payload, method: 'POST' });
    } catch (e) {
      item.retry += 1;
      // 超过 5 次或超过 24 小时则丢弃并上报
      if (item.retry <= 5 && Date.now() - item.createdAt < 86400000) rest.push(item);
      else wx.reportEvent('offline_queue_drop', { id: item.id });
    }
  }
  wx.setStorageSync(QUEUE_KEY, rest);
}

幂等性是离线队列的前提:每条记录必须带客户端生成的唯一 id,服务端按 id 去重,否则重放会造成重复下单。

六、静态资源缓存与命中率监控

6.1 图片与文件缓存

小程序对 image 组件的网络图片有客户端级缓存,但不受业务控制,无法预加载、无法指定失效时间。需要精细控制时改用文件系统缓存:

const fs = wx.getFileSystemManager();
const CACHE_DIR = `${wx.env.USER_DATA_PATH}/img_cache`;

async function getImage(url) {
  try { fs.accessSync(CACHE_DIR); } catch (e) { fs.mkdirSync(CACHE_DIR, true); }
  const filePath = `${CACHE_DIR}/${hashCode(url)}`;

  try {
    const stat = fs.statSync(filePath);
    if (Date.now() - stat.lastModifiedTime * 1000 < 7 * 86400000) {
      report('image_cache_hit');
      return filePath;
    }
  } catch (e) { /* 未命中,继续下载 */ }

  const res = await wx.downloadFile({ url });
  if (res.statusCode === 200) {
    fs.saveFileSync(res.tempFilePath, filePath);
    report('image_cache_miss');
    return filePath;
  }
  return url;   // 下载失败直接用远程地址兜底
}

// hashCode 把 URL 映射成稳定文件名,避免非法字符
function hashCode(str) {
  let h = 0;
  for (let i = 0; i < str.length; i++) h = (h << 5) - h + str.charCodeAt(i);
  return Math.abs(h | 0).toString(36);   // 36 进制字符串,只含数字与小写字母
}

文件系统上限约 200MB,需要主动清理过期文件:用 fs.readdirSync(CACHE_DIR) 遍历缓存目录,对每个文件 fs.statSync 读取 lastModifiedTime,超过 7 天的用 fs.unlinkSync 删除并累加计数,最后上报 image_cache_clean 事件。

6.2 命中率监控

缓存的价值必须可量化,否则无法判断优化是否有效:

指标计算方式健康值
Storage 命中率命中次数 / 总读取次数大于 60%
图片缓存命中率本地文件命中 / 总图片请求大于 70%
离线可用率无网络下成功渲染的页面占比大于 90%
迁移失败率storage_migrate_fail 次数接近 0
// utils/metrics.js
const counters = {};

function report(name, extra = {}) {
  counters[name] = (counters[name] || 0) + 1;
  wx.reportEvent(name, extra);
}

// 每 30 秒汇总一次容量与命中情况,避免上报过于频繁
setInterval(() => {
  wx.reportEvent('cache_health', {
    hit: counters.image_cache_hit || 0,
    miss: counters.image_cache_miss || 0,
    usedKb: wx.getStorageInfoSync().currentSize
  });
  Object.keys(counters).forEach((k) => delete counters[k]);
}, 30000);

命中率按「命中次数除以总读取次数」计算,在每次 get 与图片缓存判定处累加计数即可;上报体系可对照小程序数据统计与用户分析 中的指标分层设计,缓存指标单独成组以免与业务埋点混淆。

七、总结

小程序离线与缓存体系的关键,是把四层载体分工划清:代码包放结构、Storage 放小数据、文件系统放大文件、CDN 放可复用静态资源。Storage 有单 key 1MB 与总量 10MB 的硬限制且不会自动淘汰,所以必须自建索引表做 LRU 淘汰,并用命名空间加版本前缀支持整体失效。

工程上最容易出问题的是版本对账:代码包更新了但本地数据结构没迁移,会造成解析异常甚至白屏。解法是 __schema_version__ 加逐步迁移函数,每一步独立 try/catch 并上报失败。弱网兜底则依赖「请求层统一收口」:网络断开时读缓存、请求失败时退避重试、写操作落离线队列并在恢复后幂等重放。最后,缓存必须可观测,把命中率、占用体积、淘汰频率作为常规指标上报,否则「加了缓存但没变快」会一直隐藏在水面之下。结合小程序网络与数据层 的请求封装约定与小程序性能优化 的启动优化目标,缓存体系才能转化为首屏耗时与弱网留存上的可量化收益。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序第三方 SDK 集成与治理
  2. 小程序架构演进与遗留重构
  3. 小程序无障碍与适老化改造