509 lines
12 KiB
JavaScript
509 lines
12 KiB
JavaScript
// 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, force)
|
||
}
|
||
|
||
/**
|
||
* 失效单个 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, skipFileCache = false) {
|
||
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 && !skipFileCache && 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())
|
||
})
|
||
}
|
||
}
|