refactor(i18n): 完善多语言适配与缓存隔离逻辑
1. 扩展支持多语种并对齐后端白名单配置 2. 实现租户/应用/语言三维缓存隔离 3. 加固语言包合并的原型链安全防护 4. 优化语言列表加载逻辑与调试日志 5. 调整uniacid切换与语言更新的事件处理
This commit is contained in:
22
App.vue
22
App.vue
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user