456 lines
16 KiB
JavaScript
456 lines
16 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 || '';
|
||
|
||
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
|
||
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
|
||
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
|
||
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
|
||
console.warn(
|
||
'[lang-loader] bundle tree INCOMPLETE. ' +
|
||
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
|
||
' lang=' + lang +
|
||
' appType=' + appType +
|
||
' uniacid=' + uniacid +
|
||
' version=' + version +
|
||
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
|
||
);
|
||
}
|
||
|
||
// 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);
|
||
}
|