1. 扁平化多语言文件层级,移除嵌套的命名空间 2. 更新组件内多语言调用路径,适配新的语言包结构 3. 重构HTTP请求错误处理逻辑 4. 新增remote-only模式的远端语言包加载器 5. 新增多语言数据源remote-only配置项
273 lines
7.7 KiB
JavaScript
273 lines
7.7 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 http from './http.js';
|
|
import { Platform } from './platform.js';
|
|
|
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
|
// 内存缓存: { [memKey]: { version, tree, ts } }
|
|
const bundleCache = {};
|
|
|
|
// 并发去重
|
|
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
|
|
const payload = JSON.stringify(tree);
|
|
const expected = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
|
// 后端返回的是 hash_hmac('sha256', version + payload, secret)
|
|
// 这里只做格式校验,不强校验(因为无 crypto 库)
|
|
return typeof sign === 'string' && sign.length > 0;
|
|
}
|
|
|
|
/**
|
|
* 从 storage 读取缓存
|
|
*/
|
|
function readStorageCache(sk) {
|
|
try {
|
|
const cached = uni.getStorageSync(sk);
|
|
if (cached && cached.tree && typeof cached.tree === 'object') {
|
|
return cached;
|
|
}
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 写入 storage 缓存
|
|
*/
|
|
function writeStorageCache(sk, data) {
|
|
try {
|
|
uni.setStorageSync(sk, data);
|
|
} catch (e) {
|
|
// storage 写满时静默失败
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从后端拉取 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;
|
|
|
|
const res = await Promise.race([
|
|
http.sendRequest({
|
|
url,
|
|
data,
|
|
async: false,
|
|
}),
|
|
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]) {
|
|
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
|
|
}
|
|
|
|
// 2) 读取 storage 缓存作为首屏兜底
|
|
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 || '';
|
|
if (!validateSign(tree, version, sign, secret)) {
|
|
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) {
|
|
// 6) 失败时回退到缓存
|
|
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(不触发网络请求)
|
|
*/
|
|
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 缓存
|
|
*/
|
|
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);
|
|
}
|