Vite 项目 Icon 解决方案

核心思路

  • 从 figma 复制 svg 文件,在 /assets/svg下创建 xxx.svg 文件
  • 安装 vite-plugin-svgr 插件,该插件可以自动将你import 的 svg 转成 React 组件
js 复制代码
// vite.config.js
import svgr from "vite-plugin-svgr";

export default {
  // ...
  plugins: [svgr()],
};
jsx 复制代码
import Logo from "./logo.svg?react";
  • 但这样还不够,要再规范一点,所以我们要再写一个自定义 vite 插件,定义 virtual:module,virtual module 会告诉 vite,这个 path 下的东西要去插件里查询。生成 Icon map,map 的 key 为每一个 svg 的 basename,value 为 vite-plugin-svgr 自动生成的 react 组件
  • 编写 Icon 组件在 @/components/ui 下,入参为 name,自动进 icon map 匹配对应的 react 组件。业务层使用方式:<Icon asset="user" />

概念

  • virtual module
  • 自定义插件

代码

generate-icon-map.ts

ts 复制代码
import type { Plugin } from 'vite';
import path from 'path';
import { glob } from 'glob';

/**
 * 将连字符命名转换为驼峰命名
 * @param filename - 原始文件名
 * @returns 转换后的驼峰命名
 */
const convertToCamelCase = (filename: string): string => {
  return filename.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
};

/**
 * 生成图标导入语句
 * @param iconFiles - 图标文件名数组
 * @returns 导入语句字符串
 */
const generateImportStatements = (iconFiles: string[]): string => {
  return iconFiles
    .map((file) => {
      const iconName = path.basename(file, '.svg');
      const camelCaseIconName = convertToCamelCase(iconName);
      return `import ${camelCaseIconName}Icon from '@/assets/icons/${file}';`;
    })
    .join('\n');
};

/**
 * 生成图标映射对象条目
 * @param iconFiles - 图标文件名数组
 * @returns 映射对象条目字符串
 */
const generateIconMapEntries = (iconFiles: string[]): string => {
  return iconFiles
    .map((file) => {
      const iconName = path.basename(file, '.svg');
      const camelCaseIconName = convertToCamelCase(iconName);
      return `  '${iconName}': ${camelCaseIconName}Icon`;
    })
    .join(',\n');
};

/**
 * 生成完整的虚拟模块内容
 * @param iconFiles - 图标文件名数组
 * @returns 虚拟模块代码字符串
 */
const generateVirtualModuleContent = (iconFiles: string[]): string => {
  const importStatements = generateImportStatements(iconFiles);
  const iconMapEntries = generateIconMapEntries(iconFiles);

  console.log('Generated import statements:', importStatements);
  console.log('Generated icon map entries:', iconMapEntries);

  return `
${importStatements}

const iconMap = {
${iconMapEntries}
};

export default iconMap;
`;
};

/**
 * Vite 插件:自动导入图标文件并生成类型安全的图标映射
 * @returns Vite 插件配置对象
 */
export const generateIconMap = (): Plugin => {
  const VIRTUAL_MODULE_ID = 'virtual:icons';
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;

  return {
    name: 'vite-plugin-generate-icon-map',
    
    resolveId(id: string) {
      if (id === VIRTUAL_MODULE_ID) {
        return RESOLVED_VIRTUAL_MODULE_ID;
      }
    },
    
    async load(id: string) {
      if (id !== RESOLVED_VIRTUAL_MODULE_ID) {
        return;
      }

      const iconsDirectory = path.resolve(__dirname, '../src/assets/icons');
      const iconFiles = await glob('*.svg', { cwd: iconsDirectory });

      return generateVirtualModuleContent(iconFiles);
    },
  };
};

Icon.tsx

tsx 复制代码
import type { FC } from 'react';
import { cn } from '@/utils/cn';
import iconMap from 'virtual:icons';

/**
 * 从图标映射中提取的图标名称类型
 */
type IconName = keyof typeof iconMap;

/**
 * 图标组件的属性接口
 */
