一、缓存能力全景
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 并上报失败。弱网兜底则依赖「请求层统一收口」:网络断开时读缓存、请求失败时退避重试、写操作落离线队列并在恢复后幂等重放。最后,缓存必须可观测,把命中率、占用体积、淘汰频率作为常规指标上报,否则「加了缓存但没变快」会一直隐藏在水面之下。结合小程序网络与数据层
的请求封装约定与小程序性能优化
的启动优化目标,缓存体系才能转化为首屏耗时与弱网留存上的可量化收益。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。