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:
51
App.vue
51
App.vue
@@ -42,24 +42,41 @@
|
||||
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
|
||||
}
|
||||
|
||||
// remote-only 模式下 main.js 已先 await Lang.boot() 再挂载 Vue,这里复用其结果
|
||||
// 非 remote-only 模式下仍需主动 boot 以拉取远端覆写
|
||||
const langBoot = Lang.boot();
|
||||
this.$langReady = langBoot;
|
||||
// remote-only 模式:
|
||||
// H5: main.js 已 await boot 完成, 这里秒回
|
||||
// 小程序/鸿蒙: main.js 同步 $mount(), boot 在这里异步执行
|
||||
// → boot 期间 showLoading 遮罩, 完成后 hideLoading
|
||||
const langBoot = Lang.boot();
|
||||
this.$langReady = langBoot;
|
||||
let _hideLangLoading = false;
|
||||
// #ifndef H5
|
||||
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
|
||||
try {
|
||||
uni.showLoading({ title: '加载中...', mask: true });
|
||||
_hideLangLoading = true;
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await )
|
||||
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
|
||||
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
|
||||
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
|
||||
await Promise.all([
|
||||
// A) 主题第一次 apply — 走 preset,失败静默走默认
|
||||
this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||
.catch(e => console.warn('[theme] pre-apply 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)),
|
||||
]);
|
||||
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await )
|
||||
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
|
||||
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
|
||||
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
|
||||
await Promise.all([
|
||||
// A) 主题第一次 apply — 走 preset,失败静默走默认
|
||||
this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||
.catch(e => console.warn('[theme] pre-apply fail', e)),
|
||||
// B) Lang boot
|
||||
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)),
|
||||
]);
|
||||
|
||||
// #ifndef H5
|
||||
if (_hideLangLoading) {
|
||||
try { uni.hideLoading(); } catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
|
||||
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -256,6 +256,7 @@ export async function loadBundleTree(options = {}) {
|
||||
|
||||
/**
|
||||
* 获取已缓存的 bundle(不触发网络请求)
|
||||
* 注意: 受 TTL 限制,过期缓存返回 null
|
||||
*/
|
||||
export function getCachedBundle(options = {}) {
|
||||
const lang = options.lang || getCurrentLocale();
|
||||
|
||||
@@ -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();
|
||||
|
||||
44
main.js
44
main.js
@@ -14,18 +14,18 @@ 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
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.prototype.$util = Util;
|
||||
Vue.prototype.$api = Http;
|
||||
|
||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||
|
||||
import configExternal from './common/js/config-external.js'
|
||||
|
||||
Vue.prototype.$store = store //挂在vue
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.prototype.$util = Util;
|
||||
Vue.prototype.$api = Http;
|
||||
|
||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||
|
||||
Vue.prototype.$config = Config;
|
||||
|
||||
|
||||
@@ -55,14 +55,22 @@ uniStorage();
|
||||
|
||||
// 组件已改为按需引入,不再全局注册
|
||||
|
||||
// H5 平台: remote-only 下先 await Lang.boot() 再挂载 Vue,避免首屏 raw key
|
||||
// 小程序/鸿蒙: 同步注册 App,Lang.boot() 在 App.onLaunch 中异步执行(不阻塞挂载)
|
||||
// 原因: 小程序/鸿蒙的 App() 必须同步注册,async bootstrap 会导致 App 注册延迟,白屏
|
||||
// ============================================================
|
||||
// 平台差异处理:
|
||||
// H5: 可在 $mount() 前 await boot, 首屏直接有翻译
|
||||
// 小程序/鸿蒙: App() 必须同步注册, $mount() 不能推迟
|
||||
// → 同步 $mount() + lang() 短路返回空串 + boot 完成后 $forceUpdate
|
||||
//
|
||||
// 以下代码所有平台共享:
|
||||
// - lang.js: bootCompleted 标志, boot 未完成时 lang() 返回空串
|
||||
// - http.js: sendRequest 门控, boot 未完成时业务请求排队
|
||||
// - App.vue: onLaunch 中 showLoading + await boot + hideLoading
|
||||
// ============================================================
|
||||
|
||||
// #ifdef H5
|
||||
// H5: remote-only 下先 await boot 再 $mount(), 首屏直接有翻译数据
|
||||
async function bootstrap() {
|
||||
const isRemoteOnly = langConfig && langConfig.source === 'remote-only';
|
||||
if (isRemoteOnly) {
|
||||
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
|
||||
let _hideLoading = false;
|
||||
try {
|
||||
uni.showLoading({ title: '加载中...', mask: true });
|
||||
@@ -89,6 +97,8 @@ bootstrap()
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
// 小程序/鸿蒙: 同步 $mount(), boot 在 App.vue onLaunch 中异步执行
|
||||
// lang() 在 boot 未完成时返回空串, boot 完成后 emitUpdated 触发 $forceUpdate
|
||||
const app = new Vue({
|
||||
...App,
|
||||
store
|
||||
|
||||
Reference in New Issue
Block a user