预加载技术在前端性能优化中的实战应用

令狐江梅 前端 阅读 2,980
赞 34 收藏
二维码
手机扫码查看
反馈

又翻车了,预加载把页面卡成幻灯片

今天上线前最后测一把性能,好家伙,Lighthouse直接给我干到40多分,首屏加载时间快3秒。明明昨天还是80+的,我寻思也没加啥大资源啊。打开Network面板一看,一堆图片在页面初始化的时候全挤着一起请求,瀑布流长得离谱。仔细一查,是我之前加的预加载逻辑出了问题——本来是为了提升体验,结果反手把自己给埋了。

预加载技术在前端性能优化中的实战应用

项目是个内容页居多的图文站,用户滑动过程中会动态插入新的图组。为了不让用户滑到一半看到空白,我早早就上了 new Image().src 那一套预加载方案,监听滚动位置,在可视区域前500px就开始加载下一组图片。本以为挺稳妥,结果这次是因为某个运营活动页塞了12组高清大图,每张都700KB以上,一上来就并发12个请求,浏览器直接限流,连JS执行都卡了半秒。

排查过程:从“感觉没问题”到“这不对劲”

一开始我以为是CDN缓存没生效,各种清缓存、换节点、看响应头,折腾了快一小时发现根本不是网络问题。后来打开控制台的Performance面板录了一段,才发现主线程被一堆 Image.decode() 和解码任务占满,帧率直接掉到十几。这才意识到:我只考虑了“提前加载”,压根没控并发,也没做优先级调度。

试过几种方案:

  • 第一种是简单粗暴地加延时,用 setTimeout 分批触发预加载,比如每200ms加载一张。效果是好点了,但用户体验变得断断续续,滑得快的时候还是能看到空白。
  • 第二种是监听 requestIdleCallback,想在空闲时段加载。理想很丰满,现实很骨感,这个回调太不靠谱了,尤其在低端机上几乎不触发,预加载直接失效。
  • 第三种是引入 IntersectionObserver 做懒加载+预加载结合,结果发现和现有逻辑冲突,改起来成本太高,而且对“预加载距离”的控制不够灵活。

折腾了半天,最后回头去看 Chrome 的并发限制文档——桌面端一般最多6个TCP连接同域,并且浏览器内部还有解码队列。这意味着就算你发了请求,资源回来也得排队解码,照样卡界面。所以关键不是“什么时候开始加载”,而是“怎么平滑地加载”。

最终方案:节流 + 优先级 + 解码优化

最后定下来的策略是:按距离分优先级,用队列控制并发,关键图片强制前置,非关键的延迟加载。核心思路就是别一股脑全冲上去。

我写了个简单的预加载管理器,代码也不长,但解决了大问题:

class ImagePreloader {
  constructor(options = {}) {
    this.concurrency = options.concurrency || 3; // 最大并发数
    this.loaded = new Set();
    this.queue = [];
    this.active = 0;
    this.decoderSupport = 'decode' in new Image(); // 检测是否支持 decode()
  }

  add(src, priority = 0) {
    if (this.loaded.has(src)) return Promise.resolve();

    const img = new Image();
    const item = { src, img, priority, resolve: null, reject: null };

    const promise = new Promise((resolve, reject) => {
      item.resolve = resolve;
      item.reject = reject;
    });

    // 绑定事件
    img.onload = () => {
      if (this.decoderSupport) {
        img.decode().then(() => {
          this.loaded.add(src);
          item.resolve();
          this.next();
        }).catch(err => {
          this.loaded.add(src);
          item.resolve(); // 解码失败也当成功,至少能显示
          this.next();
        });
      } else {
        this.loaded.add(src);
        item.resolve();
        this.next();
      }
    };

    img.onerror = () => {
      item.reject(new Error(Failed to load image ${src}));
      this.next();
    };

    // 插入队列并按优先级排序
    this.queue.push(item);
    this.queue.sort((a, b) => b.priority - a.priority);
    this.start();

    return promise;
  }

