从icon痛苦到组件自由:一个Vite+Vue3 SVG方案的重构之路

1,261 阅读10分钟

一、为啥需要重构,第三方库不香吗?

首先基础技术栈:

  • vite
  • vue3

适配vite+vue3svg loader目前已经有很多相对成熟的方案了,但是考虑到特殊适应场景、开发便捷、易于维护、加载性能、自定义特性等等方面,各大插件还是有各自不足,下面是痛苦的尝试过程:

1. vite-plugin-svg-icons

插件信息描述
github地址传送门
github star🌟861
release versionv2.0.1
开发者vbenjs
是否活跃3 year ago

使用方式参考github readme,这里就不多说了,主要实现原理:

  1. 递归遍历配置目录及其子目录,找出所有svg文件;
  2. 解析处理svg文件内容(去除空格、注释);
  3. 生成svg雪碧图。雪碧图包含多个<symbol>元素,每个<symbol>对应一个原始的svg文件,有唯一的id属性,用于后续引用;
  4. 注入雪碧图到html;
  5. 提供使用方法。

优势:

  • 通过雪碧图减少http请求,缓存效率高;
  • 集中管理图标,易于维护,支持多层级目录分类;
  • 支持动态切换图标,基于<use>元素。

缺点:

  • 初始加载提及较大。如果项目用到大量的svg图标,生成的雪碧图文件会比较大,导致首次加载时间增加,尤其在网络不好的情况下,需要很长时间才能看到图标;
  • 不适合动态图标内容。该插件是生成静态的雪碧图,需要改变图标的颜色、形状需要通过js来修改svg属性;
  • 调试难度大。所有图标都合并在一个文件中,如果某个图标出现异常,调试困难,需要仔细查看雪碧图的解构和引用关系来定位问题。

我的痛点:

  • 项目使用的svg有点多,所有的svg直接生成在一个文件里,并且被注入到了html中,导致页面首次打开巨慢;
  • 我的项目是多webview的,每打开一个页面都会新开一个webview,再次加载一遍html,又打开巨慢,没有发挥到缓存的优势;
  • 部分复杂图标显示不全渲染不正确,这个一般是不能接受的。

svg-icons-register-html.png

请看大屏幕,页面只有3个svg图标,但是配置的icon目录中所有的svg都注入到html中了,非常影响html首次渲染时间,我们在做性能优化的时候发现这个是瓶颈之一。而且第二个和第三个svg是渲染有问题的。

2. vite-svg-loader

插件信息描述
github地址传送门
github star🌟619
release versionv5.1.0
开发者jpkleemans
是否活跃2 years ago

使用方式也暂不多说,主要原理:

  1. 拦截svg文件加载,传递给vite-svg-loader处理;
  2. 解析和转换svg文件,将svg文件转换为js模块,如果是vue项目则会转换为vue组件;
  3. 返回处理后的模块,将vue组件返回给vite, vite再整合到项目的构建流程中。

优势:

  • 灵活的使用方式。可以作为URLRawComponent的方式导入使用,方便进行样式定制和交互操作;
  • 无缝集成 Vite。无需额外复杂的配置,开发者可以在 Vite 项目中快速使用 SVG 文件。
  • 提高开发效率。可以直接在代码中导入 SVG 文件,而无需手动处理 SVG 的加载和转换,减少了开发过程中的繁琐步骤。
  • 支持按需加载,提高性能。在构建过程中,插件可以根据项目的实际使用情况对 SVG 进行优化处理,只打包项目中实际使用的 SVG 文件,避免不必要的资源浪费。

缺点:

  • 配置项复杂。为了实现不同的导入形式和功能,插件提供了较多的配置选项,对于初学者来说,可能需要花费一定的时间来理解和配置这些选项。
  • 调试困难。由于插件对 SVG 文件进行了转换,当出现问题时,调试相对困难,需要深入了解插件的转换逻辑才能定位问题。
  • id重复导致渲染有问题。因内部没有处理svg属性或引用ID唯一性,导致多个相同svg渲染出现引用混乱从而出现样式渲染不对。

