vue3+vite更优雅的svg图标使用方案

733 阅读3分钟

一、前言

不说废话,直接上干货!

65eedc7895fbc2912a07a50baed9dcd2.jpg

二、方案1: 配置插件预处理

1、整体流程图解

image.png

2、核心模块拆解

// 插件主体结构
export default function svgBuilder(): Plugin {
  return {
    name: 'svg-builder',
    buildStart() { /* 生产构建 */ },
    configureServer() { /* 开发服务配置 */ },
    transformIndexHtml() { /* HTML注入 */ },
    handleHotUpdate() { /* 热更新处理 */ }
  }
}

3、关键代码实现解析

3.1 SVG 预处理引擎

// 核心转换逻辑
const processSvgContent = (content: string, filename: string, prefix: string) => {
  let width = 0;
  let height = 0;

  return content
    .replace(/(\r|\n)/g, '')
    .replace(/<svg([^>]*)>/g, (_, attrs) => {
      // 解析原始属性
      const attributeMap = new Map<string, string>();
      let viewBox = '';

      // 专业属性解析
      attrs.replace(/(\w+)=("([^"]*)"|'([^']*)')/g, (_: any, key: string, __: any, val1: string, val2: string) => {
        const value = val1 || val2;
        switch (key.toLowerCase()) {
          case 'width':
            width = parseFloat(value) || 0;
            break;
          case 'height':
            height = parseFloat(value) || 0;
            break;
          case 'viewbox':
            viewBox = value;
            break;
          default:
            attributeMap.set(key, value);
        }
        return '';
      });

      // 构建标准化属性
      const attrsArray = Array.from(attributeMap.entries())
        .filter(([key]) => !['xmlns', 'xmlns:xlink'].includes(key))
        .map(([key, val]) => `${key}="${val}"`);

      // 自动生成 viewBox
      if (!viewBox && width && height) {
        viewBox = `0 0 ${width} ${height}`;
      }
      if (viewBox) {
        attrsArray.push(`viewBox="${viewBox}"`);
      }

      return `<symbol id="${prefix}-${filename}" ${attrsArray.join(' ')}>`;
    })
    .replace(/<\/svg>/, '</symbol>')
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};

3.2 文件监听机制

// 开发环境配置
configureServer(server) {
  watcher = watch(iconPath, { 
    recursive: true,
    persistent: true 
  }, (_, filename) => {
    if (filename?.endsWith('.svg')) {
      // 防抖处理(500ms)
      clearTimeout(updateTimer);
      updateTimer = setTimeout(() => {
        generateSprite();
        server.ws.send({ type: 'full-reload' });
      }, 500);
    }
  });
}

3.3 HTML注入逻辑

transformIndexHtml() {
  return [
    {
      tag: 'svg',
      attrs: {
        xmlns: 'http://www.w3.org/2000/svg',
        'xmlns:xlink': 'http://www.w3.org/1999/xlink',
        style: 'display:none',
      },
      children: cachedSvgContent || '',
      injectTo: 'body-prepend',
    },
  ];
},

4、完整插件配置

4.1 插件代码

// svg-builder.ts
import { readFileSync, readdirSync, watch } from 'fs';
import { join, parse } from 'path';
import type { Plugin } from 'vite';
import { normalizePath } from 'vite';

interface SvgBuilderOptions {
  path?: string;
  prefix?: string;
}

const DEFAULT_OPTIONS: SvgBuilderOptions = {
  prefix: 'icon',
};
// 缓存SVG内容
let cachedSvgContent: string | null = null;

// 文件监听器
let watcher: ReturnType<typeof watch> | null = null;

let updateTimer: NodeJS.Timeout | null = null;