  start() {
    while (this.active < this.concurrency && this.queue.length > 0) {
      const item = this.queue.shift();
      this.active++;
      item.img.src = item.src;
    }
  }

  next() {
    this.active--;
    this.start();
  }

  clear() {
    this.queue = [];
    this.loaded.clear();
  }
}

然后在滚动监听里调用:

const preloader = new ImagePreloader({ concurrency: 3 });

let ticking = false;
const preloadDistance = 800; // 提前800px开始预加载

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(() => {
      const scrollTop = window.pageYOffset;
      const viewportHeight = window.innerHeight;

      document.querySelectorAll('.image-group').forEach(group => {
        const rect = group.getBoundingClientRect();
        const top = rect.top + scrollTop;

        // 进入预加载区域,且未加载过
        if (top < scrollTop + viewportHeight + preloadDistance) {
          const imgs = group.querySelectorAll('img[data-src]');
          imgs.forEach(img => {
            const src = img.dataset.src;
            if (src && !preloader.loaded.has(src)) {
              // 越靠近视口,优先级越高
              const distance = top - scrollTop;
              const priority = Math.max(0, 1000 - distance); // 距离越小,priority越大
              preloader.add(src, priority);
            }
          });
        }
      });
      ticking = false;
    });
    ticking = true;
  }
}

window.addEventListener('scroll', onScroll, { passive: true });

这里有几个关键点:

  • concurrency 控制并发:避免一次性发起太多请求压垮网络栈。
  • decode() 异步解码:虽然失败了我也放行,不然图片可能渲染异常。
  • 优先级排序:离视口越近的越早加载,用户体验更顺滑。
  • Set 缓存已加载:防止重复加载同一张图。

上线后重新跑 Lighthouse,分数回到85+,首屏时间降到1.2秒左右,最关键的是滚动流畅多了,没有那种“卡一下出一张图”的割裂感。

还有一些细节没完美解决

说实话,现在还有个小问题:如果用户快速滚动到底部,某些中间的图可能会因为优先级被挤掉而延迟加载。不过实测下来影响不大,毕竟用户已经滑过去了,回看的概率低。而且这些图最终还是会加载,只是时机晚点。

另一个妥协是没上 loading="lazy",因为跟我们这套预加载逻辑有点冲突,还得加额外判断,暂时先不动了。后期可能会考虑用原生懒加载做兜底,预加载只针对“即将进入视野”的图。

另外,对于关键首屏图,我还是保留了直接写 <img src> 的方式,预加载只管非首屏内容。这点要分清楚,别把首屏也搞成异步,那就本末倒置了。

踩坑提醒:这几个点一定要注意

1. 不要盲目预加载所有资源。尤其是活动页这种临时内容,很容易因为图片太多拖累整体性能。建议加个数量阈值,超过6组就降级为懒加载。

2. Image.decode() 不是万能的。它能避免主线程阻塞,但有些老版本安卓浏览器不支持,甚至会抛错。记得包 try-catch 或者做兼容判断。

3. requestAnimationFrame + 节流是标配。滚动事件太频繁,不做节流的话,每帧都在算位置,本身就会卡。

4. 别忘了被动监听器{ passive: true } 能保证滚动不被JS阻塞,配合RAF效果更好。

5. 测试一定要用弱网+低端机模拟。我在本地开发环境完全看不出问题,一到真机测试就原形毕露。Chrome DevTools 的 Throttling 设置得经常用。

结语

以上是我踩坑后的总结。预加载听着简单,真要做到既快又稳,还得考虑并发、优先级、兼容性、解码这些细节。这次的问题本质不是技术选型错,而是缺乏对“资源调度”的系统性思考。

这个方案不是最优的,但够用、可控、好维护。如果你有更好的实现方式,比如用 Service Worker 缓存预判,或者基于路由的预加载策略,欢迎评论区交流。我也在持续摸索更优雅的做法。

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

暂无评论