一、前言
不说废话,直接上干货!
二、方案1: 配置插件预处理
1、整体流程图解
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 效果
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展示效果
三、方案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个图标全量渲染测试,二者的性能差异基本不大,可自由选择方案。
五、用法
二者用法完全一致,可放心替换。