第十章. 业务基石:构建统一图标系统
第十章. 业务基石:构建统一图标系统
Prorise第十章. 业务基础组件:统一图标系统与加载占位
在前两章中,我们奠定了 src/ui 原子层的基础。现在,我们将开始构建更高一层的 src/components 业务基础层。
本章的核心任务是设计并实现一个高性能、可扩展的 统一图标系统。我们将深入分析主流图标方案的技术选型,并最终采用 SVGR(处理本地/私有 SVG 资产) + @iconify/react(处理海量通用图标集) 的双轨制技术方案。最终,我们会将这两种方案封装在一个优雅的、唯一的 <Icon /> 组件中,为所有开发者提供简洁一致的使用体验。
10.1. 图标系统技术选型
在编写任何代码之前,我们必须先进行技术选型。图标系统是前端工程化中最容易出现混乱的领域之一。一个草率的选型,会导致项目后期性能瓶颈、维护困难和视觉不统一。
10.1.1. (分析) 图标方案对比
让我们客观对比三种主流的图标技术方案,分析它们在 2025 年企业级项目中的优劣。
核心原理:
将矢量图标图形打包成一个字体文件(如 .woff2)。浏览器像加载自定义字体一样加载它,然后通过 CSS 伪元素(::before)和特定的类名(如 <i class="fa fa-home">),在页面上“写”出对应的“图标字符”。
评估:
- [优点] 样式控制简单: 作为“字体”,它可以直接使用 CSS 的
color和font-size属性来控制颜色和大小。 - [缺点] 性能瓶颈 (致命): 无法被 Tree-shaking。即使页面只使用了 1 个图标,也必须下载包含成百上千个图标的、体积庞大的整个字体文件(通常 > 100KB)。
- [缺点] 渲染问题: 受浏览器字体抗锯齿效果的影响,图标有时会显得模糊。如果字体文件加载失败或延迟,用户将看到一个难看的占位方框(“FOIT” - Flash of Invisible Text)。
- [缺点] 功能受限: 无法实现多色图标,因为一个“字符”只能有一个颜色。
- [缺点] 可访问性 (a11y) 差: 屏幕阅读器可能会尝试朗读伪元素中的私有字符编码,造成困惑。
结论: 已过时。该方案的性能缺陷和功能局限性使其无法满足现代 Web 应用的需求。
核心原理:
将每一个 SVG 图标都视为一个独立的 React 组件。组件在渲染时直接输出 <svg>...</svg> 标签和路径数据到 DOM 中。
评估:
- [优点] 性能极佳 (Tree-shaking): 完美支持 Tree-shaking。构建工具(如 Vite/Rollup)只会将开发者
import的图标打包到最终产物中,实现了最优的按需加载。 - [优点] 样式灵活: 作为原生 SVG,可以通过
className接受所有 Tailwind 工具类。fill和stroke可被设置为currentColor以继承父元素的文本颜色,并完美支持多色图标。 - [优点] 可靠可控: 图标代码是项目源代码的一部分,不依赖外部网络,开发者对组件有 100% 的控制权。
- [优点] 可访问性友好: 可以轻松地为
<svg>标签添加title、role="img"等 ARIA 属性。 - [缺点] 维护负担 (SVGR): 如果采用
SVGR方案,团队需要自行收集、管理和转换所有.svg文件。如果项目需要上千个通用图标,这将是一项巨大的维护负担。 - [缺点] 生态局限 (lucide-react): 如果采用
lucide-react这样的预封装库,你只能使用它提供的图标集。
结论: 现代项目基石。尤其适合承载 核心的、私有的、品牌相关的(如 Logo)图标资产。
核心原理:
提供一个统一的 <Icon /> 组件,通过一个字符串 ID(如 icon="lucide:home"),按需从云端 API 获取 海量图标集中的任意一个图标的 SVG 数据,并在客户端渲染和 缓存。
评估:
- [优点] 图标生态极度丰富: 可访问超过 200,000 个来自上百个图标集(Ant Design, Lucide, MDI…)的图标,选择几乎是无限的。
- [优点] 加载性能优异: 初始包体积极小(
@iconify/react本身很小)。只有当某个图标 首次 需要渲染时,才会发起一次极小的网络请求(约 1KB)获取其数据。 - [优点] 智能缓存: 图标数据获取后,会立即被存入
localStorage(或sessionStorage)。该图标在项目中的 所有 后续使用都将是 瞬时 的,不再有任何网络请求。 - [优点] API 统一: 无论图标来自哪个图标集,API 调用方式完全一致。
- [缺点] 首次加载依赖网络: 首次渲染图标时必须联网。不适用于纯内网或有严格离线要求的应用。
- [缺点] 不适合私有图标: 其公共 API 不适用于承载公司内部的、具有品牌知识产权的私有图标。
结论: 最佳生态方案。是解决海量“通用图标”需求的最佳选择。
10.1.2. (决策) 确定双轨制技术方案
分析对比完毕,结论显而易见:没有任何一种方案是能够通吃所有场景的“银弹”。
- 如果我们 只选择
SVGR(方案二),虽然能完美地处理私有品牌图标(如 Logo),但我们将不得不手动收集、管理和转换成百上千个通用图标(如设置、用户、箭头等),这将是一项巨大的、毫无创造性的维护负担。 - 反之,如果我们 只选择
@iconify/react(方案三),虽然能轻松访问海量的通用图标,但我们将失去对核心品牌图标(如Logo)的 100% 控制权,并为这些最关键的视觉资产引入了不必要的网络依赖。
一个专业的、成熟的企业级项目,追求的是“取长补短”,而非“一刀切”。
因此,Prorise-Admin 的图标系统将采纳一种 双轨并行的技术方案,以求在品牌控制力、开发效率和应用性能之间,达到最佳平衡。
轨道一:
SVGR(本地 SVG 组件化)- 职责: 专门负责处理那些对
Prorise-Admin具有 独一无二身份标识 的、私有的、需要被严格版本控制 的图标。 - 范围:
Prorise-Admin的 Logo 及品牌相关图标(统一存放在src/components/icons/logos/目录下)。
- 职责: 专门负责处理那些对
轨道二:
@iconify/react(按需服务)- 职责: 作为我们的“公共图标资源库”,满足日常开发中 95% 以上的通用图标需求。
- 范围: 所有常见的界面图标(如用户、设置、邮件、搜索等)。我们将主要选用
lucide图标集以保持视觉风格一致。
技术方案已确定。现在,我们将开始动手,构建这两条“轨道”,并最终将它们封装在一个统一的 <Icon /> 组件中。
10.2. 本地 SVG 组件化:SVGR CLI 配置
在 10.1 节中,我们确定 SVGR (方案二) 是处理 Prorise-Admin 私有品牌资产(如 Logo)的最佳选择。 大多项目是 手动 维护 Logo.tsx 文件的,这意味着每次 Logo 迭代,开发者都需要手动复制粘贴 SVG 代码、转换 kebab-case 属性为 camelCase、并移除不必要的元数据。
这是一个维护性瓶颈。
作为前沿项目框架,我们将引入 SVGR 自动化工作流。我们的目标是建立一个“生产线”,使其能够:
- 读取
src/assets/icons/目录下的所有原始.svg文件。 - 自动将它们转换为高性能、标准化的 React 组件。
- 将转换后的
.tsx组件输出到src/components/icons/目录中。
10.2.1. (编码) 安装 @svgr/cli 依赖
SVGR 提供了多种集成方式,我们选择 cli 版本,因为它最容易集成到 package.json 脚本中。
1 | pnpm add -D @svgr/cli |
10.2.2. (编码) 创建 .svgrrc.js 配置文件
直接运行 svgr 命令是不够的,我们需要一个配置文件来精确控制它的行为,确保输出的 React 组件 100% 符合我们项目的规范,并 规避一个常见的工程陷阱。
工程陷阱:@svgr/cli 默认依赖 @svgr/plugin-prettier,而后者可能依赖 Prettier v2。在现代 Node.js 环境(如 Node.js 20+)下,这可能导致 ESM/CJS 模块冲突。我们必须在配置文件中 显式禁用 它,改用项目根目录的 Prettier v3。
在项目根目录创建 .svgrrc.js 文件:
文件路径: ./.svgrrc.js
1 | export default { |
配置深度解析:
prettier: false:(关键) 禁用svgr内置的 Prettier 插件,后续我们将通过package.json脚本链式调用项目自己的prettier命令。typescript: true:确保svgr生成.tsx文件。icon: true:这是svgr的一个便捷选项,它会自动设置width="1em" height="1em",使 SVG 图标可以像字体一样,通过font-size(或h-6 w-6等 Tailwind 类) 来控制大小。ref: true:为生成的组件包裹React.forwardRef,使其可以接收ref。replaceAttrValues:(关键) 自动将 SVG 中硬编码的黑色(#000,black)替换为currentColor。这使得我们的图标可以通过 Tailwind 的text-primary,text-red-500等类来控制颜色。svgProps:为所有图标统一添加role="img"和aria-hidden="true",提供了良好的可访问性 (a11y) 基础。
10.2.3. (操作) 创建源目录和目标目录
我们的工作流需要一个“输入”目录和一个“输出”目录。
- 输入 (Source):存放我们原始的
.svg文件。 - 输出 (Destination):存放
svgr自动生成的.tsx组件。
在终端中创建这两个目录:
1 | # 1. 创建原始 SVG 资产目录 |
架构职责:src/assets/icons 目录应被 Git 追踪,它是我们的"源代码"。src/components/icons 目录中自动生成的组件文件(*.tsx 和 index.ts)应被添加到 .gitignore,但手动管理的子目录(如 logos/)和配置文件(如 Icon.tsx、register-icons.ts)应被 Git 追踪。
10.2.4. (编码) 添加 package.json 自动化脚本
我们的目标是定义一个脚本,它能:
- 调用
svgrCLI,读取src/assets/icons目录。 - 使用
.svgrrc.js配置,将.svg转换为.tsx组件并输出到src/components/icons。 - (关键) 在
svgr完成后,立即调用项目 自己的 Prettier 来格式化输出的src/components/icons目录,以规避 Prettier v2 的兼容性陷阱。
打开 package.json 文件,在 scripts 块中添加新行:
文件路径: package.json
1 | { |
脚本深度解析 (build:icons):
svgr ./src/assets/icons --out-dir ./src/components/icons --config-file ./.svgrrc.js./src/assets/icons:指定输入目录。--out-dir ./src/components/icons:指定输出目录。--config-file ./.svgrrc.js:(可选但推荐) 显式指定我们的配置文件。
工作流闭环:SVGR 自动化工作流(轨道一)现已 配置完毕。今后,当设计团队提供一个新的品牌 .svg 图标(例如 hero-icon.svg)时,我们的开发流程是:
- 将
hero-icon.svg放入src/assets/icons/目录。 - 运行
pnpm build:icons。 src/components/icons/HeroIcon.tsx文件被自动生成- 将生成的组件移动到合适的子目录(如品牌图标放入
logos/),并在register-icons.ts(任务 10.4) 中注册即可使用。
现在我们手动创建一个品牌 Logo 组件。在 src/components/icons/logos/ 目录下创建 PLogo.tsx:
文件路径: src/components/icons/logos/PLogo.tsx
1 | import type { SVGProps } from "react"; |
10.3. 远程图标集成:@iconify/react
现在我们开始构建“轨道二”:集成 @iconify/react,以满足项目中 95% 的海量通用图标需求。
10.3.1. (编码) 安装 @iconify/react 依赖
这是 Iconify 方案的核心依赖,它提供了 <Icon /> React 组件。
1 | pnpm add @iconify/react |
10.3.2. (分析) @iconify/react 按需加载与缓存机制
@iconify/react 不是一个图标库,它是一个 图标加载器。理解它的工作原理至关重要:
首次渲染 (e.g.,
<Icon icon="lucide:home" />):- 组件挂载。
- 它检查浏览器的
localStorage(或sessionStorage) 中是否存在键为iconify-lucide-home的数据。 - 缓存未命中:
localStorage中没有数据。 - 组件向
Iconify的公共 CDN (api.iconify.design) 发起一次 异步网络请求,请求lucide图标集中的home图标数据。 - 这个请求的响应体 不是图片,而是该图标的 纯 SVG JSON 数据(通常 < 1KB)。
- 组件拿到 JSON 数据,将其渲染为
<svg>...</svg>标签。 - (关键) 组件将这份 JSON 数据 存入
localStorage。
二次渲染 (e.g., 刷新页面,或在另一页面再次使用
<Icon icon="lucide:home" />):
- 组件挂载。
* 检查localStorage。
* 缓存命中 (Cache Hit):localStorage中 存在iconify-lucide-home的数据。
* 组件 立即 从localStorage中读取 JSON 数据并同步渲染为<svg>...</svg>。
* 全程无任何网络请求。
架构结论:
- 极小的初始包体:
@iconify/react本身非常小。 - 按需加载:应用永远不会下载未使用的图标。
- 智能缓存:应用在整个生命周期中,对同一个图标 最多只会请求一次网络。
- 性能优异:除首次加载外,后续渲染均为 瞬时。
10.4. 统一图标组件封装
在 10.2 和 10.3 节中,SVGR 工作流(轨道一)和 @iconify/react(轨道二)都已准备就绪。
10.4.1. 痛点:混乱的 API
此时,项目面临一个明显的 API 设计问题。如果一个开发者需要 Logo 图标,他需要:import PLogo from '@/components/icons/PLogo';
如果他需要一个设置图标,他又需要:import { Icon as IconifyIcon } from '@iconify/react';
这是一种混乱且难以维护的体验。开发者必须时刻记住哪个图标是本地的,哪个是远程的,并使用两种完全不同的导入和调用方式。
10.4.2. 解决方案:设计统一的 <Icon /> API
解决方案 是创建 一个统一的 <Icon /> 组件(src/components/icon/Icon.tsx),作为全项目唯一的图标入口,将底层的实现差异完全封装起来。
为了让这个 <Icon /> 组件足够“智能”,必须设计一个清晰的 API 约定:
- 组件的 prop 统一定为
icon。 - 通过
iconprop 值的 前缀 来区分图标来源:- Iconify (远程):
icon="lucide:home"(包含set:前缀) - SVGR (本地):
icon="local:ic-logo-badge"(使用local:前缀)
- Iconify (远程):
10.4.3. 痛点:Icon 组件如何找到本地图标?
Iconify 图标(如 lucide:home)由 @iconify/react 库自动处理。但 Icon 组件如何知道 local:ic-logo-badge 这个字符串对应的是 PLogo.tsx 组件呢?
解决方案 是创建一个“本地图标注册表”。
增强实践:为了优化性能,不应该在 Icon 组件中 import 所有 本地图标,这会破坏代码分割。取而代之,将使用 React.lazy 进行动态导入。
10.4.4. (编码) 创建本地图标注册表
创建 src/components/icons/register-icons.ts 文件。
文件路径: src/components/icons/register-icons.ts
1 | import { lazy, type ComponentType, type SVGProps } from 'react'; |
这个注册表现在是高性能且类型安全的。
10.4.5. (编码) 封装统一的 Icon.tsx
现在,万事俱备,开始编写 Icon.tsx 组件。
文件路径: src/components/icons/Icon.tsx
1. 导入与 Props 定义:
1 | import type { IconProps as IconifyProps } from "@iconify/react"; |
思考:Props 接口的设计是 API 封装的第一步。通过 Omit 和重定义,我们“劫持”了 icon prop,赋予了它更强大的能力。
2. 组件实现 (调度逻辑):
1 | import { |
思考:Icon.tsx 的核心是一个 调度器 (Dispatcher)。它通过简单的字符串检查(startsWith 和 includes)将请求分发到 SVGR 轨道或 Iconify 轨道,同时通过 Suspense 优雅地处理了本地图标的异步加载。
10.4 节已完成。我们的 <Icon /> 组件现在 API 统一、性能卓越,并且 完全准备好 被 Logo.tsx 所消费。
10.5. 品牌标识组件:Logo.tsx
10.5.1. 需求分析
项目需要一个全站通用的 Logo 组件。此组件的 当前职责 是:
- 展示
Prorise-Admin专属的品牌标识。 - 其大小和样式必须是可控的。
- (关键) 它 不 负责路由。它是一个纯粹的展示组件。路由功能将在第十一章(路由)和第十二章(布局)中,由 父组件(如
Layout)来提供。
10.5.2. 解决方案
创建一个 src/components/brand/Logo.tsx 组件,它 只 消费 10.4 节构建的统一 <Icon /> 系统,并透传样式。
10.5.3. (编码) 实现 Logo.tsx
1. 创建文件与导入依赖:
创建 src/components/brand/Logo.tsx。
1 | import { cn } from "@/utils/cn"; |
2. 定义 Props 与实现组件:
1 | import { cn } from "@/utils/cn"; |
思考:Logo.tsx 现在是 职责单一 的。它只关心"显示 Logo"。未来当它被放入 Layout 的 Header 中时,Layout 可以用 Link 或 NavLink 组件 包裹 这个 Logo 组件,从而实现"关注点分离"。
任务 10.5 已完成。
10.6. 组件驱动开发 (CDD):Storybook 验证
10.6.1. 痛点与需求
我们在本章构建了两个核心的业务组件:Icon 和 Logo。
Icon组件是一个复杂的“调度器”,它能否正确加载本地图标(local:ic-logo-badge)和远程图标(lucide:home)?Logo组件能否在<Icon />的基础上被正确渲染?
我们必须通过 Storybook (CDD) 来验证这些功能。
10.6.2. (编码) 创建 Icon.stories.tsx
创建 src/components/icons/Icon.stories.tsx。
1 | import type { Meta, StoryObj } from "@storybook/react-vite"; |
验证:运行 pnpm storybook。
- 打开
Local故事,PLogo应被 瞬时 渲染(通过React.lazy)。 - 打开
Remote故事,settings图标应被渲染。 - 打开
RemoteWeather故事,首次 打开会有一个极短暂的延迟(网络请求),再次 打开(或刷新页面)则变为瞬时加载(localStorage缓存)。 - 验证成功:我们的双轨制图标系统工作正常。
10.6.3. (编码) 创建 Logo.stories.tsx
创建 src/components/brand/Logo.stories.tsx。
1 | import type { Meta, StoryObj } from '@storybook/react'; |
验证:Logo 组件被正确渲染,其内部的 local:ic-logo-badge 图标也显示正常。
任务 10.6 已完成。
10.7. 本章小结与代码入库
在本章中,我们构建了 src/components 业务基础层。
- 技术选型 (10.1):我们分析了三大主流图标方案,并确定了
SVGR(本地) +@iconify/react(远程)的双轨制技术方案。 - 自动化 (10.2):我们搭建了
SVGR+Biome自动化工作流,解决了 Node.js 兼容性陷阱,并将其固化为pnpm build:icons脚本。 - 核心封装 (10.4):我们构建了统一的
<Icon />组件,通过local:前缀和React.lazy实现了高性能、API 一致的图标调度器。 - 组件实现 (10.5):我们实现了职责单一的
<Logo />展示组件,为后续的布局和路由集成做好了准备。 - CDD 验证 (10.6):我们通过 Storybook 验证了双轨制图标系统的所有功能均按预期工作。
1 | git add . |
第十章已圆满完成。













