// ============================================================ // 启动屏单例状态 // // 设计要点: // 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 } // 总兜底超时档位 (show 时可覆盖): // 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松) // 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲) // 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲) // 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用 // 业务方覆盖: splashState.show({ timeout: 17000 }) // 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低 // 严防因为 timeout 太短导致用户看到白屏. // 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳 const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s) const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间 const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间 // 启动期阶段定义 (id → { percent, label }) // percent: 阶段完成的百分比 (0-100) // label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案) const STAGES = { boot: { percent: 5, label: 'Loading...' }, lang: { percent: 30, label: 'Loading language...' }, theme: { percent: 50, label: 'Applying theme...' }, override: { percent: 65, label: 'Loading theme tokens...' }, config: { percent: 80, label: 'Fetching configuration...' }, privacy: { percent: 95, label: 'Preparing privacy dialog...' }, done: { percent: 100, label: 'Ready' }, }; 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; // 软隐藏兜底定时器 (等 mount) // 页面就绪等待 this._pageReady = false; // 页面内容是否已就绪 (由首页通知) this._pageReadyGraceId = null; // 页面就绪兜底定时器 // 进度上报 this._stage = 'boot'; this._percent = 0; this._stageSet = new Set(['boot']); // 已报告阶段 // 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit // 避免多通道并发时, splash 组件 re-render 风暴 this._pendingProgress = null; // 待 emit 的最新进度 this._flushTimer = null; // 节流定时器 // 网络环境 (用于 show() 自动选档) // _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown' // _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见) // _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新 this._networkType = 'unknown'; this._networkReady = false; // 启动网络检测 (异步, 不阻塞 splash 显示) this._initNetworkDetect(); // 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发) // 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash) // 解耦: splash-state 不直接 import diy.js // 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用 try { if (typeof uni !== 'undefined' && uni.$on) { uni.$on('diy:load-error', (payload) => { try { this.notifyDiyLoadError(payload || {}) } catch (e) {} }) } } catch (e) {} } /** * 初始化网络检测 * - 调 uni.getNetworkType 异步获取当前网络类型 * - 监听 uni.onNetworkStatusChange 实时更新 * - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型 * 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash) */ _initNetworkDetect() { try { // #ifdef H5 // H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线 // 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5 // Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType) if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { // 初始检测: 用 _updateNetworkType (内部会触发事件) this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' }) // 监听 online/offline 事件 if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('online', () => { this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true }) }) window.addEventListener('offline', () => { this._updateNetworkType('none', { source: 'h5-offline', isConnected: false }) }) } return } // navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType // #endif // #ifndef H5 if (typeof uni === 'undefined' || !uni.getNetworkType) { // 不可用: 用 _updateNetworkType 设 unknown (会触发事件) this._updateNetworkType('unknown', { source: 'no-uni-api' }) return } // 异步获取当前网络类型 uni.getNetworkType({ success: (res) => { this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' }) }, fail: () => { this._updateNetworkType('unknown', { source: 'uni-initial-fail' }) }, }) // 监听网络变化 if (uni.onNetworkStatusChange) { uni.onNetworkStatusChange((res) => { const newType = (res && res.networkType) || (res && res.isConnected === false ? 'none' : 'unknown') this._updateNetworkType(newType, { source: 'uni-change', isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined, }) }) } // #endif } catch (e) { // 任意失败都用 unknown, 不影响 splash this._updateNetworkType('unknown', { source: 'init-error' }) } } /** * 根据当前网络环境选档 * - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发) * - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲) * - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲) * - 未知 (unknown): 17000 (17s 中档, 保守) * - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守) */ _pickTimeoutByNetwork() { if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认 switch (this._networkType) { case 'none': return 4000 // 离线: 4s 兜底 case '2g': case '3g': return 22000 // 弱网: 22s 慢网档 case '4g': case '5g': case 'wifi': return 12000 // 正常: 12s 默认档 case 'unknown': default: return 17000 // 未知: 17s 中档 (保守) } } show(opts = {}) { if (!needSplash()) return; if (this._visible) return; // 防重入 this._visible = true; this._shownAt = Date.now(); this._pendingHide = null; this._hasMounted = false; this._pageReady = false; this._stage = 'boot'; // 初始化时直接显示 'boot' 阶段的 5%, 而不是 0% // 避免首帧出现"进度条 0%"的视觉突兀 this._percent = (STAGES.boot && STAGES.boot.percent) || 5; this._stageSet = new Set(['boot']); this._pendingProgress = null; if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; } if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } this._emit('splash:show'); // H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染) // 注意: mask 设为 false。原 mask:true 会在 H5 注入 .uni-mask 全屏透明蒙层, // 配合 z-index:99999 的 Vue splash 一起把首页触摸全部拦截,导致 H5 // 首屏在 boot 期间无法滚动。Vue splash 组件本身已带白色背景提供视觉遮罩, // 这里只要 loading 文字提示,不需要拦截触摸。 if (useNativeLoading()) { try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {} } // 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效) // 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s // timeout 低于 5s 会经常触发兜底强关, 用户看到白屏 // 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn // 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT const MIN_RECOMMENDED_TIMEOUT = 5000 let timeout if (opts.timeout) { timeout = opts.timeout if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) { console.warn( `[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` + `可能导致启动期白屏. 生产环境建议 10000-20000ms.` ) } } else { // 自动选档: 根据当前网络环境选 timeout timeout = this._pickTimeoutByNetwork() } this._armTimeout(timeout); } /** * 设置/重设 splash 总超时兜底 * - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用 * - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏 */ _armTimeout(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); } /** * 网络变化时更新 _networkType, 并按需重启 splash 兜底 * @param {string} newType 新的网络类型 * @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... } * @returns {boolean} 是否真的更新了 (新旧不同时为 true) */ _updateNetworkType(newType, meta) { newType = newType || 'unknown' const oldType = this._networkType const changed = oldType !== newType // 关键: 即使 type 没变, _networkReady 也要设 true // 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来 // 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用) // 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作) this._networkReady = true if (!changed) return false this._networkType = newType // 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档) // 业务场景: // - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关 // - 2g → wifi: 兜底缩短, 避免过晚强关 // - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发 // - none → any: 兜底恢复正常档, 给足时间 const shouldReboot = this._visible && this._pendingHide === null // 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底 // _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可 // 此时网络变化不影响兜底 (业务方已接管决策) if (shouldReboot) { const newTimeout = this._pickTimeoutByNetwork() this._armTimeout(newTimeout) } // 发 splash:network-change 事件 (业务方监听) // payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout } // retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口) // rebootSplashTimeout: 是否重启了 splash 兜底 (true/false) const eventPayload = { from: oldType, to: newType, isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none', retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试 rebootSplashTimeout: shouldReboot, source: (meta && meta.source) || 'unknown', } this._emit('splash:network-change', eventPayload) return true } /** * 隐藏 splash * @param {Object} opts * @param {string} opts.reason - 隐藏原因 * - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认) * - 'timeout': 硬隐藏, 立即关 (兜底用) * - 'manual': 硬隐藏, 立即关 * @param {boolean} [opts.soft] - 强制走软隐藏 * @param {string} [opts.mode] - 离开动画模式 * - 'default': 默认 480ms 动画 * - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感 * - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底, * 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时 * 适用于: 已接入平台托管的系统弹窗 (鸿蒙), * splash 必须真等到首页 diy 内容渲染好才能关 */ hide(opts = {}) { const reason = opts.reason || 'manual'; const useStrict = opts.mode === 'strict' || opts._strict === true; // boot-complete 默认走软隐藏, 避免时序问题 const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete'); // strict 模式复用 fast 动画 const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default'); if (useSoft) { // 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict this._softHide(opts); } else { this._doHide({ reason, mode }); } } /** * 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪 * 如果组件 1s 内没 mount, 兜底自动 hide * 如果页面 2s 内没就绪, 兜底自动 hide * * strict 模式 (鸿蒙已接入平台托管): * 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底 * 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时 * 保证 splash 一定等到首页 diy 内容真的渲染好 */ _softHide(opts) { if (!this._visible) return; // 已经隐藏了 this._pendingHide = opts; // strict 模式识别: mode='strict' 或 _strict=true const useStrict = opts && (opts.mode === 'strict' || opts._strict === true); // 如果组件已经 mount 且页面已就绪, 立即隐藏 if (this._hasMounted && this._pageReady) { this._flushPendingHide(); return; } // strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady // (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底) if (useStrict) { return; } // 兜底: 1s 内没 mount, 强制隐藏 if (this._mountGraceId) clearTimeout(this._mountGraceId); this._mountGraceId = setTimeout(() => { if (this._pendingHide) { this._flushPendingHide(); } }, MOUNT_GRACE_MS); } /** * 真正触发软隐藏 * 如果页面还未就绪, 等待页面就绪后再 hide * strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady() */ _flushPendingHide() { if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } // 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop // 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费 // 不应再调 _doHide (避免重复发 splash:hide 或时序错乱) if (!this._pendingHide) return; const _opts = this._pendingHide const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true) // 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理) if (!this._pageReady) { // strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady() // 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s) if (useStrict) { return; } // 兜底: 2s 内没就绪, 强制隐藏 if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = setTimeout(() => { if (this._visible && this._pendingHide) { console.warn('[splash-state] auto-hide after page-ready timeout'); this._doHide(this._pendingHide || { reason: 'page-ready-timeout' }); } }, PAGE_READY_GRACE_MS); return; } const opts = this._pendingHide; this._pendingHide = null; this._doHide(opts || { reason: 'mount-completed' }); } /** * 实际隐藏 (硬路径) * @param {Object} opts * @param {string} [opts.reason='manual'] 隐藏原因 * @param {string} [opts.mode='default'] 动画模式 * @param {boolean} [opts._silent=false] true=不发 splash:hide 事件 * 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide */ _doHide(opts) { if (!this._visible) return; // 防重入 if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; } if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } this._pendingHide = null; this._visible = false; // H5 降级对应的关闭 if (useNativeLoading()) { try { uni.hideLoading(); } catch (e) {} } // _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide if (opts._silent) return; this._emit('splash:hide', { reason: opts.reason || 'manual', mode: opts.mode || 'default', }); } /** * 组件 mount 后通知 state 模块 * - 标记 _hasMounted * - 如果有 _pendingHide, 触发真关 * - H5 降级场景: 不再立即关掉原生 loading, 等待页面就绪后再关 */ notifyMounted() { if (this._hasMounted) return; this._hasMounted = true; if (this._pendingHide) { setTimeout(() => this._flushPendingHide(), 50); } } /** * 页面内容就绪后通知 state 模块 * - 标记 _pageReady * - 如果有 _pendingHide, 触发真关 * 调用时机: 首页 diy-group 渲染完成后调用 */ notifyPageReady() { if (this._pageReady) return; this._pageReady = true; if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } if (this._pendingHide) { // 给页面一帧时间完成渲染, 再隐藏 setTimeout(() => this._flushPendingHide(), 50); } } /** * diy 加载失败时通知 (网络异常/业务失败/无响应) * - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上) * - 触发独立事件 'splash:diy-error' (不是 'splash:hide') * 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型 * 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError) * * @param {Object} [opts] * @param {string} [opts.reason='diy-load-error'] 隐藏原因 * 用于 'splash:diy-error' 事件的 payload, 业务方可区分: * - 'diy-network-error': 网络异常 (uni.request catch) * - 'diy-no-response': 无响应 (res 为空) * - 'diy-business-error': 业务失败 (code!=0) * @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response' * @param {Object|string} [opts.err] 原始错误对象 * @param {string} [opts.url] 出错的请求地址 */ notifyDiyLoadError(opts = {}) { // 已 _pageReady=true (notifyPageReady 之后): noop // 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱) // 不应再触发 splash:diy-error (已经发过 splash:hide) if (this._pageReady) return; // 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪") // 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉 this._pageReady = true // 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError) if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId) this._pageReadyGraceId = null } if (this._mountGraceId) { clearTimeout(this._mountGraceId) this._mountGraceId = null } // 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误) this._emit('splash:diy-error', { reason: opts.reason || 'diy-load-error', errorType: opts.errorType, err: opts.err, url: opts.url, }) // 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide // 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件 // notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide // (业务方根据事件类型区分, 不能混淆) if (this._pendingHide) { // 把 _pendingHide 取出, 直接 _doHide 关闭 // _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error) const _pendingOpts = this._pendingHide this._pendingHide = null setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 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); } } } /** * 上报启动期进度 * 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴 * 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退) * @param {string} stageId - 阶段 id (STAGES 中定义) * 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done' * 或自定义阶段 id (传 percent + label) * @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' } * 阶段完成时 percent 应该是该阶段的"完成态"百分比 */ reportProgress(stageId, meta) { const def = STAGES[stageId] let percent, label if (def) { percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent label = (meta && meta.label) || def.label } else { // 自定义阶段 percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent label = (meta && meta.label) || stageId } // 取较大值, 不允许后退 if (percent < this._percent) percent = this._percent this._percent = percent this._stage = stageId if (!this._stageSet.has(stageId)) this._stageSet.add(stageId) // 节流 emit: 16ms 内的多次调用合并为 1 次 // 内部状态立即更新 (不延迟), 但 emit 推迟 // 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度 const payload = { stage: stageId, label, percent, source: (meta && meta.source) || 'channel', at: Date.now(), } this._pendingProgress = payload if (this._flushTimer) return // 已有 pending, 等 timer flush this._flushTimer = setTimeout(() => { this._flushTimer = null if (this._pendingProgress) { const p = this._pendingProgress this._pendingProgress = null this._emit('splash:progress', p) } }, 16) } /** * 监听进度上报 * callback({ stage, label, percent, source, at }) * * 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效) * 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播 */ onProgress(callback) { this._listeners['splash:progress'] = [callback] } /** * 解绑进度上报 (清空 listener) */ offProgress() { this._listeners['splash:progress'] = [] } /** * 读取当前进度 (供组件初始化) * 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL */ getProgress() { const def = STAGES[this._stage] return { stage: this._stage, percent: this._percent, label: def ? def.label : this._stage, } } /** * 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份) */ getStageDef(stageId) { return STAGES[stageId] } /** * 立即刷新挂起的进度更新 * - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render * - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法 * - 不存在 pending 时 noop */ flushProgress() { if (this._pendingProgress) { const p = this._pendingProgress this._pendingProgress = null if (this._flushTimer) { clearTimeout(this._flushTimer) this._flushTimer = null } this._emit('splash:progress', p) } } } const splashState = new SplashState(); export default splashState; export { splashState };