From 5ae4bbcbd39d7e0acd05aa65d017e82be9949781 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 1 Jul 2026 15:36:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=B1=8F=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=8A=A0=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增splash-screen全局启动屏组件,适配非APP平台的加载过渡 2. 新增splash-state状态管理模块,统一处理启动屏的显示隐藏、软隐藏时序、兜底超时逻辑 3. 重构App.vue和main.js的加载逻辑,替换原有uni.showLoading方案 4. 在首页和空页面挂载启动屏组件,适配不同页面场景 5. 兼容H5平台降级处理,解决main.js挂载前无法渲染组件的问题 --- App.vue | 18 +- common/js/splash-state.js | 225 +++++++++++++++++++++ components/splash-screen/splash-screen.vue | 178 ++++++++++++++++ main.js | 15 +- pages/empty/index.vue | 1 + pages/index/index.vue | 2 + 6 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 common/js/splash-state.js create mode 100644 components/splash-screen/splash-screen.vue diff --git a/App.vue b/App.vue index a69db66..2da1637 100644 --- a/App.vue +++ b/App.vue @@ -4,6 +4,7 @@ import Lang from 'common/js/lang.js'; import ThemeOverride from 'common/js/theme-override.js'; import PrivacyManager from 'common/js/privacy.js'; + import splashState from 'common/js/splash-state.js'; import { Weixin @@ -44,18 +45,15 @@ } // remote-only 模式: - // H5: main.js 已 await boot 完成, 这里秒回 + // H5: main.js 已 await boot 完成, 这里秒回 (splash 也由 main.js 全权负责) // 小程序/鸿蒙: main.js 同步 $mount(), boot 在这里异步执行 - // → boot 期间 showLoading 遮罩, 完成后 hideLoading + // → splashState.show() 同步设置 visible 标志, 框架推页面后 splash 组件挂载时读取 isShow() 渲染 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) {} + const _needSplash = Lang.isRemoteOnly && Lang.isRemoteOnly(); + if (_needSplash) { + splashState.show(); } // #endif @@ -74,8 +72,8 @@ ]); // #ifndef H5 - if (_hideLangLoading) { - try { uni.hideLoading(); } catch (e) {} + if (_needSplash) { + splashState.hide({ reason: 'boot-complete' }); } // #endif diff --git a/common/js/splash-state.js b/common/js/splash-state.js new file mode 100644 index 0000000..8a7f38e --- /dev/null +++ b/common/js/splash-state.js @@ -0,0 +1,225 @@ +// ============================================================ +// 启动屏单例状态 +// +// 设计要点: +// 1. 状态 + 事件双通道: 组件既可 watch visible,也可监听 'splash:hide' 事件 +// 解决 uni.$emit 在组件挂载前发出就丢失的问题 (memory lesson) +// 2. 软隐藏 (softHide): 标记应该 hide, 等组件 mount 后再真正 hide +// 解决 onLaunch 跑完 + hide() 早于 page mount 的时序问题 +// 3. 防重入: show()/hide() 多次调用安全,内部用 guard +// 4. 兜底超时: 默认 8s 未关闭则强制 hide,防止卡死 +// 5. 平台隔离: APP-PLUS 有原生 splash,这里直接返回 false +// 6. 跨平台兼容: 同步状态可被 Vue 组件直接 watch +// ============================================================ + +// APP-PLUS 直接返回 false (有原生启动屏) +function needSplash() { + // #ifdef APP-PLUS + return false; + // #endif + // #ifndef APP-PLUS + return true; + // #endif +} + +// H5 平台特殊处理: Lang.boot() 在 main.js 中 await, 此时 Vue 还没挂载, +// 组件无法渲染, 内部降级为 uni.showLoading 做兜底 (虽然样式较丑但至少有遮罩) +function useNativeLoading() { + // #ifdef H5 + return true; + // #endif + // #ifndef H5 + return false; + // #endif +} + +const DEFAULT_TIMEOUT = 8000; // 总兜底超时 +const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间 + +class SplashState { + constructor() { + this._visible = false; + this._timeoutId = null; + this._listeners = []; + this._shownAt = 0; + // 软隐藏相关 + this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount + this._hasMounted = false; // 是否有 splash 组件 mount 过 + this._mountGraceId = null; // 软隐藏兜底定时器 + } + + show(opts = {}) { + if (!needSplash()) return; + if (this._visible) return; // 防重入 + + this._visible = true; + this._shownAt = Date.now(); + this._pendingHide = null; + this._hasMounted = false; + if (this._mountGraceId) { + clearTimeout(this._mountGraceId); + this._mountGraceId = null; + } + this._emit('splash:show'); + + // H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染) + if (useNativeLoading()) { + try { uni.showLoading({ title: opts.title || '加载中...', mask: true }); } catch (e) {} + } + + const timeout = opts.timeout || DEFAULT_TIMEOUT; + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + this._timeoutId = setTimeout(() => { + // 兜底: 超时强制关闭,防止卡死 (走硬隐藏路径, 不等组件) + if (this._visible) { + console.warn('[splash-state] auto-hide after timeout', timeout); + this._doHide({ reason: 'timeout' }); + } + }, timeout); + } + + /** + * 隐藏 splash + * @param {Object} opts + * @param {string} opts.reason - 隐藏原因 + * - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认) + * - 'timeout': 硬隐藏, 立即关 (兜底用) + * - 'manual': 硬隐藏, 立即关 + * @param {boolean} [opts.soft] - 强制走软隐藏 + */ + hide(opts = {}) { + const reason = opts.reason || 'manual'; + // boot-complete 默认走软隐藏, 避免时序问题 + const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete'); + + if (useSoft) { + this._softHide({ reason }); + } else { + this._doHide({ reason }); + } + } + + /** + * 软隐藏: 标记应该 hide, 等组件 mount + * 如果组件 1s 内没 mount, 兜底自动 hide + */ + _softHide(opts) { + if (!this._visible) return; // 已经隐藏了 + this._pendingHide = opts; + + // 如果组件已经 mount, 立即隐藏 + if (this._hasMounted) { + this._flushPendingHide(); + return; + } + + // 兜底: 1s 内没 mount, 强制隐藏 + if (this._mountGraceId) clearTimeout(this._mountGraceId); + this._mountGraceId = setTimeout(() => { + if (this._pendingHide) { + this._flushPendingHide(); + } + }, MOUNT_GRACE_MS); + } + + /** + * 实际隐藏 (硬路径) + */ + _doHide(opts) { + if (!this._visible) return; // 防重入 + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + if (this._mountGraceId) { + clearTimeout(this._mountGraceId); + this._mountGraceId = null; + } + this._pendingHide = null; + this._visible = false; + // H5 降级对应的关闭 + if (useNativeLoading()) { + try { uni.hideLoading(); } catch (e) {} + } + this._emit('splash:hide', { reason: opts.reason || 'manual' }); + } + + /** + * 真正触发软隐藏 + */ + _flushPendingHide() { + const opts = this._pendingHide; + this._pendingHide = null; + if (this._mountGraceId) { + clearTimeout(this._mountGraceId); + this._mountGraceId = null; + } + this._doHide(opts || { reason: 'mount-completed' }); + } + + /** + * 组件 mount 后通知 state 模块 + * - 标记 _hasMounted + * - 如果有 _pendingHide, 触发真关 + * - H5 降级场景: 立即关掉原生 loading, 让 splash 组件接管视觉 + */ + notifyMounted() { + if (this._hasMounted) return; + this._hasMounted = true; + // H5 降级: 立即关掉原生 loading, 避免和 splash 组件重叠 + if (useNativeLoading()) { + try { uni.hideLoading(); } catch (e) {} + } + if (this._pendingHide) { + // 给组件一帧时间渲染, 再隐藏 (避免一帧闪烁) + setTimeout(() => this._flushPendingHide(), 50); + } + } + + /** + * 组件 unmount 时通知 (用于 _hasMounted 状态管理) + * 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减 + */ + notifyUnmounted() { + // 不重置 _hasMounted, 因为软隐藏的 _pendingHide 处理不依赖 unmount + } + + isShow() { + return this._visible; + } + + hasPendingHide() { + return this._pendingHide != null; + } + + elapsed() { + if (!this._visible) return 0; + return Date.now() - this._shownAt; + } + + on(event, fn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + } + + off(event, fn) { + if (!this._listeners[event]) return; + this._listeners[event] = this._listeners[event].filter(f => f !== fn); + } + + _emit(event, payload) { + const arr = this._listeners[event]; + if (!arr || !arr.length) return; + for (let i = 0; i < arr.length; i++) { + try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); } + } + } +} + +const splashState = new SplashState(); + +export default splashState; +export { splashState }; diff --git a/components/splash-screen/splash-screen.vue b/components/splash-screen/splash-screen.vue new file mode 100644 index 0000000..cbfb52e --- /dev/null +++ b/components/splash-screen/splash-screen.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/main.js b/main.js index 5caf83a..6f98650 100644 --- a/main.js +++ b/main.js @@ -16,6 +16,7 @@ import { } from './common/js/storage.js' import configExternal from './common/js/config-external.js' import PrivacyManager from './common/js/privacy.js' +import splashState from './common/js/splash-state.js' Vue.prototype.$store = store //挂在vue @@ -67,26 +68,22 @@ uniStorage(); // 以下代码所有平台共享: // - lang.js: bootCompleted 标志, boot 未完成时 lang() 返回空串 // - http.js: sendRequest 门控, boot 未完成时业务请求排队 -// - App.vue: onLaunch 中 showLoading + await boot + hideLoading +// - splash-state.js: 统一启动屏状态 (H5 降级 uni.showLoading, 小程序/鸿蒙走 splash 组件) +// - App.vue: onLaunch 中 splashState.show() + await boot + splashState.hide() // ============================================================ // #ifdef H5 // H5: remote-only 下先 await boot 再 $mount(), 首屏直接有翻译数据 +// splashState.show() 内部 H5 降级为 uni.showLoading (await 期间 Vue 还没挂载,组件层无法渲染) async function bootstrap() { if (Lang.isRemoteOnly && Lang.isRemoteOnly()) { - let _hideLoading = false; - try { - uni.showLoading({ title: '加载中...', mask: true }); - _hideLoading = true; - } catch (e) {} + splashState.show(); try { await Lang.boot(); } catch (e) { console.warn('[i18n] remote-only boot fail', e); } finally { - if (_hideLoading) { - try { uni.hideLoading(); } catch (e) {} - } + splashState.hide({ reason: 'boot-complete' }); } } diff --git a/pages/empty/index.vue b/pages/empty/index.vue index 0399adb..87cedad 100644 --- a/pages/empty/index.vue +++ b/pages/empty/index.vue @@ -1,5 +1,6 @@ diff --git a/pages/index/index.vue b/pages/index/index.vue index 48930e7..28a1b88 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -142,6 +142,8 @@ + +