第十章. 业务基石:构建统一图标系统

第十章. 业务基础组件:统一图标系统与加载占位

在前两章中,我们奠定了 src/ui 原子层的基础。现在,我们将开始构建更高一层的 src/components 业务基础层

本章的核心任务是设计并实现一个高性能、可扩展的 统一图标系统。我们将深入分析主流图标方案的技术选型,并最终采用 SVGR(处理本地/私有 SVG 资产) + @iconify/react(处理海量通用图标集) 的双轨制技术方案。最终,我们会将这两种方案封装在一个优雅的、唯一的 <Icon /> 组件中,为所有开发者提供简洁一致的使用体验。

10.1. 图标系统技术选型

在编写任何代码之前,我们必须先进行技术选型。图标系统是前端工程化中最容易出现混乱的领域之一。一个草率的选型,会导致项目后期性能瓶颈、维护困难和视觉不统一。

10.1.1. (分析) 图标方案对比

让我们客观对比三种主流的图标技术方案,分析它们在 2025 年企业级项目中的优劣。

核心原理:
将矢量图标图形打包成一个字体文件(如 .woff2)。浏览器像加载自定义字体一样加载它,然后通过 CSS 伪元素(::before)和特定的类名(如 <i class="fa fa-home">),在页面上“写”出对应的“图标字符”。

评估:

  • [优点] 样式控制简单: 作为“字体”,它可以直接使用 CSS 的 colorfont-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 工具类。fillstroke 可被设置为 currentColor 以继承父元素的文本颜色,并完美支持多色图标。
  • [优点] 可靠可控: 图标代码是项目源代码的一部分,不依赖外部网络,开发者对组件有 100% 的控制权。
  • [优点] 可访问性友好: 可以轻松地为 <svg> 标签添加 titlerole="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 自动化工作流。我们的目标是建立一个“生产线”,使其能够:

  1. 读取 src/assets/icons/ 目录下的所有原始 .svg 文件。
  2. 自动将它们转换为高性能、标准化的 React 组件。
  3. 将转换后的 .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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export default {
// 1. 禁用 Prettier v2 插件,避免 Node.js 兼容性问题
prettier: false,

// 2. 确保输出为 TypeScript (.tsx)
typescript: true,

// 3. 将 width/height 属性替换为 1em,使其大小可由 font-size 控制
icon: true,

// 4. 为组件添加 ref 转发
ref: true,

// 5. 将 SVG 根元素的 width/height 属性移除,改用 viewBox
dimensions: false,

// 6. 替换特定属性值,强制颜色可被 CSS (currentColor) 控制
replaceAttrValues: {
"#000": "currentColor",
"#000000": "currentColor",
black: "currentColor",
},

// 7. 为 <svg> 标签添加固定的属性
svgProps: {
role: "img",
"aria-hidden": "true",
},
};

配置深度解析

  • 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. (操作) 创建源目录和目标目录

我们的工作流需要一个“输入”目录和一个“输出”目录。

  1. 输入 (Source):存放我们原始的 .svg 文件。
  2. 输出 (Destination):存放 svgr 自动生成的 .tsx 组件。

在终端中创建这两个目录:

1
2
3
4
5
# 1. 创建原始 SVG 资产目录
mkdir -p src/assets/icons

# 2. 创建 SVGR 自动生成的组件目录
mkdir -p src/components/icons

架构职责src/assets/icons 目录应被 Git 追踪,它是我们的"源代码"。src/components/icons 目录中自动生成的组件文件(*.tsxindex.ts)应被添加到 .gitignore,但手动管理的子目录(如 logos/)和配置文件(如 Icon.tsxregister-icons.ts)应被 Git 追踪。


10.2.4. (编码) 添加 package.json 自动化脚本

我们的目标是定义一个脚本,它能:

  1. 调用 svgr CLI,读取 src/assets/icons 目录。
  2. 使用 .svgrrc.js 配置,将 .svg 转换为 .tsx 组件并输出到 src/components/icons
  3. (关键) 在 svgr 完成后,立即调用项目 自己的 Prettier 来格式化输出的 src/components/icons 目录,以规避 Prettier v2 的兼容性陷阱。

打开 package.json 文件,在 scripts 块中添加新行:

