Skip to content

图片预加载技术深度解析:从原生 JS 到 Vite 插件

1. 为什么需要图片预加载?

在开发中,我们常常会遇到一个体验问题:当用户切换到一个包含多张图片的页面时,图片会因为需要时间下载而逐一显示出来,这个过程可能会很慢,影响用户体验。

  • 问题现象:进入新页面后,图片加载缓慢,出现占位符或空白,用户需要等待。
  • 核心原因:浏览器在渲染页面时,遇到 <img> 标签才会去发起图片资源的 HTTP 请求。如果图片较大或网络较慢,等待时间就会变长。
  • 优化目标:让图片在用户访问页面之前就被加载。当用户真正进入页面时,图片已经存在于缓存中,可以直接从内存或磁盘缓存中读取,从而实现"秒开"的效果,提升用户体验。

最简单直接的预加载方式是使用 HTML 的 <link> 标签。

html
<link rel="preload" href="/path/to/image.jpg" as="image">
  • rel="preload":告诉浏览器这个资源需要被预加载。
  • as="image":指定资源的类型。这有助于浏览器进行正确的优先级排序、请求和缓存处理。
  • href:资源的路径。

当浏览器解析到这个标签时,它会立即以较高的优先级去下载该资源,但不会执行或渲染它。资源会被存放在内存中,当页面后续真正需要使用(例如通过 <img> 标签)时,会直接从内存中读取。

动态预加载与并发限制

如果需要预加载的图片很多,手动在 HTML 中添加大量 <link> 标签是不现实的。我们可以通过 JavaScript 动态创建和插入这些标签。

2.1 基础版本:一次性加载所有图片

我们可以编写一个函数,接收一个图片 URL 数组,然后遍历数组,为每张图片创建一个 <link> 标签并插入到 <head> 中。

typescript
// 图片 URL 列表
const images = [
  './images/1.jpg',
  './images/2.jpg',
  // ... more images
];

// 预加载函数
function preloadImages(urls: string[]) {
  urls.forEach(url => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'image';
    link.href = url;
    document.head.appendChild(link);
  });
}

preloadImages(images);

问题所在:浏览器并发限制

这种方法有一个严重缺陷:浏览器对同一域名下的 HTTP 请求有并发数量限制(Chrome 通常是 6 个)。如果你一次性发起大量(如 9 张)图片的预加载请求,前 6 个会并行执行,但剩下的 3 个必须等待前面的请求完成后才能开始。这可能会阻塞当前页面其他关键资源(如 CSS、JS)的加载,反而降低了首页的性能。

2.2 进阶版本:并发数量控制

为了解决上述问题,我们需要对预加载进行并发控制,即分批加载。例如,一次只加载 3 张图片,加载完成后再加载下一批。

这可以通过 Promise 和递归(或队列)来实现。

typescript
function preloadImagesWithConcurrency(
  urls: string[],
  maxConcurrency: number = 3
) {
  // 复制一份 URL 数组,避免修改原数组
  const imageUrls = [...urls];

  // 单个图片加载器,返回一个 Promise
  const loadImage = (src: string): Promise<void> => {
    return new Promise((resolve, reject) => {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.as = 'image';
      link.href = src;
      // 加载成功或失败都认为此任务完成
      link.onload = () => resolve();
      link.onerror = () => resolve(); // 或者 reject(),取决于你的错误处理策略
      document.head.appendChild(link);
    });
  };

  // 递归加载函数
  const loadNextBatch = async () => {
    // 递归终止条件
    if (imageUrls.length === 0) {
      return;
    }

    // 从数组中取出下一张图片的 URL
    const url = imageUrls.shift()!;
    
    // 等待这张图片加载完成(无论成功失败)
    await loadImage(url);
    
    // 递归调用,加载下一张
    loadNextBatch();
  };

  // 启动初始的一批加载任务
  for (let i = 0; i < Math.min(maxConcurrency, imageUrls.length); i++) {
    loadNextBatch();
  }
}

// 使用
const images = [ /* ... */ ];
preloadImagesWithConcurrency(images, 3); // 设置最大并发数为 3

这样,我们就能在不阻塞主页面关键资源的前提下,平滑地在后台进行图片预加载。


3. 终极方案:封装成 Vite 插件

在现代前端工程化中,手动管理预加载列表非常繁琐。我们可以将其封装成一个 Vite 插件,实现自动化预加载。

目标:插件能够自动扫描指定目录下的所有图片,并将其添加到 index.html 中进行预加载。

3.1 插件基础结构与 transformIndexHtml

一个 Vite 插件是一个返回特定对象的函数。transformIndexHtml 钩子允许我们转换 index.html 文件。

typescript
import type { PluginOption } from 'vite';

export function preloadPlugin(): PluginOption {
  return {
    name: 'vite-plugin-preload', // 插件名称,必需

    // 该钩子用于转换 index.html
    transformIndexHtml(html) {
      // 我们可以返回一个修改后的 HTML 字符串
      // return html.replace('<title>', '<title>Hello World');

      // 或者,更推荐的方式是返回一个标签描述符数组
      return [
        {
          tag: 'link',
          attrs: {
            rel: 'preload',
            href: '/images/1.jpg',
            as: 'image'
          },
          injectTo: 'head-prepend' // 注入到 <head> 的最前面
        }
      ];
    }
  };
}

3.2 区分 preloadprefetch

