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

714 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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并发去重 | **零外部依赖**(不依赖 Vue / uni / api.js / storefetcher 返回的 Promise 自带 `.abort()`core 仅存储调用)
| `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 APIimport `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
```js
// 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 导出)
```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
**关键约束**`run` 与 `get` 都必须 swallow "已登出"信号。理由:`utils/api.js` 第 86-122 行在检测到这些码时**已经同步调用 `uni.reLaunch` 跳登录页**,相当于"已处理";预加载层再抛错会造成双重跳转或冷启动被中断。
### 4.3.1 `prefetchFor` 的入参约定
`preloadApi.prefetchFor(targetPath, params)`
- `targetPath`:目标页的完整路径,如 `/pages/asset-detail/asset-detail`
- `params`**只接受基本类型 key-value**string / number / boolean`navigate.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
调用链示例:
```js
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 行):
```js
// 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 APIcomposables/usePreload.js
```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 的同步执行**。
**典型用法**
```vue
<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.js` 的 `run / get` 共享一个内部 helper `_swallowAuth(err)`
```js
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 命名空间) + 删指定用户的文件缓存目录 | — | **登出专用** |
**核心约束**
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 }`
```js
// 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 内部已经清了一堆 key**`access_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.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 等:
```js
// 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)`:手动失效缓存
业务页面常用组合:
```js
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 正确实现:
```js
// 命中访问时:删除再插入,重排到最新
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` 语义)。理由:用户高频切前后台会触发多次 onShowwarmIdle 不应每次都发起新请求。
App 端 fallback`#ifdef APP-PLUS`uniapp App 端**没有** `requestIdleCallback`scheduler 内部统一用:
```js
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` 时启用
```js
// 实时查看缓存命中率、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 阶段再细化)
0. `frontend/utils/api.js` `request()` 返回的 Promise 上挂 `.abort()` 方法~8 行改动捕获 requestTask + abort() + fail 中识别 `_aborted` 标记详见 §4.4.1
1. `frontend/utils/preloadApi/storage.js`plus.io 回调 promisify 处理详见 §6.5
2. `frontend/utils/preloadApi/core.js`
3. `frontend/utils/preloadApi/scheduler.js`
4. `frontend/utils/preloadApi/navigate.js`
5. `frontend/utils/preloadApi/index.js`
6. `frontend/composables/usePreload.js`
7. `frontend/config/preload.config.js`初始内容 castlove.config / me.profile / ranking.hot / asset-detail / activity-detail
8. `frontend/App.vue`Options API 集成import + onLaunch 调用 warmStartup() + onShow 调用 warmIdle()详见 §6.5.1
9. `frontend/store/modules/user.js`SET_USER_INFO / CLEAR_AUTH mutation patch 注入 preload 缓存失效详见 §6.5
10. `frontend/utils/preloadApi/__tests__/`核心 + 文件缓存适配器 + navigate + composable
11. README 文档片段放在 `frontend/utils/preloadApi/README.md`供业务开发查阅 config schema + API 速查表