javascript技巧

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript技巧 > JavaScript预加载图片

一文手把手教你如何使用JavaScript预加载图片告别加载卡顿

作者:DTcode7

本文详细介绍了图片预加载的原理,优势和注意事项,包括图片预加载的核心思路,预加载和懒加载的区别,如何实现JavaScript图片预加载,以及预加载的隐藏成本和适用场景,需要的朋友可以参考下

引言

“老板,首页轮播图又卡成PPT了!”
“用户说点开商品详情,主图愣是白了3秒才出来!”
如果你在前端圈子里混得够久,这类吐槽大概率耳朵都听出茧了。图片体积大、网络抖、浏览器懒,三重debuff叠满,页面再精致的动效也顶不住一张图加载慢半拍的尴尬。今天这篇,咱们就掰开揉碎聊聊“图片预加载”这门手艺——它不是银弹,但用好了,能让你的网页从“幻灯片”秒变“丝滑大片”。

为什么你的网页图片总在“慢半拍”?

先别急着甩锅给后台兄弟。浏览器在解析到<img>标签时,才会真正发起网络请求;如果这张图在可视区域外,它还会再拖一会儿(懒加载的锅)。用户一滑到关键位置,浏览器才火急火燎地去拉数据,网络再一抖,白屏、占位图、转菊花,名场面齐活。
更惨的是,现代页面动辄几十张图:轮播、头像、商品缩略图、背景装饰……它们像春运抢火车票一样挤在“最后那一刻”才进站,不怪页面卡顿,怪谁?

揭开图片加载背后的性能真相

浏览器渲染流水线大致分五步:解析HTML → 构建DOM → 计算样式 → 布局 → 绘制。
图片资源属于“渲染阻塞”之外的“延迟加载”队列,但注意,只要图片url一出现,浏览器就会立即发起网络请求,除非你用loading="lazy"显式告诉它“先别动”。如果页面里同域并发超过6个TCP连接(HTTP/1.1老黄历),剩下的请求就得排队。排队+网络RTT+图片体积,慢得有理有据。
预加载的核心思路就是:把“请求”提前,把“排队”错开,把“渲染”和“数据到达”之间的空窗期抹平

图片预加载——可不只是提前下载那么简单

有人以为预加载就是“new Image().src = url”一句话,too young。真正的预加载要考虑:

  1. 优先级分级:首屏最关键,后台闲时再去拉次屏。
  2. 内存与带宽博弈:移动端的4G/5G切换比前任变脸还快,一口气拉50张2MB大图,用户流量直接报警。
  3. 错误容灾:404、超时、CDN节点挂掉,都得兜底,别让队列卡死。
  4. 缓存复用:同一张图在A组件预加载,B组件立刻就能从缓存里拿,别再重复请求。

预加载 vs 懒加载——别再把孪生兄弟认错

懒加载:先占位,等用户快看到再拉真图,省流量、省内存,但首次进入可视区那一刻仍可能“闪白”。
预加载:先把图拉到本地缓存,真正插入DOM时秒出,爽点在于“提前”,痛点在于“可能白忙活”。
二者不是非此即彼,高优首屏预加载+次屏懒加载才是日常操作。就像吃自助餐,先拿爱吃的,再慢慢逛。

手把手实现JavaScript图片预加载

下面代码全部可跑通,注释管够,复制粘贴即可去老板面前秀肌肉。

基础版:Image对象逐张加载

/**
 * 单张预加载
 * @param {string} src - 图片地址
 * @returns {Promise<HTMLImageElement>} - 加载成功的img元素
 */
function preloadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.decoding = 'async'; // 提示浏览器可以异步解码
    img.src = src;
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`image load fail: ${src}`));
  });
}

// 用法
preloadImage('https://example.com/hero@2x.jpg')
  .then(img => console.log('英雄图搞定', img.naturalWidth))
  .catch(err => console.error('英雄图挂了', err));

进阶版:批量预加载 + 进度反馈

/**
 * 批量预加载,带进度回调
 * @param {string[]} list - 图片url数组
 * @param {object} [options] - 配置
 * @param {number} [options.concurrency=6] - 并发数,HTTP/1.1下建议≤6
 * @param {function} [options.onProgress] - 单张完成回调 (loaded, total)=>{}
 * @returns {Promise<{ok: string[], fail: string[]}>}
 */