我的痛点:

  • svg ID重复问题。因为项目中有tabs,每个tab里可能有相同的图标,导致切换的时候出现样式混乱,svg内部ID引用出错。

vite-svg-loader-1.png

vite-svg-loader-2.png

请看大屏幕,vite-svg-loader插件在切换的时候第1个和第3个直接渲染异常了,在tab1的时候第3个还直接不显示了,其实就是因为ID冲突的问题导致的。

3. unplugin-icons (原vite-plugin-icons)

插件信息描述
github地址传送门
github star🌟4.3k
release versionv22.1.0
开发者antfu
是否活跃活跃2025

这个插件是 antfu 大佬写的,他的作品包括 unocss, vueuse, nuxt 等等。

此插件功能强大,主要特性包括:

  • 通用。适用任何图标集,由Iconify支持;
  • 主要构建工具——Vite、Webpack、Rollup、Nuxt、Rspack 等。由unplugin提供支持;
  • 主要框架 - Vanilla、Web Components、React、Vue 3、Vue 2、Solid、Svelte 等。
  • 按需加载
  • SSR / SSG 友好
  • 可样式化 —按照样式和类别的方式更改大小、颜色,甚至添加动画
  • 自定义图标
  • 自动导入

此插件使用方式也参考官方文档,主要实现原理:

  • 插件预先集成了大量常见的图标集,如 Material Icons、Font Awesome 等。它支持从不同的图标库获取图标数据,并且允许开发者自定义图标集。开发者可以通过配置指定要使用的图标集;
  • 图标数据下载与缓存:如果使用的图标集在本地没有缓存,插件会从对应的图标库源下载图标数据,并将其缓存到本地,以便后续使用;
  • 动态组件创建:在构建过程中,插件会根据开发者在代码中使用的图标名称,动态生成对应的图标组件;
  • 代码转换:插件会对代码进行转换,将图标组件的引用替换为实际的 SVG 代码或经过优化处理后的图标渲染代码。对于不同的框架(如 Vue、React 等),会生成适配该框架的组件代码;
  • 构建集成:插件会在构建工具的特定钩子中执行上述操作。以 Vite 为例,它会在 transform 钩子中对代码进行处理,确保图标组件能正确地被转换和打包到最终的构建产物中。

优势:

  • 丰富的图标集支持,也支持自定义图标集;
  • 组件化使用,<i-mdi:home />
  • 按需加载,优化打包体积;
  • 多框架支持

缺点:

  • 配置复杂,图标集配置和自定义图标配置复杂;
  • 性能开销,虽然插件实现了动态生成图标组件,但运行时动态生成会带来性能问题;
  • 组件化使用虽然方便,但是对于图标切换能操作还是要引入多个组件,不太方便;
  • 自定义图标集不支持多层级目录,一次只能设置一个目录(不支持递归目录)
  • 不支持动态导入多级目录的自定义svg
  • 虽然插件解决了ID唯一性的问题,但是没有处理style标签里ID引用,导致有的svg渲染出错

我的痛点:

  • 自定义图标集只支持一个个文件夹单独配置,不能自动按文件夹分namespace;
  • 插件没有处理svg style样式中的id引用问题,对style标签里的id没有做唯一性处理。

unplugin-icons-1.png

请看大屏幕,其他图标是ok的,第三个图标因为svg中的style标签里的id引用没有被转换导致不能正常显示。

二、告别第三方束缚,自主打造svg-loader

经过第一阶段折磨后发现,并非所有的第三方插件都满足我们的需求,但是我们可以从别人的插件中汲取教训,把别人的优势加上我们发现的缺陷补齐不就能打造完美的方案了吗。

经过几个插件的对比发现unplugin-icons更接近我们的需求,所以就参考它的实现,站在巨人的肩膀上继续开发。

