返回博客

小程序内容页点击必有 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 这类"配置类接口"有一个特征:发布后基本不变,更新周期以周甚至月计算。但开发者往往用和商品价格、订单状态同一套写法:

进页面 → 发请求 → 等返回 → 渲染

这造成三个问题:

  1. 浪费了启动黄金窗口App.onLaunch 到用户点击入口之间通常有 3–10 秒空闲,网络带宽和 CPU 全部闲置
  2. wx.setStorageSync 读写是毫秒级,比 wx.request 快两个数量级,缓存命中可以做到"点击即上屏"
  3. 首次无缓存 + 网络抖动 = 用户完全卡死,没有重试路径,体验是死路
坑:onLaunch 预取不加双重 catch,失败会让整个小程序冻住

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 态",用户感知不到任何等待。

正解:onLaunch 黄金窗口预取 + SWR,二次访问 < 20ms 上屏

实测效果:启动预热后再点击协议页,loadFromCache 在 20ms 内完成 setData,用户看到"点击即有内容";后台 fetchAndRefresh 悄悄拿最新版本覆盖,用户无感知。断网时依然展示上次缓存内容,不再白屏。

适用边界:SWR 不是万能的

注意:价格、库存、订单状态禁止用 SWR

SWR 的本质是"先给旧的,后给新的"。如果旧内容和新内容的差异会让用户做出错误决策,就不能用:

| 场景 | 适用 SWR? | |---|---| | 隐私条款、使用协议、客服 FAQ | ✅ 文案差异无风险 | | 会员权益说明、活动规则 | ✅ 文案覆盖无风险 | | 商品价格、库存数量 | ❌ 用户看旧价下单,结算变价 → 投诉 | | 优惠券可用状态、订单状态 | ❌ 旧态覆盖会引发误操作 |

另一个边界:预取接口必须是公开接口(不依赖登录态)App.onLaunch 执行时用户往往还没登录,如果接口需要 token,预取 100% 失败。遇到预取总是不命中,先检查 HTTP 拦截器是否强制给所有请求带了 token。

一句话外卖

对"一周才改一次"的配置类接口,把 App.onLaunch 当成预热炉,用 wx.setStorageSync 做 L1 缓存,页面层用 SWR 先上屏后刷新——用户从此不用等任何一个不变的东西。