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) {}
|
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remote-only 模式下 main.js 已先 await Lang.boot() 再挂载 Vue,这里复用其结果
|
// remote-only 模式:
|
||||||
// 非 remote-only 模式下仍需主动 boot 以拉取远端覆写
|
// H5: main.js 已 await boot 完成, 这里秒回
|
||||||
const langBoot = Lang.boot();
|
// 小程序/鸿蒙: main.js 同步 $mount(), boot 在这里异步执行
|
||||||
this.$langReady = langBoot;
|
// → 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 )
|
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await )
|
||||||
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
|
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
|
||||||
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
|
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
|
||||||
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
|
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// A) 主题第一次 apply — 走 preset,失败静默走默认
|
// A) 主题第一次 apply — 走 preset,失败静默走默认
|
||||||
this.$store.dispatch('applyThemeWithRemote', presetName)
|
this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||||
.catch(e => console.warn('[theme] pre-apply fail', e)),
|
.catch(e => console.warn('[theme] pre-apply fail', e)),
|
||||||
// B) Lang boot(首屏强依赖,remote-only 下已提前完成)
|
// B) Lang boot
|
||||||
langBoot.catch(e => console.warn('[i18n] boot fail', e)),
|
langBoot.catch(e => console.warn('[i18n] boot fail', e)),
|
||||||
// C) ThemeOverride boot(brand/light/dark 三桶,bootTimeout=2000ms)
|
// C) ThemeOverride boot(brand/light/dark 三桶,bootTimeout=2000ms)
|
||||||
ThemeOverride.boot().catch(e => console.warn('[theme] boot fail', e)),
|
ThemeOverride.boot().catch(e => console.warn('[theme] boot fail', e)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
if (_hideLangLoading) {
|
||||||
|
try { uni.hideLoading(); } catch (e) {}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
|
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
|
||||||
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ const _callUtil = (vm, method, ...args) => {
|
|||||||
export default {
|
export default {
|
||||||
onLoad() { },
|
onLoad() { },
|
||||||
onShow() {
|
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)
|
// 刷新多语言(仅刷新标题,不触发全页面 $forceUpdate)
|
||||||
// 全量刷新仅在语言包真正变化时由 lang.js emitUpdated 触发
|
// 全量刷新仅在语言包真正变化时由 lang.js emitUpdated 触发
|
||||||
this.$langConfig.refresh(false);
|
this.$langConfig.refresh(false);
|
||||||
|
|||||||
@@ -25,6 +25,35 @@ export default {
|
|||||||
return;
|
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',
|
var method = params.data != undefined ? 'POST' : 'GET',
|
||||||
url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/'),
|
url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/'),
|
||||||
data = {
|
data = {
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export async function loadBundleTree(options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取已缓存的 bundle(不触发网络请求)
|
* 获取已缓存的 bundle(不触发网络请求)
|
||||||
|
* 注意: 受 TTL 限制,过期缓存返回 null
|
||||||
*/
|
*/
|
||||||
export function getCachedBundle(options = {}) {
|
export function getCachedBundle(options = {}) {
|
||||||
const lang = options.lang || getCurrentLocale();
|
const lang = options.lang || getCurrentLocale();
|
||||||
|
|||||||
@@ -430,6 +430,14 @@ const overrideInflight = {};
|
|||||||
// key: getOverrideMemKey(locale), value: { version, tree, ts, source }
|
// key: getOverrideMemKey(locale), value: { version, tree, ts, source }
|
||||||
const remoteTreeCache = {};
|
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'(最近一次失败)
|
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
|
||||||
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
|
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
|
||||||
let remoteStatus = 'unknown';
|
let remoteStatus = 'unknown';
|
||||||
@@ -779,6 +787,20 @@ function normalizeRemoteTreeForPage(tree, langPath) {
|
|||||||
export default {
|
export default {
|
||||||
langList: langConfig.langList,
|
langList: langConfig.langList,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否 remote-only 模式
|
||||||
|
*/
|
||||||
|
isRemoteOnly() {
|
||||||
|
return IS_REMOTE_ONLY;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boot() 是否已完成(remote-only 模式下用于判断翻译是否就绪)
|
||||||
|
*/
|
||||||
|
isBootCompleted() {
|
||||||
|
return bootCompleted;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获得当前本地语言
|
* 获得当前本地语言
|
||||||
* @returns
|
* @returns
|
||||||
@@ -814,11 +836,22 @@ export default {
|
|||||||
*
|
*
|
||||||
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>}
|
* @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();
|
const lang = getCurrentLocale();
|
||||||
|
|
||||||
// source === 'local' 时不需要预热远端,本地全量已可用
|
// source === 'local' 时不需要预热远端,本地全量已可用
|
||||||
if (SOURCE === 'local') {
|
if (SOURCE === 'local') {
|
||||||
|
bootCompleted = true;
|
||||||
return { ok: true, source: 'local', status: remoteStatus, lang };
|
return { ok: true, source: 'local', status: remoteStatus, lang };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,9 +866,9 @@ export default {
|
|||||||
uni.setStorageSync('lang_last_uniacid', currentUid);
|
uni.setStorageSync('lang_last_uniacid', currentUid);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
const uniacid = getCurrentUniacid();
|
||||||
|
const appType = getCurrentAppType();
|
||||||
try {
|
try {
|
||||||
const uniacid = getCurrentUniacid();
|
|
||||||
const appType = getCurrentAppType();
|
|
||||||
let result = await loadBundleTree({ lang, appType, uniacid });
|
let result = await loadBundleTree({ lang, appType, uniacid });
|
||||||
// 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key
|
// 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key
|
||||||
if (result.source !== 'remote') {
|
if (result.source !== 'remote') {
|
||||||
@@ -848,25 +881,29 @@ export default {
|
|||||||
const { tree, version, source } = result;
|
const { tree, version, source } = result;
|
||||||
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source };
|
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source };
|
||||||
setRemoteStatus('ok', { lang });
|
setRemoteStatus('ok', { lang });
|
||||||
// bundle 就绪后清空字段缓存并触发全局刷新,避免首屏组件在 boot 前渲染的 raw key 被缓存
|
bootCompleted = true;
|
||||||
emitUpdated(lang);
|
emitUpdated(lang);
|
||||||
return { ok: true, source, status: 'ok', version, lang };
|
return { ok: true, source, status: 'ok', version, lang };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const cached = getCachedBundle({ lang });
|
const cached = getCachedBundle({ lang, uniacid, appType });
|
||||||
if (cached) {
|
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 });
|
setRemoteStatus('fail', { reason: 'exception', lang });
|
||||||
|
bootCompleted = true;
|
||||||
|
emitUpdated(lang);
|
||||||
return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang };
|
return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang };
|
||||||
}
|
}
|
||||||
setRemoteStatus('fail', { reason: 'exception', lang });
|
setRemoteStatus('fail', { reason: 'exception', lang });
|
||||||
|
bootCompleted = true;
|
||||||
|
emitUpdated(lang);
|
||||||
return { ok: false, source: 'local', status: 'fail', reason: e.message || 'bundle failed', lang };
|
return { ok: false, source: 'local', status: 'fail', reason: e.message || 'bundle failed', lang };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非 remote-only: 走原有的 loadOverrides 逻辑
|
||||||
const remote = langConfig.remote;
|
const remote = langConfig.remote;
|
||||||
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
|
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
|
||||||
|
|
||||||
// 检测 uniacid 是否变了(防止 App.onLaunch 改了 uniacid 但没触发 onUniacidChange)
|
|
||||||
const currentUid = getCurrentUniacid();
|
const currentUid = getCurrentUniacid();
|
||||||
try {
|
try {
|
||||||
const lastUid = uni.getStorageSync('lang_last_uniacid');
|
const lastUid = uni.getStorageSync('lang_last_uniacid');
|
||||||
@@ -876,13 +913,11 @@ export default {
|
|||||||
uni.setStorageSync('lang_last_uniacid', currentUid);
|
uni.setStorageSync('lang_last_uniacid', currentUid);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// 跑一次远端拉取(被超时切走时,fail 状态会由 loadOverrides 内部设置)
|
|
||||||
const result = await Promise.race([loadOverrides(lang), t]);
|
const result = await Promise.race([loadOverrides(lang), t]);
|
||||||
const finalStatus = remoteStatus;
|
const finalStatus = remoteStatus;
|
||||||
|
bootCompleted = true;
|
||||||
|
|
||||||
// 远端成功/失败与否,本地兜底都已可用
|
|
||||||
if (result && result.__timeout) {
|
if (result && result.__timeout) {
|
||||||
// 超时分支:loadOverrides 内部 setRemoteStatus('fail') 已触发
|
|
||||||
return { ok: false, source: 'timeout', status: finalStatus, reason: 'timeout', lang };
|
return { ok: false, source: 'timeout', status: finalStatus, reason: 'timeout', lang };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -954,6 +989,14 @@ export default {
|
|||||||
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
|
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
|
||||||
return typeof field === 'string' ? field : String(field || '');
|
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++;
|
_langRecursionDepth++;
|
||||||
try {
|
try {
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
|
|||||||
44
main.js
44
main.js
@@ -14,18 +14,18 @@ import globalConfig from './common/js/golbalConfig.js';
|
|||||||
import {
|
import {
|
||||||
uniStorage
|
uniStorage
|
||||||
} from './common/js/storage.js'
|
} from './common/js/storage.js'
|
||||||
import configExternal, { langConfig } from './common/js/config-external.js'
|
import configExternal from './common/js/config-external.js'
|
||||||
|
|
||||||
Vue.prototype.$store = store //挂在vue
|
Vue.prototype.$store = store //挂在vue
|
||||||
|
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
|
|
||||||
Vue.prototype.$util = Util;
|
Vue.prototype.$util = Util;
|
||||||
Vue.prototype.$api = Http;
|
Vue.prototype.$api = Http;
|
||||||
|
|
||||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||||
|
|
||||||
Vue.prototype.$config = Config;
|
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
|
// #ifdef H5
|
||||||
|
// H5: remote-only 下先 await boot 再 $mount(), 首屏直接有翻译数据
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const isRemoteOnly = langConfig && langConfig.source === 'remote-only';
|
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
|
||||||
if (isRemoteOnly) {
|
|
||||||
let _hideLoading = false;
|
let _hideLoading = false;
|
||||||
try {
|
try {
|
||||||
uni.showLoading({ title: '加载中...', mask: true });
|
uni.showLoading({ title: '加载中...', mask: true });
|
||||||
@@ -89,6 +97,8 @@ bootstrap()
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifndef H5
|
// #ifndef H5
|
||||||
|
// 小程序/鸿蒙: 同步 $mount(), boot 在 App.vue onLaunch 中异步执行
|
||||||
|
// lang() 在 boot 未完成时返回空串, boot 完成后 emitUpdated 触发 $forceUpdate
|
||||||
const app = new Vue({
|
const app = new Vue({
|
||||||
...App,
|
...App,
|
||||||
store
|
store
|
||||||
|
|||||||
Reference in New Issue
Block a user