Webpack迁到Vite第四节:SVG 图标系统

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') 执行时:

  1. sprite.add(symbol) 运行------把这个 SpriteSymbol 实例注册到 svg-sprite-loader 管理的全局 sprite 中
  2. 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 对每个文件做了三件事:

  1. 把 <svg> 标签替换为 <symbol id="icon-xxx">
  2. 保留 viewBox 属性(SVG 缩放的关键)
  3. 保留 <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' in resolveId, and check for the same prefix in load.

\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。

相关推荐
Dovis(誓平步青云)1 小时前
车辆进入隧道后信号断开,座舱旅程怎样补回事件
java·开发语言·前端·数据库·电脑
定栓1 小时前
Webpack迁到Vite第六节:构建配置详解(vite.config.js)
前端·javascript·webpack
风骏时光牛马1 小时前
从零到精通AI全栈学习路线
前端
Autumn_ing10 小时前
Design.md生成UI怎么验收?举例运营后台的颜色、状态和数据核对
前端·ui·ai·设计系统·paico·ai生成ui·design.md
keyipatience10 小时前
JavaScript学习(1)书写位置,变量、数据类型、运算符
开发语言·前端·javascript·css·学习
朱涛的自习室11 小时前
我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)
android·前端·后端
java1234_小锋13 小时前
【技术专题】HTML5 网页技术 - HTML5简介 & HTML5 文档结构
前端·html·html5
鶴哥只手遮天13 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 1 章 项目全景:monorepo 架构与工程化体系
前端·vue.js·开源
流星。。方14 小时前
小团队官网SEO落地:前端语义化与静态生成的工程实践
前端·状态模式