Files
lucky_shop/common/js/splash-state.js
ZF sun 88921e61d2 fix(splash-screen): 修复H5环境下启动屏关闭后页面空白问题
问题根源分析:
1. diy-group 的 mounted() 在组件挂载时就触发了,但此时 diyData 还没有数据
2.
otifyMounted() 在组件 mount 时立即关闭了 H5 的 uni.showLoading(),但页面内容还没就绪
3. 导致启动屏关闭后,首页内容还是空白的

修复方案:
1. 在 diy-group.vue 中,将 loaded 事件的触发时机改为 diyData watch 中,确保数据加载完成后再通知
2. 在 splash-state.js 中,
otifyMounted() 不再立即关闭 H5 原生 loading,等待页面就绪后由 _doHide() 统一关闭
3. 在 index.js 中,onDiyGroupLoaded() 延迟 500ms 再通知页面就绪,确保内容渲染完成
2026-07-13 10:54:17 +08:00

412 lines
12 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
}
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
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(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 还没挂载,组件层无法渲染)
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] - 强制走软隐藏
* @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
*/
hide(opts = {}) {
const reason = opts.reason || 'manual';
// boot-complete 默认走软隐藏, 避免时序问题
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default';
if (useSoft) {
this._softHide({ reason, mode });
} else {
this._doHide({ reason, mode });
}
}
/**
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide
*/
_softHide(opts) {
if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts;
// 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) {
this._flushPendingHide();
return;
}
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
if (this._pendingHide) {
this._flushPendingHide();
}
}, MOUNT_GRACE_MS);
}
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide
*/
_flushPendingHide() {
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) {
// 兜底: 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' });
}
/**
* 实际隐藏 (硬路径)
*/
_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) {}
}
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);
}
}
/**
* 组件 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 };