58 KiB
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:
- Capture
requestTaskfromuni.request()return value - Add
_abortedflag - Guard
failcallback against abort-triggered errors - Attach
.abort()method to the returned Promise
// 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:
# 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
// frontend/utils/preloadApi/storage.js
// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
// APP-PLUS: 优先 plus.io(promisify),降级 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-PLUS:readdir + 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
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
// 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. 发起 fetch(fire-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
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
// 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
// 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
// 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 = {}
// 使用 URLSearchParams(uniapp 环境支持)
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
// 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
// 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
// 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):
// 在 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 }):
// ★ 新增:初始化预加载 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()):
// ★ 新增: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
// 在 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:
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).
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
// 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:
// 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
// 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
# 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. 手动失效缓存
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 中:
pages: {
'/pages/new-page/new-page': [
{ key: 'new.data', fetcher: (params) => getNewDataApi(params.id) }
]
}
4. 替换页面跳转
// 旧写法
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