核心思路
- 从 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" />