Files
lucky_shop/common/js/lang-loader.js
ZF sun 50a1c1c695 feat(i18n): 实现remote-only打包模式,优化首屏加载和产物体积
1. 新增remote-only模式下的空语言包stub
2. 新增缓存有效期配置并对齐后端Cache::set 600s
3. 重构语言包加载逻辑,适配remote-only模式
4. 添加webpack替换插件,排除本地lang目录减少产物体积
5. 优化Vue挂载时机,等待语言包就绪后再渲染首屏
6. 改进缓存策略,新增内存缓存TTL校验和语种缓存淘汰
7. 修复语言包缓存key生成逻辑,支持uniacid和appType隔离
2026-06-30 14:04:52 +08:00

305 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 远端语言包 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 = {};
// 并发去重
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;
}
// 缓存 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
*/
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;
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) => {
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 || '';
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);
}