1. 统一全平台启动页为strict模式,仅在12s兜底超时才强制关闭 2. 新增网络自适应超时策略,根据网络环境调整兜底时长 3. 补充diy加载失败时的启动页关闭逻辑,避免严格模式下无限等待 4. 优化代码格式与注释,修复部分代码细节问题
699 lines
26 KiB
JavaScript
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 };
|