feat: 项目整体缓存策略优化

This commit is contained in:
hzj 2026-05-14 17:19:06 +08:00
parent ac77b82c51
commit 80db024dbd
8 changed files with 207 additions and 101 deletions

View File

@ -6,7 +6,7 @@ server {
# Load configuration files for the default server block. # Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf; include /etc/nginx/default.d/*.conf;
location ~* \.(js|css)$ { location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2|mp4)$ {
# 缓存30天 # 缓存30天
add_header Cache-Control "public, max-age=2592000, immutable"; add_header Cache-Control "public, max-age=2592000, immutable";
@ -28,4 +28,4 @@ server {
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
} }
} }

View File

@ -6,6 +6,13 @@ server {
# Load configuration files for the default server block. # Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf; include /etc/nginx/default.d/*.conf;
location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2|mp4)$ {
add_header Cache-Control "public, max-age=2592000, immutable";
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_vary on;
}
location / { location / {
# 此处的 @router 实际上是引用下面的转发,否则在 Vue 路由刷新时可能会抛出 404 # 此处的 @router 实际上是引用下面的转发,否则在 Vue 路由刷新时可能会抛出 404
try_files $uri $uri/ @router; try_files $uri $uri/ @router;

View File

@ -1,18 +1,23 @@
// src/config/imageCacheVersion.js // 图片缓存版本控制。
// 图片缓存版本号配置 //
// 推荐用法:
// 1. 普通发版保持 version 不变。
// 2. 只替换单张图片且不改文件名时,在 assetVersions 中添加对应图片版本。
// 3. 只有大批量资源迁移或缓存污染时,才更新 version 触发清理旧缓存。
export const IMAGE_CACHE_VERSION = { export const IMAGE_CACHE_VERSION = {
// 图片缓存版本号,仅在图片资源有重大变化时更新 version: '1.0.18',
version: '1.0.18', // 可以根据需要更新此版本号 cacheType: 'all',
// 缓存类型控制:'images'、'assets' 或 'all'
// 'images': 只清除图片缓存 (likei-images-v)
// 'assets': 只清除资产缓存 (likei-assets-v)
// 'all': 清除所有缓存
cacheType: 'all', // 默认清除所有缓存,可根据需要修改
// 版本更新说明(用于记录更新原因)
updateReason: 'Initial image cache version for major release', updateReason: 'Initial image cache version for major release',
updateTime: '2026-05-14T00:00:00.000Z',
// 更新时间戳 // 精确资源路径或目录前缀 -> 资源版本。
updateTime: new Date().toISOString(), // 示例:
// 'Activities/LuckyDollars/Season6/bg0.png': '20260514',
// 'Activities/LuckyDollars/Season6/': 'season6-20260514',
assetVersions: {},
cacheLimits: {
maxItems: 120,
maxSizeMB: 80,
},
} }

View File

@ -1,10 +1,40 @@
import { protectAssetUrl } from '@/utils/protectedAssets.js' import { protectAssetUrl } from '@/utils/protectedAssets.js'
import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion.js'
export const OSS_BASE_URL = export const OSS_BASE_URL =
process.env.NODE_ENV === 'development' ? '/oss/h5/Azizi/' : 'https://cdn.azizichat.com/h5/Azizi/' process.env.NODE_ENV === 'development' ? '/oss/h5/Azizi/' : 'https://cdn.azizichat.com/h5/Azizi/'
function buildAssetUrl(imagePath, filename, extension) { function buildAssetUrl(imagePath, filename, extension) {
return `${OSS_BASE_URL}${imagePath}${filename}.${extension}` const assetPath = `${imagePath}${filename}.${extension}`
const url = `${OSS_BASE_URL}${assetPath}`
const version = getAssetVersion(assetPath)
if (!version) {
return url
}
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}iv=${encodeURIComponent(version)}`
}
function getAssetVersion(assetPath) {
const versions = IMAGE_CACHE_VERSION.assetVersions || {}
if (versions[assetPath]) {
return versions[assetPath]
}
let bestPrefix = ''
let bestVersion = ''
Object.entries(versions).forEach(([prefix, version]) => {
if (assetPath.startsWith(prefix) && prefix.length > bestPrefix.length) {
bestPrefix = prefix
bestVersion = version
}
})
return bestVersion
} }
export const getRawPngUrl = (imagePath, filename) => buildAssetUrl(imagePath, filename, 'png') export const getRawPngUrl = (imagePath, filename) => buildAssetUrl(imagePath, filename, 'png')

View File

@ -1,7 +1,6 @@
// src/main.js
import './assets/main.css' import './assets/main.css'
import './styles/fonts.css' import './styles/fonts.css'
import './styles/global.css' // 引入全局样式 import './styles/global.css'
import { logEnvInfo } from './utils/env.js' import { logEnvInfo } from './utils/env.js'
import { installProtectedAssetRuntime } from './utils/protectedAssetRuntime.js' import { installProtectedAssetRuntime } from './utils/protectedAssetRuntime.js'
@ -18,57 +17,34 @@ import LoadingSpinner from './components/LoadingSpinner.vue'
import router from './router' import router from './router'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// 初始化环境信息
logEnvInfo() logEnvInfo()
// 检查版本更新(首先判断执行一次对缓存的操作,最后返回检测结果)
installRuntimeSecurity() installRuntimeSecurity()
installProtectedAssetRuntime() installProtectedAssetRuntime()
if (await versionChecker.checkVersion()) {
console.log('版本已更新')
}
// 初始化权限系统
console.debug('🔐 Permission system initialized')
const pinia = createPinia() const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) //将插件添加到 pinia 实例上 pinia.use(piniaPluginPersistedstate)
const app = createApp(App) const app = createApp(App)
// 注册智能图片指令
app.directive('smart-img', smartImage) app.directive('smart-img', smartImage)
app.component('LoadingSpinner', LoadingSpinner) app.component('LoadingSpinner', LoadingSpinner)
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)
// 然后初始化 i18n此时 Pinia 已可用) const langStore = useLangStore()
// 获取 langStore 并传递给 initI18n
const langStore = useLangStore() // 直接使用 import 的函数
//排行榜 const rankingPages = []
const RankingPage = [] const activities = ['/recharge-reward']
const enPages = [...rankingPages, ...activities]
// 活动页面
const ACTIVITIES = [
'/recharge-reward', //充值奖励页面
]
// 固定语言(布局)的页面
const enPages = [...RankingPage, ...ACTIVITIES]
const arPages = [] const arPages = []
// 获取当前页面路径
const hashPath = window.location.hash.replace('#', '') const hashPath = window.location.hash.replace('#', '')
const currentPage = hashPath.split('?')[0] // 提取纯路径部分,去除查询参数 const currentPage = hashPath.split('?')[0]
// 检测URL参数并设置语言
const queryParams = hashPath.split('?')[1] const queryParams = hashPath.split('?')[1]
const urlParams = queryParams ? new URLSearchParams(queryParams) : new URLSearchParams() const urlParams = queryParams ? new URLSearchParams(queryParams) : new URLSearchParams()
const langParam = urlParams.get('lang') const langParam = urlParams.get('lang')
let finalLang = null // 最终使用的语言 let finalLang = null
if (langParam) { if (langParam) {
if (langParam === 'en') { if (langParam === 'en') {
finalLang = 'en' finalLang = 'en'
@ -81,38 +57,54 @@ if (langParam) {
} }
} }
// 检查当前页面是否在固定英语的页面列表中
if (enPages.includes(currentPage)) { if (enPages.includes(currentPage)) {
// 强制设置为英语不受URL参数影响
setLocale(langStore, 'en') setLocale(langStore, 'en')
} } else if (arPages.includes(currentPage)) {
// 检查当前页面是否在固定阿拉伯语的页面列表中
else if (arPages.includes(currentPage)) {
// 强制设置为阿拉伯语不受URL参数影响
setLocale(langStore, 'ar') setLocale(langStore, 'ar')
} } else if (finalLang) {
// 对于其他页面如果有URL参数则按参数设置语言 console.log('langParam:', langParam)
else if (finalLang) { console.log('finalLang:', finalLang)
console.log('langParam', langParam)
console.log('finalLang', finalLang)
setLocale(langStore, finalLang) setLocale(langStore, finalLang)
} }
initI18n(langStore) initI18n(langStore)
// 最后使用 i18n
app.use(i18n) app.use(i18n)
app.mount('#app') app.mount('#app')
// 定时检查是否更新 function runWhenIdle(callback) {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(callback, { timeout: 2000 })
return
}
window.setTimeout(callback, 0)
}
async function checkVersionInBackground() {
try {
if (await versionChecker.checkVersion()) {
console.log('Version updated')
}
} catch (error) {
console.warn('Version check failed:', error)
}
}
runWhenIdle(() => {
void checkVersionInBackground()
})
if (versionChecker.enableCheck) { if (versionChecker.enableCheck) {
setInterval( setInterval(
async () => { async () => {
const versionResult = await versionChecker.checkServerVersion() try {
if (versionResult.shouldUpdate) { const versionResult = await versionChecker.checkServerVersion()
// 自动处理更新,不需要用户交互 if (versionResult.shouldUpdate) {
await versionChecker.handleVersionUpdate(versionResult.versionType) await versionChecker.handleVersionUpdate(versionResult.versionType)
}
} catch (error) {
console.warn('Scheduled version check failed:', error)
} }
}, },
5 * 60 * 1000, 5 * 60 * 1000,

View File

@ -13,8 +13,8 @@ class ImageCacheManager {
this.imageCacheName = `likei-images-v${this.currentImageVersion}` // OSS图片 this.imageCacheName = `likei-images-v${this.currentImageVersion}` // OSS图片
this.assetCacheName = `likei-assets-v${this.currentImageVersion}` // 本地assets this.assetCacheName = `likei-assets-v${this.currentImageVersion}` // 本地assets
this.maxCacheItems = 200 // 最大缓存数量200张 this.maxCacheItems = IMAGE_CACHE_VERSION.cacheLimits?.maxItems || 120
this.maxCacheSize = 200 * 1024 * 1024 // 最大缓存大小200MB this.maxCacheSize = (IMAGE_CACHE_VERSION.cacheLimits?.maxSizeMB || 80) * 1024 * 1024
this.currentImageCacheSize = 0 // 当前image缓存大小字节 this.currentImageCacheSize = 0 // 当前image缓存大小字节
this.currentAssetCacheSize = 0 // 当前asset缓存大小字节 this.currentAssetCacheSize = 0 // 当前asset缓存大小字节
@ -363,9 +363,9 @@ class ImageCacheManager {
cachedResponse = await cache.match(normalizedUrl) cachedResponse = await cache.match(normalizedUrl)
} }
// 忽略查询参数的 URL 匹配 // 只允许无版本参数的 URL 回退匹配无 query 缓存。
// 通过 urlObj.origin 和 urlObj.pathname 拼接生成 urlWithoutQuery去除查询参数如时间戳、token 等)后进行匹配。用于兼容同一资源因携带不同查询参数而导致 URL 不同,但实际资源内容相同的场景 // 带 iv 的版本化资源不能回退命中旧的无版本缓存
if (!cachedResponse) { if (!cachedResponse && !new URL(normalizedUrl).search) {
const urlObj = new URL(normalizedUrl) const urlObj = new URL(normalizedUrl)
const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}` const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}`
cachedResponse = await cache.match(urlWithoutQuery) cachedResponse = await cache.match(urlWithoutQuery)

View File

@ -1,34 +1,107 @@
// src/utils/imagePreloader.js
import { imageCacheManager } from '@/utils/image/imageCacheManager' import { imageCacheManager } from '@/utils/image/imageCacheManager'
export const preloadImages = async (sources) => { const DEFAULT_PRELOAD_CONCURRENCY = 3
function normalizeSources(sources) {
let urls = [] let urls = []
// 支持数组和对象形式的图片源
if (Array.isArray(sources)) { if (Array.isArray(sources)) {
urls = sources urls = sources
} else if (typeof sources === 'object') { } else if (sources && typeof sources === 'object') {
urls = Object.values(sources) urls = Object.values(sources)
} }
// 去重:确保每个 URL 只处理一次 return [...new Set(urls.filter((url) => typeof url === 'string' && url.trim()))]
const uniqueUrls = [...new Set(urls)] }
// 创建预加载 Promise 数组 function getSafeConcurrency(concurrency) {
const preloadPromises = uniqueUrls.map((url) => { const value = Number(concurrency)
if (imageCacheManager.isBuildAsset(url)) {
return imageCacheManager.scheduleRequest(url) // 使用调度器统一管理 if (!Number.isFinite(value) || value <= 0) {
} else { return DEFAULT_PRELOAD_CONCURRENCY
return imageCacheManager.scheduleRequest(url) }
}
}) return Math.max(1, Math.min(Math.floor(value), 6))
}
// 等待所有预加载完成
try { async function runWithConcurrency(items, concurrency, worker) {
await Promise.all(preloadPromises) const results = new Array(items.length)
console.log('图片预加载完成') let cursor = 0
} catch (error) {
console.error('预加载图片失败:', error) async function runNext() {
throw error const currentIndex = cursor
} cursor += 1
if (currentIndex >= items.length) {
return
}
try {
results[currentIndex] = {
status: 'fulfilled',
value: await worker(items[currentIndex], currentIndex),
}
} catch (error) {
results[currentIndex] = {
status: 'rejected',
reason: error,
}
}
await runNext()
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, runNext)
await Promise.all(workers)
return results
}
function waitForIdle(timeout = 1200) {
if (typeof window === 'undefined') {
return Promise.resolve()
}
if (typeof window.requestIdleCallback === 'function') {
return new Promise((resolve) => {
window.requestIdleCallback(resolve, { timeout })
})
}
return new Promise((resolve) => {
window.setTimeout(resolve, 16)
})
}
export const preloadImages = async (sources, options = {}) => {
const uniqueUrls = normalizeSources(sources)
if (!uniqueUrls.length) {
return []
}
if (options.idle) {
await waitForIdle(options.idleTimeout)
}
const results = await runWithConcurrency(
uniqueUrls,
getSafeConcurrency(options.concurrency),
(url) => imageCacheManager.scheduleRequest(url),
)
const failedResults = results.filter((result) => result?.status === 'rejected')
if (failedResults.length && options.throwOnError) {
throw failedResults[0].reason
}
return results
}
export const preloadImagesIdle = (sources, options = {}) => {
return preloadImages(sources, {
...options,
idle: true,
})
} }

View File

@ -7,7 +7,6 @@ import { existsSync } from 'node:fs'
import { isAbsolute, relative, resolve } from 'node:path' import { isAbsolute, relative, resolve } from 'node:path'
import { VantResolver } from 'unplugin-vue-components/resolvers' import { VantResolver } from 'unplugin-vue-components/resolvers'
const buildTimestamp = Date.now()
const PRODUCTION_LIKE_MODES = new Set(['production', 'production-atu']) const PRODUCTION_LIKE_MODES = new Set(['production', 'production-atu'])
const STRIPPABLE_CONSOLE_METHODS = ['log', 'debug', 'info'] const STRIPPABLE_CONSOLE_METHODS = ['log', 'debug', 'info']
const PUBLIC_DIR = fileURLToPath(new URL('./public', import.meta.url)) const PUBLIC_DIR = fileURLToPath(new URL('./public', import.meta.url))
@ -293,17 +292,17 @@ export default defineConfig(({ mode }) => {
}, },
rollupOptions: { rollupOptions: {
output: { output: {
entryFileNames: `js/[hash]-${buildTimestamp}.js`, entryFileNames: 'js/[hash].js',
chunkFileNames: `js/[hash]-${buildTimestamp}.js`, chunkFileNames: 'js/[hash].js',
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
const info = assetInfo.name.split('.') const info = assetInfo.name.split('.')
const ext = info[info.length - 1] const ext = info[info.length - 1]
if (/\.(css)$/.test(assetInfo.name)) { if (/\.(css)$/.test(assetInfo.name)) {
return `css/[hash]-${buildTimestamp}.[ext]` return 'css/[hash].[ext]'
} }
return `assets/[hash]-${buildTimestamp}.[ext]` return 'assets/[hash].[ext]'
}, },
}, },
}, },