前端资源加载优化的实战经验与性能提升技巧

一钧 框架 阅读 1,498
赞 26 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

我们做的这个项目是个内容密集型的 Web 应用,首页一堆图文卡片,还有视频预览和懒加载的图片墙。一开始加载策略没太上心,就是常规的 img 标签加个懒加载库,结果上线前压测一跑,首屏时间直接拉胯,LCP(最大内容绘制)动不动就 4 秒开外。

前端资源加载优化的实战经验与性能提升技巧

开始没想到资源加载这块能拖这么大的后腿。后来复盘才发现,不是接口慢,也不是打包体积爆炸,而是资源调度完全无序——图片、字体、脚本、样式全都一股脑往里怼,浏览器忙得像个刚开学的班主任。

于是决定搞点动静:引入资源优先级管理 + 动态加载控制。最终方案是结合 IntersectionObserver 做可视区检测,配合 loading="lazy" 回退 + preload/prefetch 策略 + 动态 import 第三方组件。核心目标就一个:让用户在滑动过程中几乎感觉不到“卡”或“闪”。

最大的坑:滚动时图片疯狂闪烁

第一版实装后,最离谱的问题来了——用户稍微一滑动,图片就开始忽大忽小地闪,像是信号不良的老电视。排查半天发现,是因为我在 IntersectionObserver 的回调里直接设置了 img.src,但这时候 DOM 元素还没有固定高度,导致 layout shift 严重,触发了 CLS(累积布局偏移)暴增。

这里注意我踩过好几次坑:你不能只等元素进入视口才去计算尺寸,必须提前占位。后来改成在数据层就带上每张图的宽高比(aspectRatio),渲染时用伪元素撑出容器:

<div class="image-container" style="--aspect-ratio: 16/9;">
  <img data-src="https://jztheme.com/assets/photo.jpg" alt="示例图片" class="lazy-image">
</div>
.image-container {
  position: relative;
  width: 100%;
}

.image-container::before {
  content: "";
  display: block;
  padding-bottom: calc(var(--aspect-ratio) * 100%);
}

.lazy-image {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.3s;
}

.lazy-image.loaded {
  opacity: 1;
}

这样一来,即使图片还没加载,容器高度也是稳定的,CLS 直接从 0.5+ 降到 0.1 以下,舒服多了。

IntersectionObserver 踩坑记

另一个问题是,Observer 回调触发得太频繁,尤其快速滚动时,CPU 使用率一度飙到 80% 以上。折腾了半天发现,我忘了加 throttle,而且 rootMargin 没设置缓冲区。

改法很简单,但效果显著:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      const src = img.dataset.src;

      if (!src) return;

      const imageLoader = new Image();
      imageLoader.src = src;
      imageLoader.onload = () => {
        img.src = src;
        img.classList.add('loaded');
        observer.unobserve(img);
      };
      imageLoader.onerror = () => {
        img.classList.add('failed');
        observer.unobserve(img);
      };
    }
  });
}, {
  rootMargin: '50px 0px', // 提前 50px 开始加载
  threshold: 0.01
});

document.querySelectorAll('.lazy-image').forEach(img => {
  observer.observe(img);
});

加上 rootMargin: '50px 0px' 后,用户还没滑到那张图,它已经在后台加载了,体验顺滑不少。不过这里有个遗留问题:如果网络极差,图片还是会延迟出现,但我们评估了业务场景,这种情况影响不大,先放着了。

字体加载也得管

差点忘了字体——自定义字体一上来就阻塞渲染,FOIT(不可见文本)现象严重。解决方案是用了 font-display: swap,然后通过 document.fonts.ready 控制关键文本的显示时机。

CSS 部分:

@font-face {
  font-family: 'CustomFont';
  src: url('https://jztheme.com/fonts/custom.woff2') format('woff2');
  font-display: swap;
  font-weight: 400;
}

JS 中监听字体加载完成,避免内容跳变:

document.fonts.ready.then(() => {
  document.body.classList.add('fonts-loaded');
});

然后 CSS 里用类控制:

.custom-text {
  font-family: 'CustomFont', sans-serif;
  visibility: hidden;
}

.fonts-loaded .custom-text {
  visibility: visible;
}

这招亲测有效,但要注意别滥用,否则整个页面会白屏一下再弹出来,用户体验反而更糟。我们只对标题类文本做了这种处理。

静态资源的 preload 策略

最后补了个小优化:对首屏最关键的几张图,手动加了 <link rel="preload">,比如 Banner 图和 Logo。

<link rel="preload" as="image" href="https://jztheme.com/assets/banner.jpg">
<link rel="prefetch" href="https://jztheme.com/assets/next-page-content.json">

注意这里的区别:preload 是当前页急需的资源,prefetch 是预测用户下一步会用到的,浏览器会在空闲时加载。别乱用,不然会抢走主资源的带宽。

回顾与反思

改完这一套之后,LCP 从平均 4.2s 降到 2.1s,CLS 降到 0.1 以内,算是达标了。但也有些不完美的地方:

  • 低端安卓机上 still 有轻微闪动,可能跟 Observer 的帧率有关,暂时没深究
  • prefetch 的命中率其实不高,统计显示只有 30% 左右的预加载资源被真正用到,有点浪费
  • 代码量比最初多了不少,维护成本上升,但性能换来了用户停留时长提升,老板满意就行

总的来说,这套方案不是最优解,但足够简单稳定,适合我们这种迭代快、资源多的项目。如果你也在做类似的重内容页面,这套组合拳可以参考,但记得根据实际设备分布和网络环境调整阈值。

以上是我的项目经验,希望对你有帮助

这玩意儿真不是一次就能搞定的,我们前后调了三轮才上线。中间还因为忘记清缓存导致测试数据偏差,尴尬得一批。如果你有更好的实现方式,比如用 React 的 Suspense 或者新的 fetchpriority 属性,欢迎评论区交流。后续我也会继续分享这类实战踩坑记录。

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

暂无评论