文件路径: package.json

1
2
3
4
5
6
7
8
9
10
11
{
"name": "prorise-admin",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "biome lint .",
"build:icons": "svgr ./src/assets/icons --out-dir ./src/components/icons --config-file ./.svgrrc.js"
"storybook": "storybook dev -p 6006",
// ...
}
}

脚本深度解析 (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)时,我们的开发流程是:

  1. hero-icon.svg 放入 src/assets/icons/ 目录。
  2. 运行 pnpm build:icons
  3. src/components/icons/HeroIcon.tsx 文件被自动生成
  4. 将生成的组件移动到合适的子目录(如品牌图标放入 logos/),并在 register-icons.ts (任务 10.4) 中注册即可使用。

现在我们手动创建一个品牌 Logo 组件。在 src/components/icons/logos/ 目录下创建 PLogo.tsx

文件路径: src/components/icons/logos/PLogo.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type { SVGProps } from "react";
import { forwardRef, type Ref } from "react";

const SvgPLogo = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className="PLogo_svg__icon"
viewBox="0 0 1024 1024"
role="img"
aria-hidden="true"
ref={ref}
{...props}
>
<path
fill="currentColor"
d="M894.178 201.847c-17.217-11.847-41.842-26.22-60.594-36.318-20.85-11.243-40.922-24.48-61.464-36.655-40.951-24.297-81.535-47.592-121.65-72.155-20.07-12.286-41.032-23.243-60.88-36.02C571.339 8.953 542.008.001 512.596.001H506.6c-25.617 0-58.886 11.489-74.61 21.913-19.653 13.034-40.686 23.52-60.656 36.247-39.49 25.177-81.28 47.755-121.751 72.052-19.469 11.683-42.743 23.827-60.78 36.124-19.007 12.961-41.258 22.977-60.665 36.246-31.56 21.565-68.83 73.382-68.83 126.427v365.98c0 53.331 37.29 104.442 69.075 126.182 37.116 25.392 81.966 49.076 121.2 72.615 40.481 24.307 82.272 46.865 121.751 72.052 19.939 12.727 40.809 23.694 60.881 36.021C449.822 1012.666 478.549 1024 506.6 1024h13.197c45.382 0 102.59-39.264 133.588-58.64 39.97-25.002 80.513-47.244 121.036-71.571 38.742-23.254 84.042-47.122 120.473-72.114 30.957-21.248 69.801-72.574 69.801-124.217V326.546c.001-52.37-39.13-103.103-70.517-124.699m-156.985 343.69c-30.108 22.066-76.022 33.166-137.997 33.166H441.75v145.271c0 8.328 1.892 14.711 5.534 19.284 3.786 4.441 10.394 8.185 19.847 11.101v11.377h-156.76v-11.377c9.31-2.496 15.796-5.964 19.571-10.548 3.918-4.573 5.81-11.1 5.81-19.56V351.977c0-8.46-1.892-14.845-5.534-19.141-3.785-4.307-10.26-8.052-19.847-10.967v-11.376h288.824c57.116 0 100.328 8.736 129.352 26.363 16.88 10.404 29.975 24.973 39.56 43.98 9.453 18.876 14.18 40.237 14.18 63.695 0 45.371-14.987 78.947-45.095 101.004"
/>
<path
fill="currentColor"
d="M674.266 444.532c0 21.228-7.959 35.653-23.765 43.428-10.803 5.412-29.975 8.184-57.648 8.184H441.751V393.053h151.102c27.948 0 47.121 2.629 57.648 8.04 15.806 7.909 23.765 22.344 23.765 43.439"
/>
</svg>
);
const ForwardRef = forwardRef(SvgPLogo);
export default ForwardRef;

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 不是一个图标库,它是一个 图标加载器。理解它的工作原理至关重要:

  1. 首次渲染 (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
  2. 二次渲染 (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 约定:

  1. 组件的 prop 统一定为 icon
  2. 通过 icon prop 值的 前缀 来区分图标来源:
    • Iconify (远程): icon="lucide:home" (包含 set: 前缀)
    • SVGR (本地): icon="local:ic-logo-badge" (使用 local: 前缀)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { lazy, type ComponentType, type SVGProps } from 'react';

// 1. 使用 React.lazy 动态导入所有由 SVGR 生成的本地组件。
// 这确保了本地图标也能按需加载,不会增加初始包体积。
const localIcons = {
'ic-logo-badge': lazy(() => import('@/components/icons/logos/PLogo')),
// 运行 pnpm build:icons 后,在此添加更多图标:
// 'another-icon': lazy(() => import('@/components/icons/AnotherIcon')),
};

// 2. 导出本地图标名称的类型,以便 Icon.tsx 可以获得类型提示
export type LocalIconName = keyof typeof localIcons;

// 3. 导出一个查找函数
export const getLocalIcon = (name: string): ComponentType<SVGProps<SVGSVGElement>> | undefined => {
if (name in localIcons) {
return localIcons[name as LocalIconName];
}
return undefined;
};

这个注册表现在是高性能且类型安全的。

10.4.5. (编码) 封装统一的 Icon.tsx

现在,万事俱备,开始编写 Icon.tsx 组件。

文件路径: src/components/icons/Icon.tsx

1. 导入与 Props 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import type { IconProps as IconifyProps } from "@iconify/react";

// 1. 定义 props 接口。
// - 它继承了 Iconify 的所有 props (如 size, color, width, height)。
// - Omit<..., 'icon'> 移除了 Iconify 原始的 'icon' prop。
// - `icon: string` 添加了我们自己的 'icon' prop,
// 它将同时支持 'lucide:home' 和 'local:ic-logo-badge' 两种格式。
interface IconProps extends Omit<IconifyProps, "icon"> {
/**
* 图标标识符。
* - 远程 (Iconify): 'icon-set:icon-name' (e.g., 'lucide:home')
* - 本地 (SVGR): 'local:icon-name' (e.g., 'local:ic-logo-badge')
*/
icon: string;
}

思考Props 接口的设计是 API 封装的第一步。通过 Omit 和重定义,我们“劫持”了 icon prop,赋予了它更强大的能力。

2. 组件实现 (调度逻辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import {
Icon as IconifyIcon,
type IconProps as IconifyProps,
} from "@iconify/react";
import { forwardRef, type Ref, Suspense } from "react";
import { cn } from "@/utils/cn";
import { getLocalIcon } from "./register-icons";

export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ icon, className, ...props }: IconProps, ref: Ref<SVGSVGElement>) => {
// 2. 检查 'local:' 前缀
if (icon.startsWith("local:")) {
// 轨道一:渲染本地 SVGR 图标

// 移除前缀,获取图标的真实名称
const iconName = icon.replace("local:", "");
const LocalIconComponent = getLocalIcon(iconName);

if (!LocalIconComponent) {
// 在注册表中未找到该本地图标
return null;
}

// 3. (关键) 使用 <Suspense> 包裹
// 因为 LocalIconComponent 是通过 React.lazy 导入的,
// 它在首次渲染时会触发一个异步加载。
// fallback={null} 避免了加载时出现闪烁。
return (
<Suspense fallback={null}>
<LocalIconComponent
className={cn("antialiased", className)} // 统一添加抗锯齿
ref={ref}
{...props}
/>
</Suspense>
);
}

// 4. 检查是否包含 ':' (Iconify 格式)
if (icon.includes(":")) {
// 轨道二:渲染远程 Iconify 图标
return (
<IconifyIcon
icon={icon}
className={cn("antialiased", className)} // 统一添加抗锯齿
ref={ref} // Iconify ref 类型略有不同
{...props}
/>
);
}

// 5. 如果格式不匹配,返回 null
return null;
},
);