function preloadImages(list, { concurrency = 6, onProgress } = {}) {
  return new Promise(resolve => {
    const total = list.length;
    let loaded = 0, failed = 0;
    const ok = [], fail = [];
    let idx = 0;

    function next() {
      if (idx >= total) {
        // 全部完成
        if (loaded + failed === total) resolve({ ok, fail });
        return;
      }
      const cur = idx++;
      const src = list[cur];
      preloadImage(src)
        .then(() => {
          ok.push(src);
          loaded++;
          onProgress?.(loaded + failed, total);
        })
        .catch(() => {
          fail.push(src);
          failed++;
          onProgress?.(loaded + failed, total);
        })
        .finally(() => next()); // 无论成功失败都递归补位
    }

    // 启动并发池
    for (let i = 0; i < Math.min(concurrency, total); i++) next();
  });
}

// 用法
preloadImages(
  [
    'https://cdn.a.com/pic1.jpg',
    'https://cdn.a.com/pic2.jpg',
    'https://cdn.a.com/pic3.jpg'
  ],
  {
    onProgress: (cur, total) => {
      const percent = ((cur / total) * 100).toFixed(2);
      console.log(`进度:${percent}%`);
      document.querySelector('.progress-bar').style.width = percent + '%';
    }
  }
).then(({ ok, fail }) => {
  console.log(' success:', ok.length, ' fail:', fail.length);
});

Promise封装:让预加载更优雅

上面已经用Promise了,但还可以再封装成类,方便复用:

class ImagePreloader {
  constructor(options = {}) {
    this.cache = new Map(); // <url, Promise<HTMLImageElement>>
    this.concurrency = options.concurrency || 6;
  }

  /**
   * 加载单张,带缓存
   */
  load(src) {
    if (this.cache.has(src)) return this.cache.get(src);
    const job = preloadImage(src);
    this.cache.set(src, job);
    return job;
  }

  /**
   * 批量加载
   */
  loadGroup(list, onProgress) {
    return preloadImages(list, {
      concurrency: this.concurrency,
      onProgress
    });
  }

  /**
   * 清空缓存
   */
  clear() {
    this.cache.clear();
  }
}

// 全局单例
export const preloader = new ImagePreloader({ concurrency: 8 });

结合现代ES6+语法的写法优化

async/await+for...of控制并发,可读性更高:

async function asyncPool(poolLimit, list, iteratorFn) {
  const ret = [];
  const executing = [];
  for (const item of list) {
    const p = Promise.resolve().then(() => iteratorFn(item));
    ret.push(p);
    if (poolLimit <= list.length) {
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      if (executing.length >= poolLimit) await Promise.race(executing);
    }
  }
  return Promise.all(ret);
}

// 调用
const urls = ['1.jpg', '2.jpg', '3.jpg'];
await asyncPool(6, urls, url => preloadImage(url));

预加载的隐藏成本你注意了吗?

  1. 内存占用:图片解码后占用的内存是文件体积的几十倍(RGBA 4字节/像素)。一张4000×3000的图解码即48MB,移动端分分钟被杀后台。
  2. 带宽消耗:用户可能只看了首页10%就关页面,你预拉的后50张图全部浪费,流量土豪请随意。
  3. 电池:蜂窝网络下频繁唤醒射频,电量肉眼可见地掉。

经验法则:预加载总量 ≤ 首屏加一屏半,且单图体积≤200KB(WebP/AVIF压缩后)。再大就用分段加载或渐进式JPEG。

什么时候不该用预加载?

真实项目中的典型应用场景

首页轮播图提前就位

// React Hook示例
function usePreloadSlider(list) {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    preloader.loadGroup(list.map(item => item.pic)).then(() => setReady(true));
  }, [list]);
  return ready;
}

function Slider({ data }) {
  const ready = usePreloadSlider(data);
  if (!ready) return <SkeletonSlider />;
  return (
    <Swiper>
      {data.map(item => (
        <img key={item.id} src={item.pic} alt={item.title} />
      ))}
    </Swiper>
  );
}

游戏资源包预载策略

H5小游戏:脚本、音频、精灵图三件套,先拉“首关资源”,后台再拉“后续关卡”。用XHR+Blob存进IndexedDB,二次打开秒进游戏,用户直呼“本地客户端”。

电商商品详情页的无缝切换体验

商品详情5张主图+20张SKU图,用户切SKU时若图片未加载完毕,会闪现旧图。解决:鼠标hover SKU按钮即触发预加载,300ms延迟后正式切换,基本做到“无缝”。

配合Webpack或Vite做构建时预加载

Webpack的require.context+import(/* webpackPrefetch: true */),Vite的import.meta.globEager,让浏览器在空闲时提前拉下一页资源。SPA切页如丝般顺滑,SEO也不掉链子。

