轻歌漫舞 發表於 2026-1-7 08:56:45

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

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">引言</a></li><li><a href="#_label1">为什么你的网页图片总在&ldquo;慢半拍&rdquo;?</a></li><li><a href="#_label2">揭开图片加载背后的性能真相</a></li><li><a href="#_label3">图片预加载&mdash;&mdash;可不只是提前下载那么简单</a></li><li><a href="#_label4">预加载 vs 懒加载&mdash;&mdash;别再把孪生兄弟认错</a></li><li><a href="#_label5">手把手实现JavaScript图片预加载</a></li><ul class="second_class_ul"><li><a href="#_lab2_5_0">基础版:Image对象逐张加载</a></li><li><a href="#_lab2_5_1">进阶版:批量预加载 + 进度反馈</a></li><li><a href="#_lab2_5_2">Promise封装:让预加载更优雅</a></li><li><a href="#_lab2_5_3">结合现代ES6+语法的写法优化</a></li></ul><li><a href="#_label6">预加载的隐藏成本你注意了吗?</a></li><ul class="second_class_ul"></ul><li><a href="#_label7">什么时候不该用预加载?</a></li><ul class="second_class_ul"></ul><li><a href="#_label8">真实项目中的典型应用场景</a></li><ul class="second_class_ul"><li><a href="#_lab2_8_4">首页轮播图提前就位</a></li><li><a href="#_lab2_8_5">游戏资源包预载策略</a></li><li><a href="#_lab2_8_6">电商商品详情页的无缝切换体验</a></li><li><a href="#_lab2_8_7">配合Webpack或Vite做构建时预加载</a></li></ul><li><a href="#_label9">踩坑实录:那些年我们被预加载&ldquo;背刺&rdquo;的瞬间</a></li><ul class="second_class_ul"></ul><li><a href="#_label10">前端老鸟私藏技巧大放送</a></li><ul class="second_class_ul"><li><a href="#_lab2_10_8">用WeakMap缓存已加载图片避免重复请求</a></li><li><a href="#_lab2_10_9">结合Intersection Observer实现&ldquo;智能预加载&rdquo;</a></li><li><a href="#_lab2_10_10">预加载 + CDN + 图片格式优化三连招</a></li><li><a href="#_lab2_10_11">为低网速用户设计降级方案:骨架屏 or 占位图?</a></li></ul><li><a href="#_label11">彩蛋:预加载还能和Service Worker玩出什么花样?</a></li><ul class="second_class_ul"></ul><li><a href="#_label12">试试用Web Workers分担主线程压力</a></li><ul class="second_class_ul"></ul><li><a href="#_label13">未来可期:原生HTML属性 loading=&ldquo;eager&rdquo; 能替代JS吗?</a></li><ul class="second_class_ul"></ul></ul></div><p class="maodian"><a name="_label0"></a></p><h2>引言</h2>
<p>&ldquo;老板,首页轮播图又卡成PPT了!&rdquo;<br />&ldquo;用户说点开商品详情,主图愣是白了3秒才出来!&rdquo;<br />如果你在前端圈子里混得够久,这类吐槽大概率耳朵都听出茧了。图片体积大、网络抖、浏览器懒,三重debuff叠满,页面再精致的动效也顶不住一张图加载慢半拍的尴尬。今天这篇,咱们就掰开揉碎聊聊&ldquo;图片预加载&rdquo;这门手艺&mdash;&mdash;它不是银弹,但用好了,能让你的网页从&ldquo;幻灯片&rdquo;秒变&ldquo;丝滑大片&rdquo;。</p>
<p class="maodian"><a name="_label1"></a></p><h2>为什么你的网页图片总在&ldquo;慢半拍&rdquo;?</h2>
<p>先别急着甩锅给后台兄弟。浏览器在解析到<code>&lt;img&gt;</code>标签时,才会真正发起网络请求;如果这张图在可视区域外,它还会再拖一会儿(懒加载的锅)。用户一滑到关键位置,浏览器才火急火燎地去拉数据,网络再一抖,白屏、占位图、转菊花,名场面齐活。<br />更惨的是,现代页面动辄几十张图:轮播、头像、商品缩略图、背景装饰&hellip;&hellip;它们像春运抢火车票一样挤在&ldquo;最后那一刻&rdquo;才进站,不怪页面卡顿,怪谁?</p>
<p class="maodian"><a name="_label2"></a></p><h2>揭开图片加载背后的性能真相</h2>
<p>浏览器渲染流水线大致分五步:解析HTML &rarr; 构建DOM &rarr; 计算样式 &rarr; 布局 &rarr; 绘制。<br />图片资源属于&ldquo;渲染阻塞&rdquo;之外的&ldquo;延迟加载&rdquo;队列,但注意,<strong>只要图片url一出现,浏览器就会立即发起网络请求</strong>,除非你用<code>loading=&quot;lazy&quot;</code>显式告诉它&ldquo;先别动&rdquo;。如果页面里同域并发超过6个TCP连接(HTTP/1.1老黄历),剩下的请求就得排队。排队+网络RTT+图片体积,慢得有理有据。<br />预加载的核心思路就是:<strong>把&ldquo;请求&rdquo;提前,把&ldquo;排队&rdquo;错开,把&ldquo;渲染&rdquo;和&ldquo;数据到达&rdquo;之间的空窗期抹平</strong>。</p>
<p class="maodian"><a name="_label3"></a></p><h2>图片预加载&mdash;&mdash;可不只是提前下载那么简单</h2>
<p>有人以为预加载就是&ldquo;new Image().src = url&rdquo;一句话,too young。真正的预加载要考虑:</p>
<ol><li>优先级分级:首屏最关键,后台闲时再去拉次屏。</li><li>内存与带宽博弈:移动端的4G/5G切换比前任变脸还快,一口气拉50张2MB大图,用户流量直接报警。</li><li>错误容灾:404、超时、CDN节点挂掉,都得兜底,别让队列卡死。</li><li>缓存复用:同一张图在A组件预加载,B组件立刻就能从缓存里拿,别再重复请求。</li></ol>
<p class="maodian"><a name="_label4"></a></p><h2>预加载 vs 懒加载&mdash;&mdash;别再把孪生兄弟认错</h2>
<p>懒加载:先占位,等用户快看到再拉真图,省流量、省内存,但首次进入可视区那一刻仍可能&ldquo;闪白&rdquo;。<br />预加载:先把图拉到本地缓存,真正插入DOM时秒出,爽点在于&ldquo;提前&rdquo;,痛点在于&ldquo;可能白忙活&rdquo;。<br />二者不是非此即彼,<strong>高优首屏预加载+次屏懒加载</strong>才是日常操作。就像吃自助餐,先拿爱吃的,再慢慢逛。</p>
<p class="maodian"><a name="_label5"></a></p><h2>手把手实现JavaScript图片预加载</h2>
<p>下面代码全部可跑通,注释管够,复制粘贴即可去老板面前秀肌肉。</p>
<p class="maodian"><a name="_lab2_5_0"></a></p><h3>基础版:Image对象逐张加载</h3>
<div class="jb51code"><pre class="brush:js;">/**
* 单张预加载
* @param {string} src - 图片地址
* @returns {Promise&lt;HTMLImageElement&gt;} - 加载成功的img元素
*/
function preloadImage(src) {
return new Promise((resolve, reject) =&gt; {
    const img = new Image();
    img.decoding = 'async'; // 提示浏览器可以异步解码
    img.src = src;
    img.onload = () =&gt; resolve(img);
    img.onerror = () =&gt; reject(new Error(`image load fail: ${src}`));
});
}

