前端加载遮罩实现的几种实用方案与避坑指南

一莹 Dev 交互 阅读 2,898
赞 21 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月上线一个后台数据看板,用户一刷页面就卡顿,不是“加载中”慢,是整个页面直接卡住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硬加速 + 非阻塞式状态管理

核心就三步:

  1. 点击瞬间创建遮罩DOM,display: block,但初始opacity: 0
  2. 强制触发一次reflow(比如读取offsetHeight),让浏览器立刻布局
  3. 立刻用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做渐进式加载,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论