refactor(i18n): 重构多语言模块,修复递归和缓存问题
1. 添加递归保护机制防止$lang调用栈溢出 2. 新增storage缓存层减少重复调用uni.getStorageSync 3. 修复语言包合并污染缓存问题,改用深拷贝合并 4. 增加循环引用检测避免解析异常 5. 注释掉冗余的console.error日志 6. 新增重入保护防止refresh死循环 7. 优化切语言/切租户时的缓存清理逻辑
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user