插件原理

  1. 拦截svg文件加载,支持虚拟路径
  2. 加载svg文件,取svg文件路径作为collection
  3. 处理svg id问题,给svg文件加一个ref,根据属性中的ID生成{id:随机hash}映射表,将id替换为映射表表示;根据style中的id同样生成映射表,也使用模板字符串占位符,将style标签替换为<component is="style" ${attrs}></component>防止样式丢失;将id属性替换为vue的动态属性绑定;
  4. 构建注入脚本:在mounted钩子中生成svg唯一ID,创建svg style标签,替换style中的模板字符串,改为map映射表中的值,删除旧style,插入新style到svg中;
  5. @vue/compiler-sfc的compileTemplate方法编译生成code;
  6. 在code后面拼接markRaw方法并在setup方法中执行注入脚本

难点:

  • 通过注入ref,最后获取svg dom,才能注入style;
  • style中的唯一id不能像svg属性那样使用vue的动态绑定,需要通过模板字符串替换的方式;
  • 需要给style中的所有class样式也加上svg的id来隔离样式;
  • 唯一ID需要在编译后再生成,否则同一个svg经过编译后ID都是一样的。

业务组件二次封装

单独import的方式可以使用,为了能方便业务使用,做了以下封装:

<script lang="ts" setup>
const props = defineProps({
  // 名称
  name: {
    type: String,
    default: '',
    required: true,
  },
  // 大小
  size: {
    type: Number,
    default: 24,
  },
  // 颜色
  color: {
    type: String,
    default: '',
  },
  // class
  className: {
    type: String,
    default: '',
  },
  // 无障碍朗读文本
  ariaTitle: {
    type: String,
    default: '',
  },
})

const svgStyle = computed(() => {
  return {
    width: props.size + 'px',
    height: props.size + 'px',
    color: props.color,
  }
})

const svgModules = import.meta.glob('@/assets/svgs/**/*.svg')
const svgIconMap: Record<string, () => Promise<any>> = {}

Object.keys(svgModules).forEach((path) => {
  const relativePath = path.replace('/src/assets/svgs/', '').replace('.svg', '')
  // 子文件夹里svg 命名空间
  const iconName = relativePath.replace(/\//g, '-')
  svgIconMap[iconName] = svgModules[path]
})

const iconComponent = computed(() => {
  const component = defineAsyncComponent(svgIconMap[props.name])
  return component
})
</script>

<template>
  <Suspense>
    <template #default>
      <component
        v-if="iconComponent"
        :is="iconComponent"
        class="svg-icon"
        :style="svgStyle"
        :class="className"
        :aria-hidden="ariaTitle ? 'false' : 'true'"
        role="img"
        :aria-label="ariaTitle"
      ></component>
    </template>
    <template #fallback>
      <div
        class="svg-icon-placeholder"
        :style="{ width: svgStyle.width, height: svgStyle.height }"
      ></div>
    </template>
  </Suspense>
</template>

<style scoped>
.svg-icon,
.svg-icon-placeholder {
  display: inline-block;
  vertical-align: middle;
}
.svg-icon-placeholder {
  background-color: #ccc;
}
</style>

使用方式:

<my-icon name="customer"></my-icon>
<my-icon name="dashboard"></my-icon>
<my-icon name="accept" aria-title="accept" :size="100"></my-icon>

实现特性:

  • svg文件转换为vue组件
  • 按需加载
  • svg文件style样式隔离
  • svg文件ID唯一化
  • 支持虚拟路径"~icons"
  • 支持多级目录svg
  • 无障碍属性处理
  • svg骨架屏预渲染

以下是自定义插件的实现效果,完美渲染svg:

image.png

image.png

三、完整vite-svg-loader重构流程图

svg-map.png

四、SVG组件化的破局收获

  1. vite插件中可使用vue编译器改变渲染结果;
  2. 编译器结合js注入配合能修改编译后的dom;
  3. 三方插件在享受方便的同时,也承担着风险,可能有bug、可能不满足需求、可能不易于扩展功能。在探索第三方库的过程中也会尝试理解作者意图,学习作者开发方式,对自研插件有很大帮助;
  4. 再难的问题总有解决之道,善用工具打开思路。