From 6fb0e264b8d514a97cbd832d53c2e5d17fa6d725 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Tue, 16 Jun 2026 11:53:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor(i18n):=20=E9=87=8D=E6=9E=84=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=A8=A1=E5=9D=97=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=80=92=E5=BD=92=E5=92=8C=E7=BC=93=E5=AD=98=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加递归保护机制防止$lang调用栈溢出 2. 新增storage缓存层减少重复调用uni.getStorageSync 3. 修复语言包合并污染缓存问题,改用深拷贝合并 4. 增加循环引用检测避免解析异常 5. 注释掉冗余的console.error日志 6. 新增重入保护防止refresh死循环 7. 优化切语言/切租户时的缓存清理逻辑 --- common/js/config-external.js | 2 +- common/js/lang.js | 262 ++++++++++++++++++++++++----------- 2 files changed, 181 insertions(+), 83 deletions(-) diff --git a/common/js/config-external.js b/common/js/config-external.js index ad376fa..14cf379 100644 --- a/common/js/config-external.js +++ b/common/js/config-external.js @@ -90,7 +90,7 @@ class ConfigExternal { this.loadedConfigs[`lang_${lang}`] = langData; resolve(langData); } catch (error) { - console.error(`加载语言包 ${lang} 失败:`, error); + // console.error(`加载语言包 ${lang} 失败:`, error); reject(error); } }); diff --git a/common/js/lang.js b/common/js/lang.js index f1d2795..7029c04 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -6,6 +6,63 @@ import { Platform } from './platform.js'; // 缓存已加载的语言包 const loadedLangPacks = {}; +// 递归调用保护:防止 $lang() 被间接递归(如响应式 getter 回调中再次调用 $lang) +let _langRecursionDepth = 0; +const MAX_LANG_RECURSION_DEPTH = 8; + +// refresh 重入保护:防止 refresh() 内部调用 lang() 触发事件后再次调用 refresh() +let _refreshInProgress = false; + +// storage 结果内存缓存:防止反复调用 uni.getStorageSync 触发 Vue 事件导致循环 +// key: storage key, value: { value, ts } +const _storageResultCache = {}; +const STORAGE_CACHE_TTL_MS = 200; // 200ms 内复用缓存值,足以阻断单次渲染中的事件循环 + +// 从缓存读取 storage 值,未命中则调用 uni.getStorageSync 并存入缓存 +function cachedStorageGet(key, defaultValue) { + const now = Date.now(); + const cached = _storageResultCache[key]; + if (cached && (now - cached.ts) < STORAGE_CACHE_TTL_MS) { + return cached.value; + } + let value; + try { + value = uni.getStorageSync(key); + } catch (e) { + value = defaultValue; + } + _storageResultCache[key] = { value, ts: now }; + return value; +} + +// 手动清除 storage 缓存(切语言/切租户时调用) +function invalidateStorageCache(key) { + if (key) { + delete _storageResultCache[key]; + } else { + Object.keys(_storageResultCache).forEach(k => delete _storageResultCache[k]); + } +} + +// 语言包安全深复制:JSON 安全的对象即可,不处理函数/symbol/循环引用 +function deepCloneLang(obj) { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) { + const result = new Array(obj.length); + for (let i = 0; i < obj.length; i++) { + result[i] = deepCloneLang(obj[i]); + } + return result; + } + const result = {}; + for (const k in obj) { + if (Object.prototype.hasOwnProperty.call(obj, k)) { + result[k] = deepCloneLang(obj[k]); + } + } + return result; +} + // 远端覆写:内存缓存 // shape: { [app_lang]: { version, map, ts, global_version } } // key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味 @@ -111,7 +168,7 @@ function loadLangPackSync(lang, path) { loadedLangPacks[`${lang}_${path}`] = langData; return langData; } catch (error) { - console.error(`加载语言包 ${lang}/${path} 失败:`, error); + // console.error(`加载语言包 ${lang}/${path} 失败:`, error); return {}; } } @@ -121,7 +178,9 @@ function loadLangPackSync(lang, path) { * @returns */ function getCurrentLocale() { - return uni.getStorageSync('lang') || langConfig.defaultLang; + // 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归 + const val = cachedStorageGet('lang', null); + return val || langConfig.defaultLang; } /** @@ -129,7 +188,8 @@ function getCurrentLocale() { * 复用 Config.uniacid 作为兜底,避免硬编码 0 */ function getCurrentUniacid() { - const u = uni.getStorageSync('uniacid'); + // 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归 + const u = cachedStorageGet('uniacid', null); return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid; } @@ -170,6 +230,8 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function applyOverrides(merged, flatMap) { if (!flatMap) return; + // 遍历路径 visited 集合:防止循环引用(如 cur[p] 恰好指向 cur 自身或父级) + const visited = new Set(); for (const key in flatMap) { if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue; const val = flatMap[key]; @@ -179,12 +241,18 @@ function applyOverrides(merged, flatMap) { const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p)); if (parts.length === 0) continue; let cur = merged; + visited.clear(); + visited.add(cur); for (let i = 0; i < parts.length - 1; i++) { const p = parts[i]; - if (typeof cur[p] !== 'object' || cur[p] === null || Array.isArray(cur[p])) { + // 条件:非对象 / null / 数组 → 必须创建新对象 + // 或:已在 visited 中(循环引用) → 创建新对象覆盖 + const needsNew = typeof cur[p] !== 'object' || cur[p] === null || Array.isArray(cur[p]) || visited.has(cur[p]); + if (needsNew) { cur[p] = {}; } cur = cur[p]; + visited.add(cur); } cur[parts[parts.length - 1]] = val; } @@ -379,6 +447,8 @@ export default { if (SOURCE === 'local') return; // 尊重配置项:关闭时不强制刷新 if (langConfig.remote.refreshOnUniacidChange === false) return; + // 切租户后清除 storage 缓存,确保下次读时拿到新 uniacid + invalidateStorageCache('uniacid'); const lang = getCurrentLocale(); const sk = getOverrideStorageKey(lang); try { uni.removeStorageSync(sk); } catch (e) {} @@ -417,71 +487,90 @@ export default { * @param {Object} field */ lang(field) { - let _page = getCurrentPages()[getCurrentPages().length - 1]; - if (!_page) return field; - - const locale = getCurrentLocale(); // 获得当前本地语言 - - let value = field; // 存放解析后的语言值 - let langPath = ''; // 存放当前页面语言包路径 + // 递归保护:防止响应式 getter 回调中间接调用 $lang 导致栈溢出 + if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) { + return typeof field === 'string' ? field : String(field || ''); + } + _langRecursionDepth++; try { - //公共语言包(同步加载) - var lang = loadLangPackSync(locale, 'common'); + let _page = getCurrentPages()[getCurrentPages().length - 1]; + if (!_page) return typeof field === 'string' ? field : String(field || ''); - //当前页面语言包(同步加载) - let route = _page.route; - langPath = processRoutePath(route); + const locale = getCurrentLocale(); - // 加载当前页面语言包 - let currentPageLang = loadLangPackSync(locale, langPath); + let value = field; + let langPath = ''; + try { + //公共语言包(同步加载) + var lang = loadLangPackSync(locale, 'common'); - // 合并语言包 + 远端 KV 覆写 - // 兜底语义: - // - local 永远在底层(merge 进 mergedLang) - // - 远端覆写由 overrideCache[mk].map 提供;失败时 map 是 {} 或上次成功的值 - // - 无论 ok/fail,本次渲染都不会因为"远端拉不到"而崩 — 走的就是本地语言包 - const mergedLang = Object.assign({}, lang, currentPageLang); - const ovr = overrideCache[getOverrideMemKey(locale)]; - if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map); + //当前页面语言包(同步加载) + let route = _page.route; + langPath = processRoutePath(route); - // 解析字段 - var arr = field.split("."); - if (arr.length > 1) { - // 处理嵌套属性,如 common.currencySymbol - let temp = mergedLang; - let found = true; - for (let key of arr) { - if (temp[key] !== undefined) { - temp = temp[key]; - } else { - found = false; - break; + // 加载当前页面语言包 + let currentPageLang = loadLangPackSync(locale, langPath); + + // 合并语言包 + 远端 KV 覆写 + // 关键修复:使用深复制代替 Object.assign,避免 applyOverrides 污染 loadedLangPacks 缓存 + const mergedLang = Object.assign(deepCloneLang(lang), deepCloneLang(currentPageLang)); + const ovr = overrideCache[getOverrideMemKey(locale)]; + if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map); + + // 解析字段 + var arr = field.split("."); + if (arr.length > 1) { + // 处理嵌套属性,如 common.currencySymbol + // 增加 visited 检测:防止嵌套对象中的循环引用导致遍历异常 + let temp = mergedLang; + let tempVisited = new Set([temp]); + let found = true; + for (let key of arr) { + if (temp[key] !== undefined) { + temp = temp[key]; + if (tempVisited.has(temp)) { + // 检测到循环引用,立即中断,回退到原始 field + found = false; + break; + } + tempVisited.add(temp); + } else { + found = false; + break; + } } + // 关键修复:确保 value 始终是字符串,不是对象 + value = found ? (typeof temp === 'string' ? temp : field) : field; + } else { + const raw = mergedLang[field]; + // 关键修复:确保 value 始终是字符串,不是对象 + value = (raw !== undefined && typeof raw === 'string') ? raw : field; } - value = found ? temp : field; - } else { - value = mergedLang[field] !== undefined ? mergedLang[field] : field; + } catch (e) { + console.error('解析语言包失败:', e, { langPath, field, locale }); + value = field; } - } catch (e) { - console.error('解析语言包失败:', e, { langPath, field, locale }); - value = field; - } - if (arguments.length > 1) { - //有参数,需要替换 - for (var i = 1; i < arguments.length; i++) { - value = String(value).replace("{" + (i - 1) + "}", arguments[i]); + if (arguments.length > 1) { + //有参数,需要替换 + for (var i = 1; i < arguments.length; i++) { + value = String(value).replace("{" + (i - 1) + "}", arguments[i]); + } } - } - if (value == undefined || (value == 'title' && field == 'title')) value = ''; // field + if (value == undefined || (value == 'title' && field == 'title')) value = ''; // field - // 多语言调试,只在开发模式输出 - const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production'); - if (__DEV__ && field == value) { - console.warn(`[i18n] missing key "${field}" in ${langPath || ''} (${locale})`); - } + // 多语言调试,只在开发模式输出 + const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production'); + if (__DEV__ && field == value) { + console.warn(`[i18n] missing key "${field}" in ${langPath || ''} (${locale})`); + } - return value; + return value; + } finally { + // 确保无论正常返回还是异常退出,递归深度计数器都会递减 + _langRecursionDepth--; + if (_langRecursionDepth < 0) _langRecursionDepth = 0; + } }, /** @@ -495,6 +584,8 @@ export default { const prevLocale = getCurrentLocale(); uni.setStorageSync("lang", value); + // 切语言后清除 storage 缓存,确保下次读时拿到新值 + invalidateStorageCache('lang'); const locale = getCurrentLocale(); // 清空已加载的"非新语种"语言包缓存 @@ -525,33 +616,40 @@ export default { // @param {boolean} [emit=true] 是否触发 setComponentRefresh // change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复 refresh(emit = true) { - let _page = getCurrentPages()[getCurrentPages().length - 1]; - if (_page) this.title(this.lang("title")); + // 重入保护:防止 refresh() 内部调用 lang() → 触发 storage 事件 → App.vue 再次调用 refresh() 形成死循环 + if (_refreshInProgress) return; + _refreshInProgress = true; + try { + let _page = getCurrentPages()[getCurrentPages().length - 1]; + if (_page) this.title(this.lang("title")); - if (emit) { - // 远端 i18n 翻译更新后,推送重渲染信号; - // 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh - // 注册为全局 computed,commit 后所有引用它的组件自动重渲染 - emitUpdated(getCurrentLocale()); + if (emit) { + // 远端 i18n 翻译更新后,推送重渲染信号; + // 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh + // 注册为全局 computed,commit 后所有引用它的组件自动重渲染 + emitUpdated(getCurrentLocale()); + } + + //设置tabbar的文字语言 + // uni.setTabBarItem({ + // index: 0, + // text: this.lang("tabBar.home") + // }); + // uni.setTabBarItem({ + // index: 1, + // text: this.lang("tabBar.category") + // }); + // uni.setTabBarItem({ + // index: 2, + // text: this.lang("tabBar.cart") + // }); + // uni.setTabBarItem({ + // index: 3, + // text: this.lang("tabBar.member") + // }); + } finally { + _refreshInProgress = false; } - - //设置tabbar的文字语言 - // uni.setTabBarItem({ - // index: 0, - // text: this.lang("tabBar.home") - // }); - // uni.setTabBarItem({ - // index: 1, - // text: this.lang("tabBar.category") - // }); - // uni.setTabBarItem({ - // index: 2, - // text: this.lang("tabBar.cart") - // }); - // uni.setTabBarItem({ - // index: 3, - // text: this.lang("tabBar.member") - // }); }, title(str) {