前端性能优化实战:从加载速度到渲染效率的全面提升

司马瑞娜 工具 阅读 1,172
赞 33 收藏
二维码
手机扫码查看
反馈

先上代码:懒加载图片亲测有效

我之前做的一个项目首页加载巨慢,首屏白屏将近3秒。打开 Network 一看,好家伙,几十张高清图一股脑全在 HTML 里用 <img src="..."> 硬塞进来。用户根本没滚到下面,浏览器却把所有图片都下载了,纯纯浪费带宽。

前端性能优化实战:从加载速度到渲染效率的全面提升

后来改用 Intersection Observer 做懒加载,首屏加载时间直接干到 800ms 以内。核心就这几行,建议直接抄:

const imgObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      imgObserver.unobserve(img);
    }
  });
});

document.querySelectorAll('img.lazy').forEach(img => {
  imgObserver.observe(img);
});

对应的 HTML 长这样:

<img class="lazy" data-src="https://jztheme.com/assets/hero.jpg" alt="首屏大图">
<img class="lazy" data-src="https://jztheme.com/assets/content1.jpg" alt="内容图1">

CSS 里给 lazy 图加个默认占位,避免布局抖动:

img.lazy {
  background: #f0f0f0;
  min-height: 100px;
}

这个方案我在三个项目里都用过,稳定得很。注意别用 onload 再去处理,Intersection Observer 是原生 API,性能开销小得多。

踩坑提醒:这三点一定注意

别看上面代码简单,我一开始也栽过几个坑,这里重点说说:

  • 别忘了 unobserve:每次图片加载完一定要调 imgObserver.unobserve(img)。不然这个元素会一直被监听,内存泄漏警告分分钟找上门。我第一次上线后内存占用一路飙到 500MB,查了半天才发现是忘了这行。
  • 低版本浏览器兜底:虽然现在主流浏览器都支持 Intersection Observer,但如果你的用户群里还有 IE 或老安卓机,得加个 polyfill。不过说实话,现在新项目基本不用考虑 IE 了,但保险起见我还是在 webpack 里加了 @webcomponents/intersection-observer 这个包。
  • 别在 SSR 里直接渲染真实 src:如果你用 Next.js、Nuxt 这类服务端渲染框架,千万别在服务端就把 src 渲染成真实 URL。否则首屏还是会加载所有图。正确做法是在服务端只输出 data-src,客户端挂载后再初始化 observer。我见过有人为了“SEO 友好”硬在服务端塞真实 src,结果性能优化全白做了。

这个场景最好用:长列表图片

除了普通页面,懒加载在长列表里效果更明显。比如商品列表、文章 feed 流,动不动上百条数据,每条带一两张图。这时候如果不用懒加载,滚动起来卡成 PPT。

我的做法是:结合虚拟滚动(比如 react-window)+ 图片懒加载。但如果你不想引入额外库,光用 Intersection Observer 也能扛住。关键点在于提前触发——别等图片完全进入视口才加载,那样用户会看到空白。

加个 rootMargin 参数,提前 200px 加载:

const imgObserver = new IntersectionObserver((entries) => {
  // ... 同上
}, {
  rootMargin: '200px 0px'
});

实测在中低端安卓机上,配合这个 margin,滚动流畅度提升非常明显。用户还没滚到,图已经加载好了,体验顺滑很多。

高级技巧:WebP 自动降级 + 懒加载组合拳

光懒加载还不够狠。我还加了一层图片格式优化:优先用 WebP,不支持的自动切回 JPEG/PNG。

判断是否支持 WebP 的方法网上一堆,我用的是最简单的:

function supportsWebP() {
  return new Promise(resolve => {
    const webP = new Image();
    webP.onload = webP.onerror = () => {
      resolve(webP.height === 2);
    };
    webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
  });
}

然后改造懒加载逻辑:

let webPSupported = null;

supportsWebP().then(supported => {
  webPSupported = supported;
});

const imgObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      let src = img.dataset.src;
      
      if (webPSupported && src.endsWith('.jpg')) {
        src = src.replace(/.jpg$/, '.webp');
      }
      
      img.src = src;
      img.classList.remove('lazy');
      imgObserver.unobserve(img);
    }
  });
});

当然,前提是你的 CDN 或服务端能同时提供 WebP 和原始格式。我在 jztheme.com 的 API 示例里就是这样配的:https://jztheme.com/api/image?format=auto 会根据 User-Agent 返回合适格式,但你也可以前端自己拼路径。

这套组合拳打下来,图片体积平均减少 30%-50%,再配上懒加载,页面加载快得飞起。

还有个小问题:骨架屏怎么搞?

懒加载有个副作用:图片没加载出来前是空白(或灰色背景),用户体验还是差点意思。所以我后来加了简易骨架屏。

做法很简单:用 CSS 画个动画块,图片加载完成后再隐藏:

.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

img.loaded + .skeleton {
  display: none;
}

HTML 结构稍微改下:

<div class="image-wrapper">
  <img class="lazy" data-src="..." onload="this.classList.add('loaded')">
  <div class="skeleton"></div>
</div>

注意这里用了 onload 行内事件,只是为了简化。实际项目里建议用 addEventListener 绑定,避免污染全局作用域。

虽然骨架屏不是必须的,但加上之后,用户感知上的“快”会更强——至少知道这里是有内容的,不是 bug。

最后说两句

以上是我踩坑后的总结,希望对你有帮助。懒加载看起来简单,但细节特别多,尤其是和现代前端框架(React/Vue)结合时,生命周期处理不好容易重复监听或内存泄漏。

这个技术的拓展用法还有很多,比如视频懒加载、组件懒加载(用 React.lazy + Suspense),后续会继续分享这类博客。如果你有更好的实现方式,欢迎评论区交流——我上次就被读者指出可以用 loading="lazy" 原生属性,虽然兼容性差点,但在可控环境下确实更省事。

总之,性能优化没有银弹,但懒加载绝对是性价比最高的手段之一。赶紧试试吧,别等用户抱怨了才动手。

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

暂无评论