714 lines
39 KiB
Markdown
714 lines
39 KiB
Markdown
# 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 类典型场景:
|
||
|
||
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 / store;fetcher 返回的 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 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
|
||
|
||
```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 命令式 API(utils/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
|
||
├─ 查内存 → 命中且未过期 → 同步 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-detail`
|
||
- `params`:**只接受基本类型 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)
|
||
|
||
调用链示例:
|
||
```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 API(composables/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` 为 true;Promise 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)必须 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)`:
|
||
|
||
```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)时,先清内存 → 再删文件目录;仅清内存或仅删文件的 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 }`。
|
||
|
||
```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 清掉之后任何业务发起的请求都会 401;preload 缓存如果在 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` 语义)。理由:用户高频切前后台会触发多次 onShow,warmIdle 不应每次都发起新请求。
|
||
|
||
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 次
|
||
- [ ] 用户切换(onSwitchUser):me.* 前缀全失效、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 速查表) |