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隔离
This commit is contained in:
2026-06-30 14:04:52 +08:00
parent 55a5a68517
commit 50a1c1c695
7 changed files with 176 additions and 37 deletions

19
App.vue
View File

@@ -10,6 +10,12 @@
export default {
mixins: [auth],
data() {
return {
// remote-only 模式下,业务可通过 await getApp().$langReady 等待语言包就绪
$langReady: Promise.resolve({ ok: true, source: 'local' })
};
},
onLaunch: async function(options) {
// ============================================================
// 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下
@@ -36,7 +42,12 @@
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
}
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await)
// remote-only 模式下 main.js 已先 await Lang.boot() 再挂载 Vue,这里复用其结果
// 非 remote-only 模式下仍需主动 boot 以拉取远端覆写
const langBoot = Lang.boot();
this.$langReady = langBoot;
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await )
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
@@ -44,13 +55,13 @@
// A) 主题第一次 apply — 走 preset,失败静默走默认
this.$store.dispatch('applyThemeWithRemote', presetName)
.catch(e => console.warn('[theme] pre-apply fail', e)),
// B) Lang boot(首屏强依赖,bootTimeout=2000ms)
Lang.boot().catch(e => console.warn('[i18n] boot fail', e)),
// B) Lang boot(首屏强依赖,remote-only 下已提前完成)
langBoot.catch(e => console.warn('[i18n] boot fail', e)),
// C) ThemeOverride boot(brand/light/dark 三桶,bootTimeout=2000ms)
ThemeOverride.boot().catch(e => console.warn('[theme] boot fail', e)),
]);
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并)
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
// 如果跟 step 2 的 apply 值相同,Vuex 会重复 commit 但页面无视觉变化
await this.$store.dispatch('applyThemeWithRemote', presetName);

View File

