refactor(i18n): 完善remote-only模式下的首屏渲染和请求门控

1. 补充getCachedBundle的注释说明TTL过期缓存返回null
2. 修复globalConfig中onShow的重复判断逻辑,增加boot完成检查
3. 给http请求增加remote-only模式下的boot门控,等待国际化就绪后再发送业务请求
4. 重构main.js的平台差异化处理逻辑,统一各端初始化流程
5. 优化App.vue的boot流程,为小程序/鸿蒙增加加载遮罩
6. 新增bootCompleted状态标志和并发去重机制,完善lang.js的remote-only适配逻辑
7. 补充lang()方法在boot未完成时返回空串的逻辑,避免首屏raw key
This commit is contained in:
2026-06-30 15:29:45 +08:00
parent 07c38c1b1b
commit eef59211d3
6 changed files with 152 additions and 44 deletions

View File

@@ -36,6 +36,14 @@ const _callUtil = (vm, method, ...args) => {
export default {
onLoad() { },
onShow() {
// remote-only 小程序/鸿蒙: boot 未完成时跳过 refresh,避免空翻译写入标题等
// H5 不受影响(main.js 已 await boot 后才 $mount)
if (this.$langConfig.isRemoteOnly && this.$langConfig.isRemoteOnly()) {
if (!this.$langConfig.isBootCompleted || !this.$langConfig.isBootCompleted()) {
return;
}
}
// 刷新多语言(仅刷新标题,不触发全页面 $forceUpdate)
// 全量刷新仅在语言包真正变化时由 lang.js emitUpdated 触发
this.$langConfig.refresh(false);

View File

@@ -25,6 +25,35 @@ export default {
return;
}
// remote-only boot 门控: 所有业务请求必须等国际化数据就绪后才发送
// 这样业务请求返回时 lang() 已有翻译数据,不会造成 UI 污染
// bundle 请求走 lang-loader.js 的 uni.request 直接发送,不受此门控影响
const _isRemoteOnly = getLang().isRemoteOnly && getLang().isRemoteOnly();
const _bootDone = getLang().isBootCompleted && getLang().isBootCompleted();
if (_isRemoteOnly && !_bootDone) {
// 获取 boot promise(优先从 App 实例拿,避免重复 boot)
let bootPromise = null;
try {
const app = getApp();
if (app && app.$langReady) bootPromise = app.$langReady;
} catch (e) {}
if (!bootPromise) bootPromise = getLang().boot();
if (params.async === false) {
// 同步 Promise 模式: 链式等待 boot 后再发请求
return bootPromise.then(() => this._sendRequestNow(params));
} else {
// 回调模式: boot 完成后再执行请求
bootPromise.then(() => this._sendRequestNow(params));
return;
}
}
return this._sendRequestNow(params);
},
_sendRequestNow(params) {
var method = params.data != undefined ? 'POST' : 'GET',
url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/'),
data = {

View File

@@ -256,6 +256,7 @@ export async function loadBundleTree(options = {}) {
/**
* 获取已缓存的 bundle(不触发网络请求)
* 注意: 受 TTL 限制,过期缓存返回 null
*/
export function getCachedBundle(options = {}) {
const lang = options.lang || getCurrentLocale();

View File

@@ -430,6 +430,14 @@ const overrideInflight = {};
// key: getOverrideMemKey(locale), value: { version, tree, ts, source }
const remoteTreeCache = {};
// remote-only 模式: boot() 是否已完成(远端 bundle 已就绪)
// boot 完成前, lang() 返回空串且不报 missing key, 避免首屏 raw key 和噪音警告
// boot 完成后 emitUpdated 触发 $forceUpdate, 页面重新渲染显示真实翻译
let bootCompleted = false;
// boot() 并发去重: 避免多次调用 boot 时重复执行逻辑和 emitUpdated
let _bootInflight = null;
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
let remoteStatus = 'unknown';
@@ -779,6 +787,20 @@ function normalizeRemoteTreeForPage(tree, langPath) {
export default {
langList: langConfig.langList,
/**
* 是否 remote-only 模式
*/
isRemoteOnly() {
return IS_REMOTE_ONLY;
},
/**
* boot() 是否已完成(remote-only 模式下用于判断翻译是否就绪)
*/
isBootCompleted() {
return bootCompleted;
},
/**
* 获得当前本地语言
* @returns
@@ -814,11 +836,22 @@ export default {
*
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>}
*/
async boot() {
boot() {
// 并发去重: 同一次 boot 未完成时,后续调用复用同一个 promise
if (_bootInflight) return _bootInflight;
_bootInflight = this._bootImpl();
const p = _bootInflight;
// 无论成功失败,清理 inflight 允许后续重新 boot
_bootInflight.then(() => { _bootInflight = null; }, () => { _bootInflight = null; });
return p;
},
async _bootImpl() {
const lang = getCurrentLocale();
// source === 'local' 时不需要预热远端,本地全量已可用
if (SOURCE === 'local') {
bootCompleted = true;
return { ok: true, source: 'local', status: remoteStatus, lang };
}
@@ -833,9 +866,9 @@ export default {
uni.setStorageSync('lang_last_uniacid', currentUid);
} catch (e) {}
const uniacid = getCurrentUniacid();
const appType = getCurrentAppType();
try {
const uniacid = getCurrentUniacid();
const appType = getCurrentAppType();
let result = await loadBundleTree({ lang, appType, uniacid });
// 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key
if (result.source !== 'remote') {
@@ -848,25 +881,29 @@ export default {
const { tree, version, source } = result;
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source };
setRemoteStatus('ok', { lang });
// bundle 就绪后清空字段缓存并触发全局刷新,避免首屏组件在 boot 前渲染的 raw key 被缓存
bootCompleted = true;
emitUpdated(lang);
return { ok: true, source, status: 'ok', version, lang };
} catch (e) {
const cached = getCachedBundle({ lang });
const cached = getCachedBundle({ lang, uniacid, appType });
if (cached) {
remoteTreeCache[getOverrideMemKey(lang)] = { version: cached.version, tree: cached.tree, ts: cached.ts, source: 'cache' };
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version: cached.version, tree: cached.tree, ts: cached.ts, source: 'cache' };
setRemoteStatus('fail', { reason: 'exception', lang });
bootCompleted = true;
emitUpdated(lang);
return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang };
}
setRemoteStatus('fail', { reason: 'exception', lang });
bootCompleted = true;
emitUpdated(lang);
return { ok: false, source: 'local', status: 'fail', reason: e.message || 'bundle failed', lang };
}
}
// 非 remote-only: 走原有的 loadOverrides 逻辑
const remote = langConfig.remote;
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
// 检测 uniacid 是否变了(防止 App.onLaunch 改了 uniacid 但没触发 onUniacidChange)
const currentUid = getCurrentUniacid();
try {
const lastUid = uni.getStorageSync('lang_last_uniacid');
@@ -876,13 +913,11 @@ export default {
uni.setStorageSync('lang_last_uniacid', currentUid);
} catch (e) {}
// 跑一次远端拉取(被超时切走时,fail 状态会由 loadOverrides 内部设置)
const result = await Promise.race([loadOverrides(lang), t]);
const finalStatus = remoteStatus;
bootCompleted = true;
// 远端成功/失败与否,本地兜底都已可用
if (result && result.__timeout) {
// 超时分支:loadOverrides 内部 setRemoteStatus('fail') 已触发
return { ok: false, source: 'timeout', status: finalStatus, reason: 'timeout', lang };
}
return {
@@ -954,6 +989,14 @@ export default {
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
return typeof field === 'string' ? field : String(field || '');
}
// remote-only 模式(小程序/鸿蒙): boot 未完成时返回空串
// H5 不走此分支(main.js 已 await boot 后才 $mount)
// boot 完成后 emitUpdated 触发 $forceUpdate, 页面重新渲染显示真实翻译
if (IS_REMOTE_ONLY && !bootCompleted) {
return '';
}
_langRecursionDepth++;
try {
const locale = getCurrentLocale();