feat(image-cache): 新增图片本地缓存并全站接入

引入 cached-image 组件与 image-cache 工具,实现签名 URL 稳定 key 剥离、downloadFile 落盘及 TTL/LRU 清理,替换 11 个页面与 clothes-card 共 22 处网络图片渲染点,减少 CDN 重复流量。
This commit is contained in:
2026-09-26 19:49:54 +08:00
parent 7cd6c169c2
commit f169777f4a
30 changed files with 754 additions and 25 deletions
+433
View File
@@ -0,0 +1,433 @@
// 图片本地缓存:网络图片首次展示时下载落盘,后续直接复用本地文件,减少 CDN 重复流量。
// 背景:接口返回的图片是带签名/时间戳的临时 URL,每次请求都会变化;微信 image 自带的
// 缓存按「完整 URL(含 query)」记录,签名一变即失效,同一张图会被反复下载。
// 本模块按「剥离签名参数后的稳定地址」作为缓存 key 落盘,同一张图只请求 CDN 一次。
//
// 命中链路:内存 Map → 磁盘文件 → downloadFile 落盘(并发 ≤4、同 key 任务去重)。
// 失败处理:单图失败由组件回退网络地址;检测到 downloadFile 域名未配置时整会话熔断,
// 避免继续发起必然失败的请求。
// 空间治理:TTL + 容量上限,空闲时异步清扫,不阻塞渲染。
import { logger } from './logger'
// 日志模块名
const LOG = 'image-cache'
// 缓存目录(本地用户文件,随小程序代码包生命周期清理)
const CACHE_DIR = `${wx.env.USER_DATA_PATH}/imgcache`
// 过期时间:超过 7 天未修改的缓存文件优先淘汰
const TTL_MS = 7 * 24 * 60 * 60 * 1000
// 目录容量上限:本地文件总量上限约 200MB,自设 80MB 留足业务余量
const MAX_BYTES = 80 * 1024 * 1024
// 文件数量上限
const MAX_FILES = 300
// 内存映射上限,超出时整体重建(后续重新走磁盘命中,代价可控)
const MAX_MEM_ENTRIES = 500
// downloadFile 并发上限(微信侧总上限 10,留余量给业务请求)
const MAX_CONCURRENCY = 4
// 清扫延迟:首次解析后延迟执行,避免与首屏图片加载抢资源
const PRUNE_DELAY_MS = 5000
// 运行期每成功落盘 50 张再触发一次清扫
const PRUNE_EVERY_SAVES = 50
// 签名类参数名(小写,集中维护):签名 URL 每次返回都不同,必须从缓存 key 中剥离,否则永远无法命中
const SIGN_PARAMS = new Set([
// 通用签名 / 时间戳
'sign',
'signature',
'sig',
't',
'ts',
'timestamp',
'time',
'expires',
'expire',
'auth',
'token',
'nonce',
// 腾讯云 COS
'authorize',
'x-cos-security-token',
'q-sign-algorithm',
'q-sign-time',
'q-key-time',
'q-signature',
'q-header-list',
'q-url-param-list',
// 阿里云 OSS
'ossaccesskeyid',
'security-token',
'auth_key',
// AWS S3 / CloudFront
'x-signature',
'x-amz-algorithm',
'x-amz-credential',
'x-amz-date',
'x-amz-expires',
'x-amz-signature',
'x-amz-security-token',
'x-amz-signedheaders',
'key-pair-id',
'policy',
])
// ===== 运行状态 =====
// 稳定 key → 本地文件路径(内存快速通道)
const memCache = new Map<string, string>()
// 稳定 key → 进行中的下载任务(并发复用,避免同一张图重复下载)
const inflight = new Map<string, Promise<string>>()
// 下载域名未配置等不可恢复失败的会话级熔断
let disabled = false
// 缓存目录就绪状态与初始化 Promise
let dirReady = false
let dirPromise: Promise<void> | null = null
// downloadFile 并发闸门
let running = 0
const waiters: Array<() => void> = []
// 清扫节流状态
let firstPruneDone = false
let pruneScheduled = false
let savesSincePrune = 0
function fs(): WechatMiniprogram.FileSystemManager {
return wx.getFileSystemManager()
}
// 仅 https 网络图进入缓存流程;本地路径(wxfile:// / http://tmp / http://usr / 包内路径)原样返回
function isRemote(src: string): boolean {
return src.indexOf('https://') === 0
}
// ===== 稳定 key 与文件指纹 =====
// 剥离签名/时间戳参数,其余 query 排序后与 host+path 拼接;同一张图签名变化也命中同一缓存
function stableKey(src: string): string {
const idx = src.indexOf('?')
if (idx < 0) return src
const base = src.slice(0, idx)
const kept: string[] = []
for (const part of src.slice(idx + 1).split('&')) {
if (!part) continue
const eq = part.indexOf('=')
const rawKey = (eq >= 0 ? part.slice(0, eq) : part).toLowerCase()
let name = rawKey
try {
name = decodeURIComponent(rawKey)
} catch (e) {
// 参数名解码失败按原样处理
}
if (SIGN_PARAMS.has(name)) continue
kept.push(part)
}
if (!kept.length) return base
kept.sort()
return `${base}?${kept.join('&')}`
}
function hex32(n: number): string {
return ('00000000' + (n >>> 0).toString(16)).slice(-8)
}
// FNV-1a 32 位:不引入依赖的快速哈希
function fnv1a(str: string): string {
let h = 0x811c9dc5
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 0x01000193) >>> 0
}
return hex32(h)
}
// djb2 32 位:与 FNV-1a 拼接为 16 位十六进制文件名,降低碰撞概率
function djb2(str: string): string {
let h = 5381
for (let i = 0; i < str.length; i++) {
h = (Math.imul(h, 33) + str.charCodeAt(i)) >>> 0
}
return hex32(h)
}
function hashKey(key: string): string {
return fnv1a(key) + djb2(key)
}
// 扩展名取自 URL 路径,白名单外统一按 .jpg 处理(仅影响本地文件名,不影响渲染)
function extOf(src: string): string {
const path = src.split('?')[0].split('#')[0]
const m = /\.(jpg|jpeg|png|webp|gif)$/i.exec(path)
if (!m) return '.jpg'
const ext = m[1].toLowerCase()
return ext === 'jpeg' ? '.jpg' : `.${ext}`
}
function localPathOf(key: string, src: string): string {
return `${CACHE_DIR}/${hashKey(key)}${extOf(src)}`
}
// ===== 文件系统操作(全部异步包装,失败静默由调用方决定兜底) =====
function ensureDir(): Promise<void> {
if (dirReady) return Promise.resolve()
if (!dirPromise) {
dirPromise = new Promise<void>((resolve) => {
fs().mkdir({
dirPath: CACHE_DIR,
recursive: true,
success: () => resolve(),
// 目录已存在等失败一律放行,落盘失败会在下载阶段回退
fail: () => resolve(),
})
}).then(() => {
dirReady = true
})
}
return dirPromise
}
function accessFile(path: string): Promise<boolean> {
return new Promise((resolve) => {
fs().access({
path,
success: () => resolve(true),
fail: () => resolve(false),
})
})
}
function unlinkQuiet(path: string): Promise<void> {
return new Promise((resolve) => {
fs().unlink({
filePath: path,
success: () => resolve(),
fail: () => resolve(),
})
})
}
function readdir(): Promise<string[]> {
return new Promise((resolve, reject) => {
fs().readdir({
dirPath: CACHE_DIR,
success: (res) => resolve(res.files || []),
fail: (err) => reject(new Error((err && err.errMsg) || 'readdir fail')),
})
})
}
function statFile(path: string): Promise<{ size: number; mtime: number } | null> {
return new Promise((resolve) => {
fs().stat({
path,
success: (res) => {
const s = res.stats
if (typeof s.isFile === 'function' && !s.isFile()) {
resolve(null)
return
}
// 官方单位是秒,但个别端返回毫秒,统一归一化为毫秒
const raw = Number(s.lastModifiedTime) || 0
const mtime = raw > 1e11 ? raw : raw * 1000
resolve({ size: Number(s.size) || 0, mtime })
},
fail: () => resolve(null),
})
})
}
// ===== 下载:并发闸门 + 直写目标文件 =====
function acquireSlot(): Promise<void> {
if (running < MAX_CONCURRENCY) {
running++
return Promise.resolve()
}
return new Promise((resolve) => {
waiters.push(() => {
running++
resolve()
})
})
}
function releaseSlot(): void {
running--
const next = waiters.shift()
if (next) next()
}
function download(url: string, filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
wx.downloadFile({
url,
filePath,
success: (res) => {
if (res.statusCode === 200) resolve()
else reject(new Error(`http ${res.statusCode}`))
},
fail: (err) => reject(new Error((err && err.errMsg) || 'download fail')),
})
})
}
// ===== 空闲清扫:TTL 过期 + 超容量/超数量按最旧优先删除 =====
async function pruneCache(): Promise<void> {
try {
const names = await readdir()
const now = Date.now()
const alive: Array<{ path: string; size: number; mtime: number }> = []
let expired = 0
for (const name of names) {
const path = `${CACHE_DIR}/${name}`
const st = await statFile(path)
if (!st) continue
if (now - st.mtime > TTL_MS) {
await unlinkQuiet(path)
expired++
continue
}
alive.push({ path, size: st.size, mtime: st.mtime })
}
alive.sort((a, b) => a.mtime - b.mtime)
let bytes = 0
for (const it of alive) bytes += it.size
let removed = 0
let i = 0
while (i < alive.length && (bytes > MAX_BYTES || alive.length - i > MAX_FILES)) {
await unlinkQuiet(alive[i].path)
bytes -= alive[i].size
removed++
i++
}
logger.debug(LOG, 'prune done, total=', names.length, 'expired=', expired, 'overflow=', removed)
} catch (e) {
// 目录尚未创建等情况直接跳过,不重试不报错
logger.debug(LOG, 'prune skipped')
}
}
function notePrune(): void {
// 首次解析后延迟清扫一次
if (!firstPruneDone && !pruneScheduled) {
pruneScheduled = true
setTimeout(runPrune, PRUNE_DELAY_MS)
}
}
function noteSaved(): void {
savesSincePrune++
// 运行期累计落盘后再触发一次
if (firstPruneDone && !pruneScheduled && savesSincePrune >= PRUNE_EVERY_SAVES) {
pruneScheduled = true
setTimeout(runPrune, PRUNE_DELAY_MS)
}
}
function runPrune(): void {
pruneCache().then(() => {
firstPruneDone = true
savesSincePrune = 0
pruneScheduled = false
})
}
// ===== 对外接口 =====
function memSet(key: string, localPath: string): void {
if (memCache.size >= MAX_MEM_ENTRIES) memCache.clear()
memCache.set(key, localPath)
}
/** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟直出) */
export function getCachedLocalPath(src: string): string | null {
if (!src || !isRemote(src) || disabled) return null
return memCache.get(stableKey(src)) || null
}
async function loadOrDownload(key: string, src: string): Promise<string> {
const localPath = localPathOf(key, src)
const id = hashKey(key)
if (await accessFile(localPath)) {
logger.debug(LOG, 'hit disk', id)
return localPath
}
await ensureDir()
await acquireSlot()
try {
await download(src, localPath)
noteSaved()
logger.debug(LOG, 'saved', id)
return localPath
} catch (e) {
// 清理可能残留的半截文件,交由组件回退网络地址
await unlinkQuiet(localPath)
const msg = e instanceof Error ? e.message : String(e)
if (!disabled && /not in domain list/i.test(msg)) {
disabled = true
logger.warn(LOG, 'downloadFile 域名未配置,停用图片缓存并回退网络图(请在小程序后台配置 downloadFile 合法域名)')
} else {
logger.debug(LOG, 'download fail, fallback network, id=', id, 'reason=', msg)
}
throw e
} finally {
releaseSlot()
}
}
/** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */
export function resolveImage(src: string): Promise<string> {
if (!src || !isRemote(src)) return Promise.resolve(src)
if (disabled) return Promise.reject(new Error('image cache disabled'))
const key = stableKey(src)
const hit = memCache.get(key)
if (hit) {
logger.debug(LOG, 'hit mem', hashKey(key))
return Promise.resolve(hit)
}
const pending = inflight.get(key)
if (pending) return pending
notePrune()
const task = loadOrDownload(key, src).then(
(localPath) => {
memSet(key, localPath)
inflight.delete(key)
return localPath
},
(err) => {
inflight.delete(key)
throw err
}
)
inflight.set(key, task)
return task
}
/** 删除某个网络图对应的本地缓存(组件自愈用:本地文件损坏时清理后回退网络地址) */
export function dropImageCache(src: string): void {
if (!src || !isRemote(src)) return
const key = stableKey(src)
const localPath = memCache.get(key) || localPathOf(key, src)
memCache.delete(key)
unlinkQuiet(localPath).then(() => undefined)
}
/** 清空全部图片缓存(预留维护入口) */
export function clearImageCache(): Promise<void> {
memCache.clear()
return readdir()
.then((names) => Promise.all(names.map((name) => unlinkQuiet(`${CACHE_DIR}/${name}`))))
.then(() => {
logger.debug(LOG, 'clear done')
})
.catch(() => undefined)
}