// 移除多余转义
const processSvgContent = (content: string, filename: string, prefix: string) => {
  let width = 0;
  let height = 0;

  return content
    .replace(/(\r|\n)/g, '')
    .replace(/<svg([^>]*)>/g, (_, attrs) => {
      // 解析原始属性
      const attributeMap = new Map<string, string>();
      let viewBox = '';

      // 专业属性解析
      attrs.replace(/(\w+)=("([^"]*)"|'([^']*)')/g, (_: any, key: string, __: any, val1: string, val2: string) => {
        const value = val1 || val2;
        switch (key.toLowerCase()) {
          case 'width':
            width = parseFloat(value) || 0;
            break;
          case 'height':
            height = parseFloat(value) || 0;
            break;
          case 'viewbox':
            viewBox = value;
            break;
          default:
            attributeMap.set(key, value);
        }
        return '';
      });

      // 构建标准化属性
      const attrsArray = Array.from(attributeMap.entries())
        .filter(([key]) => !['xmlns', 'xmlns:xlink'].includes(key))
        .map(([key, val]) => `${key}="${val}"`);

      // 自动生成 viewBox
      if (!viewBox && width && height) {
        viewBox = `0 0 ${width} ${height}`;
      }
      if (viewBox) {
        attrsArray.push(`viewBox="${viewBox}"`);
      }

      return `<symbol id="${prefix}-${filename}" ${attrsArray.join(' ')}>`;
    })
    .replace(/<\/svg>/, '</symbol>')
    // 填充色自动转换成 currentColor
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};

// 遍历文件
const findSvgFiles = (dir: string, prefix: string) => {
  const results: string[] = [];

  const traverse = (currentDir: string) => {
    const dirents = readdirSync(currentDir, { withFileTypes: true });

    for (const dirent of dirents) {
      const fullPath = join(currentDir, dirent.name);

      if (dirent.isDirectory()) {
        traverse(fullPath);
      } else if (dirent.name.endsWith('.svg')) {
        try {
          const content = readFileSync(fullPath, 'utf-8');
          const filename = parse(dirent.name).name;
          results.push(processSvgContent(content, filename, prefix));
        } catch (e) {
          console.warn(`[svg-builder] 文件处理失败: ${fullPath}`, e);
        }
      }
    }
  };

  traverse(dir);
  return results;
};

// 生成SVG符号内容
const generateSprite = (options: SvgBuilderOptions) => {
  try {
    const symbols = findSvgFiles(options.path!, options.prefix!);
    cachedSvgContent = symbols.join('');
  } catch (e) {
    console.error('[svg-builder] 雪碧图生成失败', e);
  }
};

export default function svgBuilder(userOptions: SvgBuilderOptions): Plugin {
  const options = { ...DEFAULT_OPTIONS, ...userOptions };

  if (!options.path) {
    throw new Error('[svg-builder] SVG 路径未配置');
  }

  return {
    name: 'svg-builder',
    buildStart() {
      // 生产构建时生成 SVG 内容
      generateSprite(options);
    },
    // 配置开发服务器
    configureServer(server) {
      // 初始化生成
      generateSprite(options);
      // 标准化路径
      const normalizedPath = normalizePath(options.path!);
      // 设置文件监听
      watcher = watch(
        normalizedPath,
        {
          recursive: true,
          persistent: true,
        },
        (_, filename) => {
          if (filename?.endsWith('.svg')) {
            // 防抖处理(500ms)
            updateTimer && clearTimeout(updateTimer);
            updateTimer = setTimeout(() => {
              // 重新生成SVG内容
              generateSprite(options);
              // 通知客户端热更新
              server.ws.send({
                type: 'custom',
                event: 'svg-update',
                data: cachedSvgContent,
              });

              console.log('[svg-builder] 文件有更新,reloading...');
              // 触发页面更新
              server.ws.send({ type: 'full-reload' });
            }, 500);
          }
        }
      );
    },
    // 关闭时清除监听
    closeBundle() {
      if (watcher) {
        watcher.close();
        console.log('[svg-builder] 文件监听已关闭');
        watcher = null;
      }
    },
    // 转换HTML逻辑
    transformIndexHtml() {
      return [
        {
          tag: 'svg',
          attrs: {
            xmlns: 'http://www.w3.org/2000/svg',
            'xmlns:xlink': 'http://www.w3.org/1999/xlink',
            style: 'display:none',
          },
          children: cachedSvgContent || '',
          injectTo: 'body-prepend',
        },
      ];
    },
  };
}

插件配置

// vite.config.ts
import svgBuilder from './plugins/svg-builder';
import Components from 'unplugin-vue-components/vite';

export default defineConfig(({ mode }) => {
  return {
    // ...
    plugins: [
      // 自动导入 (如果需要自动导入的话)
      Components({
        // ...
        dirs: ['src/components'],
      }),
      svgBuilder({
        path: './src/assets/icons',
        prefix: 'icon',
      })
    ]
  }
})

5、SvgIcon组件实现

