概述
在前端开发中,我们经常会使用各种 icon 图标。传统做法包括下载 PNG/SVG 文件直接使用,或者从 Iconfont 复制 SVG 代码嵌入页面,但随着图标数量增加,项目代码往往会变得臃肿,维护成本高。
相比之下,SVG 的优势在于:
- 支持直接通过 fill 或 stroke 修改颜色
- 尺寸灵活、矢量不失真
- 可以轻松实现动态加载和类型安全
本文将分享一个 自动化、类型安全、可维护 的 SVG 管理方案,涵盖:
- 项目准备
- 自动生成 SVG 类型定义
- 封装高复用 SVG 加载组件
项目准备
技术栈:React + Vite + TypeScript + TailwindCSS
1、创建项目
$ pnpm create vite svg-examples --template react-ts
$ cd svg-examples && code .
2、配置 tailwindcss
安装依赖
$ pnpm add tailwindcss @tailwindcss/vite
配置 Vite 插件:在 vite.config.ts 配置文件中添加 @tailwindcss/vite 插件
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});
导入 Tailwind CSS
index.css
@import "tailwindcss";
:root {
--text-primary-color: orange;
}
提示:CSS 变量可用于 SVG 颜色继承,例如 currentColor。
自动生成 SVG 类型定义
随着图标数量增多,手动维护类型定义非常麻烦。我们可以通过脚本 自动扫描项目 SVG 文件,生成 TypeScript 类型,从而在组件中获得智能提示和类型检查。
1、图标统一放在 public/icons 目录下,建议按模块分类,例如:
icons/
├─ profile/orders.svg
├─ tiktok.svg
└─ wx.svg
提示:在开始前,你可以先从 iconfont ↪ 下载一些 SVG 图标放到该目录中。
2、脚本功能:
- 扫描 public/icons 下所有 .svg 文件
- 自动生成 src/components/Icon/svgPath_all.ts 类型定义
- 支持监听模式,新增或删除图标自动更新
3、脚本实现:
安装依赖:
$ pnpm add chokidar chalk prettier --save-dev
chokidar:监听文件变化chalk:命令行输出美化prettier:格式化生成的代码
在 package.json 中添加脚本命令:
{
"gen-svg": "npx tsx scripts/gen-svg-list.ts",
"gen-svg-watch": "npx tsx scripts/gen-svg-list.ts --watch"
}
创建文件 scripts/gen-svg-list.ts,粘贴以下代码 👇
/**
* 自动扫描 public/icons 下的所有 .svg 文件
* 并生成 src/components/ui/Icon/svgPath_all.ts
* 支持 --watch 模式实时监听变动
*
* 用法:
* npx tsx scripts/gen-svg-list.ts # 一次性生成
* npx tsx scripts/gen-svg-list.ts --watch # 实时监听模式
*
* "gen-svg-paths": "tsx scripts/gen-svg-paths/index.ts",
* "gen-svg-paths-watch": "tsx scripts/gen-svg-paths/index.ts --watch"
*
* 依赖:
* pnpm add chokidar chalk prettier --save-dev
*/
import fssync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import chokidar, { type FSWatcher } from "chokidar";
import prettier from "prettier";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ==================== 路径配置 ====================
/** 项目根目录 */
const ROOT = path.resolve(__dirname, "../../");
/** SVG 图标目录 */
const ICONS_DIR = path.join(ROOT, "/public/icons");
/** 输出文件路径 */
const outputFile = path.join(ROOT, "/src/components/ui/Icon/svgPath_all.ts");
// ==================== 工具函数 ====================
/**
* 递归扫描指定目录下的所有 SVG 文件
* @param dir 要扫描的目录路径
* @returns 返回包含所有 SVG 文件完整路径的数组
*/
async function walkDir(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await walkDir(fullPath)));
} else if (entry.isFile() && fullPath.endsWith(".svg")) {
results.push(fullPath);
}
}
return results;
}
/**
* 生成 svgPath_all.ts 文件
* - 扫描 ICONS_DIR 下所有 SVG 文件
* - 输出为 TypeScript const 数组及类型
* - 使用 prettier 格式化
* @param showLog 是否打印生成日志,默认为 true
*/
async function generate(showLog = true): Promise<void> {
const svgFiles = (await walkDir(ICONS_DIR)).sort();
const svgNames = svgFiles.map((fullPath) => {
const relative = path.relative(ICONS_DIR, fullPath);
const noExt = relative.replace(/\.svg$/i, "");
return noExt.split(path.sep).join(path.posix.sep);
});
const timestamp = new Date().toISOString();
const output = `
// ⚠️ 此文件由脚本自动生成,请勿手动修改
// 生成时间: ${timestamp}
export const SVG_PATH_NAMES = [
${svgNames.map((n) => `"${n}"`).join(",\n ")}
] as const;
export type SvgPathName = typeof SVG_PATH_NAMES[number];
`;
const prettierConfig = (await prettier.resolveConfig(ROOT)) ?? {};
const formatted = await prettier.format(output, {
...prettierConfig,
parser: "typescript",
});
await fs.mkdir(path.dirname(outputFile), { recursive: true });
await fs.writeFile(outputFile, formatted, "utf8");
if (showLog) {
console.log(chalk.green(`✔️ 已生成 ${chalk.yellow(outputFile)},共 ${svgNames.length} 个图标`));
}
}
// ==================== 防抖函数 ====================
/**
* 防抖函数
* @template F 原始函数类型
* @param fn 要防抖的函数
* @param delay 防抖延迟(毫秒)
* @returns 返回防抖后的函数
*/
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F {
let timer: NodeJS.Timeout | null = null;
return ((...args: Parameters<F>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as F;
}
// ==================== 监听逻辑 ====================
/**
* 主函数
* - 首次生成 svgPath_all.ts
* - 可选择开启监听模式 (--watch) 实时生成
*/
async function main(): Promise<void> {
await generate(true);
if (process.argv.includes("--watch")) {
console.log(chalk.cyan("👀 正在监听 SVG 目录变动..."));
if (!fssync.existsSync(ICONS_DIR)) {
console.log(chalk.red(`❌ 图标目录不存在: ${ICONS_DIR}`));
process.exit(1);
}
const watcher: FSWatcher = chokidar.watch(ICONS_DIR, {
ignoreInitial: true,
depth: 10,
});
// 防抖生成函数,延迟 300ms
const debouncedGenerate = debounce(() => generate(false), 300);
/**
* 文件变动事件处理
* @param event 事件类型,例如 'add', 'unlink', 'change'
* @param file 触发事件的文件完整路径
*/
const onChange = (event: string, file: string) => {
const fileName = path.relative(ICONS_DIR, file);
console.log(chalk.gray(`[${event}]`), chalk.yellow(fileName));
debouncedGenerate();
};
watcher
.on("add", (file) => onChange("➕ 新增", file))
.on("unlink", (file) => onChange("➖ 删除", file))
.on("change", (file) => onChange("✏️ 修改", file))
.on("error", (err) => console.error(chalk.red("监听错误:"), err));
}
}
// ==================== 执行入口 ====================
main().catch((err) => {
console.error(chalk.red("❌ 生成 svgPath_all.ts 失败:"), err);
process.exit(1);
});
执行命令:
$ pnpm gen-svg
控制台输出如下:
✔️ 已生成 /your path/svg-examples/src/components/IconSvg/svgPath_all.ts,共 3 个图标
最后,我们看看生成的内容
/src/components/IconSvg/svgPath_all.ts
// ⚠️ 此文件由脚本自动生成,请勿手动修改
// 生成时间: 2025-10-13T19:38:41.919Z
export const SVG_PATH_NAMES = ["profile/orders", "tiktok", "wx"] as const;
export type SvgPathName = (typeof SVG_PATH_NAMES)[number];
通过这种方式,每次新增图标时无需手动维护类型,调用组件时即可享受完整的类型提示。
提示:如果不想手动执行脚本,可以使用
pnpm gen-svg-watch开启监听模式,新增或删除图标后会自动更新类型定义。
封装可复用 SVG 组件
SVG 组件的核心目标是 动态、安全、可复用。主要特性:
- 动态加载:支持本地图标和远程 SVG
- 安全处理:自动清理 <script>、<foreignObject>、事件等危险内容
- 尺寸自适应:支持 TailwindCSS 尺寸类或内联 style
- 颜色灵活:支持 currentColor、单独颜色设置、以及高级 colors[] 顺序覆盖
- 性能优化:内存缓存,避免重复请求
- 类型安全:TS 类型提示完整
- 失败处理:加载失败自动渲染 fallback
使用该组件,你可以在项目中:
- 高效管理图标
- 灵活控制颜色和尺寸
- 自动处理加载异常
- 避免重复代码和硬编码 SVG
代码实现
话不多说,直接贴上代码:
// src/components/ui/Icon/index.tsx
"use client";
import { LRUCache } from "lru-cache";
import { type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { SVG_PATH_NAMES } from "./svgPath_all";
/* ==================== 缓存配置 ==================== */
// SVG 内容缓存,用于避免重复请求
const svgCache = new LRUCache<string, string>({ max: 100 });
// 加载中的 Promise 缓存,防止重复 fetch
const loadingCache = new LRUCache<string, Promise<string>>({
max: 100,
ttl: 60_000, // 1 分钟过期
});
/* ==================== 类型定义 ==================== */
export type SvgPathTypes = (typeof SVG_PATH_NAMES)[number];
export type IconProps = {
/** 本地图标名称(必须在 SVG_PATH_NAMES 中) */
name?: SvgPathTypes;
/** 远程 SVG 文件 URL,优先级高于 name */
src?: string;
/** 按 SVG 路径顺序覆盖颜色,忽略 currentColor */
colors?: string[];
/** 用于内部 <div> 的 className,可控制尺寸/颜色 */
className?: string;
/** 外层 wrapper <div> 的 className */
wrapperClass?: string;
/** 内联样式,会和 className 一起应用 */
style?: CSSProperties;
/** 普通颜色值,会转换为 currentColor,用于 UI icon */
color?: string;
/** 加载失败或无效图标时显示的 fallback 元素 */
fallback?: ReactNode;
/** 点击事件处理,支持鼠标和键盘回车/空格触发 */
onClick?: (e: MouseEvent | KeyboardEvent) => void;
};
/* ==================== SVG 安全清理 ==================== */
/**
* 对 SVG 内容做安全清理:
* - 移除 <script>、<foreignObject>、on* 事件等危险内容
* - 去掉无关属性 version/p-id
* - 清理空 <defs>
*/
function sanitizeSvg(svg: string): string {
if (!svg) return "";
return svg
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/<foreignObject[\s\S]*?>[\s\S]*?<\/foreignObject>/gi, "")
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "")
.replace(/javascript:[^"']*/gi, "")
.replace(/<!ENTITY[\s\S]*?>/gi, "")
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/\s+(version|p-id)\s*=\s*(["'][^"']*["']|\S+)/gi, "")
.replace(/<defs>\s*<\/defs>/gi, "")
.trim();
}
/* ==================== 颜色处理 ==================== */
const COLOR_ATTRS = ["fill", "stroke", "stop-color", "flood-color", "lighting-color", "color"];
const PRESERVE_COLORS = ["none", "transparent", "inherit", "currentcolor"];
/** 判断当前颜色是否应保留,不被 currentColor 覆盖 */
function shouldPreserveColor(color: string) {
const c = color.trim().toLowerCase();
return c === "" || PRESERVE_COLORS.includes(c) || c.startsWith("url(");
}
/** 替换属性颜色为 currentColor(保留特殊颜色) */
function replaceAttrColor(svg: string, attr: string) {
const reg = new RegExp(`${attr}=(["'])([^"']+)\\1`, "gi");
return svg.replace(reg, (m, q, val) => (shouldPreserveColor(val) ? m : `${attr}=${q}currentColor${q}`));
}
/** 替换 <style> 内的颜色为 currentColor */
function replaceCssColors(css: string) {
return css.replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)\s*:\s*([^;}\s]+)/gi, (m, _p, val) => (shouldPreserveColor(val) ? m : m.replace(val, "currentColor")));
}
/** 高级模式:按 colors[] 顺序覆盖 SVG 的 fill/stroke */
function applyColorsByList(svg: string, colors: string[]): string {
if (!svg || !colors.length) return svg;
let idx = 0;
return svg.replace(/\b(fill|stroke)\s*=\s*(['"])([^"']+)\2/gi, (match, attr, quote, val) => {
if (shouldPreserveColor(val) || idx >= colors.length) return match;
return `${attr}=${quote}${colors[idx++]}${quote}`;
});
}
/* ==================== 尺寸处理 ==================== */
/** 判断 className 中是否含有尺寸类 */
function hasSizeClass(cls?: string) {
return !!cls && /\b(?:w|h|size|min|max)-/.test(cls);
}
/** 判断 className 中是否含有颜色类 */
function hasColorClass(cls?: string) {
return !!cls && /\b(text|fill|stroke)-/.test(cls);
}
/** 对 SVG 添加 viewBox、处理宽高 */
function normalizeSvg(svg: string, hasExplicitSize: boolean) {
// 移除 width/height
svg = svg.replace(/(<svg[^>]*?)\s*(width|height)=["'][^"']*["']/gi, "$1");
// 补充 viewBox
if (!/viewBox=/i.test(svg)) {
svg = svg.replace("<svg", '<svg viewBox="0 0 16 16"');
}
// 如果外层显式设置尺寸,则 width/height 100%
if (hasExplicitSize) {
svg = svg.replace("<svg", '<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"');
}
return svg;
}
/* ==================== Icon 组件 ==================== */
export default function Icon({ name, src, className, wrapperClass, style, color, fallback, colors, onClick }: IconProps) {
const [svg, setSvg] = useState("");
const [error, setError] = useState(false);
// 决定最终使用的 SVG 路径,src 优先于 name
const iconPath = useMemo(() => {
if (src) return src;
if (name && SVG_PATH_NAMES.includes(name)) return `/icons/${name}.svg`;
return null;
}, [src, name]);
/** 核心处理 SVG 内容:清理、安全、颜色、尺寸 */
const processSvg = useCallback(
(raw: string) => {
let out = sanitizeSvg(raw);
// 优先 colors
if (colors?.length) {
out = applyColorsByList(out, colors);
} else {
// 普通 currentColor 模式
const shouldUseCurrentColor = color || hasColorClass(className);
if (shouldUseCurrentColor) {
COLOR_ATTRS.forEach((attr) => {
out = replaceAttrColor(out, attr);
});
out = out.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (m, css) => m.replace(css, replaceCssColors(css)));
}
}
out = normalizeSvg(out, hasSizeClass(className) || !!style?.width || !!style?.height);
return out;
},
[className, style, color, colors],
);
/* ==================== 载入 SVG ==================== */
useEffect(() => {
if (!iconPath) return;
let cancelled = false;
setError(false);
const load = async () => {
try {
const cached = svgCache.get(iconPath);
if (cached) {
if (!cancelled) setSvg(processSvg(cached));
return;
}
let p = loadingCache.get(iconPath);
if (!p) {
p = fetch(iconPath, {
mode: /^https?:\/\//i.test(iconPath) ? "cors" : "same-origin",
credentials: /^https?:\/\//i.test(iconPath) ? "omit" : "same-origin",
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
});
loadingCache.set(iconPath, p);
}
const text = await p;
svgCache.set(iconPath, text);
loadingCache.delete(iconPath);
if (!cancelled) setSvg(processSvg(text));
} catch (e) {
console.error("Icon load failed:", iconPath, e);
if (!cancelled) setError(true);
loadingCache.delete(iconPath);
}
};
load();
return () => {
cancelled = true;
};
}, [iconPath, processSvg]);
/* ==================== 样式和事件处理 ==================== */
const finalStyle = useMemo<CSSProperties>(
() => ({
display: "inline-block",
lineHeight: 0,
flexShrink: 0,
...(color ? { color } : null),
...style,
}),
[color, style],
);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (!onClick) return;
e.preventDefault();
e.stopPropagation();
onClick(e);
},
[onClick],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (onClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onClick(e);
}
},
[onClick],
);
/* ==================== 渲染 ==================== */
const isInvalid = !iconPath || error || !svg;
return (
<div className={`inline-flex items-center justify-center ${wrapperClass ?? ""}`} onClick={handleClick}>
{isInvalid ? (
(fallback ?? <span className="text-red-500">⚠</span>)
) : (
<div className={className} style={finalStyle} dangerouslySetInnerHTML={{ __html: svg }} role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} onKeyDown={handleKeyDown} />
)}
</div>
);
}
调用示例
"use client";
import Icon from "./components/ui/Icon";
export default function App() {
return (
<div className="p-20 flex flex-col justify-center items-center gap-8">
{/* 1. 尺寸示例 */}
<div className="flex items-center gap-4">
<Icon name="dollar" className="w-6 h-6" />
<Icon name="checkbox" className="w-6 h-6" />
<Icon name="wx" className="w-6 h-6" />
<Icon name="tiktok" style={{ width: 24, height: 24 }} />
<Icon name="profile/wx" style={{ width: 24, height: 24 }} />
</div>
{/* 2. 颜色示例 */}
<div className="flex items-center gap-4">
{/* 高级 colors 数组模式 */}
<Icon name="dollar" className="w-6 h-6" colors={["#0FD3FF", "#16AAFF", "#0FD3FF"]} />
<Icon name="checkbox" className="w-6 h-6" colors={["#16AAFF", "#0FD3FF", "#FFFFFF"]} />
{/* 普通 color / currentColor 模式 */}
<Icon name="wx" className="w-6 h-6" color="green" />
<Icon name="tiktok" style={{ width: 24, height: 24 }} color="red" />
<Icon name="profile/wx" className="w-6 h-6" color="blue" />
</div>
{/* 3. 远程 SVG */}
<div className="flex items-center gap-4">
<Icon src="https://test-dev-img.kapok.net/10401/69117aeec8934eb6bfb65e80246abfc2.svg" wrapperClass="size-10 bg-[blue] rounded-full" className="size-5 text-white" />
</div>
{/* 4. fallback / 异常示例 */}
<div className="flex items-center gap-4">
<Icon name="xxx" className="w-6 h-6" fallback={<span className="text-red-500">❌</span>} />
</div>
</div>
);
}
生成结果:
扩展
如果想在本地预览所有图标的完整效果,可以安装 **VS Code 插件 SVG Gallery ↪ **
安装后,按照插件指引操作,就能方便地浏览、搜索、预览项目中的所有 SVG 图标,非常适合调试和管理图标库。
总结
通过这套方案,你可以实现:
- 自动化类型生成:避免手动维护和错误
- 动态、安全、灵活的 SVG 渲染:支持本地/远程图标、颜色自定义
- 高复用组件:统一尺寸、事件、fallback,降低维护成本
如果觉得这套方法对你有帮助,欢迎点赞和收藏~