39 KiB
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 3:Vue 集成 | 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 类典型场景:
- 页面切换前预先拉数据:进入 A 页时按目标页 B 的清单提前拉好 B 的接口
- 应用启动时批量预热常用数据:
onLaunch期间并发拉启动清单 - 通用响应缓存层:相同
url+params在 TTL 内复用结果 - 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.io(APP-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 API):import preloadApi → onLaunch 中调用 warmStartup() → onShow 中调用 warmIdle()。注意 App.vue 使用 Options API(export 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) - 用户点击 → 立刻跳转;预拉在后台并发进行;目标页
onLoad用usePreload(key, params)时大概率命中缓存 - 绝不在
navigateTo内 await 预拉结果,否则跳转延迟与设计目标矛盾 - 不保留
uni.navigateTo兼容入口:要么用preloadApi.navigateTo(触发预拉),要么不要预拉;保留兼容会让 "哪些跳转走预拉" 变得不可见
全量替换规则:用 ESLint 规则或 PR review checklist 强制 —— 全仓 uni.navigateTo → preloadApi.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 命令式 API(utils/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
├─ 查内存 → 命中且未过期 → 同步 resolve(LRU 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(业务 401:token 失效)err.code === 16(业务 403:账号被封)err.message含 "登录已过期"(匹配utils/api.js第 86-122 行 HTTP 401 reject 时构造的 message)
关键约束:run 与 get 都必须 swallow "已登出"信号。理由:utils/api.js 第 86-122 行在检测到这些码时已经同步调用 uni.reLaunch 跳登录页,相当于"已处理";预加载层再抛错会造成双重跳转或冷启动被中断。
4.3.1 prefetchFor 的入参约定
preloadApi.prefetchFor(targetPath, params):
targetPath:目标页的完整路径,如/pages/asset-detail/asset-detailparams:只接受基本类型 key-value(string / number / boolean),由navigate.js从跳转 URL 的 query string 解析得到- 解析规则:
- 用
URLSearchParams解析 query string - 每个 value 用
decodeURIComponent解码 - 所有 value 统一为 string(fetcher 内部按需
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 并发去重
适用范围:run 与 get 共用同一套 inFlight 去重。同一个 cacheKey in-flight 时,第二个 run 或 get 共享同一个 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 清掉。
请求取消(abort):uniapp 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.request的fail回调会以err.errMsg === 'request:fail abort'触发。必须在fail中通过_aborted标记识别并静默返回,否则 preload 层会收到一个 "网络请求失败" 的 reject。- 所有现有调用方
await request(...)语法不变;abortFn仅由 preload 的core.js在组件 unmount / params 变化时调用。
4.4.1 abort 生命周期
4.5 composable API(composables/usePreload.js)
// 在 <script setup> 中:
const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })
返回:
data: Ref<any | null>:命中缓存为缓存值;未命中拉到为 fetcher 返回值;401 swallow 为 null;其他失败为上一次值或 nullloading: 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为 true;Promise 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)必须 swallow,resolve(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.js 的 run / 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 onShow →
warmIdle()触发时只对 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 命名空间) + 删指定用户的文件缓存目录 | — | 登出专用 |
核心约束:
invalidateAll()永远不动文件缓存。理由:避免把当前会话(登录态改变前)的 persistence 缓存白白清掉;文件清理必须显式按 userId 走。clearUser(userId)的userId必须等于当前登录用户。否则会清空当前用户的内存缓存(与文件删的目标用户不一致)。调用方需保证 userId 正确性;不在 API 层做防御。
6.4 数据一致性
写:先写内存 → 再异步写文件缓存(fire-and-forget,不 await)
读:先查内存 → 未命中查文件缓存 → 仍未命中才 fetch
清:组合清空(如 clearUser)时,先清内存 → 再删文件目录;仅清内存或仅删文件的 API(invalidateAll / 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) |
实施注意:
- store/user.js 当前没有事件总线(只有 action / mutation,没有 emit/on)。实施方式:包装 mutation,在
SET_USER_INFOmutation 里 patch 一下:如果旧用户存在(oldUserId != null),调用preloadApi.clearForUser(oldUserId);在CLEAR_AUTHmutation 里 patch 一下(注入preloadApi.clearUser(userId)调用)。注意:登录场景下oldUserId为 null/undefined,必须 guard 住,否则会误删_doc/preload/undefined/。 - 调用顺序约束(重要):CLEAR_AUTH 时先
clearUser(userId)再清 token / user。原因:token 清掉之后任何业务发起的请求都会 401;preload 缓存如果在 token 清之前清理完,业务不会有 401 + 缓存不一致的窗口。 - CLEAR_AUTH 内部已经清了一堆 key(
access_token / user / star_id等),与clearUser职责正交(preload 只在_doc/preload/下操作,不碰 uni.storage)。两者互不冲突,组合使用即可。 - API 选型:
#ifdef APP-PLUS主路径走plus.io(项目已有先例,useShare.js里用了plus.io.copyTo操作_doc/);#ifdef H5/ 小程序等降级用uni.getFileSystemManager(仅当 persist=file 时走文件,memory 不变)。注意:plus.ioAPI 均为回调风格,storage.js 内部需用new Promise((resolve, reject) => { plus.io.xxx(..., resolve, reject) })包装为 Promise(参照useShare.js中copyStaticToDoc的 promisify 模式)。uni.getFileSystemManager在 APP-PLUS 也可用且支持 Promise,优先使用;仅在需要递归删目录等uni.getFileSystemManager不支持的操作时走plus.io。
6.5.1 App.vue 集成方式(Options API)
App.vue 当前使用 Options API(export 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 API。import + 直接函数调用即可覆盖所有集成需求。
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(默认 4)按 scheduler 入口粒度生效:
| 入口 | 并发限制 |
|---|---|
warmStartup() |
整个 startup 数组最多 4 并发 |
warmIdle() |
整个 idle 数组最多 4 并发 |
prefetchFor(targetPath) |
单目标页内的多个 key 最多 4 并发 |
实现:scheduler.js 用一个简单的 semaphore(计数器 + 等待队列)控制并发。
7.3 idle 调度触发点与 App 端 fallback
warmIdle() 调用点:App.vue 的 onShow 中(每次从后台回前台都跑一次;首次冷启动 onLaunch 之后 onShow 也会触发,等价于"首屏渲染完后")。
首次冷启动 onLaunch→onShow 连续触发:onLaunch 里 warmStartup 跑 startup 清单,紧接着 onShow 里 warmIdle 跑 idle 清单。如果 idle 清单与 startup 清单有重叠 key,run 的"查内存 → 未过期则跳过"机制自动处理(run 看到 startup 已写入的未过期缓存直接返回,不重拉)。如果无重叠,两者并行进行,composable 在首次渲染时可能受益于 startup 的预热而 idle 还在后台跑。
去重 / 防抖:warmIdle 必须幂等——同一进程内多次调用,对已在 in-flight 的 key 直接跳过、不重发;对已在内存且未过期的 key 也跳过(靠 run 语义)。理由:用户高频切前后台会触发多次 onShow,warmIdle 不应每次都发起新请求。
App 端 fallback(#ifdef APP-PLUS):uniapp App 端没有 requestIdleCallback,scheduler 内部统一用:
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 次
- 用户切换(onSwitchUser):me.* 前缀全失效、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 失败(swallow):
console.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 阶段再细化)
frontend/utils/api.js—request()返回的 Promise 上挂.abort()方法(~8 行改动:捕获 requestTask + 挂 abort() + fail 中识别_aborted标记,详见 §4.4.1)frontend/utils/preloadApi/storage.js(plus.io 回调 promisify 处理,详见 §6.5)frontend/utils/preloadApi/core.jsfrontend/utils/preloadApi/scheduler.jsfrontend/utils/preloadApi/navigate.jsfrontend/utils/preloadApi/index.jsfrontend/composables/usePreload.jsfrontend/config/preload.config.js(初始内容,含 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail)frontend/App.vue(Options API 集成:import + onLaunch 调用 warmStartup() + onShow 调用 warmIdle(),详见 §6.5.1)frontend/store/modules/user.js(SET_USER_INFO / CLEAR_AUTH mutation patch 注入 preload 缓存失效,详见 §6.5)frontend/utils/preloadApi/__tests__/(核心 + 文件缓存适配器 + navigate + composable)- README 文档片段(放在
frontend/utils/preloadApi/README.md,供业务开发查阅 config schema + API 速查表)