小程序内容页点击必有 300ms 空白?启动预取 + SWR 两步解决
某 C 端微信小程序上线后,有用户反馈"点隐私条款要等好几秒"。打开监控一看:getPrivacy 这个接口,一周被打了几千次,但接口返回内容一个字都没变过。每次用户点开协议页,都在等一个永远不会变的东西。
这篇文章记录如何用「启动预取 + Stale-While-Revalidate」两步把这类配置页的感知延迟压到肉眼不可见。
现象
- 点击"隐私条款 / 使用协议 / 会员政策"等入口,肉眼可见 300–800ms 空白才出现正文
- 监控面板能看到同一参数的接口被重复调用 N 次,内容 99% 时间没变
- 网络抖动或断网时直接白屏,用户只能杀进程重进
- 失败后显示静态文案"加载失败",没有重试按钮
反模式写法(最常见):
// pages/agreement.js 反例
Page({
onLoad() {
wx.showLoading({ title: '加载中' })
api.getPrivacy({}).then(res => {
wx.hideLoading()
this.setData({ content: res.data.list[0].privacy })
}).catch(() => {
wx.hideLoading()
this.setData({ content: '加载失败' }) // 死文案,无重试
})
}
})根因
隐私条款、使用协议、会员权益说明、客服 FAQ 这类"配置类接口"有一个特征:发布后基本不变,更新周期以周甚至月计算。但开发者往往用和商品价格、订单状态同一套写法:
进页面 → 发请求 → 等返回 → 渲染
这造成三个问题:
- 浪费了启动黄金窗口:
App.onLaunch到用户点击入口之间通常有 3–10 秒空闲,网络带宽和 CPU 全部闲置 wx.setStorageSync读写是毫秒级,比wx.request快两个数量级,缓存命中可以做到"点击即上屏"- 首次无缓存 + 网络抖动 = 用户完全卡死,没有重试路径,体验是死路
App.onLaunch 里的 Promise 如果不加 .catch(),在某些微信基础库版本下,未处理的 Promise rejection 会阻塞 App 生命周期,导致用户看不到首页。预取是锦上添花,不能让它把主流程搞挂。
// 错误:网络失败时可能阻塞 App 生命周期
_prefetchPrivacy: function () {
api.getPrivacy({}).then(res => { ... })
// 没有 .catch()
}正解
分三段实现:
第一段:App.onLaunch 末尾静默预取
// app.js
App({
onLaunch: function () {
// ...登录、store 挂载等其他初始化...
this._prefetchPrivacy() // 末尾追加,fire-and-forget
},
_prefetchPrivacy: function () {
try {
api.getPrivacy({}).then(function (res) {
// 必须判断业务码,避免把错误结构污染缓存
if (res.err == 0 && res.data && res.data.list && res.data.list.length > 0) {
var html = res.data.list[0].privacy || ''
wx.setStorageSync('app_privacy_content', {
content: html,
updatedAt: Date.now()
})
}
}).catch(function () {
// 预取失败静默吞掉
})
} catch (e) {
// 防御性兜底:同步异常也不影响启动
}
},
})关键点:try/catch + .catch() 双保险、写入前必须判断 res.err == 0(业务错误结构写进缓存,下次读出来直接渲染错误页)。
第二段:Page 读缓存立即上屏 + 并行后台刷新
// pages/agreement.js
Page({
data: { content: '', loading: true },
onLoad: function () {
this.loadFromCache() // 先读缓存,命中即上屏
this.fetchAndRefresh() // 并行后台刷新,不等缓存结果
},
loadFromCache: function () {
try {
var cache = wx.getStorageSync('app_privacy_content')
if (cache && cache.content) {
this.setData({ content: cache.content, loading: false })
}
} catch (e) {}
},
fetchAndRefresh: function () {
var that = this
api.getPrivacy({}).then(function (res) {
if (res.err == 0 && res.data && res.data.list && res.data.list.length > 0) {
var html = res.data.list[0].privacy || ''
that.setData({ content: html, loading: false })
try {
wx.setStorageSync('app_privacy_content', { content: html, updatedAt: Date.now() })
} catch (e) {}
} else {
that.setData({ loading: false })
}
}).catch(function () {
// 失败时保留缓存内容,只关闭 loading
that.setData({ loading: false })
})
},
onRetry: function () {
this.setData({ loading: true })
this.fetchAndRefresh()
},
})第三段:WXML 三态渲染 + 失败可重试
<view wx:if="{{loading && !content}}" class="loading">
加载中...
</view>
<rich-text wx:if="{{content}}" nodes="{{content}}"></rich-text>
<!-- 整块绑定 tap,不是死文案 -->
<view wx:if="{{!loading && !content}}"
bindtap="onRetry"
hover-class="retry-active">
加载失败,点击重试
</view>关键:loading && !content 这个条件保证"缓存命中直接跳过 loading 态",用户感知不到任何等待。
实测效果:启动预热后再点击协议页,loadFromCache 在 20ms 内完成 setData,用户看到"点击即有内容";后台 fetchAndRefresh 悄悄拿最新版本覆盖,用户无感知。断网时依然展示上次缓存内容,不再白屏。
适用边界:SWR 不是万能的
SWR 的本质是"先给旧的,后给新的"。如果旧内容和新内容的差异会让用户做出错误决策,就不能用:
| 场景 | 适用 SWR? | |---|---| | 隐私条款、使用协议、客服 FAQ | ✅ 文案差异无风险 | | 会员权益说明、活动规则 | ✅ 文案覆盖无风险 | | 商品价格、库存数量 | ❌ 用户看旧价下单,结算变价 → 投诉 | | 优惠券可用状态、订单状态 | ❌ 旧态覆盖会引发误操作 |
另一个边界:预取接口必须是公开接口(不依赖登录态)。App.onLaunch 执行时用户往往还没登录,如果接口需要 token,预取 100% 失败。遇到预取总是不命中,先检查 HTTP 拦截器是否强制给所有请求带了 token。
一句话外卖
对"一周才改一次"的配置类接口,把
App.onLaunch当成预热炉,用wx.setStorageSync做 L1 缓存,页面层用 SWR 先上屏后刷新——用户从此不用等任何一个不变的东西。