Vite 构建期图片预加载插件 —— 实现原理详解
面向文章与技术面试的深度解析文档,记录
build/plugins下两个图片预加载插件的设计思路、 关键实现与原理解答。
一、背景:为什么要做图片预加载
前端性能优化的一个重要维度是 首屏/关键渲染路径(Critical Rendering Path)。 图片通常体积大、数量多,等到浏览器真正渲染到图片所在位置才去请求,会产生明显的 白屏与布局抖动(layout shift),尤其在大图、背景图、轮播图场景下体验很差。
浏览器原生解决方案 —— <link rel="preload"> / <link rel="prefetch">:
<link rel="preload" as="image" href="/assets/hero.png" />preload:告知浏览器“这是关键资源,请在当前页面一旦可用就立即拉取”,优先级高。prefetch:告知浏览器“这是未来可能用到的资源,请在空闲时拉取”,优先级低。
预加载的作用是 把请求时机提前,让浏览器在网络空闲 / 解析阶段就并行下载图片, 从而在真正需要渲染时直接使用缓存,缩短等待时间。
问题来了:手写这些 <link> 标签非常痛苦——
- 图片数量多、路径分散,维护成本高;
- 生产构建产物会带内容 hash(
hero-a1b2c3.png),手写的路径在打包后全部失效; - 图片可能分布在
public目录和src目录(会被构建处理)两类完全不同的环境中。
于是就有了这个 构建期自动扫描 + 自动注入 的 Vite 插件:开发/打包时自动扫描图片, 生成 <link rel="preload" as="image"> 注入到 index.html 的 <head>,零手工维护。
二、仓库里有两个版本,先厘清区别
vite.config.ts 中实际启用的是 简化版 preloadImagesSimple:
preloadImagesSimple({
dir: ["public/**/*.{jpg,png,svg,jfif}", "src/assets/images/**/*.{jpg,png,svg}"],
enabled: true,
})| 版本 | 文件 | 特点 |
|---|---|---|
| 简化版 | build/plugins/preloadImages.simple.ts | 只接收一组 glob,逻辑直白,够用 |
| 完整版 | build/plugins/preloadImages.ts | 拆分 publicDirs / srcDirs,带三级路径映射兜底,更健壮 |
两者的 核心思想完全一致,完整版是简化版的加固与扩展。下文以简化版为主线讲解流程, 再单独展开完整版的三级兜底策略(这是面试里的亮点)。
三、核心流程(以简化版为例)
整个插件的生命周期如下,贯穿 Vite 的四个关键钩子:
┌─────────────────────────────────────────────────────────────┐
│ 1. preloadImagesSimple(options) → 返回 Plugin 对象 │
│ 2. configResolved:拿到最终解析配置(base / publicDir 等) │
│ 3. generateBundle:生产构建,扫描图片并映射为产物 URL │
│ 4. transformIndexHtml:把 URL 拼成 <link> 注入 <head> │
└─────────────────────────────────────────────────────────────┘1. 插件工厂:闭包持有状态
export const preloadImagesSimple = (options: PreloadImagesSimpleOptions): Plugin => {
const { dir, attrs = {}, enabled = true } = options
const assetsImages: string[] = [] // 生产构建收集到的图片 URL
const dirs = Array.isArray(dir) ? dir : [dir]
let config: ResolvedConfig // 在 configResolved 中赋值assetsImages 是 闭包缓存:generateBundle 阶段写,transformIndexHtml 阶段读, 从而把“昂贵的扫描 + 映射”只做一次。这是面试常问的“如何在多个钩子间共享状态”的答案。
2. 三个内部工具函数
// 统一路径分隔符:Windows 是 \ ,Vite/Rollup 产物流是 / ,必须归一才能比较
const normalizeSlashes = (value: string) => value.replace(/\\/g, '/')
// 把资源路路径拼到 base 之后,兼容 base 带/不带尾斜杠
const withBase = (base: string, value: string) => {
const normalizedBase = base.endsWith('/') ? base : `${base}/`
return `${normalizedBase}${value.replace(/^\/+/, '')}`
}
// 扫描所有 glob,用 Set 去重
const scanFiles = () => Array.from(new Set(fg.sync(dirs).map(normalizeSlashes)))normalizeSlashes:真正的实战细节。在 Windows 上fast-glob/ Node 返回的路径是\,而 Vite 和 Rollup 的产物路径是/,不统一就无法用includes精确匹配。withBase:适配base为子路径(如/admin/)的部署场景,是生产可用性的关键。
3. 钩子一:configResolved
configResolved(resolvedConfig) {
config = resolvedConfig
}Vite 在配置解析完成后调用此钩子,把 resolvedConfig 缓存到闭包变量 config, 供后续需要 config.base 等其他配置的地方使用。
4. 钩子二:generateBundle(仅生产)
generateBundle(_, bundle) {
if (!enabled) return
assetsImages.length = 0 // 每次构建清空,防残留
const files = scanFiles() // 扫描到一批图片(磁盘相对路径)
// ① src 资源:映射到带 hash 的构建产物路径
Object.values(bundle).forEach((item) => {
const originalFileName = Reflect.get(item, 'originalFileName') // 打包前的源码路径
const fileName = Reflect.get(item, 'fileName') // 打包后的产物路径
if (files.includes(originalFileName)) {
assetsImages.push(fileName)
}
})
// ② public 资源:生产环境保持原始访问路径(不带 public 前缀、没有 hash)
files.forEach((file) => {
if (!file.startsWith('public/')) return
assetsImages.push(toPublicUrl(file, config.base))
})
}这里体现了 src 与 public 的本质区别:
src目录:会被 Vite/Rollup 打包处理,产物文件名带内容 hash。必须通过查看bundle里每一项的originalFileName(打包前的源码相对路径),才能把源码路径 映射到真实的产物路径。public目录:Vite 会“原样拷贝”到构建根目录,不改名、不加 hash,访问时 去掉public/前缀,直接拼 base 即可。
关于
Reflect.get:Rollup 的OutputAsset类型上并没有声明originalFileName字段(它是 Vite 附加的),直接item.originalFileName会触发 TS 报错, 用Reflect.get绕开类型约束,是类型安全与实用性的权衡。
5. 钩子三:transformIndexHtml(dev / prod 都走)
transformIndexHtml(_html, ctx) {
if (!enabled) return []
let images: string[]
if (ctx.server) {
// 开发环境:public 用根路径,src 用源码路径(Vite dev server 直接托管静态资源)
const base = ctx.server.config.base || ''
images = scanFiles().map((file) =>
file.startsWith('public/') ? toPublicUrl(file, base) : withBase(base, file),
)
} else {
// 生产环境:直接用 generateBundle 收集到的产物路径
images = Array.from(new Set(assetsImages))
}
return images.map<HtmlTagDescriptor>((href) => ({
tag: 'link',
injectTo: 'head',
attrs: { rel: 'preload', href, as: 'image', ...attrs },
}))
}这个钩子的关键能力是 区分环境:
- 通过
ctx.server是否存在判断当前是 开发服务器(serve) 还是 生产构建(build)。 - 开发环境:Vite dev server 会实时托管
src下的静态资源,所以直接使用源码路径即可, 无需、也无法预知带 hash 的产物路径。 - 生产环境:复用
generateBundle里已收集好的assetsImages(去重后注入)。 - 最终产出一组
HtmlTagDescriptor,Vite 会把它渲染成<link>并注入<head>。
四、完整版的三个升级点(面试亮点)
preloadImages.ts 在简化版基础上做了三处重要加固:
1. 配置拆分:publicDirs + srcDirs + extensions
export interface PreloadImagesOptions {
publicDirs?: string[] // public 目录,未传则退回 Vite 内置的 config.publicDir
srcDirs?: string[] // src 目录,Vite 没有内置概念,未传即不扫描
extensions?: string[] // 图片扩展名白名单,默认 7 种图片格式
enabled?: boolean
attrs?: PreloadImageLinkAttrs
}设计上更贴合 Vite 的认知模型:publicDir 是 Vite 原生配置,所以缺省有兜底; 而 src 没有任何内置概念,必须显式配置。
2. link 属性组装:过滤掉脏属性
const createLinkAttrs = (attrs: PreloadImageLinkAttrs) => ({
rel: attrs.rel ?? 'preload', // 默认 preload,可覆盖为 prefetch
as: 'image', // preload 必须声明 as,否则浏览器忽略并告警
...Object.fromEntries(
Object.entries(attrs).filter(([, v]) => v !== undefined && v !== null && v !== ''),
),
})as: 'image' 是必须的——<link rel="preload"> 若不声明资源类型,浏览器会直接忽略。 过滤掉 undefined/null/空串,避免输出脏属性(如 fetchpriority="")。
3. src 图片三级路径映射兜底(核心中的核心)
const resolveBundledSrcImage = function (this: PluginEmitContext, bundle, srcFile) {
// 第一级:originalFileName 精确匹配(O(1),命中率最高)
const matchedAsset = ...
if (matchedAsset) return withBase(config.base, getFileName(matchedAsset))
// 第二级:字节级内容比对
const source = fs.readFileSync(absoluteFilePath)
const matchedBySource = findBundleAssetBySource(bundle, absoluteFilePath)
if (matchedBySource) return withBase(config.base, getFileName(matchedBySource))
// 第三级:emitFile 强制输出一份
const referenceId = this.emitFile({ type: 'asset', name: path.basename(srcFile), source })
return withBase(config.base, this.getFileName(referenceId))
}为什么要设计三级兜底?因为「扫描到的 src 图片」和「被打包的 src 图片」不一定一一对应:
| 场景 | 原因 | 兜底级别 |
|---|---|---|
| 图片被代码正常引用 | 打包后原始路径可精确反查 | ① originalFileName 匹配 |
new URL('./a.png', import.meta.url) 这类引用 | 原始路径可能缺失/变形 | ② 字节内容比对反查 |
| 图片扫描到了但从未被任何代码引用 | bundle 里根本没有它 | ③ emitFile 自己打包一份 |
字节内容比对的可行性 建立在“Vite 对图片不做任何内容变换”的前提上——图片是二进制 资源,产物与源文件字节完全一致,所以可以先用 byteLength 快速淘汰、再逐字节比对。
emitFile 兜底 是本插件最大的价值点:它把「没被打包但又想预加载」的图片也纳入 处理范围,主动把图片作为静态资源输出到产物里并取回带 hash 的路径,杜绝 404。
为什么用
function而非箭头函数?generateBundle执行时this绑定为 Rollup 的PluginContext,emitFile/getFileName都在this上,所以必须用function声明并在调用处.call(this, ...)显式传入this。这是面试中关于this绑定和 Rollup 插件上下文的典型考点。
五、项目里如何配置(现状)
vite.config.ts 中的实际用法:
import { preloadImagesSimple } from "./build/plugins/preloadImages.simple";
plugins: [
vue(),
vueDevTools(),
preloadImagesSimple({
dir: ["public/**/*.{jpg,png,svg,jfif}", "src/assets/images/**/*.{jpg,png,svg}"],
enabled: true,
}),
],- 同时覆盖
public和src/assets/images两个位置的图片。 enabled: true可随时一键关闭,无需删除代码。- 插件依赖
fast-glob做文件扫描(build/plugins中可看到其被 import)。
六、面试问答速查
Q1:为什么不在运行时(浏览器端)用 JS 预加载,而要在构建期注入 link? A:运行时 JS 预加载要等 JS 执行、DOM 构建完成才开始,时机太晚,且会阻塞主线程; 而 <link rel="preload"> 是浏览器在 HTML 解析阶段就能并行的预加载指令,时机更早、 不消耗 JS 性能。构建期注入还能自动解决产物 hash 路径问题。
Q2:<link rel="preload"> 和 <link rel="prefetch"> 的区别? A:preload 是当前页面关键资源,高优先级、立即拉取;prefetch 是未来可能用到的资源, 空闲时低优先级拉取。本项目默认 preload,通过 attrs.rel 可切换。
Q3:preload 为什么必须写 as="image"? A:浏览器需要知道资源类型才能正确设置优先级、缓存语义和 Content-Type 匹配, 省略会导致(多数浏览器)忽略该 preload 并打印告警。
Q4:public 目录和 src 目录的图片处理有什么不同? A:public 不参与打包,原样拷贝、路径不变、不加 hash;src 会被 Vite/Rollup 打包, 文件名加内容 hash,必须通过 bundle 反查映射。
Q5:src 图片没被任何代码引用怎么办? A:它会存在于磁盘扫描结果中但不在 bundle 里,用 emitFile 主动输出一份到产物, 解决“想预加载但没被打包”的矛盾。
Q6:在 Windows 上开发会踩什么坑? A:路径分隔符 \ vs / 不一致,导致与 bundler 产物路径比较失败,必须用 normalizeSlashes 统一成 /。
七、可扩展的方向
- 加压缩策略:命中 gzip/AVIF 等更优格式时跳过 preload。
- 加按需控制:用
media属性或fetchpriority做关键图/低优先图区分。 - 集成图集(sprite):把多张小图合并成雪碧图再 preload。
- 做 LCP 图感知:基于构建产物自动识别并优先 preload LCP 图片。
- 并发控制 / 优先级队列:图片过多时按 viewport 或权重分批发 link。
