topfans/docs/superpowers/specs/2026-07-02-preload-api-design.md

39 KiB
Raw Blame History

uniapp+vue3 API 预加载通用方案 — 设计

  • 日期2026-07-02
  • 作者Claude Fable 5与项目 owner 协作)
  • 范围frontend/uniapp + Vue 3 + Vite主要服务于 App 端

★ 方案概述(必读)

要解决的问题

业务问题

  • 用户进入详情页时白屏 loading 闪烁(网络请求耗时 200-800ms体验差
  • 冷启动后首页数据(排行榜、用户配置等公共数据)每次都重新拉取,浪费带宽
  • 用户在页面间反复切换时,相同接口被多次调用,服务端压力大

技术问题

  • uniapp 没有内置的请求缓存层,每个 uni.request 都是独立调用
  • uni.navigateTo 跳转和页面数据加载是串行的(先跳转 → 再 onLoad 发请求),无法利用"用户点击到页面渲染"之间的时间窗口
  • 没有统一的请求去重机制,同一数据源被多个组件同时请求时产生冗余网络调用

整体实现路径

阶段 内容 预估时间
Phase 1核心缓存引擎 storage.js + core.js(内存 Map + LRU + inFlight 去重 + TTL 1 天
Phase 2调度 & 路由集成 scheduler.js + navigate.js + config.js(启动预热 / idle 预拉 / 页面切换预拉) 0.5 天
Phase 3Vue 集成 usePreload.js composable + preload.config.js 业务配置 + App.vue / store/user.js 集成 0.5 天
Phase 4测试 & 验收 单元测试 + 集成冒烟 + 真机验收 0.5 天
合计 2.5 天

关键决策

决策 理由 详见
核心模块core.js零外部依赖 可独立测试、不耦合 uniapp / Vue未来可迁移 §2.2
wrap navigateTo 不 await 预拉结果 点击立刻跳转,预拉后台并发,不增加跳转延迟 §2.5
401/业务码7/16 统一 swallow api.js 已同步 reLaunch 跳登录页,预加载层再抛错会造成双重跳转 §5
内存 LRU + 文件 FIFO 双轨淘汰 内存注重热数据命中率LRU文件注重简单可靠FIFO §7.1
inFlight 去重(同 key 并发共享 Promise 避免启动期 / 页面切换时同一接口被多次调用 §4.4
按 userId 物理隔离文件缓存 防止用户 A 读到用户 B 的缓存数据 §6.5

核心架构图TL;DR

                        ┌──────────────────────────┐
                        │   preload.config.js       │
                        │   (业务声明 key/fetcher/ttl) │
                        └──────────┬───────────────┘
                                   │
   ┌───────────┐     ┌─────────────▼──────────────┐
   │ App.vue   │────►│       scheduler.js          │
   │ onLaunch  │     │  warmStartup() / warmIdle() │
   │ onShow    │     └─────────────┬──────────────┘
   └───────────┘                   │
                                   │
   ┌───────────┐     ┌─────────────▼──────────────┐
   │ navigate  │────►│         core.js             │
   │ .js       │     │  run / get / invalidate     │
   │ (wrap     │     │  Map<key,Entry> + LRU       │
   │  uni.nav*)│     │  inFlight dedup             │
   └───────────┘     └──────┬──────────┬──────────┘
                             │          │
                    ┌────────▼──┐  ┌────▼──────────┐
                    │ storage.js│  │ usePreload.js │
                    │ (_doc/    │  │ (composable:  │
                    │  preload/ │  │  data/loading │
                    │  {uid}/)  │  │  /error/refresh│
                    └───────────┘  └───────────────┘

文档说明

  • 适用范围frontend/ 下所有 API 调用场景(页面数据加载、启动预热、页面切换预拉),以 App 端Android/iOS为主H5 / 小程序降级兼容
  • 工作量估算:约 2.5 天(核心 1 天 + 调度&路由 0.5 天 + Vue 集成 0.5 天 + 测试 0.5 天),约 800-1000 行新代码
  • 前置版本/历史:无。这是项目首个 API 预加载方案
  • 目标读者:前端开发(需了解如何配置新的预拉 key 和使用 usePreload)、架构评审

1. 目标与范围

为 uniapp + Vue 3 项目提供一套通用、可配置、按场景分层的 API 预加载方案,覆盖 4 类典型场景:

  1. 页面切换前预先拉数据:进入 A 页时按目标页 B 的清单提前拉好 B 的接口
  2. 应用启动时批量预热常用数据onLaunch 期间并发拉启动清单
  3. 通用响应缓存层:相同 url+params 在 TTL 内复用结果
  4. idle 时机预拉:首屏渲染完成后用空闲时间拉「可能下一屏」的数据

非目标

  • 不重写 request() 本身(保持 utils/api.js 签名和返回值不变,仅新增 .abort() 方法)
  • 不做请求合并 / dedup beyond 同一 cacheKey避免引入 dispatcher complexity
  • 不做离线缓存(断网时不读文件缓存兜底)

2. 架构与模块

2.1 目录结构

frontend/
├── utils/preloadApi/
│   ├── core.js              # 命令式核心(零 Vue 依赖)
│   ├── config.js            # 默认 config 导入与合并
│   ├── storage.js           # storage 适配器用户隔离、namespace 隔离)
│   ├── scheduler.js         # idle / 启动期调度
│   ├── navigate.js          # wrap uni.navigateTo / switchTab / reLaunch
│   └── index.js             # 统一导出 preloadApi
├── composables/
│   └── usePreload.js        # Vue 3 包装:暴露响应式 state
└── config/
    └── preload.config.js    # 用户声明key / fetcher / ttl / persistence / 触发时机

2.2 模块职责

模块 职责
core.js 注册/执行/查询/失效;维护内存 Map + LRU并发去重
config.js 把用户 preload.config.js 与内置默认值合并成一个运行时 config
storage.js 文件缓存适配器:通过 plus.ioAPP-PLUS/ uni.getFileSystemManager(兜底)读写 _doc/preload/{userId}/ 目录;按 userId + namespace 隔离
scheduler.js warmStartup():跑 startup 清单;warmIdle():用 requestIdleCallback 兜底跑 idle 清单
navigate.js 重写 uni.navigateTo/switchTab/reLaunch,命中目标页时按 pages 映射触发预拉
usePreload.js 包装 core.get(),对组件返回 { data, loading, error, refresh }

2.3 调用关系

App.vue onLaunch
   └─► scheduler.warmStartup()              ─┐
                                            ├─► core.run(key) ─► request() ─► api.js
preloadApi.navigateTo('/pages/foo?id=1')   ─┘
   └─► prefetchFor(targetPath, params)     ─┐
                                            ├─► core.run(key)
Page onLoad (composable)                    ─┘
   └─► usePreload(key, params)              ─► core.get(key)

2.4 对现有代码的侵入

文件 改动
App.vue ~10 行Options APIimport preloadApionLaunch 中调用 warmStartup()onShow 中调用 warmIdle()。注意 App.vue 使用 Options APIexport default { … }),直接 import + 在生命周期方法内调用即可,<script setup> composable 风格(详见 §6.5.1 App.vue 集成方式)
store/modules/user.js ~5 行:在 SET_USER_INFO mutation 中注入 preloadApi.clearForUser(oldUserId);在 CLEAR_AUTH mutation 中注入 preloadApi.clearUser(userId)(调用顺序约束见 §6.5
pages/*/...vue 把原本 request({...}) 改成 usePreload('key')setup / store action / watch 中的请求均适用);把 uni.navigateTo/switchTab/reLaunch 全量替换为 preloadApi.navigateTo/switchTab/reLaunch(见 §2.5
utils/api.js request() 返回的 Promise 上挂 .abort() 方法(~8 行改动:捕获 requestTask + 挂 abort() + fail 中识别 abort 信号,详见 §4.4.1
其余 utils/* 不动

2.5 wrappedNavigateTo 替换策略(已确定方案 A

严格 wrap预拉 fire-and-forget 不 await

  • preloadApi.navigateTo(opts) 内部:解析 opts.url 的 query string → 按目标页路径查 config.pages 映射 → 如果命中映射对每个匹配项调用 core.run(key, params)不 await)→ 立即调用原生 `uni.navigateTo(opts)
  • 如果未命中 config.pages 映射:直接透传调用原生 uni.navigateTo(opts),不做任何预拉(预拉是 opt-in不是 opt-out
  • 用户点击 → 立刻跳转;预拉在后台并发进行;目标页 onLoadusePreload(key, params) 时大概率命中缓存
  • 绝不navigateTo 内 await 预拉结果,否则跳转延迟与设计目标矛盾
  • 不保留 uni.navigateTo 兼容入口:要么用 preloadApi.navigateTo(触发预拉),要么不要预拉;保留兼容会让 "哪些跳转走预拉" 变得不可见

全量替换规则:用 ESLint 规则或 PR review checklist 强制 —— 全仓 uni.navigateTopreloadApi.navigateTo(可用 codemod 一次性替换)。

替换影响评估(实施前确认):

  • 预计全仓 uni.navigateTo / uni.switchTab / uni.reLaunch 调用点在 40-80 处(分布在 pages/ 下各 .vue 文件 + App.vue
  • 需手动 review 的特殊场景:
    • App.vue 第 149 行 uni.navigateTo(推送通知点击跳转,在 setTimeout 内)——应改为 preloadApi.navigateTo(无对应 pages 映射时自动透传,安全)
    • App.vue 第 92/114/143 行 uni.reLaunch(登录页跳转)——应改为 preloadApi.reLaunch(同上)
    • 任何在 #ifdef / #ifndef 块内的调用 —— codemod 可能漏掉,需手动检查
  • 未命中 config.pages 映射时,preloadApi.navigateTo 直接透传调用原生 uni.navigateTo§4.1 已定义),因此即使替换出错也不会导致跳转失败,最多丢失预拉机会

3. 配置 schema

// frontend/config/preload.config.js
import {
  getCastloveConfigApi,
  getUserProfileApi,
  getHotRankingApi,
  getAssetLikersApi,
  getActivityDetailApi,
  getActivityItemsApi,
  // ...按需 import
} from '@/utils/api'

export const preloadConfig = {
  // ── 全局默认 ──
  defaults: {
    ttl: 5 * 60 * 1000,          // 5 分钟
    persistence: 'memory',       // 'memory' | 'file'
    concurrency: 4,              // 单次批量最多并发数
    timeout: 10000,              // 单接口超时ms超时后取消预拉
    silent: true,                // 失败是否静默true = 仅 warn不抛
    // ── 上限保护阈值(硬编码默认值,可在下面覆盖)──
    limits: {
      maxEntrySizeKB: 1024,      // 单 key 数据超过此值只写内存不写文件1 MB 上限,防止单文件过大拖慢读取)
      maxMemoryEntries: 100,     // 内存总条目上限,超出按 LRU 淘汰
      maxFileCacheMB: 50         // 文件缓存总容量上限uni.storage 仅 4 MB 且与其他业务共享;本地 _doc/ 容量远超此值50 MB 足够中等规模 API 响应缓存)
    }
  },

  // ── 启动期预热清单App.vue onLaunch 跑)──
  startup: [
    { key: 'castlove.config', fetcher: getCastloveConfigApi,
      ttl: 60 * 60 * 1000, persistence: 'file' },
    { key: 'me.profile', fetcher: getUserProfileApi,
      ttl: 10 * 60 * 1000 }
  ],

  // ── idle 预拉清单(首屏渲染完后跑)──
  idle: [
    { key: 'ranking.hot', fetcher: () => getHotRankingApi('total', null, 1, 10),
      ttl: 10 * 60 * 1000 }
  ],

  // ── 页面切换预拉映射wrappedNavigateTo 命中时触发)──
  pages: {
    '/pages/asset-detail/asset-detail': [
      { key: 'asset.likers', fetcher: (params) => getAssetLikersApi(Number(params.id)) }
    ],
    '/pages/activity-detail/activity-detail': [
      { key: 'activity.detail', fetcher: (params) => getActivityDetailApi(params.id) },
      { key: 'activity.items', fetcher: (params) => getActivityItemsApi(params.id) }
    ]
  }
}

字段说明:

  • key:逻辑 key业务引用缓存的唯一标识
  • fetcher(params):返回 Promise 的请求函数
  • ttl缓存有效期ms
  • persistence'memory'(仅内存 Map/ 'file'(持久化到本地文件缓存 _doc/preload/,走 plus.io
  • concurrency:单次批量内最大并发
  • timeout单接口超时ms
  • silent:失败是否静默(run 时生效,get 始终抛错)
  • limits.maxEntrySizeKB / maxMemoryEntries / maxFileCacheMB:上限保护阈值(详见 §7默认 1024 KB / 100 条 / 50 MB仅在 defaults.limits 全局配置,不支持 per-key 覆盖

4. 核心 API

4.1 命令式 APIutils/preloadApi/index.js 导出)

preloadApi.run(key, params?)              // 触发一次预拉(写缓存):①先查内存,未过期则跳过;②查 inFlight已在请求中则共享 Promise不重发③过期/未命中/未 inFlight 才发起新请求。401/7/16 静默吞掉。不返回数据
preloadApi.get(key, params?)              // 读缓存:命中直接返回;未命中/过期则拉取(同样走 inFlight 去重401/7/16 业务码静默吞掉(不抛给调用方)
preloadApi.refresh(key, params?, force?)  // 命令式刷新force=true 跳过 TTL。非组件上下文使用组件上下文用 usePreload.refresh()
preloadApi.prefetchFor(targetPath, params?)  // wrappedNavigateTo 内部调用
preloadApi.invalidate(key?)               // 失效单 key仅内存
preloadApi.invalidatePrefix(prefix)       // 失效某前缀(命中 logicalKey 前缀,仅内存)
preloadApi.invalidateAll()                // 清空全部内存缓存(不动文件缓存)
preloadApi.clearForUser(userId)           // 删除指定用户的文件缓存目录(`_doc/preload/{userId}/`
preloadApi.clearUser(userId)              // 清空全部内存 + 删除指定用户的文件缓存目录(登出用)
preloadApi.warmStartup()                  // App.vue onLaunch 调用
preloadApi.warmIdle()                     // App.vue onShow 调用(每次回前台跑一次)
preloadApi.navigateTo(opts)               // 替代 uni.navigateTo预拉 fire-and-forget
preloadApi.switchTab(opts)                // 替代 uni.switchTab
preloadApi.reLaunch(opts)                 // 替代 uni.reLaunch

4.2 key 命名规则与物理 cache key

  • 配置里写的 key 是"逻辑 key"
  • 真正缓存的物理 key
    ${userId || 'guest'}::${namespace}::${logicalKey}::${hash(params)}
    
  • 同 key 不同 params 是不同缓存条目
  • namespace硬编码为常量 'preload'(与 §6.5 的文件目录名保持一致;预留扩展位,当前不在 schema 暴露)
  • hash(params)先对 params 的 key 排序JSON.stringify → djb2 → 16 进制key 排序保证 {a:1,b:2}{b:2,a:1} 生成相同 hash避免命中率下降

4.3 读缓存流程(get

get(key, params)
  ├─ 计算 cacheKey
  ├─ 查内存 → 命中且未过期 → 同步 resolveLRU touchLRU 标记访问)→ return
  ├─ 查文件缓存async I/O→ 命中且未过期 → 写回内存 + resolve
  ├─ 未命中 → 调 fetcher(params)
  │     ├─ 成功 → 写内存 + 异步写文件缓存fire-and-forget+ resolve
  │     ├─ "已登出"信号 → swallow 错误 + resolve(null)(见 _swallowAuth
  │     └─ 其他错误 → reject(原错误)(业务方决定 toast
  └─ 注:内存命中是唯一同步路径;文件缓存命中 / fetcher 路径均返回 async Promise
       因此 composable §4.5 中只有内存命中能让首次渲染时 data 同步有值

"已登出"信号匹配规则(见 §5 _swallowAuth

  • err.code === 7(业务 401token 失效)
  • err.code === 16(业务 403账号被封
  • err.message 含 "登录已过期"(匹配 utils/api.js 第 86-122 行 HTTP 401 reject 时构造的 message

关键约束runget 都必须 swallow "已登出"信号。理由:utils/api.js 第 86-122 行在检测到这些码时已经同步调用 uni.reLaunch 跳登录页,相当于"已处理";预加载层再抛错会造成双重跳转或冷启动被中断。

4.3.1 prefetchFor 的入参约定

preloadApi.prefetchFor(targetPath, params)

  • targetPath:目标页的完整路径,如 /pages/asset-detail/asset-detail
  • params只接受基本类型 key-valuestring / number / booleannavigate.js 从跳转 URL 的 query string 解析得到
  • 解析规则:
    • URLSearchParams 解析 query string
    • 每个 value 用 decodeURIComponent 解码
    • 所有 value 统一为 stringfetcher 内部按需 Number() / Boolean() 转换)
  • 示例:uni.navigateTo({ url: '/pages/foo/bar?id=123&type=hot' }){ id: '123', type: 'hot' }
  • 数组 / 嵌套对象走 ?arr=1,2,3 这种字符串协议;不在本次 spec 范围内
  • URL 编码约束:业务方拼 URL 时必须对 value 做 encodeURIComponent(特别是含 & / = / 中文 的 value

调用链示例:

navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
// → prefetchFor('/pages/asset-detail/asset-detail', { id: '123' })
// → core.run('asset.likers', { id: '123' })
// → fetcher({ id: '123' }) = getAssetLikersApi('123')

4.4 并发去重

适用范围runget 共用同一套 inFlight 去重。同一个 cacheKey in-flight 时,第二个 runget 共享同一个 Promise不重复发请求

实现:内部维护 inFlight: Map<cacheKey, { promise, abort }>。任何入口run / get / prefetchFor / warmStartup / warmIdle触发 fetch 时,先查 inFlight —— 命中则直接返回共享 Promise未命中则创建新 Promise 并写入 inFlight。

清理时机fetch 无论成功失败都在 finally 阶段清掉 inFlight。否则失败后该 cacheKey 会永久 stuck后续调用永远拿到同一个 reject 的 Promise。401 swallow 路径也是 finally 清掉。

请求取消abortuniapp App 端 uni.request 返回的 requestTask 可调用 .abort()。当 composable 组件 unmount 或 usePreload 的 key/params 变化时,应 abort 前一个未完成的请求以避免浪费带宽。实现:

  • inFlight 每个条目存 { promise, abort: () => { promise.abort?.(); } }
  • composable 在 onBeforeUnmount / watcher 中调 abort()
  • abort 后清掉 inFlight 条目abort 不走 finally 路径,需显式清理)

request() 改造(唯一对 utils/api.js 的改动,约 8 行):

// utils/api.js — request() 内部
export function request(options) {
  let _aborted = false                        // 新增abort 标记
  const requestTask = uni.request({...})      // 改动:捕获 requestTask原来直接调用不赋值
  let abortFn = () => {
    _aborted = true                           // 新增:标记已 abort
    requestTask.abort()
  }
  const p = new Promise((resolve, reject) => {
    // ... 现有 success/fail 逻辑不变 ...
    // 改动fail 回调中识别 abort 信号
    // fail: (err) => {
    //   if (_aborted) return                 // abort 触发的 fail 静默忽略
    //   reject(new Error(err.errMsg || '网络请求失败'))
    // }
  })
  p.abort = abortFn                           // 新增这一行
  return p
}

关键细节

  • requestTask.abort() 触发后,uni.requestfail 回调会以 err.errMsg === 'request:fail abort' 触发。必须在 fail 中通过 _aborted 标记识别并静默返回,否则 preload 层会收到一个 "网络请求失败" 的 reject。
  • 所有现有调用方 await request(...) 语法不变;abortFn 仅由 preload 的 core.js 在组件 unmount / params 变化时调用。

4.4.1 abort 生命周期

4.5 composable APIcomposables/usePreload.js

// 在 <script setup> 中:
const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })

返回:

  • data: Ref<any | null>:命中缓存为缓存值;未命中拉到为 fetcher 返回值401 swallow 为 null其他失败为上一次值或 null
  • loading: Ref<boolean>true 表示当前有 in-flight 请求
  • error: Ref<Error | null>401 swallow 时为 null其他错误为 Error 对象
  • refresh(force?: boolean)手动刷新force=true 跳过 TTL 复用)

初次渲染行为(关键):

  • core.get() 命中内存缓存时返回已 resolved 的 Promise。composable 在 setup() 同步阶段立即 data.value = cached.data,首次渲染时 data 就已有值、loading 为 false。这依赖 Vue 3 同步赋值而非下一 tick 更新。
  • core.get() 未命中 / 文件缓存命中 / 未命中时返回未 resolved 的 Promise。composable 在 setup() 同步阶段 data 初始为 null、loading 为 truePromise resolve 后 .then() 异步更新 data.value,触发下一次渲染。
  • 实现:get 内部命中内存路径直接 return Promise.resolve(cached.data),未命中路径返回 inFlightEntry.promise
  • 硬约束composable 内 data.value 的赋值必须包裹在 .then() 中(不能 await 后在 setup 同步代码中赋值),否则未命中路径会阻塞整个 setup 导致白屏。这等同于不能阻塞 setup 的同步执行

典型用法

<script setup>
const { data, loading, error, refresh } = usePreload('asset.detail', { id: route.params.id })
// 模板中v-if="data" / v-else-if="error" / v-else="loading"
</script>

5. 错误处理

场景 行为 日志
启动预热失败 静默(启动期不能阻塞) console.warn('[preload] startup fail:', key, err.message)
idle 预拉失败 静默 同上
页面切换预拉失败 静默(用户可能不去目标页) 同上
usePreload.get() 在组件内失败(非 401 类) 抛错给调用方,组件 error ref 更新 无(业务自行决定 toast
401 / 业务码 7 / 16"已登出"信号) 所有链路run / get / prefetchFor必须 swallowresolve(null) console.warn('[preload] auth-expired, swallowed:', key)
网络断开 / 请求超时 抛错给调用方;已存在的未过期缓存保持不变(不会因为 fetch 失败而被清掉);下次 get 重新尝试 console.warn('[preload] network error:', key, err.message)

核心原则预拉失败绝不能阻塞主链路401 / 业务码 7 / 16 必须 swallow避免与 request() 的 reLaunch 行为冲突导致冷启动被中断或双重跳转)。

实现位置core.jsrun / get 共享一个内部 helper _swallowAuth(err)

function _swallowAuth(err) {
  if (err && (err.code === 7 || err.code === 16)) return null
  if (err && /登录已过期/.test(err.message || '')) return null
  return err
}

6. 失效策略

6.1 自动失效

  • TTL 到期per-key 配置)
  • 用户登出clearUser(oldUserId)(清该用户的内存 + 删文件缓存目录)
  • 切换粉丝身份invalidatePrefix('me.')(仅内存)
  • App.vue onShowwarmIdle() 触发时只对 TTL 到期的 key 自然过期,不主动失效任何 cache

6.2 手动失效(业务调用)

  • 点赞后:invalidate('ranking.hot')invalidatePrefix('asset.')
  • 提交评论后:invalidatePrefix('activity.messages')
  • 进入个人页前可选主动失效 me.*

6.3 关键澄清:invalidateAll() vs clearUser(userId) vs clearForUser(userId)

API 内存 文件缓存 触发场景
invalidateAll() 清空全部 不动 极少使用;保留作为"内存全清"逃生口
clearForUser(userId) 不动 仅删指定用户的 _doc/preload/{userId}/ 目录 单独清理某用户文件缓存(如切换账号但保留另一账号会话的边缘场景)
clearUser(userId) 清空全部内存缓存(所有 userId 命名空间) + 删指定用户的文件缓存目录 登出专用

核心约束

  1. invalidateAll() 永远不动文件缓存。理由:避免把当前会话(登录态改变前)的 persistence 缓存白白清掉;文件清理必须显式按 userId 走。
  2. clearUser(userId)userId 必须等于当前登录用户。否则会清空当前用户的内存缓存(与文件删的目标用户不一致)。调用方需保证 userId 正确性;不在 API 层做防御。

6.4 数据一致性

写:先写内存 → 再异步写文件缓存fire-and-forget不 await
读:先查内存 → 未命中查文件缓存 → 仍未命中才 fetch
清:组合清空(如 clearUser先清内存 → 再删文件目录;仅清内存或仅删文件的 APIinvalidateAll / clearForUser按各自定义执行

文件写入失败仅 console.warn,不影响内存中的可用数据。文件读取失败(如文件被手动删除、权限异常)视为未命中,走 fetch 路径。

6.5 用户隔离

文件缓存目录结构:

_doc/preload/
  ├── guest/          # 未登录用户
  │   ├── {hash1}.json
  │   └── {hash2}.json
  ├── 1001/           # userId = 1001
  │   ├── {hash1}.json
  │   └── {hash2}.json
  └── 1002/           # userId = 1002
      └── {hash1}.json

一个缓存条目 = 一个 JSON 文件,文件名 = {hash(cacheKey)}.json,内容 = { data, ts, ttl }

// storage.js — 文件缓存适配器
const BASE_DIR = '_doc/preload'

// 获取某个用户缓存目录下的所有条目
function listUserEntries(userId) {
  const dir = `${BASE_DIR}/${userId || 'guest'}`
  // plus.io.resolveLocalFileSystemURL 读取目录
  // 失败(目录不存在)视为空
}

// 读:解析条目 JSON 文件
async function readEntry(userId, cacheKey) {
  const path = `${BASE_DIR}/${userId || 'guest'}/${hash(cacheKey)}.json`
  // plus.io.FileReader 或 uni.getFileSystemManager().readFile
  try {
    const raw = await readFile(path, 'utf-8')
    return JSON.parse(raw)
  } catch {
    return null  // 文件不存在 / 损坏 → 视为未命中
  }
}

// 写:异步写 JSON 文件fire-and-forget 调用,因此必须内建 .catch
async function writeEntry(userId, cacheKey, data, ts, ttl) {
  const dir = `${BASE_DIR}/${userId || 'guest'}`
  const path = `${dir}/${hash(cacheKey)}.json`
  // 先确保目录存在plus.io.File.createDirectory 或 mkdir
  // 再写文件plus.io.File.writeFile
  // 注意:调用方不 await因此必须在内部 .catch(e => console.warn(...))
  // 否则未捕获的文件写入异常会变成 unhandled promise rejection
}

// 登出时:删除整个用户目录
function clearForUser(userId) {
  const dir = `${BASE_DIR}/${userId || 'guest'}`
  // plus.io.resolveLocalFileSystemURL(dir, (entry) => { entry.removeRecursively(...) })
  // 或 uni.getFileSystemManager().rmdir(dir, { recursive: true })
}

App.vue 包装 store/user mutation按 userId 变化分三种动作:

事件 动作
onLogin(userId) 主动清缓存。注意:memory 总是按 userId 物理隔离(物理 key 含 userId新用户访问任何 key包括 ranking.hot 这种"公共"数据)都需要重新拉取,不会自动复用前一会话或 guest 的内存条目。这是用户隔离的 by design 代价。
onSwitchUser(oldId, newId) invalidatePrefix('me.') + clearForUser(oldId)
onLogout(userId) clearUser(userId)

实施注意

  1. store/user.js 当前没有事件总线(只有 action / mutation没有 emit/on。实施方式包装 mutation,在 SET_USER_INFO mutation 里 patch 一下:如果旧用户存在(oldUserId != null),调用 preloadApi.clearForUser(oldUserId);在 CLEAR_AUTH mutation 里 patch 一下(注入 preloadApi.clearUser(userId) 调用)。注意:登录场景下 oldUserId 为 null/undefined必须 guard 住,否则会误删 _doc/preload/undefined/
  2. 调用顺序约束重要CLEAR_AUTH 时clearUser(userId) 再清 token / user。原因token 清掉之后任何业务发起的请求都会 401preload 缓存如果在 token 清之前清理完,业务不会有 401 + 缓存不一致的窗口。
  3. CLEAR_AUTH 内部已经清了一堆 keyaccess_token / user / star_id 等),与 clearUser 职责正交preload 只在 _doc/preload/ 下操作,不碰 uni.storage。两者互不冲突组合使用即可。
  4. API 选型#ifdef APP-PLUS 主路径走 plus.io(项目已有先例,useShare.js 里用了 plus.io.copyTo 操作 _doc/#ifdef H5 / 小程序等降级用 uni.getFileSystemManager(仅当 persist=file 时走文件memory 不变)。注意:plus.io API 均为回调风格storage.js 内部需用 new Promise((resolve, reject) => { plus.io.xxx(..., resolve, reject) }) 包装为 Promise参照 useShare.jscopyStaticToDoc 的 promisify 模式)。uni.getFileSystemManager 在 APP-PLUS 也可用且支持 Promise优先使用仅在需要递归删目录等 uni.getFileSystemManager 不支持的操作时走 plus.io

6.5.1 App.vue 集成方式Options API

App.vue 当前使用 Options APIexport default { … }),与项目中 composable<script setup>)风格不同,但集成方式很简单 —— 直接 import + 在生命周期方法内调用即可,不需要 ref/wrap 等:

// App.vue <script> 顶部
import preloadApi from '@/utils/preloadApi'

export default {
  onLaunch: function() {
    // ... 现有逻辑 ...
    preloadApi.warmStartup()  // 新增:启动期预热
  },
  onShow: function() {
    // ... 现有逻辑 ...
    preloadApi.warmIdle()     // 新增idle 预拉(幂等,多次调用安全)
  },
  // ...
}

无需将 App.vue 迁移到 Composition APIimport + 直接函数调用即可覆盖所有集成需求。

6.6 与 useBackgroundRefresh 的协作

两个模块完全独立、可并存:

  • useBackgroundRefresh(refreshFn):从后台切回时调 refreshFn(通常是重新拉当前页数据)
  • preloadApi.invalidate(prefix):手动失效缓存

业务页面常用组合:

useBackgroundRefresh(() => {
  // 从后台切回时,强制刷当前页 + 失效短期缓存
  preloadApi.invalidate('ranking.hot')
  preloadApi.invalidatePrefix('asset.')
  refresh(true)  // usePreload 的 refresh
})

7. 内存与文件缓存上限

内存泄漏防护 + 文件缓存容量控制(文件系统远比 uni.storage 大):

限制 处理
单 key 数据体积 > 1024 KB 只写内存,不写文件(即使 config 里设了 persistence: 'file' 也会降级,避免单 JSON 文件过大拖慢 I/O。不报错、静默降级下次冷启动走 fetch
总内存条目数 > 100 条 按 LRU 淘汰
总文件缓存容量 > 50 MB 按插入顺序淘汰最旧文件FIFO

7.1 LRU 实现约束

内存与文件缓存淘汰策略不同

淘汰策略 触发时机
内存 LRU最近访问优先 命中时重排delete + set
文件缓存 FIFO最早写入优先 不在读时重排;淘汰时一次性 scan 目录,按文件 stat.mtime 排序找出最旧文件删除。文件数不大(≤ 几百scan 开销可接受。也可维护一个轻量 _index.json 记录插入顺序,但 scan 方式更简单更健壮

JS Map 的迭代顺序是插入顺序而非访问顺序。内存 LRU 正确实现:

// 命中访问时:删除再插入,重排到最新
function touchLRU(map, k, v) {
  if (map.has(k)) map.delete(k)
  map.set(k, v)
  if (map.size > MAX_ENTRIES) {
    // Map 迭代顺序 = 插入顺序,最久未访问的是第一个
    const oldestKey = map.keys().next().value
    map.delete(oldestKey)
  }
}

7.2 并发粒度

defaults.concurrency(默认 4scheduler 入口粒度生效:

入口 并发限制
warmStartup() 整个 startup 数组最多 4 并发
warmIdle() 整个 idle 数组最多 4 并发
prefetchFor(targetPath) 单目标页内的多个 key 最多 4 并发

实现:scheduler.js 用一个简单的 semaphore计数器 + 等待队列)控制并发。

7.3 idle 调度触发点与 App 端 fallback

warmIdle() 调用点:App.vueonShow(每次从后台回前台都跑一次;首次冷启动 onLaunch 之后 onShow 也会触发,等价于"首屏渲染完后")。

首次冷启动 onLaunch→onShow 连续触发onLaunch 里 warmStartup 跑 startup 清单,紧接着 onShow 里 warmIdle 跑 idle 清单。如果 idle 清单与 startup 清单有重叠 keyrun 的"查内存 → 未过期则跳过"机制自动处理run 看到 startup 已写入的未过期缓存直接返回不重拉。如果无重叠两者并行进行composable 在首次渲染时可能受益于 startup 的预热而 idle 还在后台跑。

去重 / 防抖warmIdle 必须幂等——同一进程内多次调用,对已在 in-flight 的 key 直接跳过、不重发;对已在内存且未过期的 key 也跳过(靠 run 语义)。理由:用户高频切前后台会触发多次 onShowwarmIdle 不应每次都发起新请求。

App 端 fallback#ifdef APP-PLUSuniapp App 端没有 requestIdleCallbackscheduler 内部统一用:

const idle = typeof requestIdleCallback === 'function'
  ? requestIdleCallback
  : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)

8. 测试策略

8.1 层级与覆盖目标

范围 工具
核心单元测试 core.js 的 run/get/invalidate/并发去重/LRU/TTL 手动 mock uni.* / plus.io
文件缓存适配器测试 目录创建 / 读写条目 / 清空逻辑 / 大小阈值 同上mock plus.io File API
navigate 测试 wrappedNavigateTo 命中/未命中均正确 mock uni.navigateTo
composable 测试 usePreload 暴露的响应式 state 正确性 mock core
集成冒烟 App.vue 启动预热、wrappedNavigateTo 跳转 HBuilderX 真机

8.2 验收清单

  • preloadApi.run / get / invalidate* 单测通过
  • wrappedNavigateTo 命中/未命中均通过单测
  • usePreload composable 暴露响应式 state 通过单测
  • 401 / 业务码 7、16 被 _swallowAuth 正确吞掉(核心 + navigate + composable 各 1 个 case
  • query string 含 encodeURIComponent 字符(如中文)解析正确
  • App.vue 真机onLaunch 后立即访问 startup 项 key命中内存缓存
  • wrappedNavigateTo 真机:列表页跳详情页,详情页 onLoad 时立即命中预拉缓存loading 闪烁 < 50ms
  • 登出后 _doc/preload/{userId}/ 目录被删除
  • LRU 淘汰边界:
    • 塞 100 个 key第 1 个被淘汰
    • 塞 101 个 key第 1 个被淘汰
  • warmIdle 幂等:同进程内连续调 3 次fetcher 只被调用 1 次
  • 用户切换onSwitchUserme.* 前缀全失效、oldUser 的文件缓存目录已删除、newUser 缓存不受影响

8.3 调试与可观测性

开发者工具import.meta.env.DEV 时启用):

// 实时查看缓存命中率、top-N hot keys
window.__PRELOAD_DEBUG__ = {
  dumpMemory: () => Array.from(memoryMap.entries()).map(([k, v]) => ({
    key: k, age: Date.now() - v.ts, ttl: v.ttl, persistence: v.persistence
  })),
  stats: () => ({
    hits: hitCount, misses: missCount, memorySize: memoryMap.size, fileCacheBytes: totalFileCacheBytes
  }),
  // 一键 dump 所有 key + inFlight 当前几条
  all: () => ({ ... }, inFlight: Array.from(inFlightMap.keys()))
}

日志规范(生产环境统一前缀 [preload]

  • 缓存命中:不记日志(高频、无意义)
  • 缓存过期 / 未命中:不记日志(正常路径)
  • fetch 完成:console.log('[preload] fetch done:', key, elapsedMs)
  • fetch 失败swallowconsole.warn('[preload] fetch fail (swallowed):', key, err.message)
  • fetch 失败throw不记日志业务方自行处理
  • 清理事件:不记日志(正常路径)
  • LRU 淘汰:console.log('[preload] LRU evict:', oldestKey)

9. 风险与缓解

风险 缓解
401 链路被 request() 同步 reLaunch 中断冷启动 core.run / get 通过 _swallowAuth swallow业务码 code === 7 || 16 message 含 "登录已过期"HTTP 401 走此路径,见 §5
用户隔离在登出 / 切账号时混乱 clearUser / clearForUser / invalidateAll 三层职责严格分离(见 §6.3
wrappedNavigateTo 全量替换回归风险 严格方案 A预拉 fire-and-forget 不 await见 §2.5
预拉时机太早导致请求用户未登录 401 swallow§5启动清单里依赖登录态的 key 必须 silent=true
启动期批量预拉阻塞冷启动 startup 全部 silent=true、并发受 concurrency 控制、超时 timeout
文件缓存体积膨胀 1024 KB / 100 条 / 50 MB 三层上限;登出 clearUser 删除目录
usePreload 在非 setup() 上下文误用 composable 内部 getCurrentInstance() 检查 + 警告
avatarCache / screen-cache 命名冲突 文件缓存统一放在 _doc/preload/{userId}/ 目录下,与 uni.storage 隔离(见 §6.5
utils/api.js 的 mock import 耦合bundle 体积) preload.config.js 的 fetcher 引用按需 import必要时在 vite 分包配置里隔离
JSON.stringify(params) key 顺序敏感导致命中率下降 实现里对 params key 排序后再 stringify见 §4.2 hash 约定)

10. 实施清单(落到 writing-plans 阶段再细化)

  1. frontend/utils/api.jsrequest() 返回的 Promise 上挂 .abort() 方法(~8 行改动:捕获 requestTask + 挂 abort() + fail 中识别 _aborted 标记,详见 §4.4.1
  2. frontend/utils/preloadApi/storage.jsplus.io 回调 promisify 处理,详见 §6.5
  3. frontend/utils/preloadApi/core.js
  4. frontend/utils/preloadApi/scheduler.js
  5. frontend/utils/preloadApi/navigate.js
  6. frontend/utils/preloadApi/index.js
  7. frontend/composables/usePreload.js
  8. frontend/config/preload.config.js(初始内容,含 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail
  9. frontend/App.vueOptions API 集成import + onLaunch 调用 warmStartup() + onShow 调用 warmIdle(),详见 §6.5.1
  10. frontend/store/modules/user.jsSET_USER_INFO / CLEAR_AUTH mutation patch 注入 preload 缓存失效,详见 §6.5
  11. frontend/utils/preloadApi/__tests__/(核心 + 文件缓存适配器 + navigate + composable
  12. README 文档片段(放在 frontend/utils/preloadApi/README.md,供业务开发查阅 config schema + API 速查表)