// src/components/SvgIcon.vue
<template>
  <i class="svg-icon" :class="[`svg-icon-${props.name}`]" v-bind="$attrs">
    <svg aria-hidden="true">
      <use :xlink:href="`#icon-${props.name}`" />
    </svg>
  </i>
</template>

<script lang="ts" setup>
const props = defineProps<{
  name: string;
}>();
</script>

<style lang="scss" scoped>
.svg-icon {
  display: inline-flex;
  width: 1em;
  height: 1em;
  line-height: 1;
  vertical-align: middle;
  color: inherit;
  transition: color 0.2s;

  :deep(svg) {
    width: 100%;
    height: 100%;
    fill: currentColor;
    stroke: currentColor;
    pointer-events: none;
    display: block;
  }
}
</style>

6、使用示例

6.1 使用

// 颜色和字体大小都可从父级集成,也可直接使用class或style定义,当成文字用就完事儿了。
<SvgIcon name="edit" style="font-size: 16px;color: red;" />

6.2 效果

image.png

7、展示所有图标(最好是在开发环境下)

7.1 文件代码

// src/views/icons/index.vue
<template>
  <div class="all-icons">
    <div class="font-[18px] mb-8">目前系统中的所有图标(点击复制):</div>
    <div class="icons-wrapper">
      <div v-for="icon in icons" :key="icon" @click="onCopy(icon)">
        <SvgIcon :name="icon" />
        <label>{{ icon }}</label>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { copyContent } from '@/utils';

const modules: any = import.meta.glob('@/assets/icons/**/*.svg', {
  eager: true,
});
const svgs = Object.keys(modules).reduce<Record<string, string>>((map, key) => {
  map[key.split('/').pop()!.split('.svg').shift()!] = modules[key].default;
  return map;
}, {});
const icons = markRaw(
  Object.keys(svgs)
    .map(key => key.split('/').pop()!.split('.').shift()!)
    .sort()
);

const onCopy = (icon: string) => {
  copyContent(`<SvgIcon :name="${icon}" />`);
  ElMessage.success(`复制成功:${icon}`);
};
</script>

<style lang="scss" scoped>
.all-icons {
  padding: 24px;

  .icons-wrapper {
    display: flex;
    flex-wrap: wrap;
    user-select: none;

    & > div {
      display: flex;
      flex-direction: column;
      font-size: 16px;
      padding: 15px 0;
      align-items: center;
      min-width: 100px;

      cursor: pointer;

      label {
        margin-top: 5px;
      }

      .svg-icon {
        font-size: 30px;
        color: var(--color-primary);
      }
    }
  }
}
</style>

7.2展示效果

image.png

三、方案2: 按需加载处理

使用时进行按需加载处理,无需插件处理

<template>
  <i class="svg-icon" :class="[`svg-icon-${props.name}`]" v-bind="$attrs" v-html="svgStr"> </i>
</template>

<script lang="ts" setup>
const props = defineProps<{
  name: string;
}>();

const svgStr = ref<string>();

const svgModules = import.meta.glob<string>('../../assets/icons/*.svg', {
  eager: true,
  query: '?raw',
  import: 'default',
});

// svg内容处理
const processSvgContent = (content: string) => {
  return content
    .replace(/(\r|\n)/g, '')
    .replace(/(fill|stroke)=["']#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})["']/gi, '$1="currentColor"');
};
const getPath = () => {
  const targetPath = `../../assets/icons/${props.name}.svg`;
  svgStr.value = processSvgContent(svgModules[targetPath]);
};

getPath();
</script>

<style lang="scss" scoped>
.svg-icon {
  display: inline-flex;
  width: 1em;
  height: 1em;
  line-height: 1;
  vertical-align: middle;
  color: inherit;
  transition: color 0.2s;

  :deep(svg) {
    width: 100%;
    height: 100%;
    fill: currentColor;
    stroke: currentColor;
    pointer-events: none;
    display: block;
  }
}
</style>

四、对比

  • 1、第一种方案在编译时进行全量编译,会在html中插入svg标签和一大堆内容(实际不影响使用);
  • 2、第二种方案在运行时进行编译,但是会使用v-html插入,潜在XSS风险(一般也不会有这个问题,因为图标是UI给的);
  • 3、经1200个图标全量渲染测试,二者的性能差异基本不大,可自由选择方案。

五、用法

二者用法完全一致,可放心替换。