Skip to content

Vite 构建期图片预加载插件 —— 实现原理详解 ​

面向文章与技术面试的深度解析文档,记录 build/plugins 下两个图片预加载插件的设计思路、 关键实现与原理解答。


一、背景:为什么要做图片预加载 ​

前端性能优化的一个重要维度是 首屏/关键渲染路径(Critical Rendering Path)。 图片通常体积大、数量多,等到浏览器真正渲染到图片所在位置才去请求,会产生明显的 白屏与布局抖动(layout shift),尤其在大图、背景图、轮播图场景下体验很差。

浏览器原生解决方案 —— <link rel="preload"> / <link rel="prefetch">:

html
<link rel="preload" as="image" href="/assets/hero.png" />
  • preload:告知浏览器“这是关键资源,请在当前页面一旦可用就立即拉取”,优先级高。
  • prefetch:告知浏览器“这是未来可能用到的资源,请在空闲时拉取”,优先级低。

预加载的作用是 把请求时机提前,让浏览器在网络空闲 / 解析阶段就并行下载图片, 从而在真正需要渲染时直接使用缓存,缩短等待时间。

问题来了:手写这些 <link> 标签非常痛苦——

  1. 图片数量多、路径分散,维护成本高;
  2. 生产构建产物会带内容 hash(hero-a1b2c3.png),手写的路径在打包后全部失效;
  3. 图片可能分布在 public 目录和 src 目录(会被构建处理)两类完全不同的环境中。

于是就有了这个 构建期自动扫描 + 自动注入 的 Vite 插件:开发/打包时自动扫描图片, 生成 <link rel="preload" as="image"> 注入到 index.html 的 <head>,零手工维护。


二、仓库里有两个版本,先厘清区别 ​

vite.config.ts 中实际启用的是 简化版 preloadImagesSimple:

ts
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. 插件工厂:闭包持有状态 ​

ts
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. 三个内部工具函数 ​

ts
// 统一路径分隔符: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 ​

ts
configResolved(resolvedConfig) {
  config = resolvedConfig
}

Vite 在配置解析完成后调用此钩子,把 resolvedConfig 缓存到闭包变量 config, 供后续需要 config.base 等其他配置的地方使用。

4. 钩子二:generateBundle(仅生产) ​

ts
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 都走) ​

ts
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 ​

ts
export interface PreloadImagesOptions {
  publicDirs?: string[]   // public 目录,未传则退回 Vite 内置的 config.publicDir
  srcDirs?: string[]      // src 目录,Vite 没有内置概念,未传即不扫描
  extensions?: string[]   // 图片扩展名白名单,默认 7 种图片格式
  enabled?: boolean
  attrs?: PreloadImageLinkAttrs
}

设计上更贴合 Vite 的认知模型:publicDir 是 Vite 原生配置,所以缺省有兜底; 而 src 没有任何内置概念,必须显式配置。

ts
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 图片三级路径映射兜底(核心中的核心) ​

ts
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 中的实际用法:

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 统一成 /。


七、可扩展的方向 ​

  1. 加压缩策略:命中 gzip/AVIF 等更优格式时跳过 preload。
  2. 加按需控制:用 media 属性或 fetchpriority 做关键图/低优先图区分。
  3. 集成图集(sprite):把多张小图合并成雪碧图再 preload。
  4. 做 LCP 图感知:基于构建产物自动识别并优先 preload LCP 图片。
  5. 并发控制 / 优先级队列:图片过多时按 viewport 或权重分批发 link。