Icon.displayName = "Icon";

思考Icon.tsx 的核心是一个 调度器 (Dispatcher)。它通过简单的字符串检查(startsWithincludes)将请求分发到 SVGR 轨道或 Iconify 轨道,同时通过 Suspense 优雅地处理了本地图标的异步加载。

10.4 节已完成。我们的 <Icon /> 组件现在 API 统一、性能卓越,并且 完全准备好Logo.tsx 所消费。


10.5. 品牌标识组件:Logo.tsx

10.5.1. 需求分析

项目需要一个全站通用的 Logo 组件。此组件的 当前职责 是:

  1. 展示 Prorise-Admin 专属的品牌标识。
  2. 其大小和样式必须是可控的。
  3. (关键) 它 负责路由。它是一个纯粹的展示组件。路由功能将在第十一章(路由)和第十二章(布局)中,由 父组件(如 Layout)来提供。

10.5.2. 解决方案

创建一个 src/components/brand/Logo.tsx 组件,它 消费 10.4 节构建的统一 <Icon /> 系统,并透传样式。

10.5.3. (编码) 实现 Logo.tsx

1. 创建文件与导入依赖:
创建 src/components/brand/Logo.tsx

1
2
3
import { cn } from "@/utils/cn";
// 1. (关键) 只导入 Icon 组件。不导入 'react-router-dom'。
import { Icon } from "../icons/Icon";

