核心思路
- 从 figma 复制 svg 文件,在 /assets/svg下创建 xxx.svg 文件
- 安装 vite-plugin-svgr 插件,该插件可以自动将你import 的 svg 转成 React 组件
// vite.config.js
import svgr from "vite-plugin-svgr";
export default {
// ...
plugins: [svgr()],
};
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
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
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,再引用自定义插件
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'),
},
}
};
});
使用方式
<Icon name="user" className="size-4" />
20250610 更新
支持svg根据css变量自定义配色
首先保证 global.css 里定义了 css 变量,如下所示
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: PingFangSC-Regular;
src: url('/fonts/PingFangSC Regular.woff') format('woff');
font-display: swap;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: var(--background);
height: 100vh;
font-family:
PingFangSC-Regular,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
}
#root {
width: 100vw;
height: 100vh;
}
:root {
--radius: 8px;
/** 背景色 */
--background: #ffffff;
/** 前景色 主要文字 按钮边框 */
--foreground: #1d1f22;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #725dff;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
/** 次要背景色 大外框 标签 按钮 进度条 */
--muted: #f6f7fb;
/** 次要前景色 次要文字 次要图标 */
--muted-foreground: #8e9bae;
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: #ff446a;
--destructive-foreground: #fff;
/** 边框 分割线 */
--border: #e2e8f0;
/** placeholder */
--input: #e2e8f0;
--placeholder: #c1c7d0;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* 滚动条变量 */
--scrollbar-size: 6px;
--scrollbar-track: #f0f0f0;
--scrollbar-thumb: rgba(142, 155, 174, 0.7);
--scrollbar-thumb-hover: rgba(142, 155, 174, 1);
--scrollbar-border-radius: 4px;
}
我们主要关注:root 里面的--开头的东西,就是 css 变量。然后在粘贴的 svg 代码中,我们将 stroke 或者 fill 改成var(--variable-color),这个例子中我们定义的是 stroke="var(--color-background)"
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.27539 5.75C4.27539 5.75 5.02539 7.25 7.65039 7.25C10.2754 7.25 11.0254 5.75 11.0254 5.75" stroke="var(--color-background)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
之后使用方式就还是和之前一样,只不过你现在再也不用自己定义 svg 的颜色了,可以跟着全局主题色走