@@ -29,6 +29,8 @@ export const langConfig = {
bootTimeout: 2000,
// 单次请求超时(ms)
requestTimeout: 4000,
// 缓存有效期(秒),与后端 Cache::set 600s 对齐;过期后后台刷新
cacheTtl: 600,
// 本地缓存 key 前缀: <prefix><uniacid>_<appType>_<lang>
// 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致
storagePrefix: 'LANG_OVR_',

View File

@@ -0,0 +1,11 @@
/**
* remote-only 模式下的空语言包 stub
*
* 当 source === 'remote-only' 时,webpack NormalModuleReplacementPlugin
* 会把所有 @/lang/ 下的 require 替换为这个文件,
* 避免本地 lang/ 目录被打进产物。
*
* 导出格式与真实语言包一致: { lang: {} }
*/
export const lang = {};
export default { lang };

View File

@@ -14,7 +14,7 @@
*/
import { langConfig } from './config-external.js';
import http from './http.js';
import Config from './config.js';
import { Platform } from './platform.js';
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -101,19 +101,34 @@ function validateSign(tree, version, sign, secret) {
return typeof sign === 'string' && sign.length > 0;
}
// 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新
const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000;
/**
* 从 storage 读取缓存
* 从 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') {
return cached;
// 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 缓存
*/
@@ -132,12 +147,29 @@ 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([
http.sendRequest({
url,
data,
async: false,
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)),
]);
@@ -174,12 +206,12 @@ export async function loadBundleTree(options = {}) {
const task = (async () => {
try {
// 1) 强制刷新时跳过缓存
if (!force && allowCache && bundleCache[mk]) {
// 1) 内存缓存未过期直接返回
if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) {
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
}
// 2) 读取 storage 缓存作为首屏兜底
// 2) 读取 storage 缓存(已含 TTL 检查)
let cached = null;
if (!force && allowCache) {
cached = readStorageCache(sk);

View File

@@ -575,8 +575,10 @@ function getOverrideStorageKey(lang) {
* 内存 cache key: <uniacid>_<appType>_<lang>
* 必须与 storage key 三维一致 — 切租户时内存不会"巧合"命中旧值
*/
function getOverrideMemKey(lang) {
return `${getCurrentUniacid()}_${getCurrentAppType()}_${lang}`;
function getOverrideMemKey(lang, uniacid, appType) {
const u = uniacid !== undefined ? uniacid : getCurrentUniacid();
const a = appType !== undefined ? appType : getCurrentAppType();
return `${u}_${a}_${lang}`;
}
/**
@@ -833,8 +835,19 @@ export default {
} catch (e) {}
try {
const { tree, version, source } = await loadBundleTree({ lang });
remoteTreeCache[getOverrideMemKey(lang)] = { version, tree, ts: Date.now(), source };
const uniacid = getCurrentUniacid();
const appType = getCurrentAppType();
let result = await loadBundleTree({ lang, appType, uniacid });
// 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key
if (result.source !== 'remote') {
try {
result = await loadBundleTree({ lang, appType, uniacid, force: true });
} catch (e) {
// 网络刷新失败仍用缓存兜底
}
}
const { tree, version, source } = result;
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source };
setRemoteStatus('ok', { lang });
// bundle 就绪后清空字段缓存并触发全局刷新,避免首屏组件在 boot 前渲染的 raw key 被缓存
emitUpdated(lang);
@@ -929,7 +942,7 @@ export default {
getRemoteStatus() { return remoteStatus; },
/**
* 当前 source 配置(规范化后):'local' | 'remote' | 'hybrid'
* 当前 source 配置(规范化后):'local' | 'remote' | 'hybrid' | 'remote-only'
*/
getSource() { return SOURCE; },
@@ -1116,30 +1129,46 @@ export default {
invalidateStorageCache('lang');
const locale = getCurrentLocale();
// 切语言后清除语言包合并缓存 + 字段解析缓存
// 切语言后清除语言包合并缓存 + 字段解析缓存(这些是翻译结果缓存,必须清)
invalidateLangCaches();
// 清空已加载的"非新语种"语言包缓存
// 用 Object.keys 防止被原型链污染(for...in 会遍历继承属性)
// 策略优化: 保留旧语种的 bundle/override 缓存,只在内存中限制最大语种数
// 这样切回旧语种时能直接命中缓存,不重复请求
// 仅清空 loadedLangPacks 的合并结果缓存(非远端原始数据)
Object.keys(loadedLangPacks).forEach(key => {
if (!key.startsWith(locale + '_')) delete loadedLangPacks[key];
});
// 清空内存里的"非新语种"远端覆写 — 防止反复切语种导致内存泄漏
Object.keys(overrideCache).forEach(mk => {
if (!mk.endsWith('_' + locale)) delete overrideCache[mk];
});
// remote-only 模式:清除旧语种 bundle 缓存
Object.keys(remoteTreeCache).forEach(mk => {
if (!mk.endsWith('_' + locale)) delete remoteTreeCache[mk];
});
// 限制 remoteTreeCache 最大保留 3 个语种,超出时淘汰最旧的
const maxLocaleCache = 3;
const cachedLocales = Object.keys(remoteTreeCache);
if (cachedLocales.length >= maxLocaleCache) {
// 按时间戳排序,淘汰最旧的非当前语种
const sorted = cachedLocales
.filter(mk => !mk.endsWith('_' + locale))
.sort((a, b) => (remoteTreeCache[a].ts || 0) - (remoteTreeCache[b].ts || 0));
while (sorted.length >= maxLocaleCache) {
const evict = sorted.shift();
delete remoteTreeCache[evict];
}
}
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
// 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit
if (IS_REMOTE_ONLY) {
try {
const { tree, version, source } = await loadBundleTree({ lang: value });
remoteTreeCache[getOverrideMemKey(value)] = { version, tree, ts: Date.now(), source };
const uniacid = getCurrentUniacid();
const appType = getCurrentAppType();
const { tree, version, source } = await loadBundleTree({ lang: value, uniacid, appType });
remoteTreeCache[getOverrideMemKey(value, uniacid, appType)] = { version, tree, ts: Date.now(), source };
setRemoteStatus('ok', { lang: value });
// 新语种 bundle 就绪后,同步 App 全局 promise 并触发刷新
try {
const app = getApp();
if (app && app.$langReady) app.$langReady = Promise.resolve({ ok: true, source, status: 'ok', version, lang: value });
} catch (e) {}
emitUpdated(value);
} catch (e) { /* 静默 */ }
} else if (SOURCE !== 'local') {
try { await loadOverrides(value); } catch (e) { /* 静默 */ }

33
main.js
View File

@@ -14,6 +14,7 @@ import globalConfig from './common/js/golbalConfig.js';
import {
uniStorage
} from './common/js/storage.js'
import configExternal, { langConfig } from './common/js/config-external.js'
Vue.prototype.$store = store //挂在vue
@@ -54,9 +55,31 @@ uniStorage();
// 组件已改为按需引入,不再全局注册
const app = new Vue({
...App,
store
})
async function bootstrap() {
const isRemoteOnly = langConfig && langConfig.source === 'remote-only';
if (isRemoteOnly) {
// remote-only 模式下必须等 bundle 就绪后再挂载 Vue,避免首屏组件在翻译就绪前渲染
let _hideLoading = false;
try {
uni.showLoading({ title: '加载中...', mask: true });
_hideLoading = true;
} catch (e) {}
try {
await Lang.boot();
} catch (e) {
console.warn('[i18n] remote-only boot fail', e);
} finally {
if (_hideLoading) {
try { uni.hideLoading(); } catch (e) {}
}
}
}
app.$mount()
const app = new Vue({
...App,
store
});
app.$mount();
}
bootstrap()

View File

@@ -2,9 +2,26 @@ const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { zionUniMpLoadPackagePlugin } = require('zion-uniapp-mp-load-package/webpack');
const webpack = require('webpack');
console.log('[vue.config] 已加载');
// 检测是否启用 remote-only 模式(读取 config-external.js 中的 source)
function _isRemoteOnly() {
try {
const configExternal = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf8');
const match = configExternal.match(/source:\s*['"]remote-only['"]/);
return !!match;
} catch (e) {
return false;
}
}
const IS_REMOTE_ONLY = _isRemoteOnly();
if (IS_REMOTE_ONLY) {
console.log('[i18n] remote-only 模式已启用,将从构建产物中排除本地 lang/ 目录');
}
// 确保 UNI_APP_DOMAIN 有值(从 package.json 脚本配置中读取)
if (!process.env.UNI_APP_DOMAIN && process.env.UNI_SCRIPT) {
try {
@@ -356,6 +373,20 @@ module.exports = {
configureWebpack: config => {
console.log('config.mode = ', config.mode);
// remote-only 模式: 排除本地 lang/ 目录,减少产物体积
// 用 NormalModuleReplacementPlugin 把 @/lang/ 下的 require 替换为空模块
if (IS_REMOTE_ONLY) {
config.plugins = config.plugins || [];
config.plugins.push(new webpack.NormalModuleReplacementPlugin(
/@\/lang\//,
(resource) => {
// 替换为一个空的虚拟模块,导出 { lang: {} }
resource.request = path.join(__dirname, 'common/js/lang-empty-stub.js');
}
));
console.log('[i18n] NormalModuleReplacementPlugin 已添加,lang/ 目录将被替换为空 stub');
}
if (config.mode === 'production') {
console.log('开始尝试配置极限压缩');