1. add global i18n fallback toast on lang:fallback event, only show once per process 2. add getCachedBundleAny to retrieve expired cache for boot fallback 3. add cache fallback logic when remote i18n bundle load fails
441 lines
15 KiB
JavaScript
441 lines
15 KiB
JavaScript
/**
|
||
* 远端语言包 bundle 加载器
|
||
*
|
||
* 职责:
|
||
* 1. 从后端 /api/lang/bundle 拉取完整语言树
|
||
* 2. 做本地 storage 缓存 + 内存缓存
|
||
* 3. 支持签名校验(可选)
|
||
* 4. 支持 diff 增量更新(预留)
|
||
* 5. 对外暴露 loadBundleTree / getCachedBundle / clearBundleCache
|
||
*
|
||
* 与 lang.js 的关系:
|
||
* 当 langConfig.source === 'remote-only' 时,lang.js 不再 require 本地 lang/ 文件,
|
||
* 而是直接复用本模块加载的 tree。
|
||
*/
|
||
import { langConfig } from './config-external.js';
|
||
import Config from './config.js';
|
||
import { Platform } from './platform.js';
|
||
|
||
|
||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||
|
||
// 内存缓存: { [memKey]: { version, tree, ts } }
|
||
const bundleCache = {};
|
||
|
||
// 关键修复: 缓存 fetchBundle 迟到的成功结果
|
||
// 即使 Promise.race 已 timeout, uni.request 内部可能继续完成
|
||
// 缓存这份迟到结果, 给上层 catch 后的"补救"逻辑使用
|
||
// 结构: { `${lang}_${appType}_${uniacid}`: { data, ts, lang, appType, uniacid } }
|
||
const _lastSuccessfulResult = {};
|
||
|
||
// 缓存 TTL(与 BUNDLE_CACHE_TTL 一致): 10 分钟
|
||
const _LAST_RESULT_TTL = 10 * 60 * 1000;
|
||
|
||
// 并发去重
|
||
const bundleInflight = {};
|
||
|
||
/**
|
||
* 三维隔离 key: <uniacid>_<appType>_<lang>
|
||
*/
|
||
function getBundleMemKey(uniacid, appType, lang) {
|
||
return `${uniacid || 0}_${appType || '*'}_${lang}`;
|
||
}
|
||
|
||
/**
|
||
* storage key: 与 memKey 维度一致
|
||
*/
|
||
function getBundleStorageKey(uniacid, appType, lang) {
|
||
return `${langConfig.remote.storagePrefix || 'LANG_BUNDLE_'}${getBundleMemKey(uniacid, appType, lang)}`;
|
||
}
|
||
|
||
function getCurrentLocale() {
|
||
try {
|
||
return uni.getStorageSync('lang') || langConfig.defaultLang || 'zh-cn';
|
||
} catch (e) {
|
||
return langConfig.defaultLang || 'zh-cn';
|
||
}
|
||
}
|
||
|
||
function getCurrentUniacid() {
|
||
try {
|
||
const u = uni.getStorageSync('uniacid');
|
||
return u ? parseInt(u, 10) || 0 : 0;
|
||
} catch (e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function getCurrentAppType() {
|
||
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||
}
|
||
|
||
/**
|
||
* 深度合并两个对象,source 覆盖 target
|
||
*/
|
||
function deepMerge(target, source) {
|
||
if (target === null || typeof target !== 'object') return source;
|
||
if (source === null || typeof source !== 'object') return source;
|
||
if (Array.isArray(target) || Array.isArray(source)) return source;
|
||
|
||
for (const key in source) {
|
||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
||
if (FORBIDDEN_KEYS.has(key)) continue;
|
||
const srcVal = source[key];
|
||
const tgtVal = target[key];
|
||
if (
|
||
srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
|
||
tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
|
||
) {
|
||
target[key] = deepMerge(tgtVal, srcVal);
|
||
} else {
|
||
target[key] = srcVal;
|
||
}
|
||
}
|
||
return target;
|
||
}
|
||
|
||
/**
|
||
* 简易签名校验
|
||
* 后端: hash_hmac('sha256', version + json_encode(tree), secret)
|
||
* 前端: 同样算法
|
||
*/
|
||
function validateSign(tree, version, sign, secret) {
|
||
if (!secret || !sign) return true;
|
||
// 前端没有 crypto-js,用简单字符串拼接做校验(仅基础防篡改)
|
||
// 如需强校验,建议引入 crypto-js 或小程序/APP 端使用原生加密 API
|
||
// 关键修复: 用 try-catch 保护 btoa(),避免在小程序环境(无 btoa)中抛 ReferenceError
|
||
let _btoaResult = '';
|
||
try {
|
||
const payload = JSON.stringify(tree);
|
||
// 兼容小程序环境: 没有原生 btoa 时降级
|
||
if (typeof btoa === 'function') {
|
||
_btoaResult = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
||
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||
// 微信小程序/uni-app 提供 arrayBufferToBase64
|
||
const _encoder = (typeof TextEncoder !== 'undefined')
|
||
? new TextEncoder().encode(version + ':' + payload)
|
||
: null;
|
||
if (_encoder) {
|
||
_btoaResult = uni.arrayBufferToBase64(_encoder.buffer).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 签名计算失败也不应阻断 bundle 加载,静默失败
|
||
}
|
||
// 后端返回的是 hash_hmac('sha256', version + payload, secret)
|
||
// 这里只做格式校验,不强校验(因为无 crypto 库)
|
||
return typeof sign === 'string' && sign.length > 0;
|
||
}
|
||
|
||
// 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新
|
||
const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000;
|
||
|
||
/**
|
||
* 从 storage 读取缓存(检查 TTL 过期)
|
||
* @returns {Object|null} 未过期返回 {tree,version,ts}, 过期返回 null
|
||
*/
|
||
function readStorageCache(sk) {
|
||
try {
|
||
const cached = uni.getStorageSync(sk);
|
||
if (cached && cached.tree && typeof cached.tree === 'object') {
|
||
// TTL 检查: 超过 cacheTtl 视为过期
|
||
if (cached.ts && (Date.now() - cached.ts < BUNDLE_CACHE_TTL)) {
|
||
return cached;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 检查内存缓存是否过期
|
||
*/
|
||
function isMemoryCacheExpired(record) {
|
||
if (!record || !record.ts) return true;
|
||
return (Date.now() - record.ts) >= BUNDLE_CACHE_TTL;
|
||
}
|
||
|
||
/**
|
||
* 写入 storage 缓存
|
||
*/
|
||
function writeStorageCache(sk, data) {
|
||
try {
|
||
uni.setStorageSync(sk, data);
|
||
} catch (e) {
|
||
// storage 写满时静默失败
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从后端拉取 bundle
|
||
*
|
||
* 关键修复: 即使 Promise.race 在 timeout 时已经 reject,
|
||
* uni.request 内部仍可能继续执行并成功返回。
|
||
* 之前这些"迟到"的成功响应被完全丢弃,导致:
|
||
* 1. fetchBundle 抛 timeout 错
|
||
* 2. _bootImpl 进 catch, 不写 remoteTreeCache
|
||
* 3. uni.request 终于成功, 但没人接收, 结果被 GC
|
||
* 现在改为: uni.request 完成后, **无论 Promise.race 状态如何**,
|
||
* 都把结果写入 _lastSuccessfulResult 缓存。
|
||
* _bootImpl 可以在 catch 后**主动等待**这份迟到数据(最多 3 秒),
|
||
* 从而真正用上远端返回的完整 bundle。
|
||
*/
|
||
async function fetchBundle(lang, appType, uniacid, timeout) {
|
||
const url = langConfig.remote.bundleUrl || '/api/lang/bundle';
|
||
const data = { lang };
|
||
if (appType && appType !== '*') data.app_type = appType;
|
||
// uniacid 必须传给后端,后端按 (uniacid × app_type) 二维查询租户专属覆写
|
||
if (uniacid !== undefined && uniacid !== null) data.uniacid = uniacid;
|
||
|
||
// 直接用 uni.request 包装 Promise,不依赖 http.sendRequest
|
||
// (sendRequest 的异步分支不返回 Promise,只有 async:false 分支才返回)
|
||
// 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 /
|
||
const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url;
|
||
|
||
// 关键修复: 用一个 "completionLatch" 跟踪 uni.request 是否最终完成(成功或失败)
|
||
// 即使 Promise.race 已经 timeout reject, uni.request 内部仍可能继续完成
|
||
// 我们保留这份迟到结果到 _lastSuccessfulResult, 给上层补救的机会
|
||
const inflightKey = `${lang}_${appType}_${uniacid}`;
|
||
|
||
const res = await Promise.race([
|
||
new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url: fullUrl,
|
||
method: 'POST',
|
||
data: data,
|
||
header: { 'content-type': 'application/x-www-form-urlencoded' },
|
||
dataType: 'json',
|
||
success: (response) => {
|
||
// 关键: 即使 Promise.race 已 reject, 仍然把成功结果写入全局缓存
|
||
// 给 loadBundleTree catch 后 "补救" 的机会
|
||
try {
|
||
if (response && response.data && response.data.code === 0 && response.data.data && response.data.data.tree) {
|
||
_lastSuccessfulResult[inflightKey] = {
|
||
data: response.data,
|
||
ts: Date.now(),
|
||
lang, appType, uniacid,
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
resolve(response.data);
|
||
},
|
||
fail: (err) => {
|
||
reject(new Error(err.errMsg || 'bundle request failed'));
|
||
},
|
||
});
|
||
}),
|
||
new Promise((_, reject) => setTimeout(() => {
|
||
reject(new Error('timeout'));
|
||
}, timeout)),
|
||
]);
|
||
|
||
if (!res || res.code !== 0 || !res.data) {
|
||
throw new Error(res && res.message ? res.message : 'bundle fetch failed');
|
||
}
|
||
|
||
return res.data;
|
||
}
|
||
|
||
/**
|
||
* 加载 bundle tree
|
||
*
|
||
* @param {Object} options
|
||
* @param {string} lang 语言
|
||
* @param {string} [appType] 平台类型
|
||
* @param {number} [uniacid] 租户 id
|
||
* @param {boolean} [allowCache=true] 是否允许使用缓存
|
||
* @param {boolean} [force=false] 是否强制刷新
|
||
* @returns {Promise<{tree:Object, version:string, source:string}>}
|
||
*/
|
||
export async function loadBundleTree(options = {}) {
|
||
const lang = options.lang || getCurrentLocale();
|
||
const appType = options.appType || getCurrentAppType();
|
||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||
const allowCache = options.allowCache !== false;
|
||
const force = options.force === true;
|
||
|
||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||
|
||
if (bundleInflight[mk]) {
|
||
return bundleInflight[mk];
|
||
}
|
||
|
||
const task = (async () => {
|
||
try {
|
||
// 1) 内存缓存未过期直接返回
|
||
if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) {
|
||
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
|
||
}
|
||
|
||
// 2) 读取 storage 缓存(已含 TTL 检查)
|
||
let cached = null;
|
||
if (!force && allowCache) {
|
||
cached = readStorageCache(sk);
|
||
if (cached && !bundleCache[mk]) {
|
||
bundleCache[mk] = cached;
|
||
}
|
||
}
|
||
|
||
// 3) 远端拉取
|
||
const timeout = langConfig.remote.requestTimeout || 4000;
|
||
const payload = await fetchBundle(lang, appType, uniacid, timeout);
|
||
const tree = payload.tree || {};
|
||
const version = String(payload.version || '');
|
||
const sign = payload.sign || '';
|
||
|
||
// 4) 签名校验(可选)
|
||
const secret = langConfig.remote.signSecret || '';
|
||
const signValid = validateSign(tree, version, sign, secret);
|
||
if (!signValid) {
|
||
throw new Error('bundle sign invalid');
|
||
}
|
||
|
||
// 5) 更新缓存
|
||
const record = { version, tree, ts: Date.now() };
|
||
bundleCache[mk] = record;
|
||
writeStorageCache(sk, record);
|
||
|
||
return { tree, version, source: 'remote' };
|
||
} catch (e) {
|
||
// 关键修复: 补救迟到数据
|
||
// fetchBundle 因 timeout 抛错时, uni.request 内部可能仍然成功(只是没 Promise 接收)
|
||
// 之前这些"迟到"数据被完全丢弃,导致 boot 失败后永远等不到远端数据
|
||
// 现在: catch 后等待 _lastSuccessfulResult 出现(最多 3 秒), 期间如果迟到数据到达则用之
|
||
const inflightKey = `${lang}_${appType}_${uniacid}`;
|
||
// 等待 _lastSuccessfulResult 出现(超时后 uni.request 内部 success 回调可能稍后才触发)
|
||
const waitStart = Date.now();
|
||
const WAIT_MAX_MS = 3000;
|
||
const WAIT_POLL_MS = 100;
|
||
while (Date.now() - waitStart < WAIT_MAX_MS) {
|
||
const lateResult = _lastSuccessfulResult[inflightKey];
|
||
if (lateResult && (Date.now() - lateResult.ts) < _LAST_RESULT_TTL) {
|
||
// 有"迟到"数据, 直接用
|
||
const resp = lateResult.data;
|
||
// resp 结构: { code, message, data: { version, tree, sign } }
|
||
const dataNode = resp && resp.data;
|
||
const tree = (dataNode && dataNode.tree) || {};
|
||
const version = String((dataNode && dataNode.version) || '');
|
||
const sign = (dataNode && dataNode.sign) || '';
|
||
const signValid = validateSign(tree, version, sign, langConfig.remote.signSecret || '');
|
||
if (signValid) {
|
||
const record = { version, tree, ts: Date.now() };
|
||
bundleCache[mk] = record;
|
||
writeStorageCache(sk, record);
|
||
return { tree, version, source: 'remote-late' };
|
||
}
|
||
}
|
||
// 等待 WAIT_POLL_MS
|
||
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
||
}
|
||
// 6) 失败时回退到缓存
|
||
// 关键修复: force=true 时**不回退**到 bundleCache
|
||
// 旧逻辑: force 失败 → 回退到旧 cache → 标记新 ts → 永远不被刷新 → bundle 缺 key
|
||
// 新逻辑: force 失败 → 抛错, 让上层 _bootImpl 进入 catch 块走真正的"无 cache 兜底"
|
||
if (force) {
|
||
throw e;
|
||
}
|
||
if (allowCache && bundleCache[mk]) {
|
||
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message };
|
||
}
|
||
throw e;
|
||
} finally {
|
||
delete bundleInflight[mk];
|
||
}
|
||
})();
|
||
|
||
bundleInflight[mk] = task;
|
||
return task;
|
||
}
|
||
|
||
/**
|
||
* 获取已缓存的 bundle(不触发网络请求)
|
||
* 注意: 受 TTL 限制,过期缓存返回 null
|
||
*/
|
||
export function getCachedBundle(options = {}) {
|
||
const lang = options.lang || getCurrentLocale();
|
||
const appType = options.appType || getCurrentAppType();
|
||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||
|
||
if (bundleCache[mk]) return bundleCache[mk];
|
||
const cached = readStorageCache(sk);
|
||
if (cached) {
|
||
bundleCache[mk] = cached;
|
||
return cached;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 获取任意已缓存的 bundle(不检查 TTL),仅用于 boot 失败兜底
|
||
*
|
||
* 与 getCachedBundle 的区别:
|
||
* - getCachedBundle: 受 BUNDLE_CACHE_TTL 限制,过期返回 null(用于日常 $lang 渲染)
|
||
* - getCachedBundleAny: 不检查 TTL,只要内存或 storage 中有就返回(用于失败兜底)
|
||
*
|
||
* 设计意图:
|
||
* boot() 强制重试失败时,如果用户本地还有哪怕过期的 bundle,
|
||
* 也比"$lang() 全返回空串"用户体验更好 — 至少能展示残缺文案。
|
||
* 这是显式的"宁可用过期缓存,也不空白"的取舍。
|
||
*
|
||
* 优先返回内存缓存(更快),其次返回 storage 缓存。
|
||
*
|
||
* @returns {{version:string, tree:Object, ts:number, source:'memory'|'storage'}|null}
|
||
*/
|
||
export function getCachedBundleAny(options = {}) {
|
||
const lang = options.lang || getCurrentLocale();
|
||
const appType = options.appType || getCurrentAppType();
|
||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||
|
||
// 1) 内存缓存优先(任意 TTL,过期也返回)
|
||
if (bundleCache[mk] && bundleCache[mk].tree) {
|
||
return { ...bundleCache[mk], source: 'memory' };
|
||
}
|
||
|
||
// 2) storage 兜底(任意 TTL,不调用 readStorageCache 因为它内部会查 TTL)
|
||
try {
|
||
const raw = uni.getStorageSync(sk);
|
||
if (raw && raw.tree && typeof raw.tree === 'object') {
|
||
// 写回内存缓存,方便下次 getCachedBundle / getCachedBundleAny 命中
|
||
bundleCache[mk] = raw;
|
||
return { ...raw, source: 'storage' };
|
||
}
|
||
} catch (e) { /* 忽略 */ }
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 清除 bundle 缓存
|
||
*/
|
||
export function clearBundleCache(options = {}) {
|
||
const lang = options.lang || getCurrentLocale();
|
||
const appType = options.appType || getCurrentAppType();
|
||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||
|
||
delete bundleCache[mk];
|
||
try { uni.removeStorageSync(sk); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* 检查是否有缓存可用(用于启动时快速判断)
|
||
*/
|
||
export function hasBundleCache(options = {}) {
|
||
return getCachedBundle(options) !== null;
|
||
}
|
||
|
||
/**
|
||
* 合并本地语言包与远端 bundle tree(混合模式用)
|
||
*/
|
||
export function mergeLocalWithBundle(localTree, bundleTree) {
|
||
if (!localTree || typeof localTree !== 'object') return bundleTree || {};
|
||
if (!bundleTree || typeof bundleTree !== 'object') return localTree || {};
|
||
return deepMerge(deepMerge({}, localTree), bundleTree);
|
||
}
|