topfans/frontend/utils/preloadApi/core.js
2026-07-29 16:03:33 +08:00

537 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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'
import { getStoredUser } from '@/utils/getStoredUser.js'
// ── 常量 ──
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
// 清理代际计数器invalidateAll() 增加_doFetch 启动时捕获,提交结果前比对,避免清理期间写入过期数据
let _cleanupGeneration = 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 获取函数(由外部注入)
// ★ 2026-07-27 refactor:委托 @/utils/getStoredUser.js,统一空值/损坏 JSON 的安全读取
let _getUserId = () => {
try {
return getStoredUser()?.uid ?? null
} catch (e) { /* ignore */ }
return null
}
// ── 并发控制 semaphore ──
function createSemaphore(max) {
let running = 0
const queue = []
return {
acquire: () => new Promise(resolve => {
if (running < max) { running++; resolve() }
else { queue.push(resolve) }
}),
release: () => {
running--
const next = queue.shift()
if (next) { running++; next() }
}
}
}
let _semaphore = createSemaphore(_config.defaults.concurrency)
// ── hash 工具 ──
function djb2(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
}
return (hash >>> 0).toString(16)
}
function hashParams(params) {
if (!params || Object.keys(params).length === 0) return ''
const sorted = {}
Object.keys(params).sort().forEach(k => { sorted[k] = params[k] })
return djb2(JSON.stringify(sorted))
}
function buildCacheKey(userId, logicalKey, params) {
const uid = userId || 'guest'
const paramHash = hashParams(params)
return `${uid}::${NAMESPACE}::${logicalKey}::${paramHash}`
}
// ── LRU touch ──
function touchLRU(map, key, value) {
if (map.has(key)) map.delete(key)
map.set(key, value)
if (map.size > _config.defaults.limits.maxMemoryEntries) {
const oldestKey = map.keys().next().value
map.delete(oldestKey)
if (typeof console !== 'undefined') {
console.log('[preload] LRU evict:', oldestKey)
}
}
}
// ── 401/7/16 swallow ──
function _swallowAuth(err) {
if (err && (err.code === 7 || err.code === 16)) return null
if (err && /登录已过期/.test(err.message || '')) return null
return err
}
// ── 数据大小检查 ──
function getDataSizeKB(data) {
try {
return new Blob([JSON.stringify(data)]).size / 1024
} catch (e) {
return JSON.stringify(data || '').length / 1024
}
}
// ── 配置 API ──
/**
* 设置运行时配置 + fetcher 注册表
* 由 config.js 在初始化时调用
*/
export function setConfig(config, fetchers) {
if (config) {
_config = {
defaults: {
..._config.defaults,
...(config.defaults || {}),
limits: {
..._config.defaults.limits,
...((config.defaults && config.defaults.limits) || {})
}
},
startup: config.startup || _config.startup || [],
idle: config.idle || _config.idle || [],
pages: config.pages || _config.pages || {}
}
_semaphore = createSemaphore(_config.defaults.concurrency)
}
if (fetchers) {
_fetchers = { ..._fetchers, ...fetchers }
}
}
/**
* 设置 userId 获取函数(用于测试注入)
*/
export function setUserIdGetter(fn) {
if (typeof fn === 'function') _getUserId = fn
}
// ── 核心 API ──
/**
* 获取缓存值(组件使用)
* 命中内存 → 同步 resolve文件缓存命中 / fetch → async resolve
*/
export function get(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
touchLRU(memoryMap, cacheKey, memEntry)
hitCount++
return Promise.resolve(memEntry.data)
}
// 2. inFlight 去重
const inFlight = inFlightMap.get(cacheKey)
if (inFlight) {
return inFlight.promise.then(result => result.data)
}
// 3. 发起 fetch含文件缓存回退
return _doFetch(logicalKey, params, cacheKey, userId, false)
}
/**
* 触发预拉fire-and-forget
* 查内存 → inFlight → 发起 fetch
*/
export function run(logicalKey, params) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// 1. 查内存
const memEntry = memoryMap.get(cacheKey)
if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
return // 未过期,跳过
}
// 2. inFlight 去重
if (inFlightMap.has(cacheKey)) {
return // 已在请求中
}
// 3. 发起 fetchfire-and-forget不返回 Promise
_doFetch(logicalKey, params, cacheKey, userId, true)
}
/**
* 命令式刷新
*/
export function refresh(logicalKey, params, force = false) {
const userId = _getUserId()
const cacheKey = buildCacheKey(userId, logicalKey, params)
// force=true 时先删除内存缓存 + 跳过文件缓存
if (force) {
memoryMap.delete(cacheKey)
}
return _doFetch(logicalKey, params, cacheKey, userId, false, 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)
}
}
}
/**
* 清空全部内存缓存 + 取消所有 in-flight 请求(不动文件缓存)
* 写入路径会在 _doFetch 内通过 _cleanupGeneration 比对自动失效
*/
export function invalidateAll() {
// 1. 标记清理事件in-flight fetch 在写入前会比对 generation
_cleanupGeneration++
// 2. 清空内存缓存
memoryMap.clear()
// 3. 取消所有 in-flight 请求 + 清空 inFlightMap
for (const [key, entry] of inFlightMap) {
try { entry.abort() } catch (e) { /* skip */ }
}
inFlightMap.clear()
}
/**
* 获取当前清理代际(调试 / 测试用)
*/
export function getCleanupGeneration() {
return _cleanupGeneration
}
/**
* 删除指定用户的文件缓存目录(不动内存)
*/
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)
}
// 捕获启动时的清理代际,提交结果前比对,避免在 invalidateAll() 之后写入过期数据
const genAtStart = _cleanupGeneration
// 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')
// Generation check若 invalidateAll() 在 fetch 期间已触发,丢弃本次结果不写入
if (genAtStart !== _cleanupGeneration) {
console.warn('[preload] fetch stale, cleanup generation changed:', logicalKey)
resolveInFlight(null)
return null
}
// 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())
})
}
}