2. 定义 Props 与实现组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { cn } from "@/utils/cn";
import { Icon } from "../icons/Icon";

interface Props {
width?: number | string;
height?: number | string;
className?: string;
}

/**
* 全站 Logo 品牌标识组件。
* 纯展示组件,不包含路由逻辑。
*/
function Logo({ width = 50, height = 50, className }: Props) {
return (
<span className={cn(className)}>
<Icon
icon="local:ic-logo-badge"
width={width}
height={height}
color="var(--colors-palette-primary-default)"
/>
</span>
);
}

export default Logo;

思考Logo.tsx 现在是 职责单一 的。它只关心"显示 Logo"。未来当它被放入 Layout 的 Header 中时,Layout 可以用 LinkNavLink 组件 包裹 这个 Logo 组件,从而实现"关注点分离"。

任务 10.5 已完成。


10.6. 组件驱动开发 (CDD):Storybook 验证

10.6.1. 痛点与需求

我们在本章构建了两个核心的业务组件:IconLogo

  • Icon 组件是一个复杂的“调度器”,它能否正确加载本地图标(local:ic-logo-badge)和远程图标(lucide:home)?
  • Logo 组件能否在 <Icon /> 的基础上被正确渲染?

我们必须通过 Storybook (CDD) 来验证这些功能。

10.6.2. (编码) 创建 Icon.stories.tsx

创建 src/components/icons/Icon.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Icon } from "./Icon";

const meta: Meta<typeof Icon> = {
title: "Components/Icon",
component: Icon,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
icon: {
control: "text",
description:
'Iconify (e.g., "lucide:home") or Local (e.g., "local:ic-logo-badge")',
},
width: { control: "number" },
height: { control: "number" },
color: { control: "color" },
},
};

export default meta;
type Story = StoryObj<typeof meta>;

// 1. 验证“轨道一”:本地 SVGR 图标
export const Local: Story = {
args: {
icon: "local:ic-logo-badge",
width: 100,
height: 100,
color: "var(--colors-palette-primary-default)",
},
};

// 2. 验证“轨道二”:远程 Iconify 图标
export const Remote: Story = {
args: {
icon: "lucide:settings",
width: 100,
height: 100,
color: "var(--colors-palette-error-default)",
},
};

// 3. 验证远程图标的按需加载(缓存)
export const RemoteWeather: Story = {
args: {
icon: "mdi:weather-hurricane",
width: 100,
height: 100,
},
};

验证:运行 pnpm storybook

  1. 打开 Local 故事,PLogo 应被 瞬时 渲染(通过 React.lazy)。
  2. 打开 Remote 故事,settings 图标应被渲染。
  3. 打开 RemoteWeather 故事,首次 打开会有一个极短暂的延迟(网络请求),再次 打开(或刷新页面)则变为瞬时加载(localStorage 缓存)。
  4. 验证成功:我们的双轨制图标系统工作正常。

10.6.3. (编码) 创建 Logo.stories.tsx

创建 src/components/brand/Logo.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type { Meta, StoryObj } from '@storybook/react';
import Logo from './Logo';

const meta: Meta<typeof Logo> = {
title: 'Components/Brand/Logo',
component: Logo,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
width: { control: 'number' },
height: { control: 'number' },
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
width: 100,
height: 100,
},
};

验证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
2
git add .
git commit -m "feat(components): build unified icon system and Logo"

第十章已圆满完成。