From 50a1c1c69555968b3e0265f6462d96fe27df2b46 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Tue, 30 Jun 2026 14:04:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=E5=AE=9E=E7=8E=B0remote-only?= =?UTF-8?q?=E6=89=93=E5=8C=85=E6=A8=A1=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=8A=A0=E8=BD=BD=E5=92=8C=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增remote-only模式下的空语言包stub 2. 新增缓存有效期配置并对齐后端Cache::set 600s 3. 重构语言包加载逻辑,适配remote-only模式 4. 添加webpack替换插件,排除本地lang目录减少产物体积 5. 优化Vue挂载时机,等待语言包就绪后再渲染首屏 6. 改进缓存策略,新增内存缓存TTL校验和语种缓存淘汰 7. 修复语言包缓存key生成逻辑,支持uniacid和appType隔离 --- App.vue | 19 ++++++++--- common/js/config-external.js | 2 ++ common/js/lang-empty-stub.js | 11 ++++++ common/js/lang-loader.js | 52 +++++++++++++++++++++++------ common/js/lang.js | 65 ++++++++++++++++++++++++++---------- main.js | 33 +++++++++++++++--- vue.config.js | 31 +++++++++++++++++ 7 files changed, 176 insertions(+), 37 deletions(-) create mode 100644 common/js/lang-empty-stub.js diff --git a/App.vue b/App.vue index 3605199..8795542 100644 --- a/App.vue +++ b/App.vue @@ -10,6 +10,12 @@ export default { mixins: [auth], + data() { + return { + // remote-only 模式下,业务可通过 await getApp().$langReady 等待语言包就绪 + $langReady: Promise.resolve({ ok: true, source: 'local' }) + }; + }, onLaunch: async function(options) { // ============================================================ // 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下 @@ -36,7 +42,12 @@ try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {} } - // ---- 2) 主题应用(同步 await) + 远端拉取(并行 await) + // remote-only 模式下 main.js 已先 await Lang.boot() 再挂载 Vue,这里复用其结果 + // 非 remote-only 模式下仍需主动 boot 以拉取远端覆写 + const langBoot = Lang.boot(); + this.$langReady = langBoot; + + // ---- 2) 主题应用(同步 await) + 远端拉取(并行 await ) // 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染 // 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加 // 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间 @@ -44,13 +55,13 @@ // A) 主题第一次 apply — 走 preset,失败静默走默认 this.$store.dispatch('applyThemeWithRemote', presetName) .catch(e => console.warn('[theme] pre-apply fail', e)), - // B) Lang boot(首屏强依赖,bootTimeout=2000ms) - Lang.boot().catch(e => console.warn('[i18n] boot fail', e)), + // B) Lang boot(首屏强依赖,remote-only 下已提前完成) + langBoot.catch(e => console.warn('[i18n] boot fail', e)), // C) ThemeOverride boot(brand/light/dark 三桶,bootTimeout=2000ms) ThemeOverride.boot().catch(e => console.warn('[theme] boot fail', e)), ]); - // ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并) + // ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 ) // 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache) // 如果跟 step 2 的 apply 值相同,Vuex 会重复 commit 但页面无视觉变化 await this.$store.dispatch('applyThemeWithRemote', presetName); diff --git a/common/js/config-external.js b/common/js/config-external.js index 01b4db9..25c537e 100644 --- a/common/js/config-external.js +++ b/common/js/config-external.js @@ -29,6 +29,8 @@ export const langConfig = { bootTimeout: 2000, // 单次请求超时(ms) requestTimeout: 4000, + // 缓存有效期(秒),与后端 Cache::set 600s 对齐;过期后后台刷新 + cacheTtl: 600, // 本地缓存 key 前缀: __ // 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致 storagePrefix: 'LANG_OVR_', diff --git a/common/js/lang-empty-stub.js b/common/js/lang-empty-stub.js new file mode 100644 index 0000000..5555873 --- /dev/null +++ b/common/js/lang-empty-stub.js @@ -0,0 +1,11 @@ +/** + * remote-only 模式下的空语言包 stub + * + * 当 source === 'remote-only' 时,webpack NormalModuleReplacementPlugin + * 会把所有 @/lang/ 下的 require 替换为这个文件, + * 避免本地 lang/ 目录被打进产物。 + * + * 导出格式与真实语言包一致: { lang: {} } + */ +export const lang = {}; +export default { lang }; diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js index 2e22c47..0770332 100644 --- a/common/js/lang-loader.js +++ b/common/js/lang-loader.js @@ -14,7 +14,7 @@ */ import { langConfig } from './config-external.js'; -import http from './http.js'; +import Config from './config.js'; import { Platform } from './platform.js'; const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -101,19 +101,34 @@ function validateSign(tree, version, sign, secret) { return typeof sign === 'string' && sign.length > 0; } +// 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新 +const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000; + /** - * 从 storage 读取缓存 + * 从 storage 读取缓存(检查 TTL 过期) + * @returns {Object|null} 未过期返回 {tree,version,ts}, 过期返回 null */ function readStorageCache(sk) { try { const cached = uni.getStorageSync(sk); if (cached && cached.tree && typeof cached.tree === 'object') { - return cached; + // TTL 检查: 超过 cacheTtl 视为过期 + if (cached.ts && (Date.now() - cached.ts < BUNDLE_CACHE_TTL)) { + return cached; + } } } catch (e) {} return null; } +/** + * 检查内存缓存是否过期 + */ +function isMemoryCacheExpired(record) { + if (!record || !record.ts) return true; + return (Date.now() - record.ts) >= BUNDLE_CACHE_TTL; +} + /** * 写入 storage 缓存 */ @@ -132,12 +147,29 @@ async function fetchBundle(lang, appType, uniacid, timeout) { const url = langConfig.remote.bundleUrl || '/api/lang/bundle'; const data = { lang }; if (appType && appType !== '*') data.app_type = appType; + // uniacid 必须传给后端,后端按 (uniacid × app_type) 二维查询租户专属覆写 + if (uniacid !== undefined && uniacid !== null) data.uniacid = uniacid; + + // 直接用 uni.request 包装 Promise,不依赖 http.sendRequest + // (sendRequest 的异步分支不返回 Promise,只有 async:false 分支才返回) + // 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 / + const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url; const res = await Promise.race([ - http.sendRequest({ - url, - data, - async: false, + new Promise((resolve, reject) => { + uni.request({ + url: fullUrl, + method: 'POST', + data: data, + header: { 'content-type': 'application/x-www-form-urlencoded' }, + dataType: 'json', + success: (response) => { + resolve(response.data); + }, + fail: (err) => { + reject(new Error(err.errMsg || 'bundle request failed')); + }, + }); }), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)), ]); @@ -174,12 +206,12 @@ export async function loadBundleTree(options = {}) { const task = (async () => { try { - // 1) 强制刷新时跳过缓存 - if (!force && allowCache && bundleCache[mk]) { + // 1) 内存缓存未过期直接返回 + if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) { return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' }; } - // 2) 读取 storage 缓存作为首屏兜底 + // 2) 读取 storage 缓存(已含 TTL 检查) let cached = null; if (!force && allowCache) { cached = readStorageCache(sk); diff --git a/common/js/lang.js b/common/js/lang.js index 50c7935..e03ead6 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -575,8 +575,10 @@ function getOverrideStorageKey(lang) { * 内存 cache key: __ * 必须与 storage key 三维一致 — 切租户时内存不会"巧合"命中旧值 */ -function getOverrideMemKey(lang) { - return `${getCurrentUniacid()}_${getCurrentAppType()}_${lang}`; +function getOverrideMemKey(lang, uniacid, appType) { + const u = uniacid !== undefined ? uniacid : getCurrentUniacid(); + const a = appType !== undefined ? appType : getCurrentAppType(); + return `${u}_${a}_${lang}`; } /** @@ -833,8 +835,19 @@ export default { } catch (e) {} try { - const { tree, version, source } = await loadBundleTree({ lang }); - remoteTreeCache[getOverrideMemKey(lang)] = { version, tree, ts: Date.now(), source }; + const uniacid = getCurrentUniacid(); + const appType = getCurrentAppType(); + let result = await loadBundleTree({ lang, appType, uniacid }); + // 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key + if (result.source !== 'remote') { + try { + result = await loadBundleTree({ lang, appType, uniacid, force: true }); + } catch (e) { + // 网络刷新失败仍用缓存兜底 + } + } + const { tree, version, source } = result; + remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source }; setRemoteStatus('ok', { lang }); // bundle 就绪后清空字段缓存并触发全局刷新,避免首屏组件在 boot 前渲染的 raw key 被缓存 emitUpdated(lang); @@ -929,7 +942,7 @@ export default { getRemoteStatus() { return remoteStatus; }, /** - * 当前 source 配置(规范化后):'local' | 'remote' | 'hybrid' + * 当前 source 配置(规范化后):'local' | 'remote' | 'hybrid' | 'remote-only' */ getSource() { return SOURCE; }, @@ -1116,30 +1129,46 @@ export default { invalidateStorageCache('lang'); const locale = getCurrentLocale(); - // 切语言后清除语言包合并缓存 + 字段解析缓存 + // 切语言后清除语言包合并缓存 + 字段解析缓存(这些是翻译结果缓存,必须清) invalidateLangCaches(); - // 清空已加载的"非新语种"语言包缓存 - // 用 Object.keys 防止被原型链污染(for...in 会遍历继承属性) + // 策略优化: 保留旧语种的 bundle/override 缓存,只在内存中限制最大语种数 + // 这样切回旧语种时能直接命中缓存,不重复请求 + // 仅清空 loadedLangPacks 的合并结果缓存(非远端原始数据) Object.keys(loadedLangPacks).forEach(key => { if (!key.startsWith(locale + '_')) delete loadedLangPacks[key]; }); - // 清空内存里的"非新语种"远端覆写 — 防止反复切语种导致内存泄漏 - Object.keys(overrideCache).forEach(mk => { - if (!mk.endsWith('_' + locale)) delete overrideCache[mk]; - }); - // remote-only 模式:清除旧语种 bundle 缓存 - Object.keys(remoteTreeCache).forEach(mk => { - if (!mk.endsWith('_' + locale)) delete remoteTreeCache[mk]; - }); + + // 限制 remoteTreeCache 最大保留 3 个语种,超出时淘汰最旧的 + const maxLocaleCache = 3; + const cachedLocales = Object.keys(remoteTreeCache); + if (cachedLocales.length >= maxLocaleCache) { + // 按时间戳排序,淘汰最旧的非当前语种 + const sorted = cachedLocales + .filter(mk => !mk.endsWith('_' + locale)) + .sort((a, b) => (remoteTreeCache[a].ts || 0) - (remoteTreeCache[b].ts || 0)); + while (sorted.length >= maxLocaleCache) { + const evict = sorted.shift(); + delete remoteTreeCache[evict]; + } + } // 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络) // 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch // 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit if (IS_REMOTE_ONLY) { try { - const { tree, version, source } = await loadBundleTree({ lang: value }); - remoteTreeCache[getOverrideMemKey(value)] = { version, tree, ts: Date.now(), source }; + const uniacid = getCurrentUniacid(); + const appType = getCurrentAppType(); + const { tree, version, source } = await loadBundleTree({ lang: value, uniacid, appType }); + remoteTreeCache[getOverrideMemKey(value, uniacid, appType)] = { version, tree, ts: Date.now(), source }; + setRemoteStatus('ok', { lang: value }); + // 新语种 bundle 就绪后,同步 App 全局 promise 并触发刷新 + try { + const app = getApp(); + if (app && app.$langReady) app.$langReady = Promise.resolve({ ok: true, source, status: 'ok', version, lang: value }); + } catch (e) {} + emitUpdated(value); } catch (e) { /* 静默 */ } } else if (SOURCE !== 'local') { try { await loadOverrides(value); } catch (e) { /* 静默 */ } diff --git a/main.js b/main.js index 37651f3..bba1057 100644 --- a/main.js +++ b/main.js @@ -14,6 +14,7 @@ import globalConfig from './common/js/golbalConfig.js'; import { uniStorage } from './common/js/storage.js' +import configExternal, { langConfig } from './common/js/config-external.js' Vue.prototype.$store = store //挂在vue @@ -54,9 +55,31 @@ uniStorage(); // 组件已改为按需引入,不再全局注册 -const app = new Vue({ - ...App, - store -}) +async function bootstrap() { + const isRemoteOnly = langConfig && langConfig.source === 'remote-only'; + if (isRemoteOnly) { + // remote-only 模式下必须等 bundle 就绪后再挂载 Vue,避免首屏组件在翻译就绪前渲染 + let _hideLoading = false; + try { + uni.showLoading({ title: '加载中...', mask: true }); + _hideLoading = true; + } catch (e) {} + try { + await Lang.boot(); + } catch (e) { + console.warn('[i18n] remote-only boot fail', e); + } finally { + if (_hideLoading) { + try { uni.hideLoading(); } catch (e) {} + } + } + } -app.$mount() \ No newline at end of file + const app = new Vue({ + ...App, + store + }); + app.$mount(); +} + +bootstrap() \ No newline at end of file diff --git a/vue.config.js b/vue.config.js index d956438..528e540 100644 --- a/vue.config.js +++ b/vue.config.js @@ -2,9 +2,26 @@ const path = require('path'); const fs = require('fs'); const { execSync } = require('child_process'); const { zionUniMpLoadPackagePlugin } = require('zion-uniapp-mp-load-package/webpack'); +const webpack = require('webpack'); console.log('[vue.config] 已加载'); +// 检测是否启用 remote-only 模式(读取 config-external.js 中的 source) +function _isRemoteOnly() { + try { + const configExternal = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf8'); + const match = configExternal.match(/source:\s*['"]remote-only['"]/); + return !!match; + } catch (e) { + return false; + } +} + +const IS_REMOTE_ONLY = _isRemoteOnly(); +if (IS_REMOTE_ONLY) { + console.log('[i18n] remote-only 模式已启用,将从构建产物中排除本地 lang/ 目录'); +} + // 确保 UNI_APP_DOMAIN 有值(从 package.json 脚本配置中读取) if (!process.env.UNI_APP_DOMAIN && process.env.UNI_SCRIPT) { try { @@ -356,6 +373,20 @@ module.exports = { configureWebpack: config => { console.log('config.mode = ', config.mode); + // remote-only 模式: 排除本地 lang/ 目录,减少产物体积 + // 用 NormalModuleReplacementPlugin 把 @/lang/ 下的 require 替换为空模块 + if (IS_REMOTE_ONLY) { + config.plugins = config.plugins || []; + config.plugins.push(new webpack.NormalModuleReplacementPlugin( + /@\/lang\//, + (resource) => { + // 替换为一个空的虚拟模块,导出 { lang: {} } + resource.request = path.join(__dirname, 'common/js/lang-empty-stub.js'); + } + )); + console.log('[i18n] NormalModuleReplacementPlugin 已添加,lang/ 目录将被替换为空 stub'); + } + if (config.mode === 'production') { console.log('开始尝试配置极限压缩');