# 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.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** ```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 const inFlightMap = new Map() // Map // 统计 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** ```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 = {} // 使用 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** ```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} key - 逻辑 key * @param {object|Ref} [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 ` ``` ### 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