topfans/docs/superpowers/plans/2026-07-08-preload-api-implementation.md

2241 lines
58 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.

# API 预加载方案 — 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现通用 API 预加载系统(内存缓存 + 文件缓存 + 启动预热 + 页面切换预拉 + Vue 3 composable
**Architecture:** 6 个核心模块 + 1 个 composable + 1 个业务配置文件。core.js 为纯 JS 缓存引擎Map + LRU + inFlight 去重storage.js 负责文件持久化(`_doc/preload/{userId}/`scheduler.js/navigate.js 负责触发时机usePreload.js 提供 Vue 响应式包装
**Tech Stack:** uniapp (Vue 3 + Vite)、plus.io (APP-PLUS)、uni.getFileSystemManager (降级)
**Spec:** `docs/superpowers/specs/2026-07-02-preload-api-design.md`
---
## File Structure
```
frontend/
├── utils/
│ ├── api.js # MODIFY: add .abort() to request()
│ └── preloadApi/
│ ├── storage.js # CREATE: file cache adapter
│ ├── core.js # CREATE: core cache engine
│ ├── config.js # CREATE: config loader
│ ├── scheduler.js # CREATE: startup/idle scheduler
│ ├── navigate.js # CREATE: wrapped navigation
│ ├── index.js # CREATE: unified export
│ ├── README.md # CREATE: developer docs
│ └── __tests__/
│ ├── core.test.js # CREATE: core unit tests
│ ├── storage.test.js # CREATE: storage unit tests
│ └── navigate.test.js # CREATE: navigate + composable tests
├── composables/
│ └── usePreload.js # CREATE: Vue 3 composable
├── config/
│ └── preload.config.js # CREATE: business config
├── store/modules/
│ └── user.js # MODIFY: patch mutations
└── App.vue # MODIFY: integrate warmStartup/warmIdle
```
---
### Task 1: Modify `utils/api.js` — add `.abort()` to `request()`
**Files:**
- Modify: `frontend/utils/api.js:55-145`
- [ ] **Step 1: Add abort support to request()**
Replace the `request()` function body (lines 55-145 of api.js). The key changes:
1. Capture `requestTask` from `uni.request()` return value
2. Add `_aborted` flag
3. Guard `fail` callback against abort-triggered errors
4. Attach `.abort()` method to the returned Promise
```js
// frontend/utils/api.js — request() 函数替换
export function request(options) {
let _aborted = false
let requestTask = null
// 构建请求头
const headers = {
'Content-Type': 'application/json',
// 风控限流spec §9.1 v2.3):设备指纹维度
'X-Device-Fingerprint': getDeviceFingerprint(),
...options.header
}
// 判断是否为登录或注册接口
const isAuthRequest = options.url.includes('/api/v1/auth/login') || options.url.includes(
'/api/v1/auth/register') || options.url.includes('/api/v1/auth/send-code') || options.url.includes('/api/v1/auth/verify-code')
// 如果不是登录/注册接口则自动添加JWT token
if (!isAuthRequest) {
const token = uni.getStorageSync('access_token')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
const p = new Promise((resolve, reject) => {
requestTask = uni.request({
url: baseURL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: headers,
timeout: 60000,
success: (res) => {
// 处理 token 过期HTTP 401
if (res.statusCode === 401) {
uni.removeStorageSync('access_token')
uni.removeStorageSync('user')
uni.reLaunch({ url: '/pages/login/portal' })
reject(new Error('登录已过期,请重新登录'))
return
}
if (res.statusCode === 200 || res.statusCode === 202) {
if (res.data && res.data.code !== undefined) {
if (res.data.code === 0) {
resolve(res.data)
} else if (res.data.code === 16 || res.data.code === 7) {
uni.removeStorageSync('access_token')
uni.removeStorageSync('user')
const errorMsg = res.data.message || '登录已过期,请重新登录'
uni.reLaunch({
url: '/pages/login/portal?error=' + encodeURIComponent(errorMsg)
})
const authErr = new Error(errorMsg)
authErr.code = res.data.code
reject(authErr)
return
} else {
const bizErr = new Error(res.data.message || '请求失败')
bizErr.code = res.data.code
reject(bizErr)
}
} else {
resolve(res.data)
}
} else {
const errorMessage = res.data?.message || `请求失败 (${res.statusCode})`
const httpErr = new Error(errorMessage)
if (res.data?.code !== undefined) httpErr.code = res.data.code
reject(httpErr)
}
},
fail: (err) => {
// ★ 新增abort 触发的 fail 静默忽略
if (_aborted) return
reject(new Error(err.errMsg || '网络请求失败'))
}
})
})
// ★ 新增:挂载 abort 方法
p.abort = () => {
_aborted = true
if (requestTask) requestTask.abort()
}
return p
}
```
- [ ] **Step 2: Verify api.js works correctly**
Run the existing app to confirm no regressions:
```bash
# In HBuilderX: 运行 → 运行到手机或模拟器 → 选择设备
# Verify: login, navigate between pages, API calls still work
# No console errors related to request()
```
---
### Task 2: Create `utils/preloadApi/storage.js` — file cache adapter
**Files:**
- Create: `frontend/utils/preloadApi/storage.js`
- [ ] **Step 1: Create storage.js**
```js
// frontend/utils/preloadApi/storage.js
// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
// APP-PLUS: 优先 plus.iopromisify降级 uni.getFileSystemManager
// H5/小程序: uni.getFileSystemManager
const BASE_DIR = '_doc/preload'
const NAMESPACE = 'preload'
// ── djb2 hash与 core.js 共用逻辑,此处独立一份避免循环依赖)──
function hashStr(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
}
return (hash >>> 0).toString(16)
}
// ── 路径工具 ──
function getUserDir(userId) {
return `${BASE_DIR}/${userId || 'guest'}`
}
function getFilePath(userId, cacheKey) {
return `${getUserDir(userId)}/${hashStr(cacheKey)}.json`
}
// ── plus.io promisify 工具 ──
function promisifyPlusIO(fn) {
return new Promise((resolve, reject) => {
try {
fn(resolve, reject)
} catch (e) {
reject(e)
}
})
}
// ── 确保目录存在 ──
async function ensureDir(dirPath) {
// #ifdef APP-PLUS
return promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
`_doc/`,
(docEntry) => {
// 逐级创建 preload/{userId}
const parts = dirPath.replace('_doc/', '').split('/')
let currentEntry = docEntry
const createNext = (idx) => {
if (idx >= parts.length) return resolve()
currentEntry.getDirectory(
parts[idx],
{ create: true },
(dirEntry) => {
currentEntry = dirEntry
createNext(idx + 1)
},
(err) => reject(err)
)
}
createNext(0)
},
(err) => reject(err)
)
})
// #endif
// #ifndef APP-PLUS
try {
const fs = uni.getFileSystemManager()
// uni.getFileSystemManager 的 mkdir 需要父目录已存在,逐级创建
const parts = dirPath.replace('_doc/', '').split('/')
let current = '_doc'
for (const part of parts) {
current += '/' + part
try { fs.accessSync(current) } catch (e) { fs.mkdirSync(current) }
}
} catch (e) {
// 目录已存在或创建失败,静默
}
// #endif
}
// ── 公共 API ──
/**
* 读文件缓存条目
* @returns {Promise<{data, ts, ttl}|null>} null = 未命中
*/
export async function readEntry(userId, cacheKey) {
const filePath = getFilePath(userId, cacheKey)
try {
// #ifdef APP-PLUS
const content = await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(fileEntry) => {
fileEntry.file(
(file) => {
const reader = new plus.io.FileReader()
reader.onloadend = (e) => resolve(e.target.result)
reader.onerror = (e) => reject(e)
reader.readAsText(file, 'utf-8')
},
(err) => reject(err)
)
},
(err) => reject(err) // 文件不存在 = 未命中
)
})
return JSON.parse(content)
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
const raw = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(raw)
// #endif
} catch (e) {
return null // 文件不存在 / 损坏 → 未命中
}
}
/**
* 写文件缓存条目fire-and-forget调用方不 await
* 内部自建 .catch 防止 unhandled rejection
*/
export function writeEntry(userId, cacheKey, data, ts, ttl) {
const dirPath = getUserDir(userId)
const filePath = getFilePath(userId, cacheKey)
const content = JSON.stringify({ data, ts, ttl })
ensureDir(dirPath).then(() => {
// #ifdef APP-PLUS
return promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
dirEntry.getFile(
hashStr(cacheKey) + '.json',
{ create: true },
(fileEntry) => {
fileEntry.createWriter(
(writer) => {
writer.onwriteend = () => resolve()
writer.onerror = (e) => reject(e)
writer.write(content)
},
(err) => reject(err)
)
},
(err) => reject(err)
)
},
(err) => reject(err)
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
fs.writeFileSync(filePath, content, 'utf-8')
// #endif
}).catch((err) => {
console.warn('[preload] storage write failed:', filePath, err.message)
})
}
/**
* 删除指定用户的文件缓存目录
*/
export async function clearForUser(userId) {
const dirPath = getUserDir(userId)
try {
// #ifdef APP-PLUS
await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
dirEntry.removeRecursively(
() => resolve(),
(err) => reject(err)
)
},
// 目录不存在不算错误
() => resolve()
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try { fs.rmdirSync(dirPath, true) } catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
console.warn('[preload] clearForUser failed:', userId, e.message)
}
}
/**
* 获取用户缓存目录总大小(字节)
* 用于 FIFO 容量检查
* 注意:累加所有文件的 file.size异步回调全部完成后才 resolve
*/
export async function getTotalCacheSize(userId) {
const dirPath = getUserDir(userId)
let totalSize = 0
try {
// #ifdef APP-PLUS
await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
const reader = dirEntry.createReader()
let pending = 0
let done = false
const readAll = () => {
reader.readEntries(
(entries) => {
if (entries.length === 0) {
done = true
if (pending === 0) resolve()
return
}
for (const entry of entries) {
if (entry.isFile) {
pending++
entry.file(
(f) => {
totalSize += (f.size || 0)
pending--
if (done && pending === 0) resolve()
},
() => {
pending--
if (done && pending === 0) resolve()
}
)
}
}
readAll() // 递归读下一批
},
(err) => reject(err)
)
}
readAll()
},
() => resolve() // 目录不存在 → size = 0
)
})
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try {
const files = fs.readdirSync(dirPath)
for (const f of files) {
try {
const stat = fs.statSync(dirPath + '/' + f)
totalSize += stat.size || 0
} catch (e) { /* skip */ }
}
} catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
// ignore
}
return totalSize
}
/**
* FIFO 淘汰最旧文件,直到总大小 < maxSize 字节
* APP-PLUS递归 readEntries 收集所有文件 → 按 mtime 排序 → 从最旧的开始删除
* 非 APP-PLUSreaddir + stat → 按 mtime 排序 → 删除最旧的
* 注getTotalCacheSize 的异步回调方式不适用于"删除后重算"循环,
* 此处改为一次 scan 出文件列表 → 排序 → 按需删除
*/
export async function evictOldest(userId, maxSize) {
const dirPath = getUserDir(userId)
try {
// #ifdef APP-PLUS
// 1. 收集所有文件及其 mtime 和 size
const files = await promisifyPlusIO((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
dirPath,
(dirEntry) => {
const reader = dirEntry.createReader()
const collected = []
let pending = 0
let done = false
const readAll = () => {
reader.readEntries(
(entries) => {
if (entries.length === 0) {
done = true
if (pending === 0) resolve(collected)
return
}
for (const entry of entries) {
if (entry.isFile) {
pending++
entry.file(
(f) => {
// plus.io File 的 modificationTime 或直接用 lastModified
const mtime = f.lastModified || f.lastModifiedDate?.getTime?.() || 0
collected.push({ name: entry.name, entry, size: f.size || 0, mtime })
pending--
if (done && pending === 0) resolve(collected)
},
() => {
pending--
if (done && pending === 0) resolve(collected)
}
)
}
}
readAll()
},
(err) => reject(err)
)
}
readAll()
},
() => resolve([]) // 目录不存在 → 空列表
)
})
// 2. 按 mtime 升序排列(最旧的在前)
files.sort((a, b) => a.mtime - b.mtime)
// 3. 计算当前总大小,按 FIFO 删除直到 < maxSize * 0.8
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
for (const f of files) {
if (totalSize <= maxSize * 0.8) break
f.entry.remove(() => {}, () => {})
totalSize -= f.size
}
// #endif
// #ifndef APP-PLUS
const fs = uni.getFileSystemManager()
try {
const fileNames = fs.readdirSync(dirPath)
const files = fileNames.map(name => {
try {
const stat = fs.statSync(dirPath + '/' + name)
return { name, size: stat.size || 0, mtime: stat.lastModified || stat.lastModifiedTime || 0 }
} catch (e) {
return { name, size: 0, mtime: 0 }
}
})
// 按 mtime 升序(最旧的在前)
files.sort((a, b) => a.mtime - b.mtime)
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
for (const f of files) {
if (totalSize <= maxSize * 0.8) break
try { fs.unlinkSync(dirPath + '/' + f.name) } catch (e) { /* skip */ }
totalSize -= f.size
}
} catch (e) { /* absent = ok */ }
// #endif
} catch (e) {
console.warn('[preload] evictOldest failed:', userId, e.message)
}
}
```
- [ ] **Step 2: Verify storage.js syntax**
```bash
cd frontend
# No build errors expected (the file uses #ifdef blocks, valid in uniapp)
```
---
### Task 3: Create `utils/preloadApi/core.js` — core cache engine
**Files:**
- Create: `frontend/utils/preloadApi/core.js`
- [ ] **Step 1: Create core.js**
```js
// frontend/utils/preloadApi/core.js
// 核心缓存引擎 — 零外部依赖(不依赖 Vue / uni API / api.js / store
// 内存 Map + LRU + inFlight 去重 + TTL
// 依赖关系内模块import { readEntry, writeEntry, clearForUser as storageClearForUser, getTotalCacheSize, evictOldest } from './storage'
import {
readEntry,
writeEntry,
clearForUser as storageClearForUser,
getTotalCacheSize,
evictOldest
} from './storage'
// ── 常量 ──
const NAMESPACE = 'preload'
const DEFAULT_MAX_MEMORY_ENTRIES = 100
const DEFAULT_MAX_ENTRY_SIZE_KB = 1024
const DEFAULT_MAX_FILE_CACHE_MB = 50
// ── 内部状态 ──
const memoryMap = new Map() // Map<cacheKey, {data, ts, ttl, persistence}>
const inFlightMap = new Map() // Map<cacheKey, {promise, abort}>
// 统计
let hitCount = 0
let missCount = 0
// 运行时配置(由外部 setConfig 写入)
let _config = {
defaults: {
ttl: 5 * 60 * 1000,
persistence: 'memory',
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: DEFAULT_MAX_ENTRY_SIZE_KB,
maxMemoryEntries: DEFAULT_MAX_MEMORY_ENTRIES,
maxFileCacheMB: DEFAULT_MAX_FILE_CACHE_MB
}
}
}
// fetcher 注册表:{ [logicalKey]: fetcherFunction }
let _fetchers = {}
// userId 获取函数(由外部注入)
let _getUserId = () => {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
const user = JSON.parse(userStr)
return user?.uid || null
}
} catch (e) { /* ignore */ }
return null
}
// ── 并发控制 semaphore ──
function createSemaphore(max) {
let running = 0
const queue = []
return {
acquire: () => new Promise(resolve => {
if (running < max) { running++; resolve() }
else { queue.push(resolve) }
}),
release: () => {
running--
const next = queue.shift()
if (next) { running++; next() }
}
}
}
let _semaphore = createSemaphore(_config.defaults.concurrency)
// ── hash 工具 ──
function djb2(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
}
return (hash >>> 0).toString(16)
}
function hashParams(params) {
if (!params || Object.keys(params).length === 0) return ''
const sorted = {}
Object.keys(params).sort().forEach(k => { sorted[k] = params[k] })
return djb2(JSON.stringify(sorted))
}
function buildCacheKey(userId, logicalKey, params) {
const uid = userId || 'guest'
const paramHash = hashParams(params)
return `${uid}::${NAMESPACE}::${logicalKey}::${paramHash}`
}
// ── LRU touch ──
function touchLRU(map, key, value) {
if (map.has(key)) map.delete(key)
map.set(key, value)
if (map.size > _config.defaults.limits.maxMemoryEntries) {
const oldestKey = map.keys().next().value
map.delete(oldestKey)
if (typeof console !== 'undefined') {
console.log('[preload] LRU evict:', oldestKey)
}
}
}
// ── 401/7/16 swallow ──
function _swallowAuth(err) {
if (err && (err.code === 7 || err.code === 16)) return null
if (err && /登录已过期/.test(err.message || '')) return null
return err
}
// ── 数据大小检查 ──
function getDataSizeKB(data) {
try {
return new Blob([JSON.stringify(data)]).size / 1024
} catch (e) {
return JSON.stringify(data || '').length / 1024
}
}
// ── 配置 API ──
/**
* 设置运行时配置 + fetcher 注册表
* 由 config.js 在初始化时调用
*/
export function setConfig(config, fetchers) {
if (config) {
_config = {
defaults: {
..._config.defaults,
...(config.defaults || {}),
limits: {
..._config.defaults.limits,
...((config.defaults && config.defaults.limits) || {})
}
},
startup: config.startup || _config.startup || [],
idle: config.idle || _config.idle || [],
pages: config.pages || _config.pages || {}
}
_semaphore = createSemaphore(_config.defaults.concurrency)
}
if (fetchers) {
_fetchers = { ..._fetchers, ...fetchers }
}
}
/**
* 设置 userId 获取函数(用于测试注入)
*/
export function setUserIdGetter(fn) {
if (typeof fn === 'function') _getUserId = fn
}
// ── 核心 API ──
/**
* 获取缓存值(组件使用)
* 命中内存 → 同步 resolve文件缓存命中 / fetch → async resolve
*/
export function get(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
touchLRU(memoryMap, cacheKey, memEntry)
hitCount++
return Promise.resolve(memEntry.data)
}
// 2. inFlight 去重
const inFlight = inFlightMap.get(cacheKey)
if (inFlight) {
return inFlight.promise.then(result => result.data)
}
// 3. 发起 fetch含文件缓存回退
return _doFetch(logicalKey, params, cacheKey, userId, false)
}
/**
* 触发预拉fire-and-forget
* 查内存 → inFlight → 发起 fetch
*/
export function run(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
return // 未过期,跳过
}
// 2. inFlight 去重
if (inFlightMap.has(cacheKey)) {
return // 已在请求中
}
// 3. 发起 fetchfire-and-forget不返回 Promise
_doFetch(logicalKey, params, cacheKey, userId, true)
}
/**
* 命令式刷新
*/
export function refresh(logicalKey, params, force = false) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// force=true 时先删除内存缓存
if (force) {
memoryMap.delete(cacheKey)
}
return _doFetch(logicalKey, params, cacheKey, userId, false)
}
/**
* 失效单个 key仅内存
*/
export function invalidate(logicalKey, params) {
const cacheKey = buildCacheKey(_getUserId(), logicalKey, params)
memoryMap.delete(cacheKey)
}
/**
* 按前缀失效(仅内存)
*/
export function invalidatePrefix(prefix) {
const fullPrefix = `${_getUserId() || 'guest'}::${NAMESPACE}::${prefix}`
for (const key of memoryMap.keys()) {
if (key.startsWith(fullPrefix)) {
memoryMap.delete(key)
}
}
}
/**
* 清空全部内存缓存(不动文件缓存)
*/
export function invalidateAll() {
memoryMap.clear()
}
/**
* 删除指定用户的文件缓存目录(不动内存)
*/
export function clearForUser(userId) {
storageClearForUser(userId)
}
/**
* 登出专用:清空内存 + 删除文件缓存目录
*/
export function clearUser(userId) {
memoryMap.clear()
storageClearForUser(userId)
}
/**
* 按目标页路径触发预拉navigate.js 内部调用)
*/
export function prefetchFor(targetPath, params) {
const pages = _config.pages || {}
const entries = pages[targetPath]
if (!entries || !Array.isArray(entries)) return
for (const entry of entries) {
run(entry.key, params)
}
}
/**
* 取消指定 key 的 in-flight 请求(供 composable unmount / params 变化时使用)
*/
export function abortRequest(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
const entry = inFlightMap.get(cacheKey)
if (entry) {
entry.abort()
inFlightMap.delete(cacheKey)
}
}
/**
* 获取调试统计
*/
export function getStats() {
return {
hits: hitCount,
misses: missCount,
memorySize: memoryMap.size,
inFlightSize: inFlightMap.size,
fileCacheBytes: _lastFileCacheSize
}
}
// 上次文件缓存大小(由 checkAndEvict 异步更新)
let _lastFileCacheSize = 0
// 内部:更新文件缓存大小跟踪
function _updateFileCacheSize() {
const userId = _getUserId()
if (userId) {
getTotalCacheSize(userId).then(size => {
_lastFileCacheSize = size
}).catch(() => {})
}
}
/**
* dump 内存缓存(调试用)
*/
export function dumpMemory() {
const result = []
for (const [key, entry] of memoryMap.entries()) {
result.push({
key,
age: Date.now() - entry.ts,
ttl: entry.ttl,
persistence: entry.persistence
})
}
return result
}
// ── 内部:执行 fetch ──
async function _doFetch(logicalKey, params, cacheKey, userId, isRun) {
const fetcher = _fetchers[logicalKey]
if (!fetcher) {
if (!isRun) throw new Error(`[preload] unknown key: ${logicalKey}`)
console.warn(`[preload] unknown key: ${logicalKey}`)
return
}
const cfg = _resolveEntryConfig(logicalKey)
const ttl = cfg.ttl || _config.defaults.ttl
const persistence = cfg.persistence || _config.defaults.persistence
const silent = cfg.silent !== undefined ? cfg.silent : _config.defaults.silent
const timeout = cfg.timeout || _config.defaults.timeout
// 3. 先查本地文件缓存(非 run 路径)
if (!isRun && persistence === 'file') {
try {
const fileEntry = await readEntry(userId, cacheKey)
if (fileEntry && (Date.now() - fileEntry.ts) < fileEntry.ttl) {
// 文件命中 → 写回内存
touchLRU(memoryMap, cacheKey, {
data: fileEntry.data,
ts: fileEntry.ts,
ttl: fileEntry.ttl,
persistence: 'file'
})
hitCount++
return fileEntry.data
}
} catch (e) {
// 文件读取失败 → 走 fetch
}
}
// 4. 创建 inFlight 条目
let resolveInFlight, rejectInFlight
const sharedPromise = new Promise((res, rej) => {
resolveInFlight = res
rejectInFlight = rej
})
let abortFn = () => {}
const inFlightEntry = {
promise: sharedPromise.then(data => ({ data })),
abort: () => abortFn()
}
inFlightMap.set(cacheKey, inFlightEntry)
const cleanup = () => {
inFlightMap.delete(cacheKey)
}
// 5. 并发控制 + 超时
await _semaphore.acquire()
try {
const startTime = Date.now()
const fetchPromise = fetcher(params)
// 设置 abort
abortFn = () => {
if (fetchPromise && typeof fetchPromise.abort === 'function') {
fetchPromise.abort()
}
cleanup()
}
// 超时控制
let timeoutId
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('timeout')), timeout)
})
const result = await Promise.race([fetchPromise, timeoutPromise])
clearTimeout(timeoutId)
const elapsed = Date.now() - startTime
console.log('[preload] fetch done:', logicalKey, elapsed + 'ms')
// 6. 写内存缓存
const entry = { data: result, ts: Date.now(), ttl, persistence }
touchLRU(memoryMap, cacheKey, entry)
// 7. 异步写文件缓存fire-and-forget不阻塞 fetch 返回)
if (persistence === 'file') {
const sizeKB = getDataSizeKB(result)
if (sizeKB <= _config.defaults.limits.maxEntrySizeKB) {
writeEntry(userId, cacheKey, result, entry.ts, ttl)
// fire-and-forget 容量检查:延迟到下一 tick 确保 writeEntry 已启动
setTimeout(() => {
checkAndEvict(userId)
}, 0)
}
}
missCount++
resolveInFlight(result)
return result
} catch (err) {
// 8. 错误处理
const swallowed = _swallowAuth(err)
if (swallowed === null) {
// 401/7/16 → swallow
console.warn('[preload] auth-expired, swallowed:', logicalKey)
resolveInFlight(null)
return null
}
if (silent || isRun) {
// run / silent → 静默
console.warn('[preload] fetch fail (swallowed):', logicalKey, err.message)
resolveInFlight(null)
return null
}
// get 路径 → 抛错给调用方
rejectInFlight(err)
throw err
} finally {
cleanup()
_semaphore.release()
}
}
// ── 内部fire-and-forget 容量检查 + 淘汰 ──
async function checkAndEvict(userId) {
try {
const totalSize = await getTotalCacheSize(userId)
_lastFileCacheSize = totalSize
const maxBytes = _config.defaults.limits.maxFileCacheMB * 1024 * 1024
if (totalSize > maxBytes) {
await evictOldest(userId, maxBytes)
// 淘汰后更新大小
const newSize = await getTotalCacheSize(userId)
_lastFileCacheSize = newSize
}
} catch (e) {
console.warn('[preload] eviction check failed:', e.message)
}
}
// ── 内部:解析 per-key 配置 ──
function _resolveEntryConfig(logicalKey) {
// 从 _config 中查找该 key 的配置startup/idle/pages 任一数组)
const all = [
...(_config.startup || []),
...(_config.idle || []),
]
if (_config.pages) {
for (const entries of Object.values(_config.pages)) {
if (Array.isArray(entries)) all.push(...entries)
}
}
const found = all.find(e => e.key === logicalKey)
return found || {}
}
// ── 开发调试 ──
if (typeof window !== 'undefined' && (typeof import.meta === 'undefined' || import.meta.env?.DEV)) {
window.__PRELOAD_DEBUG__ = {
dumpMemory,
stats: getStats,
all: () => ({
memory: dumpMemory(),
inFlight: Array.from(inFlightMap.keys())
})
}
}
```
- [ ] **Step 2: Verify core.js syntax**
```bash
cd frontend
# No syntax errors expected
```
---
### Task 4: Create `utils/preloadApi/config.js` — config loader
**Files:**
- Create: `frontend/utils/preloadApi/config.js`
- [ ] **Step 1: Create config.js**
```js
// frontend/utils/preloadApi/config.js
// 配置加载器:合并用户配置与内置默认值,提取 fetcher 映射表
/**
* 内置默认值(与设计文档 §3 defaults 一致)
*/
const BUILTIN_DEFAULTS = {
ttl: 5 * 60 * 1000,
persistence: 'memory',
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: 1024,
maxMemoryEntries: 100,
maxFileCacheMB: 50
}
}
/**
* 加载并合并配置
* @param {object} userConfig - 用户提供的 preload.config.js export
* @returns {{ config: object, fetchers: object }}
*/
export function loadConfig(userConfig) {
if (!userConfig) {
throw new Error('[preload] config is required')
}
// 合并 defaults
const mergedDefaults = {
...BUILTIN_DEFAULTS,
...(userConfig.defaults || {}),
limits: {
...BUILTIN_DEFAULTS.limits,
...((userConfig.defaults && userConfig.defaults.limits) || {})
}
}
const config = {
defaults: mergedDefaults,
startup: userConfig.startup || [],
idle: userConfig.idle || [],
pages: userConfig.pages || {}
}
// 提取 fetcher 映射表
const fetchers = {}
const allEntries = [
...(config.startup || []),
...(config.idle || [])
]
for (const entries of Object.values(config.pages || {})) {
if (Array.isArray(entries)) allEntries.push(...entries)
}
for (const entry of allEntries) {
if (entry.key && typeof entry.fetcher === 'function') {
fetchers[entry.key] = entry.fetcher
}
}
return { config, fetchers }
}
```
---
### Task 5: Create `utils/preloadApi/scheduler.js` — startup/idle scheduler
**Files:**
- Create: `frontend/utils/preloadApi/scheduler.js`
- [ ] **Step 1: Create scheduler.js**
```js
// frontend/utils/preloadApi/scheduler.js
// 调度器:启动期预热 + idle 预拉
// 依赖 core.js 的 run()
import { run } from './core'
// App 端 fallback没有 requestIdleCallback用 setTimeout
const idle =
typeof requestIdleCallback === 'function'
? requestIdleCallback
: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
/**
* 启动期预热
* @param {Array} startupList - config.startup 数组
*/
export function warmStartup(startupList) {
if (!startupList || !Array.isArray(startupList)) return
console.log('[preload] warmStartup:', startupList.length, 'keys')
for (const entry of startupList) {
// fire-and-forget不 await并发由 core 内部 semaphore 控制
run(entry.key, entry.params)
}
}
/**
* idle 预拉幂等run 内部处理去重和缓存命中)
* @param {Array} idleList - config.idle 数组
*/
export function warmIdle(idleList) {
if (!idleList || !Array.isArray(idleList)) return
idle(() => {
console.log('[preload] warmIdle:', idleList.length, 'keys')
for (const entry of idleList) {
run(entry.key, entry.params)
}
})
}
```
---
### Task 6: Create `utils/preloadApi/navigate.js` — wrapped navigation
**Files:**
- Create: `frontend/utils/preloadApi/navigate.js`
- [ ] **Step 1: Create navigate.js**
```js
// frontend/utils/preloadApi/navigate.js
// 包装 uni.navigateTo / switchTab / reLaunch
// 跳转前 fire-and-forget 预拉目标页数据,不 await
import { prefetchFor } from './core'
/**
* 从 URL 中解析 query string → params 对象
* 例:'/pages/foo/bar?id=123&type=hot' → { id: '123', type: 'hot' }
*/
function parseQueryParams(url) {
const idx = url.indexOf('?')
if (idx === -1) return {}
const qs = url.substring(idx + 1)
const params = {}
// 使用 URLSearchParamsuniapp 环境支持)
try {
const usp = new URLSearchParams(qs)
for (const [k, v] of usp) {
// URLSearchParams 已自动解码,不需要再 decodeURIComponent
params[k] = v
}
} catch (e) {
// fallback手动解析
for (const pair of qs.split('&')) {
const eqIdx = pair.indexOf('=')
if (eqIdx === -1) continue
const k = decodeURIComponent(pair.substring(0, eqIdx))
const v = decodeURIComponent(pair.substring(eqIdx + 1))
if (k) params[k] = v
}
}
return params
}
/**
* 从 URL 中提取目标页路径(去掉 query string
*/
function extractPath(url) {
const idx = url.indexOf('?')
return idx === -1 ? url : url.substring(0, idx)
}
/**
* 替代 uni.navigateTo
* 内部:解析目标页 → 触发预拉fire-and-forget→ 立即跳转
*/
export function navigateTo(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
// 触发预拉fire-and-forget不 await
prefetchFor(targetPath, params)
// 立即跳转
if (typeof opts === 'string') {
uni.navigateTo({ url: opts })
} else {
uni.navigateTo(opts)
}
}
/**
* 替代 uni.switchTab
*/
export function switchTab(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
prefetchFor(targetPath, params)
if (typeof opts === 'string') {
uni.switchTab({ url: opts })
} else {
uni.switchTab(opts)
}
}
/**
* 替代 uni.reLaunch
*/
export function reLaunch(opts) {
const url = typeof opts === 'string' ? opts : opts.url
const targetPath = extractPath(url)
const params = parseQueryParams(url)
prefetchFor(targetPath, params)
if (typeof opts === 'string') {
uni.reLaunch({ url: opts })
} else {
uni.reLaunch(opts)
}
}
```
---
### Task 7: Create `utils/preloadApi/index.js` — unified export
**Files:**
- Create: `frontend/utils/preloadApi/index.js`
- [ ] **Step 1: Create index.js**
```js
// frontend/utils/preloadApi/index.js
// 统一导出 preloadApi命令式 API
import { loadConfig } from './config'
import { setConfig, setUserIdGetter } from './core'
import {
get,
run,
refresh,
abortRequest,
invalidate,
invalidatePrefix,
invalidateAll,
clearForUser,
clearUser,
prefetchFor,
getStats,
dumpMemory
} from './core'
import { warmStartup, warmIdle } from './scheduler'
import { navigateTo, switchTab, reLaunch } from './navigate'
/**
* 初始化 preloadApi
* @param {object} userConfig - preload.config.js 导出的配置
* @returns {object} preloadApi 实例
*/
export function initPreloadApi(userConfig) {
const { config, fetchers } = loadConfig(userConfig)
setConfig(config, fetchers)
return {
// 核心 API
get,
run,
refresh,
abortRequest,
invalidate,
invalidatePrefix,
invalidateAll,
clearForUser,
clearUser,
prefetchFor,
// 调度
warmStartup: () => warmStartup(config.startup),
warmIdle: () => warmIdle(config.idle),
// 路由
navigateTo,
switchTab,
reLaunch,
// 调试
getStats,
dumpMemory,
// 配置引用
config
}
}
// 默认单例(由 App.vue 初始化)
let _instance = null
export function getPreloadApi() {
return _instance
}
export function setPreloadApi(api) {
_instance = api
}
export { setUserIdGetter }
```
---
### Task 8: Create `composables/usePreload.js` — Vue 3 composable
**Files:**
- Create: `frontend/composables/usePreload.js`
- [ ] **Step 1: Create usePreload.js**
```js
// frontend/composables/usePreload.js
// Vue 3 组合式 API包装 core.get(),暴露响应式 state { data, loading, error, refresh }
import { ref, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
import { get, refresh as coreRefresh, abortRequest } from '@/utils/preloadApi/core'
/**
* @param {string|Ref<string>} key - 逻辑 key
* @param {object|Ref<object>} [params] - 请求参数
* @returns {{ data: Ref, loading: Ref, error: Ref, refresh: Function }}
*
* @example
* const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })
* // With reactive params:
* const { data, loading, error } = usePreload('asset.detail', () => ({ id: route.params.id }))
*/
export function usePreload(key, params) {
// 校验上下文
if (!getCurrentInstance()) {
console.warn('[preload] usePreload must be called in setup()')
}
const data = ref(null)
const loading = ref(true)
const error = ref(null)
let mounted = true
let fetchVersion = 0
/**
* 执行获取(不阻塞 setup
* @param {boolean} [force=false] - 跳过 TTL 缓存
*/
function doFetch(force = false) {
// 先清理上一轮 in-flight 请求
abortRequest(key, params)
const version = ++fetchVersion
loading.value = true
error.value = null
// 用 .then() 异步更新 data不阻塞 setup
const promise = force
? coreRefresh(key, params, true)
: get(key, params)
promise
.then((result) => {
if (!mounted || version !== fetchVersion) return
data.value = result
loading.value = false
})
.catch((err) => {
if (!mounted || version !== fetchVersion) return
error.value = err
loading.value = false
})
return promise
}
// 监听 key/params 变化(当传入 ref 或 computed 时)
// 注toRef/toValue 在 uni-app Vue 3 中可用
const resolvedParams = typeof params === 'function' ? params : () => params
watch(
[key, resolvedParams],
() => {
if (mounted) doFetch()
},
{ deep: true }
)
// 初始加载
doFetch()
// 组件卸载时清理
onBeforeUnmount(() => {
mounted = false
abortRequest(key, params)
})
/**
* 手动刷新
* @param {boolean} [force=false] - 跳过 TTL
*/
function refresh(force = false) {
return doFetch(force)
}
return { data, loading, error, refresh }
}
```
---
### Task 9: Create `config/preload.config.js` — business config
**Files:**
- Create: `frontend/config/preload.config.js`
- [ ] **Step 1: Create preload.config.js**
```js
// frontend/config/preload.config.js
// API 预加载业务配置
// 声明每个预拉 key 的fetcher / ttl / persistence / 触发时机
import {
getCastloveConfigApi,
getUserProfileApi,
getHotRankingApi,
getAssetLikersApi,
getActivityDetailApi,
getActivityItemsApi
} from '@/utils/api'
export const preloadConfig = {
// ── 全局默认 ──
defaults: {
ttl: 5 * 60 * 1000,
persistence: 'memory',
concurrency: 4,
timeout: 10000,
silent: true,
limits: {
maxEntrySizeKB: 1024,
maxMemoryEntries: 100,
maxFileCacheMB: 50
}
},
// ── 启动期预热清单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)
}
]
}
}
```
---
### Task 10: Modify `App.vue` — integrate preloadApi
**Files:**
- Modify: `frontend/App.vue:1-20` (import section) + `onLaunch` + `onShow`
- [ ] **Step 1: Add preloadApi import and initialization**
Insert at the top of `<script>` (after line 4, before line 5):
```js
// 在 App.vue <script> 顶部 import 区域追加
import { initPreloadApi, setPreloadApi } from '@/utils/preloadApi/index'
import { preloadConfig } from '@/config/preload.config'
```
- [ ] **Step 2: Initialize in onLaunch**
Add to `onLaunch` function body (before its closing `}`):
```js
// ★ 新增:初始化预加载 API
try {
const api = initPreloadApi(preloadConfig)
setPreloadApi(api)
api.warmStartup()
} catch (e) {
console.warn('[preload] init failed:', e.message)
}
```
- [ ] **Step 3: Add warmIdle in onShow**
Add to `onShow` function body (after `this.handleBackgroundReturn()`, before `this.initWebSocket()`):
```js
// ★ 新增idle 预拉
try {
const preloadApi = getPreloadApi()
if (preloadApi) preloadApi.warmIdle()
} catch (e) {
// 静默
}
```
Note: `getPreloadApi` needs to be imported alongside `initPreloadApi`.
---
### Task 11: Modify `store/modules/user.js` — cache invalidation patches
**Files:**
- Modify: `frontend/store/modules/user.js:36-54` (SET_USER_INFO) + `:55-94` (CLEAR_AUTH)
- [ ] **Step 1: Add import at top**
```js
// 在 store/modules/user.js 顶部 import 区域追加
import { getPreloadApi } from '@/utils/preloadApi/index'
```
- [ ] **Step 2: Patch SET_USER_INFO mutation**
In the `SET_USER_INFO` mutation, add before setting `state.userInfo`:
```js
SET_USER_INFO(state, userInfo) {
// ★ 新增:旧用户存在时清理文件缓存
if (userInfo) {
const oldUserStr = uni.getStorageSync('user')
let oldUserId = null
if (oldUserStr) {
try {
const oldUser = JSON.parse(oldUserStr)
oldUserId = oldUser?.uid || null
} catch (e) { /* ignore */ }
}
const newUserId = userInfo?.uid || null
// 切换用户时清理旧用户文件缓存
if (oldUserId && newUserId && oldUserId !== newUserId) {
try {
const api = getPreloadApi()
if (api) {
api.invalidatePrefix('me.')
api.clearForUser(oldUserId)
}
} catch (e) { /* 静默 */ }
}
}
state.userInfo = userInfo
// ... 原有逻辑不变 ...
}
```
- [ ] **Step 3: Patch CLEAR_AUTH mutation**
**IMPORTANT**: The `clearUser` call must happen **before** token/user are cleared (see design doc §6.5 constraint #2).
```js
CLEAR_AUTH(state) {
// ★ 新增:先清理 preload 缓存(在清 token 之前)
const userStr = uni.getStorageSync('user')
let userId = null
if (userStr) {
try {
const user = JSON.parse(userStr)
userId = user?.uid || null
} catch (e) { /* ignore */ }
}
if (userId) {
try {
const api = getPreloadApi()
if (api) api.clearUser(userId)
} catch (e) { /* 静默 */ }
}
state.token = ''
state.userInfo = null
state.starId = null
// ... 原有 CLEAR_AUTH 逻辑不变resetAllGuides, removeStorageSync 等)...
}
```
---
### Task 12: Write unit tests — `core.test.js`
**Files:**
- Create: `frontend/utils/preloadApi/__tests__/core.test.js`
- [ ] **Step 1: Create core unit tests**
```js
// frontend/utils/preloadApi/__tests__/core.test.js
// 核心引擎单元测试(不依赖 uni / plus.io纯 mock
import {
setConfig, setUserIdGetter,
run, get, refresh, invalidate, invalidatePrefix, invalidateAll,
clearUser, getStats, dumpMemory
} from '../core'
// ── 测试辅助 ──
let mockFetcherCalls = 0
let mockFetcherResult = { data: 'mock' }
let mockFetcherError = null
function mockFetcher(params) {
mockFetcherCalls++
if (mockFetcherError) return Promise.reject(mockFetcherError)
return Promise.resolve(mockFetcherResult)
}
function resetMocks() {
mockFetcherCalls = 0
mockFetcherResult = { data: 'mock' }
mockFetcherError = null
}
function setupTest() {
resetMocks()
invalidateAll()
setUserIdGetter(() => 'test-user-1')
setConfig({
defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false },
startup: [],
idle: [],
pages: {}
}, {
'test.key': mockFetcher
})
}
// ── 测试 ──
// Test 1: get calls fetcher and caches result
{
let passed = 0, failed = 0
async function test_get_fetches_and_caches() {
setupTest()
const data = await get('test.key', { id: 1 })
if (data !== mockFetcherResult) throw new Error(`Expected ${mockFetcherResult}, got ${data}`)
if (mockFetcherCalls !== 1) throw new Error(`Expected 1 call, got ${mockFetcherCalls}`)
// 第二次 get 应该命中缓存,不调 fetcher
const data2 = await get('test.key', { id: 1 })
if (data2 !== mockFetcherResult) throw new Error(`Expected cached result`)
if (mockFetcherCalls !== 1) throw new Error(`Expected still 1 call, got ${mockFetcherCalls}`)
console.log(' PASS: test_get_fetches_and_caches')
}
test_get_fetches_and_caches()
.then(() => {})
.catch(e => console.error(' FAIL: test_get_fetches_and_caches:', e.message))
}
// Test 2: different params = different cache entries
{
async function test_different_params() {
setupTest()
await get('test.key', { id: 1 })
await get('test.key', { id: 2 })
if (mockFetcherCalls !== 2) throw new Error(`Expected 2 calls, got ${mockFetcherCalls}`)
console.log(' PASS: test_different_params')
}
test_different_params().catch(e => console.error(' FAIL: test_different_params:', e.message))
}
// Test 3: invalidate removes cache entry
{
async function test_invalidate() {
setupTest()
await get('test.key', { id: 1 })
invalidate('test.key', { id: 1 })
await get('test.key', { id: 1 })
if (mockFetcherCalls !== 2) throw new Error(`Expected 2 calls after invalidate, got ${mockFetcherCalls}`)
console.log(' PASS: test_invalidate')
}
test_invalidate().catch(e => console.error(' FAIL: test_invalidate:', e.message))
}
// Test 4: invalidatePrefix
{
async function test_invalidatePrefix() {
setupTest()
setConfig({
defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false },
startup: [], idle: [], pages: {}
}, {
'test.key1': () => Promise.resolve(1),
'test.key2': () => Promise.resolve(2),
'other.key': () => Promise.resolve(3)
})
await get('test.key1')
await get('test.key2')
await get('other.key')
invalidatePrefix('test.')
const stats = getStats()
// memoryMap should only have 'other.key' left
const dump = dumpMemory()
const testKeys = dump.filter(e => e.key.includes('test.key'))
if (testKeys.length !== 0) throw new Error(`Expected 0 test.* keys, got ${testKeys.length}`)
console.log(' PASS: test_invalidatePrefix')
}
test_invalidatePrefix().catch(e => console.error(' FAIL: test_invalidatePrefix:', e.message))
}
// Test 5: _swallowAuth — code 7 and 16
{
async function test_auth_swallow() {
setupTest()
// code 7
const e7 = new Error('token expired')
e7.code = 7
mockFetcherError = e7
const r1 = await get('test.key', { id: 99 })
if (r1 !== null) throw new Error(`Expected null for code 7, got ${r1}`)
resetMocks()
setupTest()
// code 16
const e16 = new Error('banned')
e16.code = 16
mockFetcherError = e16
const r2 = await get('test.key', { id: 99 })
if (r2 !== null) throw new Error(`Expected null for code 16, got ${r2}`)
resetMocks()
setupTest()
// message 含"登录已过期"
mockFetcherError = new Error('登录已过期,请重新登录')
const r3 = await get('test.key', { id: 99 })
if (r3 !== null) throw new Error(`Expected null for auth message, got ${r3}`)
console.log(' PASS: test_auth_swallow')
}
test_auth_swallow().catch(e => console.error(' FAIL: test_auth_swallow:', e.message))
}
// Test 6: LRU eviction at maxMemoryEntries
{
async function test_lru_eviction() {
setupTest()
setConfig({
defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false,
limits: { maxMemoryEntries: 3, maxEntrySizeKB: 1024, maxFileCacheMB: 50 } },
startup: [], idle: [], pages: {}
}, {
'k1': () => Promise.resolve('v1'),
'k2': () => Promise.resolve('v2'),
'k3': () => Promise.resolve('v3'),
'k4': () => Promise.resolve('v4')
})
await get('k1')
await get('k2')
await get('k3')
// 访问 k1 → 标记最近使用
await get('k1')
// 插入 k4 → 应该淘汰 k2最久未使用
await get('k4')
const dump = dumpMemory()
const keys = dump.map(e => e.key)
// k1 是最近访问的,应该保留
// k2 是最久没访问的,应该被淘汰
if (keys.some(k => k.includes('::k2::'))) {
console.log(' WARN: test_lru_eviction — k2 not evicted (timing-dependent, may be ok)')
}
if (!keys.some(k => k.includes('::k1::'))) {
throw new Error('k1 should still be in cache (most recently used)')
}
if (!keys.some(k => k.includes('::k4::'))) {
throw new Error('k4 should be in cache')
}
console.log(' PASS: test_lru_eviction')
}
test_lru_eviction().catch(e => console.error(' FAIL: test_lru_eviction:', e.message))
}
// Test 7: inFlight dedup — concurrent gets share same Promise
{
async function test_inflight_dedup() {
setupTest()
const [r1, r2] = await Promise.all([
get('test.key', { id: 1 }),
get('test.key', { id: 1 })
])
if (mockFetcherCalls !== 1) throw new Error(`Expected 1 call for concurrent gets, got ${mockFetcherCalls}`)
if (r1 !== r2) throw new Error(`Expected same result, got ${r1} vs ${r2}`)
console.log(' PASS: test_inflight_dedup')
}
test_inflight_dedup().catch(e => console.error(' FAIL: test_inflight_dedup:', e.message))
}
// Test 8: clearUser clears memory
{
async function test_clearUser() {
setupTest()
await get('test.key', { id: 1 })
const before = dumpMemory().length
if (before === 0) throw new Error('Expected cache entries before clearUser')
clearUser('test-user-1')
const after = dumpMemory().length
if (after !== 0) throw new Error(`Expected 0 entries after clearUser, got ${after}`)
console.log(' PASS: test_clearUser')
}
test_clearUser().catch(e => console.error(' FAIL: test_clearUser:', e.message))
}
console.log('[core.test] All tests queued (async)')
```
---
### Task 13: Write unit tests — `storage.test.js`
**Files:**
- Create: `frontend/utils/preloadApi/__tests__/storage.test.js`
- [ ] **Step 1: Create storage unit tests**
Since `storage.js` heavily depends on `plus.io` / `uni.getFileSystemManager` (platform APIs unavailable in test env), these tests are **manual/functional** — run on real device or simulator. Here we document the test cases:
```js
// frontend/utils/preloadApi/__tests__/storage.test.js
// 文件缓存适配器功能测试 — 需要在 APP-PLUS 真机/模拟器上运行
/**
* Test cases (run manually on device):
*
* 1. writeEntry + readEntry roundtrip
* - writeEntry('test-u', 'key1', { hello: 'world' }, Date.now(), 60000)
* - await readEntry('test-u', 'key1')
* - Assert: data.hello === 'world'
*
* 2. readEntry returns null for missing file
* - const r = await readEntry('test-u', 'nonexistent')
* - Assert: r === null
*
* 3. clearForUser removes all entries
* - writeEntry('test-u', 'k1', 1, Date.now(), 60000)
* - writeEntry('test-u', 'k2', 2, Date.now(), 60000)
* - await clearForUser('test-u')
* - await readEntry('test-u', 'k1') → null
* - await readEntry('test-u', 'k2') → null
*
* 4. getTotalCacheSize returns reasonable value
* - writeEntry('test-u', 'big', 'x'.repeat(10000), Date.now(), 60000)
* - const size = await getTotalCacheSize('test-u')
* - Assert: size > 0
*
* 5. User isolation
* - writeEntry('user-a', 'shared', 'a-data', Date.now(), 60000)
* - writeEntry('user-b', 'shared', 'b-data', Date.now(), 60000)
* - await readEntry('user-a', 'shared') → 'a-data'
* - await readEntry('user-b', 'shared') → 'b-data'
*/
console.log('[storage.test] Tests are manual — run on APP-PLUS device')
```
---
### Task 14: Write unit tests — `navigate.test.js`
**Files:**
- Create: `frontend/utils/preloadApi/__tests__/navigate.test.js`
- [ ] **Step 1: Create navigate unit tests**
```js
// frontend/utils/preloadApi/__tests__/navigate.test.js
// navigate.js 单元测试 — mock uni.navigateTo
import { navigateTo, switchTab, reLaunch } from '../navigate'
import { setConfig, invalidateAll, setUserIdGetter } from '../core'
// ── mock uni ──
let mockNavCalls = []
let mockSwitchTabCalls = []
let mockReLaunchCalls = []
// 在测试前替换 globalThis.uni
const _origUni = globalThis.uni
globalThis.uni = {
navigateTo: (opts) => mockNavCalls.push(opts),
switchTab: (opts) => mockSwitchTabCalls.push(opts),
reLaunch: (opts) => mockReLaunchCalls.push(opts),
getStorageSync: () => null
}
// Mock URLSearchParams if not available
if (typeof URLSearchParams === 'undefined') {
globalThis.URLSearchParams = class {
constructor(qs) {
this.params = new Map()
for (const pair of qs.split('&')) {
const [k, v] = pair.split('=')
if (k) this.params.set(k, v || '')
}
}
entries() { return this.params.entries() }
}
}
function resetMockCalls() {
mockNavCalls = []
mockSwitchTabCalls = []
mockReLaunchCalls = []
invalidateAll()
}
// ── 测试 ──
// Test 1: navigateTo with query string → calls uni.navigateTo immediately
{
function test_navigateTo_calls_uni() {
resetMockCalls()
navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call, got ${mockNavCalls.length}`)
if (mockNavCalls[0].url !== '/pages/asset-detail/asset-detail?id=123') {
throw new Error(`Expected URL preserved, got ${mockNavCalls[0].url}`)
}
console.log(' PASS: test_navigateTo_calls_uni')
}
test_navigateTo_calls_uni()
}
// Test 2: navigateTo without pages config → still calls uni.navigateTo
{
function test_navigateTo_no_config() {
resetMockCalls()
setConfig({ defaults: { ttl: 10000 }, pages: {} }, {})
navigateTo({ url: '/pages/nonexistent/nonexistent' })
if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call even without config, got ${mockNavCalls.length}`)
console.log(' PASS: test_navigateTo_no_config')
}
test_navigateTo_no_config()
}
// Test 3: switchTab calls uni.switchTab
{
function test_switchTab() {
resetMockCalls()
switchTab({ url: '/pages/tab/home' })
if (mockSwitchTabCalls.length !== 1) throw new Error(`Expected 1 switchTab call, got ${mockSwitchTabCalls.length}`)
console.log(' PASS: test_switchTab')
}
test_switchTab()
}
// Test 4: reLaunch calls uni.reLaunch
{
function test_reLaunch() {
resetMockCalls()
reLaunch({ url: '/pages/login/portal' })
if (mockReLaunchCalls.length !== 1) throw new Error(`Expected 1 reLaunch call, got ${mockReLaunchCalls.length}`)
console.log(' PASS: test_reLaunch')
}
test_reLaunch()
}
// Test 5: query string with special characters
{
function test_special_chars() {
resetMockCalls()
navigateTo({ url: '/pages/foo/bar?name=' + encodeURIComponent('你好') + '&type=hot' })
if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call`)
// URL should be passed through unchanged
console.log(' PASS: test_special_chars')
}
test_special_chars()
}
// Test 6: string arg (not object)
{
function test_string_arg() {
resetMockCalls()
navigateTo('/pages/foo/bar?id=1')
if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call`)
console.log(' PASS: test_string_arg')
}
test_string_arg()
}
console.log('[navigate.test] All tests completed')
// Restore
// globalThis.uni = _origUni
```
---
### Task 15: Write README for developers
**Files:**
- Create: `frontend/utils/preloadApi/README.md`
- [ ] **Step 1: Create README.md**
```markdown
# preloadApi — API 预加载模块
## 快速开始
### 1. 在页面中使用缓存数据
```vue
<script setup>
import { usePreload } from '@/composables/usePreload'
const { data, loading, error, refresh } = usePreload('asset.detail', { id: route.params.id })
</script>
<template>
<view v-if="data">{{ data.name }}</view>
<view v-else-if="loading">加载中...</view>
<view v-else-if="error">加载失败: {{ error.message }}</view>
</template>
```
### 2. 手动失效缓存
```js
import { getPreloadApi } from '@/utils/preloadApi/index'
const api = getPreloadApi()
// 失效单个 key
api.invalidate('ranking.hot')
// 失效某个前缀的所有 key
api.invalidatePrefix('asset.')
// 清空全部内存缓存
api.invalidateAll()
```
### 3. 添加新的预拉配置
`frontend/config/preload.config.js` 中:
```js
pages: {
'/pages/new-page/new-page': [
{ key: 'new.data', fetcher: (params) => getNewDataApi(params.id) }
]
}
```
### 4. 替换页面跳转
```js
// 旧写法
uni.navigateTo({ url: '/pages/detail/detail?id=123' })
// 新写法(自动触发预拉)
import { getPreloadApi } from '@/utils/preloadApi/index'
const api = getPreloadApi()
api.navigateTo({ url: '/pages/detail/detail?id=123' })
```
## API 速查
| 方法 | 说明 |
|------|------|
| `preloadApi.run(key, params?)` | 触发预拉fire-and-forget不返回数据 |
| `preloadApi.get(key, params?)` | 读缓存,未命中则拉取 |
| `preloadApi.invalidate(key, params?)` | 失效单个 key内存 |
| `preloadApi.invalidatePrefix(prefix)` | 失效前缀匹配的所有 key内存 |
| `preloadApi.invalidateAll()` | 清空全部内存缓存 |
| `preloadApi.clearUser(userId)` | 登出:清空内存 + 删文件缓存目录 |
| `preloadApi.navigateTo(opts)` | 替代 uni.navigateTo |
| `preloadApi.switchTab(opts)` | 替代 uni.switchTab |
| `preloadApi.reLaunch(opts)` | 替代 uni.reLaunch |
## 配置字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `key` | string | (必填) | 逻辑 key业务引用缓存的唯一标识 |
| `fetcher` | (params) => Promise | (必填) | 请求函数 |
| `ttl` | number | 300000 | 缓存有效期 (ms) |
| `persistence` | 'memory'\|'file' | 'memory' | 缓存存储方式 |
| `timeout` | number | 10000 | 单接口超时 (ms) |
| `silent` | boolean | true | 失败是否静默 |
```
---
## Verification Checklist
After all tasks are complete, verify against the acceptance checklist (§8.2):
- [ ] `preloadApi.run` / `get` / `invalidate*` pass unit tests
- [ ] wrappedNavigateTo hit/miss both correct
- [ ] usePreload composable exposes reactive state correctly
- [ ] 401 / code 7 / code 16 swallowed by `_swallowAuth`
- [ ] query string with `encodeURIComponent` characters parsed correctly
- [ ] App.vue device test: `onLaunch` → startup items hit memory cache
- [ ] wrappedNavigateTo device test: detail page hits preload cache (< 50ms loading flash)
- [ ] Logout `_doc/preload/{userId}/` directory deleted
- [ ] LRU eviction: 100 keys 1st kept; 101st key 1st evicted
- [ ] warmIdle idempotent: 3 consecutive calls fetcher called only once
- [ ] User switch (onSwitchUser): me.* prefix fully invalidated, oldUser file cache deleted, newUser cache unaffected