// 用法
preloadImage('https://example.com/hero@2x.jpg')
.then(img =&gt; console.log('英雄图搞定', img.naturalWidth))
.catch(err =&gt; console.error('英雄图挂了', err));
</pre></div>
<p class="maodian"><a name="_lab2_5_1"></a></p><h3>进阶版:批量预加载 + 进度反馈</h3>
<div class="jb51code"><pre class="brush:js;">/**
* 批量预加载,带进度回调
* @param {string[]} list - 图片url数组
* @param {object} - 配置
* @param {number} - 并发数,HTTP/1.1下建议≤6
* @param {function} - 单张完成回调 (loaded, total)=&gt;{}
* @returns {Promise&lt;{ok: string[], fail: string[]}&gt;}
*/
function preloadImages(list, { concurrency = 6, onProgress } = {}) {
return new Promise(resolve =&gt; {
    const total = list.length;
    let loaded = 0, failed = 0;
    const ok = [], fail = [];
    let idx = 0;

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

    // 启动并发池
    for (let i = 0; i &lt; 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) =&gt; {
      const percent = ((cur / total) * 100).toFixed(2);
      console.log(`进度:${percent}%`);
      document.querySelector('.progress-bar').style.width = percent + '%';
    }
}
).then(({ ok, fail }) =&gt; {
console.log(' success:', ok.length, ' fail:', fail.length);
});
</pre></div>
<p class="maodian"><a name="_lab2_5_2"></a></p><h3>Promise封装:让预加载更优雅</h3>
<p>上面已经用Promise了,但还可以再封装成类,方便复用:</p>
<div class="jb51code"><pre class="brush:js;">class ImagePreloader {
constructor(options = {}) {
    this.cache = new Map(); // &lt;url, Promise&lt;HTMLImageElement&gt;&gt;
    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 });
</pre></div>
<p class="maodian"><a name="_lab2_5_3"></a></p><h3>结合现代ES6+语法的写法优化</h3>
<p>用<code>async/await</code>+<code>for...of</code>控制并发,可读性更高:</p>
<div class="jb51code"><pre class="brush:js;">async function asyncPool(poolLimit, list, iteratorFn) {
const ret = [];
const executing = [];
for (const item of list) {
    const p = Promise.resolve().then(() =&gt; iteratorFn(item));
    ret.push(p);
    if (poolLimit &lt;= list.length) {
      const e = p.then(() =&gt; executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      if (executing.length &gt;= poolLimit) await Promise.race(executing);
    }
}
return Promise.all(ret);
}

// 调用
const urls = ['1.jpg', '2.jpg', '3.jpg'];
await asyncPool(6, urls, url =&gt; preloadImage(url));
</pre></div>
<p class="maodian"><a name="_label6"></a></p><h2>预加载的隐藏成本你注意了吗?</h2>
<ol><li>内存占用:图片解码后占用的内存是文件体积的几十倍(RGBA 4字节/像素)。一张4000&times;3000的图解码即48MB,移动端分分钟被杀后台。</li><li>带宽消耗:用户可能只看了首页10%就关页面,你预拉的后50张图全部浪费,流量土豪请随意。</li><li>电池:蜂窝网络下频繁唤醒射频,电量肉眼可见地掉。</li></ol>
<p>经验法则:<strong>预加载总量 &le; 首屏加一屏半</strong>,且单图体积&le;200KB(WebP/AVIF压缩后)。再大就用分段加载或渐进式JPEG。</p>
<p class="maodian"><a name="_label7"></a></p><h2>什么时候不该用预加载?</h2>
<ul><li>弱网用户占比&gt;30%的海外业务,先保证可用性,再谈爽点。</li><li>图片尺寸极大(全景图、海报长图)且用户仅低概率查看。</li><li>浏览器已支持原生<code>&lt;img loading=&quot;eager&quot;&gt;</code>且HTTP/2多路复用良好,重复造轮子收益趋近于0。</li></ul>
<p class="maodian"><a name="_label8"></a></p><h2>真实项目中的典型应用场景</h2>
<p class="maodian"><a name="_lab2_8_4"></a></p><h3>首页轮播图提前就位</h3>
<div class="jb51code"><pre class="brush:js;">// React Hook示例
function usePreloadSlider(list) {
const = useState(false);
useEffect(() =&gt; {
    preloader.loadGroup(list.map(item =&gt; item.pic)).then(() =&gt; setReady(true));
}, );
return ready;
}

function Slider({ data }) {
const ready = usePreloadSlider(data);
if (!ready) return &lt;SkeletonSlider /&gt;;
return (
    &lt;Swiper&gt;
      {data.map(item =&gt; (
      &lt;img key={item.id} src={item.pic} alt={item.title} /&gt;
      ))}
    &lt;/Swiper&gt;
);
}
</pre></div>
<p class="maodian"><a name="_lab2_8_5"></a></p><h3>游戏资源包预载策略</h3>
<p>H5小游戏:脚本、音频、精灵图三件套,先拉&ldquo;首关资源&rdquo;,后台再拉&ldquo;后续关卡&rdquo;。用XHR+Blob存进IndexedDB,二次打开秒进游戏,用户直呼&ldquo;本地客户端&rdquo;。</p>
<p class="maodian"><a name="_lab2_8_6"></a></p><h3>电商商品详情页的无缝切换体验</h3>
<p>商品详情5张主图+20张SKU图,用户切SKU时若图片未加载完毕,会闪现旧图。解决:鼠标hover SKU按钮即触发预加载,300ms延迟后正式切换,基本做到&ldquo;无缝&rdquo;。</p>
<p class="maodian"><a name="_lab2_8_7"></a></p><h3>配合Webpack或Vite做构建时预加载</h3>
<p>Webpack的<code>require.context</code>+<code>import(/* webpackPrefetch: true */)</code>,Vite的<code>import.meta.globEager</code>,让浏览器在空闲时提前拉下一页资源。SPA切页如丝般顺滑,SEO也不掉链子。</p>
<p class="maodian"><a name="_label9"></a></p><h2>踩坑实录:那些年我们被预加载&ldquo;背刺&rdquo;的瞬间</h2>
<ol><li>图片404导致整个队列卡死<br />解决:单张失败不影响整体,Promise.finally递归补位,上面批量代码已处理。</li><li>重复加载同一张图<br />解决:用Map/WeakMap做全局缓存,key用完整url。</li><li>跨域图片加载失败<br />解决:CDN加<code>Access-Control-Allow-Origin: *</code>,或前端<code>img.crossOrigin=&quot;anonymous&quot;</code>,否则canvas绘制会报污染。</li><li>加载完成但图片损坏<br />解决:监听<code>img.onerror</code>还不够,需用<code>createImageBitmap</code>或<code>decode()</code>API,解码失败即视为损坏。</li></ol>
<div class="jb51code"><pre class="brush:js;">// 检测损坏
async function isImageBroken(src) {
try {
    const img = await preloadImage(src);
    await img.decode(); // 如果解码失败会抛错
    return false;
} catch {
    return true;
}
}
</pre></div>
<p class="maodian"><a name="_label10"></a></p><h2>前端老鸟私藏技巧大放送</h2>
<p class="maodian"><a name="_lab2_10_8"></a></p><h3>用WeakMap缓存已加载图片避免重复请求</h3>
<div class="jb51code"><pre class="brush:js;">const cache = new WeakMap(); // 键是Image对象,值无所谓
function loadOnce(imgEl) {
if (cache.has(imgEl)) return Promise.resolve(imgEl);
return new Promise((resolve, reject) =&gt; {
    imgEl.onload = () =&gt; {
      cache.set(imgEl, true);
      resolve(imgEl);
    };
    imgEl.onerror = reject;
    if (imgEl.complete) resolve(imgEl); // 已缓存过
});
}
</pre></div>
<p class="maodian"><a name="_lab2_10_9"></a></p><h3>结合Intersection Observer实现&ldquo;智能预加载&rdquo;</h3>
<div class="jb51code"><pre class="brush:js;">const io = new IntersectionObserver(entries =&gt; {
entries.forEach(en =&gt; {
    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').forEach(img =&gt; io.observe(img));
</pre></div>
<p class="maodian"><a name="_lab2_10_10"></a></p><h3>预加载 + CDN + 图片格式优化三连招</h3>
<ol><li>图片裁切:用<code>?imageView2/2/w/750</code>这类参数,避免前端自己压。</li><li>格式:WebP省30%,AVIF省50%,但不支持老Safari,用<code>&lt;picture&gt;</code>兜底。</li><li>CDN:把首屏图推送到边缘节点,TTL设短,更新时主动预热。</li></ol>
<p class="maodian"><a name="_lab2_10_11"></a></p><h3>为低网速用户设计降级方案:骨架屏 or 占位图?</h3>
<p>骨架屏(Skeleton)适合结构固定,占位图(BlurUp)适合视觉冲击强。实测3G网络下,LCP(最大内容绘制)骨架屏比空白+转菊花快600ms,用户体感明显。</p>
<p class="maodian"><a name="_label11"></a></p><h2>彩蛋:预加载还能和Service Worker玩出什么花样?</h2>
<p>SW拦截图片请求,先查CacheStorage,没有再回源,同时后台拉取最新版本,下次访问即更新。用户第一次秒开,第二次看到新图,双赢。</p>
<div class="jb51code"><pre class="brush:js;">// sw.js
self.addEventListener('fetch', e =&gt; {
if (e.request.destination === 'image') {
    e.respondWith(
      caches.open('img-v1').then(async cache =&gt; {
      const cached = await cache.match(e.request);
      if (cached) {
          // 后台更新
          fetch(e.request).then(res =&gt; cache.put(e.request, res.clone()));
          return cached;
      }
      const res = await fetch(e.request);
      cache.put(e.request, res.clone());
      return res;
      })
    );
}
});
</pre></div>
<p class="maodian"><a name="_label12"></a></p><h2>试试用Web Workers分担主线程压力</h2>
<p>图片解码放主线程会掉帧,尤其在低端机。借助<code>OffscreenCanvas</code>+Web Worker,可把解码任务甩给子线程,主线程继续90fps滚动。</p>
<div class="jb51code"><pre class="brush:js;">// worker.js
self.onmessage = async e =&gt; {
const { url, id } = e.data;
const res = await fetch(url);
const blob = await res.blob();
const bmp = await createImageBitmap(blob);
self.postMessage({ id, bmp }, );
};
</pre></div>
<p class="maodian"><a name="_label13"></a></p><h2>未来可期:原生HTML属性 loading=&ldquo;eager&rdquo; 能替代JS吗?</h2>
<p><code>loading=&quot;eager&quot;</code>只是告诉浏览器&ldquo;这张图很重要,请尽快&rdquo;,但<strong>不会提前发起请求</strong>,和预加载不是一回事。<br />真正值得期待的是<code>&lt;link rel=&quot;preload&quot; as=&quot;image&quot; imagesrcset=&quot;...&quot;&gt;</code>,结合HTTP/3多路复用,浏览器可以在解析HTML前就拉图,届时我们或许可以少写一半预加载代码。但眼下,兼容性、缓存策略、业务定制仍需JS兜底。</p>
<p>至此,从&ldquo;为什么&rdquo;到&ldquo;怎么做&rdquo;,从&ldquo;坑&rdquo;到&ldquo;彩蛋&rdquo;,图片预加载的十八般武艺悉数奉上。拿去撸代码吧,下次再遇到&ldquo;图片慢半拍&rdquo;,你就能拍着胸脯说:&ldquo;放心,我提前都拉好了!&rdquo;</p>
<p>以上就是一文手把手教你如何使用JavaScript预加载图片告别加载卡顿的详细内容,更多关于JavaScript预加载图片的资料请关注琼殿技术社区其它相关文章!</p>
頁: [1]
查看完整版本: 一文手把手教你如何使用JavaScript预加载图片告别加载卡顿