Files
lucky_shop/common/js/splash-state.js
ZF sun f5f893b445 refactor(splash&diy): 重构启动页逻辑,优化白屏问题
1. 统一全平台启动页为strict模式,仅在12s兜底超时才强制关闭
2. 新增网络自适应超时策略,根据网络环境调整兜底时长
3. 补充diy加载失败时的启动页关闭逻辑,避免严格模式下无限等待
4. 优化代码格式与注释,修复部分代码细节问题
2026-07-24 16:11:41 +08:00

699 lines
26 KiB
JavaScript

// ============================================================
// 启动屏单例状态
//
// 设计要点:
// 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 };