topfans/frontend/utils/preloadApi/core.js
zerosaturation 83999995f5 refactor(frontend): consolidate user storage read via getStoredUser helper
uni.getStorageSync('user') 在 key 从未写过的 UniApp 平台下默认返回 ""(空串)
而非 null/undefined,旧式 `JSON.parse(uni.getStorageSync('user')) || {}` 在
parse 阶段直接抛 `SyntaxError: Unexpected end of JSON input`,导致 quickLogin
setup 崩溃,并触发 Vue 二次告警 `Invalid vnode type: undefined`。

新增 utils/getStoredUser.js 单点封装,统一处理:
  - 空值/损坏 JSON 兜底(返回 null,不抛)
  - 与 store/modules/user.js 初始化块同源守卫
  - 失败 warn 级别日志,不影响业务

替换全库 16 处不安全 `JSON.parse(userStr)` 调用:
  composables/useLaserMint.js                  composables/useShare.js
  utils/guideConfig.js                         utils/preloadApi/core.js
  pages/castlove/lenticular/lenticular-result.vue
  pages/support-activity/index.vue             pages/support-activity/components/{TopRanking,ActionBar}.vue
  pages/components/{Header,ShareModal,ShareReportButtons}.vue
  pages/tasks/{GuideModal,daily-tasks}.vue
  pages/asset-detail/asset-detail.vue          pages/exhibition/exhibition.vue
  pages/profile/profile.vue                    pages/login/quickLogin.vue(原崩点)

净 -18 行,17 文件改动 + 1 文件新增。后续如需支持多 key(如 'adminUser'),
把 getStoredUser 扩成泛型 getStoredJSON(key) 即可。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:16:49 +08:00

508 lines
13 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
// 运行时配置(由外部 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)
}
}
}
/**
* 清空全部内存缓存(不动文件缓存)
*/
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())
})
}
}