前端加载遮罩实现的几种实用方案与避坑指南
优化前:卡得不行
上个月上线一个后台数据看板,用户一刷页面就卡顿,不是“加载中”慢,是整个页面直接卡住1.5秒以上——连右上角的关闭按钮都点不动。更离谱的是,有些低端安卓机上,遮罩弹出来后,底下内容还能滚动,手指一划,遮罩居然跟着抖动,像卡在4fps的GIF里。
我第一反应是“肯定又是CSS动画没开硬件加速”,结果加了transform: translateZ(0)也没用。后来发现,问题根本不在遮罩本身,而在于我们把它和一个巨型渲染逻辑绑死了:
- 点击按钮 → 触发
fetch请求 → 等接口返回 → 再渲染100+个图表组件 → 最后才显示遮罩 - 遮罩居然是在
then里才show()的!
也就是说,用户点了按钮,UI毫无反馈,干等5秒(接口+渲染),然后“唰”一下遮罩+结果同时出现。这不是加载遮罩,这是加载诈尸。
找到病灶了!
我打开Chrome DevTools Performance面板,录了一次点击流程。重点不是看FPS,而是看Main线程的长任务。果然,一个3200ms的JS执行块,里面全是Vue组件mount、ECharts初始化、数据格式转换……而遮罩DOM节点直到第2800ms才被插入。
接着切到Network,发现有个接口虽然只返回12KB JSON,但用了1.1秒——因为后端查了4张表+做聚合计算。但我们前端还傻乎乎地把这1.1秒全算成“等待时间”,其实真正卡的是后面那2秒的同步渲染。
结论很清晰:遮罩延迟显示,本质是它没抢在JS长任务开始前就占位。只要让它在用户点击后的第一个microtask就挂载并显示,哪怕内容还没出来,视觉上也立刻有反馈。
试了几种方案,最后这个效果最好
我试过三种方式:
- 方案A:用
setTimeout(() => showMask(), 0)—— 结果在iOS Safari里还是晚了300ms,不靠谱 - 方案B:用
requestIdleCallback—— 太佛系,有时候等半天才触发,用户早点第二下了 - 方案C(最终采用):立即挂载DOM + 强制重排 + CSS硬加速 + 非阻塞式状态管理
核心就三步:
- 点击瞬间创建遮罩DOM,
display: block,但初始opacity: 0 - 强制触发一次reflow(比如读取
offsetHeight),让浏览器立刻布局 - 立刻用
requestAnimationFrame把opacity设为1,保证动画进60fps队列
这样做的好处是:不管JS主线程多忙,遮罩DOM已经存在、已布局、只差一个动画帧就能显现。实测在Redmi Note 9这种机器上,从点击到遮罩完全显示,稳定在60~80ms之间。
核心代码就这几行
我们封装了一个createLoadingMask函数,全局复用。重点看show方法里的force reflow和RAF这两步,其他都是配套:
function createLoadingMask() {
const mask = document.createElement('div');
mask.className = 'loading-mask';
mask.innerHTML = '<div class="loading-spinner"></div>';
// 插入body末尾,避免z-index冲突
document.body.appendChild(mask);
return {
show() {
// 立即可见(但透明)
mask.style.display = 'block';
mask.style.opacity = '0';
// 🔥 关键:强制reflow,让浏览器立刻计算布局
mask.offsetHeight; // 这行不能删!
// 🔥 关键:下一帧再淡入,确保进动画队列
requestAnimationFrame(() => {
mask.style.opacity = '1';
mask.style.transition = 'opacity 0.2s ease';
});
},
hide() {
mask.style.opacity = '0';
mask.style.transition = 'opacity 0.2s ease';
setTimeout(() => {
mask.style.display = 'none';
}, 200);
}
};
}
CSS部分也很简单,但有两个坑我踩过好几次:
- 必须加
will-change: opacity,不然iOS Safari动画掉帧 - 遮罩背景不能用
rgba(0,0,0,0.5),要用background: #000+opacity,否则某些安卓WebView会闪烁
.loading-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
opacity: 0;
z-index: 9999;
display: none;
will-change: opacity;
}
.loading-mask .loading-spinner {
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
性能数据对比
我们挑了三台典型设备实测(所有测试关闭缓存,强制弱网Throttling: Fast 3G):
| 设备 | 优化前首显遮罩时间 | 优化后首显遮罩时间 | 用户感知变化 |
|---|---|---|---|
| iPhone 12 | 1420ms | 76ms | 从“点了没反应”变成“点了马上有动静” |
| Pixel 4a | 2850ms | 89ms | 遮罩出现后,主内容加载期间手指滑动不再卡顿 |
| Redmi Note 9 | 4100ms | 132ms | 原来要等3秒才看到遮罩,现在不到0.2秒 |
另外,Lighthouse的First Contentful Paint平均提前了1.2秒——因为遮罩本身就是FCP的内容。不过要注意:FCP提前不代表真实数据加载变快,只是UI反馈快了。这点跟产品同学对齐清楚,别让他们误以为接口提速了。
还有两个小问题没彻底解决
一是Safari下requestAnimationFrame偶尔会延迟一帧,导致遮罩闪一下再淡入(概率约3%)。我加了个兜底:setTimeout(..., 16),虽然不够优雅,但比闪一下强。
二是如果用户快速连点两次,可能产生两个遮罩DOM没清理干净。我们的解法很土:每次show前先hide再清空,不追求极致,够用就行。
还有个细节:我们没用任何第三方库(比如NProgress),纯原生实现,就为了少一个HTTP请求、少几百字节JS。上线后CDN缓存命中率涨了7%,也算意外收获。
以上是我的优化经验,有更好的方案欢迎交流
这次优化没动后端、没换框架、没上Web Worker,就是把遮罩的生命周期往前挪了几十毫秒,但用户体感差别巨大。说白了,性能优化很多时候不是拼技术深度,而是拼对用户等待心理的拿捏——他需要的不是“更快”,而是“我知道你在动”。
如果你也遇到类似问题,或者有更好的处理方式(比如用IntersectionObserver监听遮罩渲染完成?或者用CSS @property做更可控的动画?),欢迎评论区聊聊。这个技巧的拓展用法还有很多,比如结合Suspense做渐进式加载,后续会继续分享这类博客。

暂无评论