interface IconProps {
  /** 图标名称,必须是已注册的图标之一 */
  name: IconName;
  /** 自定义 CSS 类名 */
  className?: string;
  /** 图标宽度,默认为 16 */
  width?: number | string;
  /** 图标高度,默认为 16 */
  height?: number | string;
  /** 图标的可访问性标签 */
  'aria-label'?: string;
}

/**
 * 渲染错误占位符图标
 * @param className - CSS 类名
 * @param iconName - 未找到的图标名称
 */
const renderErrorFallback = (className?: string, iconName?: IconName) => {
  console.error(`图标 "${iconName}" 不存在于 iconMap 中`);
  
  return (
    <span 
      className={cn(
        'flex h-4 w-4 items-center justify-center rounded-sm bg-red-500 text-xs font-bold text-white',
        className
      )}
      aria-label={`图标加载失败: ${iconName}`}
      role="img"
    >
      ?
    </span>
  );
};

/**
 * 通用图标组件
 * 
 * 从虚拟图标模块中动态加载 SVG 图标,提供类型安全的图标渲染。
 * 当图标不存在时会显示错误占位符。
 * 
 * @param props - 图标组件属性
 * @returns 渲染的图标组件或错误占位符
 */
export const Icon: FC<IconProps> = ({ 
  name, 
  className, 
  width = 16, 
  height = 16,
  'aria-label': ariaLabel,
}) => {
  const IconComponent = iconMap[name];

  // 早期返回:如果图标组件不存在,显示错误占位符
  if (!IconComponent) {
    return renderErrorFallback(className, name);
  }

  return (
    <IconComponent 
      className={className} 
      width={width} 
      height={height} 
      aria-hidden={!ariaLabel}
      aria-label={ariaLabel}
      role={ariaLabel ? 'img' : undefined}
    />
  );
};

vite.config.ts

记得要先引用 svgr,再引用自定义插件

ts 复制代码
import { cloudflare } from '@cloudflare/vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import reactSwc from '@vitejs/plugin-react-swc';
import path, { resolve } from 'path';
import type { Plugin } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import svgr from 'vite-plugin-svgr';
import { generateIconMap } from './vite-plugins/generate-icon-map';

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  return {
    build: {
      minify: false,
      cssMinify: false,
      sourcemap: true,
    },
    plugins: [
      reactSwc(),
      generateIconMap(),
      svgr({
        svgrOptions: { exportType: 'default', ref: true, svgo: false, titleProp: true },
        include: '**/*.svg',
      }),
      tailwindcss(),
    ] as unknown as Plugin[],
    environments: {},
    optimizeDeps: {},
    appType: 'spa',
    resolve: {
      alias: {
        '@': path.resolve(__dirname, './src'),
        '@utils': resolve('src/utils'),
        '@hooks': resolve('src/hooks'),
      },
    }
  };
});

使用方式

jsx 复制代码
<Icon name="user" className="size-4" />
相关推荐
仟濹3 小时前
【HTML】基础学习【数据分析全栈攻略:爬虫+处理+可视化+报告】
大数据·前端·爬虫·数据挖掘·数据分析·html
小小小小宇4 小时前
前端WebWorker笔记总结
前端
小小小小宇4 小时前
前端监控用户停留时长
前端
小小小小宇4 小时前
前端性能监控笔记
前端
烛阴5 小时前
Date-fns教程:现代JavaScript日期处理从入门到精通
前端·javascript
全栈小55 小时前
【前端】Vue3+elementui+ts,TypeScript Promise<string>转string错误解析,习惯性请出DeepSeek来解答
前端·elementui·typescript·vue3·同步异步
穗余5 小时前
NodeJS全栈开发面试题讲解——P6安全与鉴权
前端·sql·xss
小蜜蜂嗡嗡6 小时前
flutter项目迁移空安全
javascript·安全·flutter
穗余7 小时前
NodeJS全栈开发面试题讲解——P2Express / Nest 后端开发
前端·node.js
航Hang*7 小时前
WEBSTORM前端 —— 第3章:移动 Web —— 第4节:移动适配-VM
前端·笔记·edge·less·css3·html5·webstorm