前端 SVG 图标管理最佳实践

532 阅读4分钟

概述

在前端开发中,我们经常会使用各种 icon 图标。传统做法包括下载 PNG/SVG 文件直接使用,或者从 Iconfont 复制 SVG 代码嵌入页面,但随着图标数量增加,项目代码往往会变得臃肿,维护成本高。

相比之下,SVG 的优势在于:

  • 支持直接通过 fill 或 stroke 修改颜色
  • 尺寸灵活、矢量不失真
  • 可以轻松实现动态加载和类型安全

本文将分享一个 自动化、类型安全、可维护 的 SVG 管理方案,涵盖:

  1. 项目准备
  2. 自动生成 SVG 类型定义
  3. 封装高复用 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 组件的核心目标是 动态、安全、可复用。主要特性:

  1. 动态加载:支持本地图标和远程 SVG
  2. 安全处理:自动清理 <script>、<foreignObject>、事件等危险内容
  3. 尺寸自适应:支持 TailwindCSS 尺寸类或内联 style
  4. 颜色灵活:支持 currentColor、单独颜色设置、以及高级 colors[] 顺序覆盖
  5. 性能优化:内存缓存,避免重复请求
  6. 类型安全:TS 类型提示完整
  7. 失败处理:加载失败自动渲染 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>
  );
}

生成结果:

svg_examples.jpg

扩展

如果想在本地预览所有图标的完整效果,可以安装 **VS Code 插件 SVG Gallery ↪ **

安装后,按照插件指引操作,就能方便地浏览、搜索、预览项目中的所有 SVG 图标,非常适合调试和管理图标库。

总结

通过这套方案,你可以实现:

  • 自动化类型生成:避免手动维护和错误
  • 动态、安全、灵活的 SVG 渲染:支持本地/远程图标、颜色自定义
  • 高复用组件:统一尺寸、事件、fallback,降低维护成本

如果觉得这套方法对你有帮助,欢迎点赞和收藏~