踩坑实录:那些年我们被预加载“背刺”的瞬间

  1. 图片404导致整个队列卡死
    解决:单张失败不影响整体,Promise.finally递归补位,上面批量代码已处理。
  2. 重复加载同一张图
    解决:用Map/WeakMap做全局缓存,key用完整url。
  3. 跨域图片加载失败
    解决:CDN加Access-Control-Allow-Origin: *,或前端img.crossOrigin="anonymous",否则canvas绘制会报污染。
  4. 加载完成但图片损坏
    解决:监听img.onerror还不够,需用createImageBitmapdecode()API,解码失败即视为损坏。
// 检测损坏
async function isImageBroken(src) {
  try {
    const img = await preloadImage(src);
    await img.decode(); // 如果解码失败会抛错
    return false;
  } catch {
    return true;
  }
}

前端老鸟私藏技巧大放送

用WeakMap缓存已加载图片避免重复请求

const cache = new WeakMap(); // 键是Image对象,值无所谓
function loadOnce(imgEl) {
  if (cache.has(imgEl)) return Promise.resolve(imgEl);
  return new Promise((resolve, reject) => {
    imgEl.onload = () => {
      cache.set(imgEl, true);
      resolve(imgEl);
    };
    imgEl.onerror = reject;
    if (imgEl.complete) resolve(imgEl); // 已缓存过
  });
}

结合Intersection Observer实现“智能预加载”

const io = new IntersectionObserver(entries => {
  entries.forEach(en => {
    if (en.isIntersecting) {
      const img = en.target;
      const src = img.dataset.prefetch;
      if (src) {
        preloader.load(src); // 进入视口前200px开始拉
        io.unobserve(img);
      }
    }
  });
}, { rootMargin: '200px' });

document.querySelectorAll('img[data-prefetch]').forEach(img => io.observe(img));

预加载 + CDN + 图片格式优化三连招

  1. 图片裁切:用?imageView2/2/w/750这类参数,避免前端自己压。
  2. 格式:WebP省30%,AVIF省50%,但不支持老Safari,用<picture>兜底。
  3. CDN:把首屏图推送到边缘节点,TTL设短,更新时主动预热。

为低网速用户设计降级方案:骨架屏 or 占位图?

骨架屏(Skeleton)适合结构固定,占位图(BlurUp)适合视觉冲击强。实测3G网络下,LCP(最大内容绘制)骨架屏比空白+转菊花快600ms,用户体感明显。

彩蛋:预加载还能和Service Worker玩出什么花样?

SW拦截图片请求,先查CacheStorage,没有再回源,同时后台拉取最新版本,下次访问即更新。用户第一次秒开,第二次看到新图,双赢。

// sw.js
self.addEventListener('fetch', e => {
  if (e.request.destination === 'image') {
    e.respondWith(
      caches.open('img-v1').then(async cache => {
        const cached = await cache.match(e.request);
        if (cached) {
          // 后台更新
          fetch(e.request).then(res => cache.put(e.request, res.clone()));
          return cached;
        }
        const res = await fetch(e.request);
        cache.put(e.request, res.clone());
        return res;
      })
    );
  }
});

试试用Web Workers分担主线程压力

图片解码放主线程会掉帧,尤其在低端机。借助OffscreenCanvas+Web Worker,可把解码任务甩给子线程,主线程继续90fps滚动。

// worker.js
self.onmessage = async e => {
  const { url, id } = e.data;
  const res = await fetch(url);
  const blob = await res.blob();
  const bmp = await createImageBitmap(blob);
  self.postMessage({ id, bmp }, [bmp]);
};

未来可期:原生HTML属性 loading=“eager” 能替代JS吗?

loading="eager"只是告诉浏览器“这张图很重要,请尽快”,但不会提前发起请求,和预加载不是一回事。
真正值得期待的是<link rel="preload" as="image" imagesrcset="...">,结合HTTP/3多路复用,浏览器可以在解析HTML前就拉图,届时我们或许可以少写一半预加载代码。但眼下,兼容性、缓存策略、业务定制仍需JS兜底。

至此,从“为什么”到“怎么做”,从“坑”到“彩蛋”,图片预加载的十八般武艺悉数奉上。拿去撸代码吧,下次再遇到“图片慢半拍”,你就能拍着胸脯说:“放心,我提前都拉好了!”

以上就是一文手把手教你如何使用JavaScript预加载图片告别加载卡顿的详细内容,更多关于JavaScript预加载图片的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文