在深入之前,需要理解 preloadprefetch 的关键区别:

  • preload

    • 时机:与父文档一同加载。
    • 优先级:高。浏览器会优先加载它。
    • 用途:用于当前页面肯定会用到的关键资源。
    • 缓存:加载后存放在内存中,立即使用,无二次网络请求。
  • prefetch

    • 时机:在浏览器空闲时加载。
    • 优先级:低。不会阻塞当前页面的渲染。
    • 用途:用于未来可能访问的页面的资源,如下一个页面。
    • 缓存:加载后存放在 HTTP 缓存中。当真正需要时,会发起请求,但会命中强缓存或协商缓存,实现快速获取。

插件应该允许用户根据需求选择使用 preload 还是 prefetch

3.3 插件实现:处理 public 目录资源

对于 public 目录下的静态资源,处理相对简单。我们可以使用 fast-glob 包来扫描文件。

typescript
// vite-plugin-preload.ts
import type { PluginOption } from 'vite';
import glob from 'fast-glob';

interface PluginOptions {
  dirs: string[]; // e.g., ['public/images/*.png']
  rel?: 'preload' | 'prefetch';
}

export function preloadPlugin(options: PluginOptions): PluginOption {
  let config; // 用于存储 Vite 配置
  const { dirs, rel = 'prefetch' } = options;

  return {
    name: 'vite-plugin-preload',
    
    // 获取 Vite 的最终配置
    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },

    async transformIndexHtml() {
      // 使用 glob 扫描指定目录下的文件
      const files = await glob(dirs, {
        // cwd: config.publicDir, // publicDir 是根目录,glob 模式应相对于它
        // 注意:glob 的模式应该已经包含了 publicDir 部分,如 'public/images/**'
      });

      // 拼接上 base URL
      const base = config.base || '/';
      
      return files.map(file => {
        // public 目录下的文件在构建后会被移动到根目录,所以要移除 public/ 前缀
        const href = base + file.replace(/^public\//, '');
        return {
          tag: 'link',
          attrs: { rel, href, as: 'image' },
          injectTo: 'head-prepend',
        };
      });
    },
  };
}

3.4 插件升级:处理 src/assets 目录资源

挑战src/assets 下的资源在 build 阶段会被 Vite/Rollup 处理,文件名会加上 hash 值(如 image-a1b2c3d4.png)。这意味着我们不能在 transformIndexHtml 中简单地使用原始路径。

解决方案:利用 generateBundle 钩子。这个钩子在文件写入磁盘前执行,我们可以从中获取到原始文件名和最终打包后文件名的映射关系。

typescript
// vite-plugin-preload.ts
import type { PluginOption, ResolvedConfig } from 'vite';
import glob from 'fast-glob';
import path from 'path';

interface PluginOptions {
  dirs: string[];
  rel?: 'preload' | 'prefetch';
}

export function preloadPlugin(options: PluginOptions): PluginOption {
  const { dirs, rel = 'prefetch' } } = options;
  let config: ResolvedConfig;
  let preloadedAssets: string[] = [];
  
  return {
    name: 'vite-plugin-preload-assets',

    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },

    // 钩子1:在开发模式下,直接转换 HTML
    async transformIndexHtml(html, ctx) {
      // 仅在开发服务器下运行
      if (ctx.server) {
        const files = await glob(dirs); // e.g., ['src/assets/images/*.png']
        const base = config.base || '/';
        return files.map(file => ({
          tag: 'link',
          attrs: { rel, href: path.join(base, file), as: 'image' },
          injectTo: 'head-prepend',
        }));
      }
      // 在构建模式下,返回收集到的资源
      return preloadedAssets.map(href => ({
          tag: 'link',
          attrs: { rel, href, as: 'image' },
          injectTo: 'head-prepend',
        }));
    },

    // 钩子2:在构建模式下,收集资源信息
    async generateBundle(_options, bundle) {
      // 如果是 lib 模式或者 SSR,则不执行
      if (config.command !== 'build' || config.build.lib || config.build.ssr) {
        return;
      }
      
      const filesToPreload = await glob(dirs); // ['src/assets/images/1.png', ...]

      const assetsToInject = new Set<string>();
      
      for (const file of Object.values(bundle)) {
        // 确认是资源文件(Asset)且有原始文件名
        if (file.type === 'asset' && file.source) {
            // Rollup 使用反斜杠,需要统一路径分隔符
            const originalPath = (file as any).facadeModuleId?.split('?')[0].replace(/\\/g, '/');

            // 如果该资源的原始路径在我们想要预加载的列表里
            if (originalPath && filesToPreload.includes(originalPath)) {
                // 添加最终生成的文件名(带 base 和 hash)
                assetsToInject.add(path.join(config.base, file.fileName));
            }
        }
      }
      preloadedAssets = Array.from(assetsToInject);
    }
  };
}

使用插件 (vite.config.ts)

typescript
import { defineConfig } from 'vite';
import { preloadPlugin } from './vite-plugin-preload';

export default defineConfig({
  plugins: [
    preloadPlugin({
      // 预加载 src/assets/images 目录下的所有 png 和 jpg 图片
      dirs: ['src/assets/images/*.png', 'src/assets/images/*.jpg'],
      rel: 'preload' // 使用 preload
    })
  ]
})

通过这种方式,我们构建了一个强大的、自动化的预加载插件,它能智能地处理开发和生产环境的不同情况,极大地简化了项目中的性能优化工作。