2241 lines
58 KiB
Markdown
2241 lines
58 KiB
Markdown
# 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<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**
|
||
|
||
```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<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
|