refactor(i18n): 完善多语言适配与缓存隔离逻辑

1. 扩展支持多语种并对齐后端白名单配置
2. 实现租户/应用/语言三维缓存隔离
3. 加固语言包合并的原型链安全防护
4. 优化语言列表加载逻辑与调试日志
5. 调整uniacid切换与语言更新的事件处理
This commit is contained in:
2026-06-10 14:26:48 +08:00
parent 071db2d70c
commit 2f5f23d21a
3 changed files with 89 additions and 49 deletions

22
App.vue
View File

@@ -11,30 +11,28 @@
mixins: [auth],
onLaunch: async function(options) {
// 方式:支持快应用,从url中query部分获取uniacid,或useragent中获取uniacid
if(options.query.uniacid){
const uniacidChanged = options.query.uniacid && String(uni.getStorageSync('uniacid') || '') !== String(options.query.uniacid);
if (options.query.uniacid) {
uni.setStorageSync('uniacid', options.query.uniacid);
}
// uniacid 显式切换后,主动通知 Lang 清旧租户缓存,避免 boot 期间读到旧值
if (uniacidChanged) {
try { Lang.onUniacidChange(); } catch (e) {}
}
// 首屏强依赖远端翻译覆写(多租户文案强隔离)
// boot() 内部已有 bootTimeout 上限 + 失败静默,这里包 try 防止阻塞首屏
try { await Lang.boot(); } catch (e) { console.warn('[i18n] boot fail', e); }
// 监听远端翻译更新事件(管理后台改了词条后由后端推送 / 主动 reload)
// 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,这里
// 只做副作用(navigation title 等),不重复 forceUpdate
uni.$on && uni.$on('lang:updated', (info) => {
// 复用现有 componentRefresh 机制,触发所有引用它的组件 $forceUpdate
// 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,
// 这里再调用一次 Lang.refresh() 是为了保证 setNavigationBarTitle
// 这类副作用也被一并触发
if (info && info.lang) {
Lang.refresh();
Lang.refresh(true); // true = 显式推送 emitUpdated(虽然 loadOverrides 内部已发,这里保险起见)
}
});
// 远端不可用 -> 自动落到本地兜底;这里给个静默提示,业务可自行改 UI
uni.$on && uni.$on('lang:fallback', (info) => {
// console.warn('[i18n] remote unavailable, fallback to local', info);
// 不阻塞首屏,不抛错,仅记录
});
// lang:fallback / lang:remote-status 不在 App.vue 监听,留给业务按需订阅
uni.hideTabBar();
// #ifdef MP

View File

@@ -3,8 +3,9 @@
// 语言包配置
export const langConfig = {
// 语言列表
langList: ['zh-cn', 'en-us'],
// 语言列表 — 与后端 Lang.php::overrides() 白名单对齐
// 增删需要后端同步改 LangOverrideModel::normalizeAppType 旁的 $allow_lang
langList: ['zh-cn', 'en-us', 'zh-tw', 'ja-jp', 'ko-kr', 'th-th', 'vi-vn', 'id-id', 'es-es', 'fr-fr', 'de-de', 'ru-ru', 'ar-sa', 'pt-pt', 'it-it', 'tr-tr'],
// 默认语言
defaultLang: 'zh-cn',
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
@@ -22,7 +23,8 @@ export const langConfig = {
bootTimeout: 2000,
// 单次请求超时(ms)
requestTimeout: 4000,
// 本地缓存 key 前缀: <prefix><uniacid>_<lang>
// 本地缓存 key 前缀: <prefix><uniacid>_<appType>_<lang>
// 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致
storagePrefix: 'LANG_OVR_',
// 切租户/uniacid 时强制重拉
refreshOnUniacidChange: true,

View File

@@ -28,22 +28,41 @@ function normalizeSource(s) {
}
const SOURCE = normalizeSource(langConfig.source);
/**
* 远端失败原因归一(对外稳定枚举,内部细节不外泄)
* - timeout : 网络/服务端超时
* - network : 网络层失败(断网/DNS/...)
* - http : 业务码非 0
* - parse : 响应解析失败
* - exception : JS 异常
*/
function normalizeReason(s) {
const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown'];
return allow.includes(s) ? s : 'unknown';
}
/**
* 设置远端状态 + 触发监听器 + 发 uni.$emit
* 注意:ok 和 fail 都是"终态",允许相互覆盖
* detail 字段只对外透出 enum(无原始 message / error),避免内部栈外泄
*/
function setRemoteStatus(s, detail) {
if (remoteStatus === s) return;
const prev = remoteStatus;
remoteStatus = s;
remoteStatusAt = Date.now();
// 归一化 detail:reason,detail 内部不向外透 error/message
const safeDetail = {
reason: normalizeReason(detail && detail.reason),
lang: detail && detail.lang,
};
remoteStatusListeners.forEach(fn => {
try { fn(s, detail || {}); } catch (e) { /* 忽略监听器异常 */ }
try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ }
});
try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...(detail || {}) }); } catch (e) {}
try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {}
// 状态变成 fail 时,额外发一个"已切到本地兜底"事件,方便 UI 弹 toast
if (s === 'fail') {
try { uni.$emit && uni.$emit('lang:fallback', { reason: (detail && detail.reason) || 'remote-unavailable' }); } catch (e) {}
try { uni.$emit && uni.$emit('lang:fallback', { reason: safeDetail.reason }); } catch (e) {}
}
}
@@ -124,7 +143,7 @@ function getCurrentAppType() {
/**
* 持久化 key: <prefix><uniacid>_<appType>_<lang>
* 加入 appType 是为了:H5 端缓存不会污染 MP/APP 端
* 三维隔离:多租户 × 多端 × 多语种,任何一维切换都拿不到旧值
*/
function getOverrideStorageKey(lang) {
const u = getCurrentUniacid();
@@ -133,18 +152,22 @@ function getOverrideStorageKey(lang) {
}
/**
* 内存 cache key: <appType>_<lang>
* 与 storage key 一样按平台+语种二维区分
* 内存 cache key: <uniacid>_<appType>_<lang>
* 必须与 storage key 三维一致 — 切租户时内存不会"巧合"命中旧值
*/
function getOverrideMemKey(lang) {
return `${getCurrentAppType()}_${lang}`;
return `${getCurrentUniacid()}_${getCurrentAppType()}_${lang}`;
}
/**
* 把"远端扁平覆写 map"按 key 路径合并到 nested 对象上
* null/undefined/非 string 视为"还原",不写入(本地值保留)
* 空串 "" 是合法覆写,会写入
*
* 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function applyOverrides(merged, flatMap) {
if (!flatMap) return;
for (const key in flatMap) {
@@ -152,7 +175,9 @@ function applyOverrides(merged, flatMap) {
const val = flatMap[key];
// 类型白名单:只接 string,其它都视为非法值,跳过(不污染 merged)
if (typeof val !== 'string') continue;
const parts = String(key).split('.');
// 危险 key 过滤
const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p));
if (parts.length === 0) continue;
let cur = merged;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
@@ -450,9 +475,10 @@ export default {
}
if (value == undefined || (value == 'title' && field == 'title')) value = ''; // field
// 多语言调试,注释后可以关闭控制台输出
if (field == value) {
console.warn(`警告: 字段 ${field} 在语言包 ${langPath} 中未找到对应值,使用默认值 ${field} 当前语言: ${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 || '<root>'} (${locale})`);
}
return value;
@@ -467,23 +493,28 @@ export default {
let _page = getCurrentPages()[getCurrentPages().length - 1];
if (!_page) return;
const prevLocale = getCurrentLocale();
uni.setStorageSync("lang", value);
const locale = getCurrentLocale();
// 清空已加载的语言包缓存
for (let key in loadedLangPacks) {
if (!key.startsWith(locale)) {
delete loadedLangPacks[key];
}
}
// 清空已加载的"非新语种"语言包缓存
// 用 Object.keys 防止被原型链污染(for...in 会遍历继承属性)
Object.keys(loadedLangPacks).forEach(key => {
if (!key.startsWith(locale + '_')) delete loadedLangPacks[key];
});
// 清空内存里的"非新语种"远端覆写 — 防止反复切语种导致内存泄漏
Object.keys(overrideCache).forEach(mk => {
if (!mk.endsWith('_' + locale)) delete overrideCache[mk];
});
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
// 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit
if (SOURCE !== 'local') {
try { await loadOverrides(value); } catch (e) { /* 静默 */ }
}
this.refresh();
this.refresh(false); // false = 不重复 emitUpdated
if (url) {
uni.reLaunch({ url: url });
@@ -491,14 +522,18 @@ export default {
},
//刷新标题、tabbar
refresh() {
// @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"));
// 远端 i18n 翻译更新后,推送重渲染信号;
// 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh
// 注册为全局 computed,commit 后所有引用它的组件自动重渲染
emitUpdated(getCurrentLocale());
if (emit) {
// 远端 i18n 翻译更新后,推送重渲染信号;
// 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh
// 注册为全局 computed,commit 后所有引用它的组件自动重渲染
emitUpdated(getCurrentLocale());
}
//设置tabbar的文字语言
// uni.setTabBarItem({
@@ -532,20 +567,25 @@ export default {
},
// 获取语言包列表
// 优化:只读取 common.name 字段,不要全量 require 进 loadedLangPacks
// 否则后续切到不常用的语种会"恰好命中缓存",但首次访问要全量加载
list() {
var list = [];
try {
//公共语言包
for (var i = 0; i < langConfig.langList.length; i++) {
let langType = langConfig.langList[i];
let item = loadLangPackSync(langType, 'common');
list.push({
name: item.common ? item.common.name : langType,
value: langType
});
// 用一次性临时缓存避免污染 loadedLangPacks
const tmp = {};
const getCommonName = (langType) => {
try {
if (tmp[langType]) return tmp[langType];
const data = require(`@/lang/${langType}/common.js`).lang;
tmp[langType] = (data && data.common) ? data.common.name : langType;
return tmp[langType];
} catch (e) {
return langType;
}
} catch (e) {
console.error('获取语言包列表失败:', e);
};
for (var i = 0; i < langConfig.langList.length; i++) {
let langType = langConfig.langList[i];
list.push({ name: getCommonName(langType), value: langType });
}
return list;
},