# 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 + 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` + 在生命周期方法内调用即可,**不**是 ` ``` --- ## 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(`