refactor(i18n): 重构多语言模块,修复递归和缓存问题

1.  添加递归保护机制防止$lang调用栈溢出
2.  新增storage缓存层减少重复调用uni.getStorageSync
3.  修复语言包合并污染缓存问题,改用深拷贝合并
4.  增加循环引用检测避免解析异常
5.  注释掉冗余的console.error日志
6.  新增重入保护防止refresh死循环
7.  优化切语言/切租户时的缓存清理逻辑
This commit is contained in:
2026-06-16 11:53:45 +08:00
parent 64d3ac8f8a
commit 6fb0e264b8
2 changed files with 181 additions and 83 deletions

View File

@@ -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);
}
});

View File

@@ -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,13 +487,19 @@ export default {
* @param {Object} field
*/
lang(field) {
// 递归保护:防止响应式 getter 回调中间接调用 $lang 导致栈溢出
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
return typeof field === 'string' ? field : String(field || '');
}
_langRecursionDepth++;
try {
let _page = getCurrentPages()[getCurrentPages().length - 1];
if (!_page) return field;
if (!_page) return typeof field === 'string' ? field : String(field || '');
const locale = getCurrentLocale(); // 获得当前本地语言
const locale = getCurrentLocale();
let value = field; // 存放解析后的语言值
let langPath = ''; // 存放当前页面语言包路径
let value = field;
let langPath = '';
try {
//公共语言包(同步加载)
var lang = loadLangPackSync(locale, 'common');
@@ -436,11 +512,8 @@ export default {
let currentPageLang = loadLangPackSync(locale, langPath);
// 合并语言包 + 远端 KV 覆写
// 兜底语义:
// - local 永远在底层(merge 进 mergedLang)
// - 远端覆写由 overrideCache[mk].map 提供;失败时 map 是 {} 或上次成功的值
// - 无论 ok/fail,本次渲染都不会因为"远端拉不到"而崩 — 走的就是本地语言包
const mergedLang = Object.assign({}, lang, currentPageLang);
// 关键修复:使用深复制代替 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);
@@ -448,19 +521,30 @@ export default {
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 = found ? temp : field;
// 关键修复:确保 value 始终是字符串,不是对象
value = found ? (typeof temp === 'string' ? temp : field) : field;
} else {
value = mergedLang[field] !== undefined ? mergedLang[field] : field;
const raw = mergedLang[field];
// 关键修复:确保 value 始终是字符串,不是对象
value = (raw !== undefined && typeof raw === 'string') ? raw : field;
}
} catch (e) {
console.error('解析语言包失败:', e, { langPath, field, locale });
@@ -482,6 +566,11 @@ export default {
}
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,6 +616,10 @@ export default {
// @param {boolean} [emit=true] 是否触发 setComponentRefresh
// change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复
refresh(emit = true) {
// 重入保护:防止 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"));
@@ -552,6 +647,9 @@ export default {
// index: 3,
// text: this.lang("tabBar.member")
// });
} finally {
_refreshInProgress = false;
}
},
title(str) {