Webpack迁到Vite第四节:SVG 图标系统
本篇定位 :SVG 图标从
require.context+svg-sprite-loader到 Vite 虚拟模块的完整演进------技术背景(Sprite)、替代方案、插件代码逐行解析、迁移中遇到的坑(86 次请求、JSON.stringify),以及「为什么自研约 25 行插件而不引入第三方」。
思维导图
SVG 图标系统
├── 1.1 背景:SVG Sprite 技术
│ ├── 什么是 SVG Sprite(隐藏的 <symbol> 集合 + <use> 引用)
│ ├── 为什么不直接 <img src="icon.svg">(无法通过 CSS 改色)
│ └── Webpack 时代的实现
│ ├── require.context 批量导入(逐行拆解 + 数据流)
│ ├── svg-sprite-loader 自动转 symbol(chainWebpack 配置详解)
│ ├── 完整串联流程(6 步:编译扫描→模块执行→DOM 注入)
│ └── svg-sprite-loader 输入→输出对照
├── 1.2 Vite 的 virtual module 替代方案
│ ├── import.meta.glob 的固有限制(请求数不减少)
│ ├── 错误尝试:eager: false + Promise.all
│ ├── 正确方案:Vite 插件虚拟模块
│ │ ├── resolveId:拦截 virtual:svg-icons
│ │ └── load:Node.js 编译时读取所有 SVG 并拼接
│ ├── src/assets/svgIcons/index.js:只需一行 import
│ └── JSON.stringify 的必要性(转义 SVG 内容中的引号)
├── 1.3 逐行解析插件代码
│ ├── ① resolveId:拦截虚拟模块 ID
│ │ └── 虚拟模块 ID 设计约定(加不加 \0、Rollup 解析链自闭环机制、实测结论)
│ ├── ② readdirSync + readFileSync:编译时读取文件系统
│ ├── ③ 提取 viewBox(为什么不能硬编码)
│ ├── ④ <svg> → <symbol>(为什么需要去外层标签)
│ ├── ⑤ 注入 DOM(为什么用隐藏 div)
│ └── ⑥ JSON.stringify(为什么不能直接拼接字符串)
├── 1.4 遇到的坑
│ ├── 坑一:import.meta.glob 请求数无法减少
│ └── 坑二:JSON.stringify 缺失导致 JS 解析错误
└── 1.5 为什么不引入第三方插件
└── vite-plugin-svg-icons 的利弊分析
1.1 背景:SVG Sprite 技术
什么是 SVG Sprite?
项目中有 86 个 SVG 图标文件(src/assets/svgIcons/*.svg),页面中通过 <svg-icon name="application" /> 使用它们。但这里有一个问题:每个 SVG 是一个独立文件,浏览器怎么高效加载它们?
如果每个图标都发一次 HTTP 请求,86 个图标就是 86 次请求------性能很差。SVG Sprite 技术解决了这个问题:
把所有 SVG 图标合并为一个"精灵图",每个图标作为其中的一个 <symbol>,
通过 <use href="#icon-xxx"> 按需引用。
┌─────────────────────────────────────────────────┐
│ SVG Sprite(一个隐藏的 <svg> 元素) │
│ │
│ <symbol id="icon-application" viewBox="0 0 20 20"> │
│ <path d="M12.67,10..."/> │
│ </symbol> │
│ <symbol id="icon-attachment" viewBox="0 0 20 20"> │
│ <path d="M10.5,1.32..."/> │
│ </symbol> │
│ <symbol id="icon-date" viewBox="0 0 16 16"> │
│ <path d="..."/> │
│ </symbol> │
│ ... 86 个 symbol ... │
└─────────────────────────────────────────────────┘
页面中引用:
<svg><use href="#icon-application" /></svg> → 渲染 application 图标
<svg><use href="#icon-date" /></svg> → 渲染 date 图标
为什么不直接 <img src="icon.svg">?
核心区别在于:SVG 内容在不在你的 DOM 里。
<img> 方式------SVG 被隔离:
你的页面 DOM SVG 文件(独立文档)
┌─────────────────────┐ ┌──────────────────────┐
│ │ 加载但不 │ <svg viewBox="..."> │
│ <img src="a.svg"> │ ← 融入 DOM │ <path d="..." │
│ │ │ fill="black"/> │
│ 你的 CSS 在这里 │ CSS 边界 │ 你的 CSS 到不了这里 │
│ ═══════════════════╪══════════════╪══════════════════════ │
│ │ │ │
└─────────────────────┘ └──────────────────────┘
浏览器把 <img> 当作一个替换元素 (replaced element)------就像 <img src="photo.png"> 一样。SVG 文件在独立的文档上下文中渲染,你的页面 CSS 无法穿透这个边界。你能控制 <img> 标签本身的宽高、边框,但无法控制 SVG 内部的 <path> 的颜色。
<use> 方式------SVG 被克隆到你的 DOM:
你的页面 DOM
┌─────────────────────────────────────────────────┐
│ │
│ <svg style="color: red;"> ← 你的 CSS 在这里 │
│ <use href="#icon-app" /> │
│ │ │
│ │ 浏览器把 #icon-app 的内容克隆到这里 │
│ ▼ │
│ <path d="..." fill="currentColor"/> ← 克隆体 │
│ (fill="currentColor" 继承父 <svg> 的 color:red │
│ → 最终渲染为红色 ✅) │
│ │
└─────────────────────────────────────────────────┘
<use> 元素会把被引用的 <symbol> 内容克隆 到当前 DOM 中。克隆体是真实存在于你的 DOM 树中的,所以你的 CSS 可以选中它、继承给它。SVG 图标通常使用 fill="currentColor"(继承当前文字颜色),所以只需设置父 <svg> 的 color,图标颜色就跟着变了。
一句话 :<img> 像"裱在相框里的画"------你只能换相框,改不了画的内容。<use> 像"复印到你的纸上"------复印出来就是你的,你可以随意涂改。
Webpack 时代的实现
在 Webpack 中,这套流程由两个工具配合完成:
① require.context --- 批量导入
javascript
// src/assets/svgIcons/index.js
const req = require.context('./', false, /\.svg$/)
const requireAll = (requireContext) => requireContext.keys().map(requireContext)
requireAll(req)
这三行代码很短,但每一步都藏着 Webpack 的机制。下面逐行拆解执行过程。
第一步:require.context('./', false, /\.svg$/) --- 创建上下文模块
require.context 是 Webpack 特有的 API,它在编译时扫描指定目录,把匹配的文件打包成一个"上下文模块"。三个参数:
| 参数 | 值 | 含义 |
|---|---|---|
| 目录 | './' |
当前文件所在的目录(src/assets/svgIcons/) |
| 递归 | false |
不搜索子目录 |
| 匹配 | /\.svg$/ |
只匹配 .svg 结尾的文件 |
返回值 req:一个函数,但不是一个普通函数------它身上挂载了三个属性:
javascript
req('./application.svg') // 函数本身:相当于 require('./application.svg')
req.keys() // → ['./application.svg', './attachment.svg', ...]
req.id // → './src/assets/svgIcons sync \.svg$'(模块 ID)
req.keys() 返回的是编译时确定的 文件路径数组,不是运行时扫描文件系统。Webpack 构建时扫描了 svgIcons/ 目录,把匹配到的文件路径硬编码到了 bundle 中:
javascript
// Webpack 构建后,req.keys() 等价于:
['./application.svg', './attachment.svg', './center-aligned.svg', ...共 86 个文件]
第二步:requireAll 函数的定义
javascript
const requireAll = (requireContext) => requireContext.keys().map(requireContext)
拆解这个箭头函数:
| 部分 | 含义 |
|---|---|
requireContext |
参数,即第一步返回的 req |
requireContext.keys() |
调用 req.keys(),获取所有文件路径数组 |
.map(requireContext) |
对数组中每个路径,调用 req(路径) ,即 require(路径) |
.map(requireContext) 等价于 .map(key => req(key)),因为 req 本身就是一个函数------req('./application.svg') 就是 require('./application.svg')。
第三步:requireAll(req) --- 执行,批量加载所有 SVG
javascript
requireAll(req)
// 等价于:
// req.keys().map(req)
// 等价于:
// ['./application.svg', './attachment.svg', ...].map(key => req(key))
// 等价于:
// ['./application.svg', './attachment.svg', ...].map(key => require(key))
执行后,.map() 遍历每个文件路径,调用 require() 加载对应的模块。每个 require() 返回什么 取决于 svg-sprite-loader 的配置。
第三步的细节:每个 require('./application.svg') 发生了什么
Webpack 构建时,svg-sprite-loader 已经把每个 SVG 文件转换成了 JavaScript 模块。以 application.svg 为例:
构建前(application.svg 源文件):
<svg xmlns="..." width="20" height="20" viewBox="0 0 20 20">
<path d="M12.67,10..."/>
</svg>
构建后(被 svg-sprite-loader 编译为 JS 模块):
// 生成的模块代码(简化示意,v6 默认 esModule: true)
import SpriteSymbol from 'svg-baker-runtime/browser-symbol'
import sprite from 'svg-sprite-loader/runtime/browser-sprite.build'
var symbol = new SpriteSymbol({
id: 'icon-application',
viewBox: '0 0 20 20',
content: '<symbol id="icon-application" viewBox="0 0 20 20"><path d="M12.67,10..."/></symbol>'
})
var result = sprite.add(symbol) // ← 副作用:将 symbol 注册到全局 sprite
export default symbol // ← 导出 SpriteSymbol 实例
当 require('./application.svg') 执行时:
sprite.add(symbol)运行------把这个 SpriteSymbol 实例注册到svg-sprite-loader管理的全局 sprite 中export default symbol导出该 SpriteSymbol 实例(其content即<symbol>字符串)
最终结果
javascript
requireAll(req)
// 返回值:
// [
// '<symbol id="icon-application" viewBox="0 0 20 20"><path d="..."/></symbol>',
// '<symbol id="icon-attachment" viewBox="0 0 20 20"><path d="..."/></symbol>',
// '<symbol id="icon-date" viewBox="0 0 16 16"><path d="..."/></symbol>',
// // ...共 86 个 symbol 字符串
// ]
但这个返回值没有被赋值给任何变量 ------它被丢弃了。真正起作用的是副作用:
每个
require()执行时,sprite.add(symbol)已经把 symbol 注册到了全局 sprite 中。svg-sprite-loader的运行时在页面加载时会自动把汇总后的 sprite 注入 DOM。所以requireAll(req)不需要返回值,它的副作用已经完成了所有工作。
一句话总结 :requireAll 遍历所有 SVG 文件,对每个文件执行 require()。每个 require() 触发 svg-sprite-loader 的副作用------把 symbol 注册到全局 sprite。86 个 require 执行完,sprite 就收集齐了,自动注入 DOM。
② svg-sprite-loader --- 自动转 Symbol
上面说了 require() 执行时发生了什么。那 svg-sprite-loader 是怎么把 .svg 文件变成 JS 模块的?这需要看 webpack 配置:
javascript
// vue.config.js(chainWebpack 中)
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/svgIcons')).end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({ symbolId: 'icon-[name]' })
逐行拆解:
config.module.rule('icons') :在 Webpack 的模块处理规则中,新增一条名为 'icons' 的规则。这个名称是自定义的,叫什么都行。
.test(/\.svg$/) :这条规则匹配所有 .svg 文件。但项目中可能还有其他 SVG 文件(比如 src/assets/images/ 下的图片),不能全部走 sprite 处理。所以需要 .include 来限定范围。
.include.add(resolve('src/assets/svgIcons')).end() :只处理 src/assets/svgIcons/ 目录下的 SVG 。.end() 是 chainWebpack 的 API,表示回到上一级,后续的 .use() 调用是在 rule 上添加 loader。
为什么需要
.end()? chainWebpack 的链式调用像一个"光标"在嵌套对象中移动。每次.include、.use等调用会让光标进入子对象:
config.module ← 光标在 module .rule('icons') ← 光标进入 rule .include ← 光标进入 rule 的 include 子对象 .add(...) ← 光标仍在 include .end() ← 光标回到 rule ← 关键! .use('svg-sprite') ← 光标进入 rule 的 use 子对象如果不写
.end(),光标还停留在.include上,后续的.use()会尝试在include对象上调用------但include根本没有.use()方法,会直接报错。所以.end()的作用就是**"退一层,回到父对象"**,让后续调用作用于正确的层级。
.use('svg-sprite-loader').loader('svg-sprite-loader') :在这条规则上添加一个名为 svg-sprite-loader 的 loader 工位,指定使用 svg-sprite-loader 这个 npm 包。
.options({ symbolId: 'icon-[name]' }) :给 loader 传参数。symbolId: 'icon-[name]' 表示每个 SVG 文件生成的 <symbol> 的 id 属性为 icon-文件名。
完整串联:从 require.context 到 DOM 注入
把 ① 和 ② 串起来,整个流程是:
① require.context('./', false, /\.svg$/) → 编译时扫描,生成 req 函数
② req.keys() → ['./application.svg', ...]
③ ['./application.svg', ...].map(req) → 每个路径执行 require()
④ 每个 require('./application.svg'):
├── webpack 匹配到 'icons' 规则
├── svg-sprite-loader 处理:
│ 读取 application.svg → 替换 <svg> 为 <symbol id="icon-application">
│ → 生成 JS 模块:sprite.add('<symbol>...</symbol>')
└── 模块执行:sprite.add() 注册到全局 sprite
⑤ 86 个 SVG 全部处理完毕,全局 sprite 收集了所有 symbol
⑥ svg-sprite-loader 运行时自动把 sprite 注入 DOM
svg-sprite-loader 实际做了什么
svg-sprite-loader 在 Webpack 构建时对 src/assets/svgIcons/ 下的每个 .svg 文件单独处理。每个文件变成一个独立的 JS 模块 ,各自导出一个 SpriteSymbol 实例(其 content 字段即对应的 <symbol> 字符串):
application.svg → 模块 A
content: '<symbol id="icon-application" viewBox="0 0 20 20"><path d="M12.67,10..."/></symbol>'
attachment.svg → 模块 B
content: '<symbol id="icon-attachment" viewBox="0 0 20 20"><path d="M10.5,1.32..."/></symbol>'
date.svg → 模块 C
content: '<symbol id="icon-date" viewBox="0 0 16 16"><path d="..."/></symbol>'
... 共 86 个独立模块
以 application.svg 为例,输入→输出:
输入:application.svg
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M12.67,10..."/>
</svg>
输出:一个 JavaScript 模块(Webpack 构建产物)
// 模块导出 SpriteSymbol 实例(export default symbol),实例的 content 为:
'<symbol id="icon-application" viewBox="0 0 20 20"><path d="M12.67,10..."/></symbol>'
当 require('./application.svg') 执行时,Webpack 加载这个模块,拿到 exports 值(SpriteSymbol 实例),同时模块内部的 sprite.add() 把该 symbol 注册到全局 sprite。
svg-sprite-loader 对每个文件做了三件事:
- 把
<svg>标签替换为<symbol id="icon-xxx"> - 保留
viewBox属性(SVG 缩放的关键) - 保留
<path>等内部元素
86 个模块,如何合并成一个 Sprite?------编译时 vs 运行时
每个 SVG 文件是独立的 JS 模块,最终却只发一次 HTTP 请求。整个过程分两个阶段:
编译时(Webpack 构建阶段)
Webpack 执行 npm run build 时,按以下顺序处理:
① Webpack 从入口文件 src/main.js 出发,追踪依赖
↓
② 遇到 src/assets/svgIcons/index.js
↓
③ 发现 require.context('./', false, /\.svg$/)
↓
④ Webpack 识别 require.context 是编译时 API
→ 在编译时扫描 src/assets/svgIcons/ 目录
→ 找到所有匹配 /\.svg$/ 的文件:application.svg, attachment.svg, ... 共 86 个
→ 为每个文件在依赖图中创建一个模块节点
↓
⑤ 每个 .svg 模块经过 'icons' 规则的处理流水线:
├── 匹配 .test(/\.svg$/) ✅
├── 检查 .include(src/assets/svgIcons) ✅
└── svg-sprite-loader 处理:
读取文件 → 替换 <svg> 为 <symbol> → 生成 JS 模块代码
↓
⑥ 生成的 JS 模块代码(每个 SVG 一个):
var symbol = new SpriteSymbol({ id: 'icon-xxx', content: '<symbol id="icon-xxx">...</symbol>', ... })
sprite.add(symbol) // 副作用代码
export default symbol // 导出代码
↓
⑦ Webpack 把所有 86 个模块 + require.context 生成的映射代码
全部打包进一个 JS bundle 文件
→ dist/js/chunk-vendors.xxx.js 或 app.xxx.js
关键 :require.context 的 glob 匹配发生在编译时 ,不是运行时。Webpack 构建时就确定了要打包哪些文件,req.keys() 返回的路径数组是硬编码在 bundle 中的:
javascript
// 编译后 req.keys() 等价于(不是运行时扫描文件系统):
['./application.svg', './attachment.svg', './date.svg', ...86 个固定路径]
注意 :这 86 个模块的代码中,sprite.add(symbol) 是副作用代码,export default symbol 是导出代码。编译时只是生成了这些代码,还没有执行------代码要等到浏览器加载 bundle 后才运行。
运行时(浏览器执行阶段)
浏览器加载 JS bundle 后,代码开始执行:
① index.js 模块执行
↓
② requireAll(req) 执行
→ req.keys() 返回硬编码的 86 个路径
→ 逐个调用 require('./application.svg'), require('./attachment.svg'), ...
↓
③ 每个 require() 执行对应模块的代码:
├── sprite.add(symbol) 执行 → 把 symbol 追加到内存中的 sprite 集合
└── export default symbol → 返回 SpriteSymbol 实例
↓
④ 86 个 require() 全部执行完毕
→ 内存中的 sprite 集合包含了所有 symbol
↓
⑤ svg-sprite-loader 的运行时自动把 sprite 集合注入 DOM:
<div style="display:none">
<svg>
<symbol id="icon-application">...</symbol>
<symbol id="icon-attachment">...</symbol>
... 全部 86 个 symbol
</svg>
</div>
对比:
编译时:Webpack 扫描目录 → svg-sprite-loader 逐个处理 → 生成 86 个 JS 模块 → 打包进一个 bundle
运行时:浏览器加载 bundle → 执行 86 个 require() → sprite.add() 收集 → 注入 DOM
HTTP 请求:1 次(只加载 bundle)
页面引用:<use href="#icon-xxx"> → 0 次请求(symbol 已在 DOM 中)
1.2 Vite 的虚拟模块(virtual module)替代方案
import.meta.glob 的固有限制
最初尝试用 import.meta.glob 替代 require.context:
import.meta.glob 是编译时语法糖,Vite 在编译时将其展开为独立的 import 语句:
javascript
// 源代码
const modules = import.meta.glob('./*.svg', { eager: true, query: '?raw' })
// Vite 编译后展开为:
import * as __glob_0 from './menu.svg?raw'
import * as __glob_1 from './logo.svg?raw'
// ... 86 个独立 import
在开发模式,Vite 不打包,每个 import 就是一次 HTTP 请求。无论 eager: true(同步)还是 eager: false(异步),请求数量不变 ------86 个 SVG 就是 86 次请求。eager: false 只是把请求从同步改为异步,不减少请求数。
javascript
// ❌ 错误尝试:请求数没变,只是从同步变成异步
const svgModules = import.meta.glob('./*.svg', { query: '?raw' })
Promise.all(Object.entries(svgModules).map(([, loader]) => loader()))
正确方案:Vite 插件虚拟模块
在 vite.config.js 中创建一个插件,用 resolveId/load 钩子生成一个虚拟模块 。load 中直接用 Node.js 的 readdirSync + readFileSync 在编译时 读取所有 SVG 文件,拼接成 sprite 注入代码。浏览器只请求一个虚拟模块:
javascript
// vite.config.js
{
name: 'svg-sprite',
resolveId(id) {
if (id === 'virtual:svg-icons') return 'virtual:svg-icons'
},
load(id) {
if (id === 'virtual:svg-icons') {
const { readdirSync, readFileSync } = require('fs')
const svgDir = path.resolve(__dirname, 'src/assets/svgIcons')
const symbols = readdirSync(svgDir)
.filter((f) => f.endsWith('.svg'))
.map((f) => {
const raw = readFileSync(path.resolve(svgDir, f), 'utf-8')
const name = f.replace('.svg', '')
const viewBoxMatch = raw.match(/viewBox="([^"]+)"/)
const viewBox = viewBoxMatch ? ` viewBox="${viewBoxMatch[1]}"` : ''
const inner = raw.replace(/<svg[^>]*>/, '').replace('</svg>', '').trim()
return `<symbol id="icon-${name}"${viewBox}>${inner}</symbol>`
}).join('')
// JSON.stringify 确保 SVG 内容中的引号被正确转义
return `
const el = document.createElement('div')
el.style.cssText = 'width:0;height:0;position:absolute;overflow:hidden;'
el.innerHTML = ${JSON.stringify(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">${symbols}</svg>`)}
document.body.appendChild(el)
`
}
},
}
src/assets/svgIcons/index.js 只需一行核心代码:
javascript
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon'
import 'virtual:svg-icons'
Vue.component('svg-icon', SvgIcon)
虚拟模块的工作原理
编译时(Node.js 端):
vite.config.js 插件
→ load('virtual:svg-icons') 被调用
→ readdirSync 扫描 src/assets/svgIcons/ 目录
→ readFileSync 逐个读取 SVG 文件
→ 正则提取 viewBox + 去掉 <svg> 标签 + 包装为 <symbol>
→ 拼接为完整 SVG Sprite HTML
→ JSON.stringify 转义
→ 返回 JS 代码字符串
运行时(浏览器端):
import 'virtual:svg-icons' 被执行
→ 创建隐藏 div
→ innerHTML 注入 SVG Sprite
→ <use href="#icon-xxx"> 即可引用图标
与 import.meta.glob 的对比
import.meta.glob |
virtual module 插件 | |
|---|---|---|
| 请求数 | 86 个(每个 SVG 一个请求) | 1 个(虚拟模块) |
| 处理时机 | 编译时展开为 import 语句;运行时浏览器逐个加载 | 编译时(Node.js 端读取) |
| SVG 内容 | 通过 ?raw 查询参数获取 |
通过 readFileSync 直接读取 |
| 适用场景 | 少量文件(< 10 个) | 大量文件(86 个) |
JSON.stringify 的必要性
SVG 内容包含大量 "(双引号),直接拼进 JS 字符串会破坏语法结构:
javascript
// ❌ 直接拼接:SVG 中的 " 会截断 JS 字符串
el.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">...</svg>`
// ↑ 这个 " 提前结束了字符串
// ✅ JSON.stringify 自动转义所有特殊字符
el.innerHTML = ${JSON.stringify(`<svg xmlns="http://www.w3.org/2000/svg">...</svg>`)}
// 生成:el.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\">...</svg>"
效果
| 改动前 | 改动后 | |
|---|---|---|
| SVG 请求 | 86 个独立请求 | 1 个虚拟模块请求 |
| 请求方式 | 浏览器逐个加载 | 服务端编译时合并 |
1.3 逐行解析插件代码
① resolveId --- 拦截虚拟模块 ID
javascript
resolveId(id) {
if (id === 'virtual:svg-icons') return 'virtual:svg-icons'
}
resolveId 是 Rollup 的钩子,在模块解析阶段被调用。当 src/assets/svgIcons/index.js 中执行 import 'virtual:svg-icons' 时,Vite 调用 resolveId 来解析这个模块 ID。返回 'virtual:svg-icons'(非 null)表示"本插件认领这个 ID"------解析链即刻终止,后续进入 load 取内容。
虚拟模块 ID 的设计约定:加不加 \0?
Rollup 插件文档对虚拟模块的惯例建议:
Convention: Plugins that implement virtual modules should prefix the module ID with
'\0'inresolveId, and check for the same prefix inload.
\0(null byte)让模块 ID 成为一个"不可能与真实文件路径混淆"的标识。但本项目最终实现并未加 \0 (resolveId 直接返回 'virtual:svg-icons',load 检查同样不含前缀)------且开发与生产构建均实测正常 (dev 请求 /@id/virtual:svg-icons 返回 200;vite build 退出码 0,sprite 代码正常进入产物)。
为什么去掉 \0 也能工作?------Rollup 模块解析的真实机制:
import 'virtual:svg-icons' 触发解析:
│
▼ 【resolveId 链】依次询问各插件,第一个返回非 null 者胜出
① 本插件 resolveId 命中 → return 'virtual:svg-icons'(非 null)
└── 解析链在此【终止】------其他插件(node-resolve 等)不会被问到
│
▼ 解析成功,进入【load 链】
② 本插件 load 命中 → 返回 JS 代码 → 模块内容就绪
关键 :resolveId 返回非 null + load 配对处理 = 解析链完全自闭环 ,Rollup 根本没有机会去文件系统查找这个"路径"。Could not resolve 错误只发生在所有插件都返回 null(无人认领该 id)的场景------本项目插件自己认领了,因此不会发生。
两种写法的对比:
加 \0(Rollup 推荐惯例) |
不加 \0(本项目实现) |
|
|---|---|---|
| 可工作情况 | ✅ | ✅(resolveId + load 自闭环) |
| 保护作用 | 后续钩子(load 链其他插件、transform)若对 id 做路径化处理,\0 使其明确跳过;也防与真实路径意外冲突 |
无此保护------若未来新增插件对 virtual:svg-icons 做路径整形,理论上有出问题的风险(当前插件集下无实际影响) |
| dev 调试 URL | /@id/__x00__virtual:svg-icons(\0 被编码为 __x00__) |
/@id/virtual:svg-icons(更可读) |
结论 :\0 是生态推荐的防御性惯例,不是"不加就崩"的硬性要求。本项目去掉后(插件集固定,无插件对 svg-icons 的 id 做路径化处理)零实际影响,且换来更可读的调试 URL;若未来新增插件,需留意这一约定。
resolveId 和 load 的配对关系:
import 'virtual:svg-icons' ← 源代码中的 import
│
▼
resolveId('virtual:svg-icons') ← Rollup 询问:这个 ID 解析到哪里?
│
return 'virtual:svg-icons' ← 回答:已认领(返回非 null 即解析成功)
│
▼
load('virtual:svg-icons') ← Rollup 请求:给我这个模块的内容
│
return 'const el = ...' ← 返回 JS 代码字符串
注意:resolveId 的输入与返回、load 的输入是同一个 id ------两者必须配套(load 里的判断条件与 resolveId 的返回值保持一致),这是虚拟模块能工作的真正前提。
② readdirSync + readFileSync --- 编译时读取文件系统
javascript
const { readdirSync, readFileSync } = require('fs')
const svgDir = path.resolve(__dirname, 'src/assets/svgIcons')
const symbols = readdirSync(svgDir)
.filter((f) => f.endsWith('.svg'))
.map((f) => {
const raw = readFileSync(path.resolve(svgDir, f), 'utf-8')
// ...
}).join('')
关键区别 :这段代码运行在 Node.js 端(编译时) ,不是浏览器端。readdirSync 直接读取文件系统,readFileSync 读取每个 SVG 文件内容。这与 import.meta.glob 的根本区别在于------import.meta.glob 展开为浏览器端的 import 语句,每个文件一次 HTTP 请求;而 readFileSync 在编译时一次性读取所有文件,只产生一个模块。
filter((f) => f.endsWith('.svg')) 确保只处理 .svg 文件,忽略目录中的其他文件(如 .DS_Store 等)。
③ 提取 viewBox --- 为什么不能硬编码
javascript
const viewBoxMatch = raw.match(/viewBox="([^"]+)"/)
const viewBox = viewBoxMatch ? ` viewBox="${viewBoxMatch[1]}"` : ''
viewBox 是 SVG 的缩放属性,定义了 SVG 的坐标系统。项目中的 SVG 图标来自不同来源,它们的 viewBox 各不相同(0 0 20 20、0 0 16 16、0 0 1024 1024 等)。如果硬编码 1024 1024,小坐标系的图标会被强制拉伸到 1024×1024 的坐标空间,导致图标严重变形。
④ <svg> → <symbol> --- 为什么需要去外层标签
javascript
const inner = raw.replace(/<svg[^>]*>/, '').replace('</svg>', '').trim()
原始 SVG 文件的结构是 <svg> 包裹内部元素。但 Sprite 中,外层是统一的 <svg xmlns="...">,内部放的是 <symbol>。所以需要把每个文件的 <svg> 标签去掉,只保留内部内容(<path>、<circle> 等),然后包装为 <symbol> 标签。
正则 /<svg[^>]*>/ 匹配 <svg 开标签及其所有属性。
⑤ 注入 DOM --- 为什么用隐藏 div
javascript
const el = document.createElement('div')
el.style.cssText = 'width:0;height:0;position:absolute;overflow:hidden;'
el.innerHTML = ...
document.body.appendChild(el)
Sprite 需要存在于 DOM 中(<use> 引用的是 DOM 中的元素),但不需要可见。width:0;height:0;position:absolute;overflow:hidden 的组合确保元素不占空间、不显示、但 DOM 中仍存在且可引用。这是业界标准做法------不可见但存在于 DOM 中。
为什么不用 display: none? 某些浏览器会对 display:none 的元素不渲染子元素,导致 <use> 引用不到。
⑥ JSON.stringify --- 为什么不能直接拼接字符串
SVG 内容包含大量 "(双引号),直接拼进 JS 字符串会导致语法错误。JSON.stringify 自动转义所有特殊字符,确保生成的代码是合法 JS。详细原理见 §1.2「JSON.stringify 的必要性」。
与 svg-sprite-loader 的对比
| svg-sprite-loader | virtual module 插件 | |
|---|---|---|
| 运行环境 | Webpack loader 管线 | Vite 插件 load 钩子 |
| 读取方式 | Webpack 传递文件内容 | readFileSync 直接读取 |
| 处理时机 | 构建时(每个文件独立处理) | 构建时(一次性处理所有文件) |
| 依赖 | 需要 npm 包 | 零依赖(Vite 内置插件 API) |
| 灵活性 | 固定行为 | 完全可控 |
1.4 遇到的坑
坑一:import.meta.glob 请求数无法减少
import.meta.glob 无论 eager: true 还是 eager: false,展开后的 import 语句数量不变------86 个 SVG 就是 86 次请求。详细分析见 §1.2。
坑二:JSON.stringify 缺失导致 JS 解析错误
错误代码:
javascript
// ❌ 直接拼接字符串
return `
const el = document.createElement('div')
el.innerHTML = \`<svg xmlns="http://www.w3.org/2000/svg">${symbols}</svg>\`
document.body.appendChild(el)
`
现象 :Vite 报错 Failed to parse source for import analysis,提示 JS 语法错误。
根因 :SVG 内容包含大量 "(双引号)。插件 load 钩子返回的是 JS 代码字符串,这个字符串会被 Vite 当作 JS 源文件进行语法解析。如果 SVG 的内容直接拼接到 JS 字符串中,SVG 内部的 " 会提前结束 JS 字符串字面量,导致语法错误。
生成的代码(有语法错误):
el.innerHTML = "<svg xmlns="http://www.w3.org/2000/svg">...</svg>"
// ↑ 这个 " 提前结束了 JS 字符串
修复 :使用 JSON.stringify 包裹 SVG 内容,自动转义所有特殊字符:
javascript
// ✅ JSON.stringify 自动转义
el.innerHTML = ${JSON.stringify(`<svg ...>${symbols}</svg>`)}
// 生成:el.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\">...</svg>"
// 所有 " 都被转义为 \" → 合法 JS 字符串
1.5 为什么不引入第三方插件
社区有 vite-plugin-svg-icons 插件,安装后自动处理 SVG Sprite,功能与本项目的自定义插件方案几乎一样。
不用的原因:
| 考量 | 自定义插件方案 | vite-plugin-svg-icons |
|---|---|---|
| 依赖 | 0 个额外依赖(使用 Vite 内置插件 API) | +1 个 npm 包(及其传递依赖) |
| 代码量 | ~25 行(在 vite.config.js 中) |
0 行配置代码 |
| 可控性 | 完全可控 | 受插件 API 限制 |
| 长期维护 | 自包含,不受外部影响 | 依赖插件作者更新 |
| 迁移成本 | 零(纯 Vite 插件 API,无框架绑定) | 插件废弃时需重写 |
权衡:对于 25 行代码就能解决的问题,引入一个外部依赖的性价比不高。况且项目中的 SVG 图标已经稳定,不会频繁变更,自定义插件的维护成本几乎为零。
注意 :本方案的核心是自定义 Vite 插件(resolveId/load 钩子),利用 Rollup 的虚拟模块机制。它本身就是一个插件------只是这个插件是自己写的、内嵌在 vite.config.js 中的,不需要从 npm 安装。这与"不用任何插件"是不同的概念------我们用的是"零依赖的自定义插件",而非"零插件的纯 JS 表达式"。
SVG 图标显示异常的排查过程(虚拟模块注入、viewBox、xlink:href)见第八节 §1.6。