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;
|
this.loadedConfigs[`lang_${lang}`] = langData;
|
||||||
resolve(langData);
|
resolve(langData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`加载语言包 ${lang} 失败:`, error);
|
// console.error(`加载语言包 ${lang} 失败:`, error);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,63 @@ import { Platform } from './platform.js';
|
|||||||
// 缓存已加载的语言包
|
// 缓存已加载的语言包
|
||||||
const loadedLangPacks = {};
|
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 } }
|
// shape: { [app_lang]: { version, map, ts, global_version } }
|
||||||
// key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味
|
// key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味
|
||||||
@@ -111,7 +168,7 @@ function loadLangPackSync(lang, path) {
|
|||||||
loadedLangPacks[`${lang}_${path}`] = langData;
|
loadedLangPacks[`${lang}_${path}`] = langData;
|
||||||
return langData;
|
return langData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`加载语言包 ${lang}/${path} 失败:`, error);
|
// console.error(`加载语言包 ${lang}/${path} 失败:`, error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +178,9 @@ function loadLangPackSync(lang, path) {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
function getCurrentLocale() {
|
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
|
* 复用 Config.uniacid 作为兜底,避免硬编码 0
|
||||||
*/
|
*/
|
||||||
function getCurrentUniacid() {
|
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;
|
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) {
|
function applyOverrides(merged, flatMap) {
|
||||||
if (!flatMap) return;
|
if (!flatMap) return;
|
||||||
|
// 遍历路径 visited 集合:防止循环引用(如 cur[p] 恰好指向 cur 自身或父级)
|
||||||
|
const visited = new Set();
|
||||||
for (const key in flatMap) {
|
for (const key in flatMap) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
|
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
|
||||||
const val = flatMap[key];
|
const val = flatMap[key];
|
||||||
@@ -179,12 +241,18 @@ function applyOverrides(merged, flatMap) {
|
|||||||
const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p));
|
const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p));
|
||||||
if (parts.length === 0) continue;
|
if (parts.length === 0) continue;
|
||||||
let cur = merged;
|
let cur = merged;
|
||||||
|
visited.clear();
|
||||||
|
visited.add(cur);
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
const p = parts[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[p] = {};
|
||||||
}
|
}
|
||||||
cur = cur[p];
|
cur = cur[p];
|
||||||
|
visited.add(cur);
|
||||||
}
|
}
|
||||||
cur[parts[parts.length - 1]] = val;
|
cur[parts[parts.length - 1]] = val;
|
||||||
}
|
}
|
||||||
@@ -379,6 +447,8 @@ export default {
|
|||||||
if (SOURCE === 'local') return;
|
if (SOURCE === 'local') return;
|
||||||
// 尊重配置项:关闭时不强制刷新
|
// 尊重配置项:关闭时不强制刷新
|
||||||
if (langConfig.remote.refreshOnUniacidChange === false) return;
|
if (langConfig.remote.refreshOnUniacidChange === false) return;
|
||||||
|
// 切租户后清除 storage 缓存,确保下次读时拿到新 uniacid
|
||||||
|
invalidateStorageCache('uniacid');
|
||||||
const lang = getCurrentLocale();
|
const lang = getCurrentLocale();
|
||||||
const sk = getOverrideStorageKey(lang);
|
const sk = getOverrideStorageKey(lang);
|
||||||
try { uni.removeStorageSync(sk); } catch (e) {}
|
try { uni.removeStorageSync(sk); } catch (e) {}
|
||||||
@@ -417,71 +487,90 @@ export default {
|
|||||||
* @param {Object} field
|
* @param {Object} field
|
||||||
*/
|
*/
|
||||||
lang(field) {
|
lang(field) {
|
||||||
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
// 递归保护:防止响应式 getter 回调中间接调用 $lang 导致栈溢出
|
||||||
if (!_page) return field;
|
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
|
||||||
|
return typeof field === 'string' ? field : String(field || '');
|
||||||
const locale = getCurrentLocale(); // 获得当前本地语言
|
}
|
||||||
|
_langRecursionDepth++;
|
||||||
let value = field; // 存放解析后的语言值
|
|
||||||
let langPath = ''; // 存放当前页面语言包路径
|
|
||||||
try {
|
try {
|
||||||
//公共语言包(同步加载)
|
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
||||||
var lang = loadLangPackSync(locale, 'common');
|
if (!_page) return typeof field === 'string' ? field : String(field || '');
|
||||||
|
|
||||||
//当前页面语言包(同步加载)
|
const locale = getCurrentLocale();
|
||||||
let route = _page.route;
|
|
||||||
langPath = processRoutePath(route);
|
|
||||||
|
|
||||||
// 加载当前页面语言包
|
let value = field;
|
||||||
let currentPageLang = loadLangPackSync(locale, langPath);
|
let langPath = '';
|
||||||
|
try {
|
||||||
|
//公共语言包(同步加载)
|
||||||
|
var lang = loadLangPackSync(locale, 'common');
|
||||||
|
|
||||||
// 合并语言包 + 远端 KV 覆写
|
//当前页面语言包(同步加载)
|
||||||
// 兜底语义:
|
let route = _page.route;
|
||||||
// - local 永远在底层(merge 进 mergedLang)
|
langPath = processRoutePath(route);
|
||||||
// - 远端覆写由 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);
|
|
||||||
|
|
||||||
// 解析字段
|
// 加载当前页面语言包
|
||||||
var arr = field.split(".");
|
let currentPageLang = loadLangPackSync(locale, langPath);
|
||||||
if (arr.length > 1) {
|
|
||||||
// 处理嵌套属性,如 common.currencySymbol
|
// 合并语言包 + 远端 KV 覆写
|
||||||
let temp = mergedLang;
|
// 关键修复:使用深复制代替 Object.assign,避免 applyOverrides 污染 loadedLangPacks 缓存
|
||||||
let found = true;
|
const mergedLang = Object.assign(deepCloneLang(lang), deepCloneLang(currentPageLang));
|
||||||
for (let key of arr) {
|
const ovr = overrideCache[getOverrideMemKey(locale)];
|
||||||
if (temp[key] !== undefined) {
|
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
|
||||||
temp = temp[key];
|
|
||||||
} else {
|
// 解析字段
|
||||||
found = false;
|
var arr = field.split(".");
|
||||||
break;
|
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;
|
} catch (e) {
|
||||||
} else {
|
console.error('解析语言包失败:', e, { langPath, field, locale });
|
||||||
value = mergedLang[field] !== undefined ? mergedLang[field] : field;
|
value = field;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('解析语言包失败:', e, { langPath, field, locale });
|
|
||||||
value = field;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arguments.length > 1) {
|
if (arguments.length > 1) {
|
||||||
//有参数,需要替换
|
//有参数,需要替换
|
||||||
for (var i = 1; i < arguments.length; i++) {
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
value = String(value).replace("{" + (i - 1) + "}", arguments[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');
|
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
|
||||||
if (__DEV__ && field == value) {
|
if (__DEV__ && field == value) {
|
||||||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
} finally {
|
||||||
|
// 确保无论正常返回还是异常退出,递归深度计数器都会递减
|
||||||
|
_langRecursionDepth--;
|
||||||
|
if (_langRecursionDepth < 0) _langRecursionDepth = 0;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -495,6 +584,8 @@ export default {
|
|||||||
|
|
||||||
const prevLocale = getCurrentLocale();
|
const prevLocale = getCurrentLocale();
|
||||||
uni.setStorageSync("lang", value);
|
uni.setStorageSync("lang", value);
|
||||||
|
// 切语言后清除 storage 缓存,确保下次读时拿到新值
|
||||||
|
invalidateStorageCache('lang');
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
|
|
||||||
// 清空已加载的"非新语种"语言包缓存
|
// 清空已加载的"非新语种"语言包缓存
|
||||||
@@ -525,33 +616,40 @@ export default {
|
|||||||
// @param {boolean} [emit=true] 是否触发 setComponentRefresh
|
// @param {boolean} [emit=true] 是否触发 setComponentRefresh
|
||||||
// change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复
|
// change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复
|
||||||
refresh(emit = true) {
|
refresh(emit = true) {
|
||||||
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
// 重入保护:防止 refresh() 内部调用 lang() → 触发 storage 事件 → App.vue 再次调用 refresh() 形成死循环
|
||||||
if (_page) this.title(this.lang("title"));
|
if (_refreshInProgress) return;
|
||||||
|
_refreshInProgress = true;
|
||||||
|
try {
|
||||||
|
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
||||||
|
if (_page) this.title(this.lang("title"));
|
||||||
|
|
||||||
if (emit) {
|
if (emit) {
|
||||||
// 远端 i18n 翻译更新后,推送重渲染信号;
|
// 远端 i18n 翻译更新后,推送重渲染信号;
|
||||||
// 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh
|
// 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh
|
||||||
// 注册为全局 computed,commit 后所有引用它的组件自动重渲染
|
// 注册为全局 computed,commit 后所有引用它的组件自动重渲染
|
||||||
emitUpdated(getCurrentLocale());
|
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) {
|
title(str) {
|
||||||
|
|||||||
Reference in New Issue
Block a user