图片预加载技术深度解析:从原生 JS 到 Vite 插件
1. 为什么需要图片预加载?
在开发中,我们常常会遇到一个体验问题:当用户切换到一个包含多张图片的页面时,图片会因为需要时间下载而逐一显示出来,这个过程可能会很慢,影响用户体验。
- 问题现象:进入新页面后,图片加载缓慢,出现占位符或空白,用户需要等待。
- 核心原因:浏览器在渲染页面时,遇到
<img>标签才会去发起图片资源的 HTTP 请求。如果图片较大或网络较慢,等待时间就会变长。 - 优化目标:让图片在用户访问页面之前就被加载。当用户真正进入页面时,图片已经存在于缓存中,可以直接从内存或磁盘缓存中读取,从而实现"秒开"的效果,提升用户体验。
2. 基础实现:<link rel="preload">
最简单直接的预加载方式是使用 HTML 的 <link> 标签。
<link rel="preload" href="/path/to/image.jpg" as="image">rel="preload":告诉浏览器这个资源需要被预加载。as="image":指定资源的类型。这有助于浏览器进行正确的优先级排序、请求和缓存处理。href:资源的路径。
当浏览器解析到这个标签时,它会立即以较高的优先级去下载该资源,但不会执行或渲染它。资源会被存放在内存中,当页面后续真正需要使用(例如通过 <img> 标签)时,会直接从内存中读取。
动态预加载与并发限制
如果需要预加载的图片很多,手动在 HTML 中添加大量 <link> 标签是不现实的。我们可以通过 JavaScript 动态创建和插入这些标签。
2.1 基础版本:一次性加载所有图片
我们可以编写一个函数,接收一个图片 URL 数组,然后遍历数组,为每张图片创建一个 <link> 标签并插入到 <head> 中。
// 图片 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 和递归(或队列)来实现。
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 文件。
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 区分 preload 和 prefetch
在深入之前,需要理解 preload 和 prefetch 的关键区别:
preload- 时机:与父文档一同加载。
- 优先级:高。浏览器会优先加载它。
- 用途:用于当前页面肯定会用到的关键资源。
- 缓存:加载后存放在内存中,立即使用,无二次网络请求。
prefetch- 时机:在浏览器空闲时加载。
- 优先级:低。不会阻塞当前页面的渲染。
- 用途:用于未来可能访问的页面的资源,如下一个页面。
- 缓存:加载后存放在 HTTP 缓存中。当真正需要时,会发起请求,但会命中强缓存或协商缓存,实现快速获取。
插件应该允许用户根据需求选择使用 preload 还是 prefetch。
3.3 插件实现:处理 public 目录资源
对于 public 目录下的静态资源,处理相对简单。我们可以使用 fast-glob 包来扫描文件。
// 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 钩子。这个钩子在文件写入磁盘前执行,我们可以从中获取到原始文件名和最终打包后文件名的映射关系。
// 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)
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
})
]
})通过这种方式,我们构建了一个强大的、自动化的预加载插件,它能智能地处理开发和生产环境的不同情况,极大地简化了项目中的性能优化工作。