第八章 组件化进阶:用 Tailwind v4 定制主题、DaisyUI v5 封装微件,掌握 CSS-First 组件化思维

第八章: 组件化艺术:基于 Tailwind v4 & DaisyUI v5 构建专业级 Dashboard


摘要: 在前面的章节中,我们已经熟练掌握了如何使用 Ant Design 这样的重量级组件库。现在,我们将探索一个截然不同但同样强大的 UI 构建范式。本章将聚焦于 DaisyUI,一个基于 Tailwind CSS 的、以 class 为核心的组件库。我们的核心目标将不再是简单地“调用”现成的组件,而是要学会一种更高级的技能:将 DaisyUI 作为“原材料”,亲手“创造”一套服务于我们 Dashboard 项目的、高内聚、类型安全的专属组件系统


8.0 思维转变:从“组件驱动”到“样式驱动”

在开始之前,我们需要进行一次关键的思维切换。Ant Design 代表了一类“组件驱动”的库,我们通过导入 JS 组件并传递 props 来构建 UI。而 DaisyUI 则代表了“样式驱动”的哲学,我们通过为原生 HTML 标签添加语义化的 class 来赋予其外观。

这两种模式孰优孰劣?这正是“现代化转译者”需要深入理解的核心。

深度解析:这难道不是回到 Bootstrap 的老路吗?
2025-10-05

等等,给 <button> 添加 class="btn btn-primary"… 这听起来和我多年前用的 Bootstrap 一模一样。我们花了好几年才拥抱了“组件化”,现在又要回滚到面向 class 开发吗?

问得好!这正是关键所在。问题不在于“组件”或“class”的形式,而在于“定制性”。Bootstrap 的问题是,它的组件高度封装,一旦你想修改某个细节,就得用复杂的 CSS 权重覆盖去对抗它的默认样式,非常痛苦。

DaisyUI 则完全不同。它的 btn 只是 Tailwind CSS 功能类 (p-4, flex, rounded…) 的一组高级“宏”。你拥有了快速开发的能力,但随时可以“降维”,用 Tailwind 的原子类去覆盖和定制任何一个细节,比如 className="btn btn-primary rounded-full shadow-lg"

正如 DaisyUI 官方所言,这是 “utility-first, not utility-only” (功能优先,而非功能唯一)。你得到了“组件”的开发速度,也保留了“原子类”的完全自由。

明白了。所以 DaisyUI + Tailwind = Bootstrap 的速度 + 纯手写 CSS 的灵活性。

完全正确。

在这种思想下,我们可以用“原子设计”理论来理解这个组合:

  • Tailwind CSS 是“原子”: 它提供 p-4, flex, text-lg 等最小化的功能单元。
  • DaisyUI 是“分子”: 它将这些原子组合成有意义的 UI 部件,如 btn, card, alert

而我们,即将在本章扮演“创造者”的角色,利用这些原子和分子,构建出属于我们应用的、更复杂的“组织”和“器官”。

8.0.1. 本章核心任务:从“API 使用者”到“系统构建者”

既然 DaisyUI 提供了 btn 这样的类名,我们为什么不直接在项目里到处写 <button className="btn"> 呢?这就是本章要传授的核心思想。

核心任务: 我们将进行“二次封装”,亲手创建 <Button />, <Card /> 等属于我们自己项目的组件。

这样做的好处是巨大的:

  1. 设计一致性: 如果未来项目的设计规范要求所有 primary 按钮都要带一个特定的图标,我们只需修改自己封装的 <Button /> 组件一处,而非全局搜索和替换成百上千的 className 字符串。
  2. 逻辑内聚: 我们可以为封装的组件添加复杂的行为逻辑。例如,给我们的 <Button /> 增加一个 loading prop,当它为 true 时,自动显示加载动画并禁用按钮。这是纯 CSS 类无法实现的。
  3. 类型安全: 我们可以为组件的 props 定义严格的 TypeScript 类型,例如,variant 只能是 'primary' | 'secondary',从根本上杜绝无效的样式组合。

本章,我们将以一个专业级 Dashboard 的开发为载体,完整地实践这套“二次封装”的组件化最佳实践。

8.0.2. 【组件概览】DaisyUI 的“原材料”仓库

在开始“创造”之前,让我们先来检阅一下 DaisyUI 为我们提供了哪些强大的“分子”和“组织”。下面列出了其核心的组件类别,它将是我们整个章节的“灵感源泉”和“参考手册”。

分类核心组件举例
Actions (操作)Button, Dropdown, Modal, Theme Controller
Data display (数据展示)Card, Table, Stat, Avatar, Badge, Alert
Navigation (导航)Navbar, Menu, Pagination, Breadcrumbs, Steps
Data input (数据输入)Input, Select, Checkbox, Toggle, File Input
Layout (布局)Drawer, Footer, Hero, Stack, Divider

想要交互式地体验所有组件的样式和用法,强烈建议您访问官方的组件展示页面。


8.1. 奠定基石:集成 Tailwind v4 与 DaisyUI v5 的现代化工程流

在我们开始“创造”组件之前,必须先搭建好我们的“工坊”。一个配置完善、开发体验流畅的环境,是高效产出的前提。本节,我们将一步步地为一个纯净的 Vite + React 项目,集成最新的 Tailwind CSS v4 和 DaisyUI v5,并配置路径别名等关键的效率优化。

8.1.1. 步入 v4 时代:集成 Tailwind CSS v4

我们将遵循您提供的 Tailwind CSS 4.0 完全配置指南 中的现代化流程。

第一步:创建项目并安装依赖
首先,确保您已按照 pnpm create vite 创建了一个 Vite + React + TS 项目。然后,在项目根目录安装 tailwindcss 和官方的 Vite 插件。

1
2
# 我们将安装标记为 @next 的 v4 版本
pnpm add -D tailwindcss@next @tailwindcss/vite

第二步:配置 Vite
接下来,编辑 vite.config.ts 文件,引入并使用 Tailwind CSS 插件。这是 v4 时代的核心变化,它将取代过去繁琐的 postcss.config.js

文件路径: vite.config.ts

1
2
3
4
5
6
7
8
9
10
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' // 1. 导入插件

export default defineConfig({
plugins: [
react(),
tailwindcss(), // 2. 将插件添加到 plugins 数组
],
})

第三步:在主 CSS 文件中引入 Tailwind
打开 src/index.css清空所有内容,然后只添加一行代码。

文件路径: src/index.css

1
@import "tailwindcss";

思维转变: 这一行 @import "tailwindcss"; 是 v4 的魔法所在。它等价于 v3 中的 @tailwind base;, @tailwind components;, 和 @tailwind utilities; 三个指令的总和,并且由新的 Rust 引擎驱动,速度极快。

8.1.2. 接入“分子库”:集成 DaisyUI v5

Tailwind 为我们提供了“原子”,现在我们来引入 DaisyUI 这个强大的“分子”库。

第一步:安装 DaisyUI

1
pnpm add -D daisyui@latest

第二步:在 CSS 中注册为插件
根据您提供的最新文档,DaisyUI v5 完美适配 Tailwind v4 的 CSS-First 理念。我们同样在主 CSS 文件中,使用 @plugin 指令来引入它。

文件路径: src/index.css (修改)

1
2
3
4
@import "tailwindcss";

/* 在 tailwindcss 导入之后,使用 @plugin 指令引入 daisyui */
@plugin "daisyui";

至此,Tailwind 和 DaisyUI 的核心功能已经集成完毕!就是这么简单。我们暂时还不需要 tailwind.config.js 文件。

8.1.3. 【开发体验提升】配置 Vite 路径别名

为了告别恼人的 ../../../ 相对路径,我们将配置一个 @ 别名,使其直接指向 src 目录。

第一步:修改 Vite 配置
我们需要再次编辑 vite.config.ts,为它增加 resolve.alias 配置。

文件路径: vite.config.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' // 1. 引入 Node.js 的 'path' 模块

export default defineConfig({
plugins: [react(), tailwindcss()],
// 2. 新增 resolve 配置
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

第二步:同步 TypeScript 配置
为了让 VS Code / Cursor 和 TypeScript 编译器也能理解我们的新别名,提供正确的代码提示和类型检查,我们需要同步修改 tsconfig.json

文件路径: tsconfig.app.json (修改)

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
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,

/* Path alias configuration */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

关键一步: 修改 tsconfig.json 后,您需要重启 TypeScript 服务才能让更改生效。在 VS Code / Cursor 中,按下 Ctrl+Shift+P (或 Cmd+Shift+P),输入并选择 TypeScript: Restart TS Server

8.1.4. 验证成果

所有配置都已完成,让我们通过一个简单的例子来验证一切是否正常工作。

第一步:创建验证用的组件

文件路径: @/components/SetupValidator.tsx (使用别名创建新文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const SetupValidator = () => {
return (
<div className="hero min-h-screen bg-base-200">
<div className="hero-content text-center">
<div className="max-w-md">
<h1 className="text-5xl font-bold">环境配置成功!</h1>
<p className="py-6">Tailwind v4 和 DaisyUI v5 已经准备就绪。</p>
<button className="btn btn-primary">开始构建</button>
</div>
</div>
</div>
);
};

export default SetupValidator;

第二步:在 App.tsx 中使用

文件路径: src/App.tsx (修改)

image-20251003112608973

1
2
3
4
5
6
7
8
// 使用路径别名导入我们的新组件
import SetupValidator from '@/components/SetupValidator';

function App() {
return <SetupValidator />;
}

export default App;

现在,重新运行 pnpm run dev。如果您的浏览器中显示了一个包含标题、段落和紫色按钮的居中卡片,那么恭喜您!我们已经成功搭建了一个专业、高效且具备最佳实践的现代化开发环境。


8.2. 【核心】DaisyUI 的灵魂:理解语义化颜色与主题系统

8.1 节中,我们已经搭建好了坚实的地基。现在,我们将在这片地基上,探索 DaisyUI 最令人兴奋的特性。您将亲身体会到,为什么说 DaisyUI 不仅仅是一个组件库,更是一个强大的主题化引擎。

为了让我们的 Dashboard 界面更加美观和专业,我们将集成经典的 Font Awesome 图标库。为保持简单,我们直接在 index.html 中通过 CDN 引入。

文件路径: public/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<title>Prorise React Guide</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

现在,我们就可以在项目中使用 <i> 标签和 Font Awesome 的 class (如 <i className="fas fa-palette"></i>) 来渲染图标了。

8.2.1. 告别硬编码:bg-blue-500 vs. bg-primary

作为一名经验丰富的开发者,您一定处理过这样的场景:为了适配深色模式,需要在大量元素上添加 dark: 前缀,代码变得冗长且难以维护。

传统方式的困境:

1
2
3
4
5
6
7
8
9
<div class="bg-zinc-100 p-4">
<p class="text-zinc-800">这是一段文字</p>
<button class="bg-blue-500 text-white">按钮</button>
</div>

<div class="bg-zinc-100 dark:bg-zinc-900 p-4">
<p class="text-zinc-800 dark:text-zinc-200">这是一段文字</p>
<button class="bg-blue-500 dark:bg-blue-700 text-white">按钮</button>
</div>

这种硬编码颜色的方式,不仅繁琐,而且每增加一个新主题(比如“复古主题”),维护成本都会指数级增长。

DaisyUI 的解决方案:语义化颜色
DaisyUI 引入了一套基于“角色”而非“色值”的颜色名称。您不再关心某个按钮“是不是蓝色”,而是关心它“是不是一个 主要 按钮”。

语义化名称CSS 变量含义
primary--color-primary品牌主色,用于最重要的操作
secondary--color-secondary品牌次色,用于次要操作
accent--color-accent品牌强调色
base-100--color-base-100基础背景色 (如页面背景)
base-200--color-base-200基础背景色的深一阶
base-content--color-base-content在基础背景色上应该呈现的前景色 (如文本颜色)

现在,我们用语义化的方式重写上面的卡片:

1
2
3
4
<div class="bg-base-200 p-4">
<p class="text-base-content">这是一段文字</p>
<button class="btn btn-primary">按钮</button>
</div>
深度解析:语义化的魔力
2025-10-06

我明白了。bg-primary 不再是一个具体的颜色,而是一个“占位符”,它的具体色值由当前激活的主题来决定。

完全正确!bg-primary 的真正含义是“使用当前主题所定义的 primary 颜色作为背景”。text-base-content 的意思是“使用当前主题中,最适合在基础背景上阅读的文本颜色”。

这就实现了样式与主题的解耦。您的组件样式(HTML class)保持不变,但只要切换主题,这些语义化 class 渲染出的最终颜色就会自动改变。从此告别繁琐的 dark: 前缀。


8.2.2. 一键换肤:探索 DaisyUI 内置主题

DaisyUI 的“主题”本质上就是一套预设好的、为所有语义化颜色变量赋值的颜色表。

第一步:启用你需要的主题
我们修改主 CSS 文件,来启用更多的主题。

文件路径: src/index.css

重要信息: 最新的官方文档主题示例保持如下的对象语法,截止至 2025/10/3 日,此方法是最新版本唯一有效的方案

1
2
3
4
5
@import "tailwindcss";

@plugin "daisyui" {
themes: light --default, dark, cupcake, dracula, synthwave;
}

第二步:应用主题
通过在 <html> 标签上设置 data-theme 属性来应用主题。

文件路径: public/index.html (临时修改以预览)

1
2
<!doctype html>
<html lang="en" data-theme="dracula">

刷新应用,您会发现所有 base-*primary 等颜色都已自动变为 dracula 主题的色值。


8.2.3. 实战:集成一个极简的动态主题切换器

现在,我们将分三步完成动态主题切换功能的完整集成。

第一步:创建 <ThemeSwitcher /> 业务组件
这个组件负责主题的切换逻辑和 UI。

文件路径: @/components/ThemeSwitcher.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
import { useEffect, useState } from "react";

// 与 index.css 中启用的主题列表保持一致
const themes = ["light", "dark", "cupcake", "dracula", "synthwave"];

const ThemeSwitcher = () => {
const [theme, setTheme] = useState(localStorage.getItem("theme") || "light");
useEffect(() => {
// 将主题应用到 <html> 元素
document.documentElement.setAttribute("data-theme", theme);
// 将选择的主题保存到 localStorage
localStorage.setItem("theme", theme);
}, [theme]);

return (
<div className="flex items-center gap-2">
<i className="fa-solid fa-palette"></i>
<select
className="select select-bordered"
value={theme}
onChange={(e) => setTheme(e.target.value)}
aria-label="选择主题"
>
{theme &&
themes.map((theme) => (
<option key={theme} value={theme}>
{theme}
</option>
))}
</select>
</div>
);
};

export default ThemeSwitcher;

第二步:创建 <AppLayout /> 布局组件
这个组件将作为我们所有页面的通用外壳,并包含主题切换器。

文件路径: @/layouts/AppLayout.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
import { Outlet, NavLink } from 'react-router-dom';
import ThemeSwitcher from '@/components/ThemeSwitcher';

const AppLayout = () => {
return (
<div className="flex flex-col min-h-screen">
<header className="navbar bg-base-100 shadow-md">
<div className="flex-1">
<NavLink to="/" className="btn btn-ghost text-xl">
Prorise Dashboard
</NavLink>
</div>
<div className="flex-none">
<ThemeSwitcher />
</div>
</header>

<main className="flex-grow p-4 bg-base-200">
<Outlet />
</main>

<footer className="footer footer-center p-4 bg-base-300 text-base-content">
<aside>
<p>© 2025 Prorise. All rights reserved.</p>
</aside>
</footer>
</div>
);
};

export default AppLayout;

第三步(关键):将布局应用到我们的路由系统
现在,我们需要告诉 React Router,我们希望所有页面都使用这个 AppLayout 作为布局。我们将修改 router/index.tsxmain.tsx 来完成这个最终的连接。

文件路径: src/router/index.tsx (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createBrowserRouter } from 'react-router-dom';
import AppLayout from '@/layouts/AppLayout'; // 使用别名导入布局

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />, // 将 AppLayout 设置为根路由的元素
children: [
// 未来我们的所有页面都将作为这里的子路由
{
index: true, // 默认子路由
element: <div>这是首页内容</div>,
},
{
path: 'about',
element: <div>这是关于页面</div>,
}
],
},
]);

export default router;

文件路径: src/main.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'

import { RouterProvider } from 'react-router-dom';
import router from './router'; // 导入我们创建的 router 实例
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)

清理工作: 由于路由现在直接管理 AppLayout,之前 Vite 模板自带的 App.tsxApp.css 文件已不再需要,您可以安全地删除它们。

现在,重新启动您的应用。您将看到一个包含导航栏和页脚的完整布局,并且导航栏右侧有一个功能完备、可记忆选择的主题切换器!

img

8.2.4. 灵感来源:官方主题生成器

您可能会问:“这些内置主题的颜色是怎么定的?我能创建自己的吗?”

image-20251003194053218

当然可以!DaisyUI 的所有主题都基于一套 CSS 颜色变量。为了方便用户创建自己的主题,官方提供了一个强大的可视化工具——主题生成器 (Theme Generator)

在这个工具里,您可以:

  • 实时修改 primary, secondary, base-100 等所有语义化颜色。
  • 在右侧的预览区域,立刻看到新颜色应用在所有组件上的效果。
  • 完成后,点击 CSS 按钮,直接生成可用于您项目的自定义主题代码。

本节小结

我们已经深入了 DaisyUI 的灵魂,掌握了其强大能力的核心:

  • 语义化颜色: 通过使用 primary, base-100 等颜色,我们将样式与具体色值解耦,为主题化奠定了基础。
  • data-theme 属性: 这是切换 DaisyUI 主题的核心机制,通过修改 <html> 标签的这个属性,可以改变整个应用的色板。
  • 持久化主题切换: 我们通过 React Hooks 和 localStorage,实现了一个简洁但功能完备的、可记忆的动态主题切换器。
  • 主题生成器: 我们了解了创建自定义主题的强大可视化工具,为下一步的深度定制做好了准备。

8.3. 【核心】深度定制:打造你的品牌专属主题 (语法修正重构版)

在上一节中,我们体验了 DaisyUI 内置主题“一键换肤”的魔力。但这仅仅是开始。DaisyUI 最强大的地方在于它极高的可定制性。本节,我们将从“主题的使用者”蜕变为“主题的创造者”,学习如何打造一套完全符合我们品牌形象的专属主题。

8.3.1. 解析配置文件:@plugin "daisyui" {}

DaisyUI 的全局配置完全遵循 Tailwind v4 的“CSS-First”理念,直接在我们的主 CSS 文件中完成。通过在 @plugin "daisyui" 后添加花括号 {},我们就可以传入配置对象。

文件路径: src/index.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@import "tailwindcss";

@plugin "daisyui" {
/*
这是 daisyUI 的默认配置 (等同于不写配置)
themes: light --default, dark --prefersdark;
root: ":root";
include: ;
exclude: ;
prefix: ;
logs: true;
*/

/* 我们可以覆盖这些配置,例如只启用特定主题并关闭日志 */
/* 关键语法:themes 是一个逗号分隔的字符串,不是数组! */
themes: light --default, dark --prefersdark, cupcake, dracula, synthwave;
logs: false; /* 关闭 daisyUI 在控制台的日志输出 */
}
深度解析:常用配置项解读
2025-10-07

这些配置项里,哪些是我们最需要关注的?

问得好。在日常开发中,你最需要关注的是 themesprefix

themes: 控制哪些主题被打包进你的最终 CSS 文件。只包含你需要的,可以减小打包体积。你还可以用 --default--prefersdark 标志来指定默认的亮色和暗色主题。

prefix: 如果你需要在一个已经存在其他 CSS 框架的项目中使用 DaisyUI,为了避免 class 命名冲突,可以设置一个前缀,比如 prefix: "d-"。这样,.btn 就会变成 .d-btn

includeexclude 呢?

这两个用于更细粒度的控制,比如你只想用 DaisyUI 的按钮和表单,而不想用它的卡片样式,就可以配置 include。这在大型项目中进行渐进式迁移或混合使用不同库时很有用,但我们目前用不到。

8.3.2. 方案一 (推荐):使用主题生成器创建全新主题

这是最激动人心的部分。我们将使用官方的 Theme Generator 来为我们的“Prorise Dashboard”创建一个独一无二的主题。

第一步:设计你的主题
请访问 DaisyUI Theme Generator

在这个页面中,尽情发挥你的创造力。通过左侧的颜色选择器,修改 Primary (主色), Secondary (次色), Base 100 (背景色) 等核心颜色。右侧的所有组件都会实时预览你的设计效果。

我们的目标: 创建一个名为 prorise 的主题,以科技感的绿色 (#00b96b) 为主色。

第二步:生成并复制代码
当您对设计满意后,点击页面顶部的 CSS 按钮,生成器会为您生成一段可以直接使用的 @plugin "daisyui/theme" 代码。请将其复制。

第三步:在项目中应用新主题
现在,回到 src/index.css 文件,我们将在这里应用我们的新主题。

文件路径: src/index.css (修改)

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
@import "tailwindcss";

@plugin "daisyui" {
/* 1. 我们不再需要内置主题,设置为 false 可以优化打包体积 */
themes: false;
logs: false;
}

/* 2. 将从主题生成器复制的代码粘贴到这里 */

@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.002 247.839);
--color-base-200: oklch(96% 0.003 264.542);
--color-base-300: oklch(92% 0.006 264.531);
--color-base-content: oklch(21% 0.034 264.665);
--color-primary: oklch(58% 0.158 241.966);
--color-primary-content: oklch(97% 0.013 236.62);
--color-secondary: oklch(44% 0.017 285.786);
--color-secondary-content: oklch(98% 0 0);
--color-accent: oklch(64% 0.222 41.116);
--color-accent-content: oklch(98% 0.016 73.684);
--color-neutral: oklch(13% 0.028 261.692);
--color-neutral-content: oklch(98% 0.002 247.839);
--color-info: oklch(78% 0.154 211.53);
--color-info-content: oklch(30% 0.056 229.695);
--color-success: oklch(84% 0.238 128.85);
--color-success-content: oklch(27% 0.072 132.109);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(28% 0.066 53.813);
--color-error: oklch(71% 0.202 349.761);
--color-error-content: oklch(28% 0.109 3.907);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1px;
--depth: 0;
--noise: 1;
}

见证奇迹: 保存文件并刷新浏览器。您会发现整个应用的色调已经完全变成了我们设计的“Prorise”主题,而我们甚至没有修改一行 React 组件代码!这就是语义化颜色与主题系统的威力。

8.3.3. 方案二 (微调):快速定制一个内置主题

有时候,我们并不需要一个全新的主题,可能只是觉得某个内置主题(比如 light)的 primary 颜色不是我们想要的。DaisyUI 同样支持对现有主题进行“微调”。

场景: 假设我们想使用 light 主题,但希望主色是品牌蓝色。

文件路径: src/index.css (另一种配置方式的示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@import "tailwindcss";

@plugin "daisyui" {
/* 我们依然启用 light 主题,并设为默认 */
themes: light --default, dark;
logs: false;
}

/* 使用相同的语法,但 name 指向一个已存在的主题 */
@plugin "daisyui/theme" {
name: "light";

/* 只提供你想要覆盖的变量 */
--color-primary: #3b82f6;
--color-primary-content: #ffffff;
}

这样,应用将使用 light 主题的所有默认颜色,但 primary 相关的颜色 (btn-primary, bg-primary 等) 会被替换为我们指定的蓝色。这是一种非常高效的快速定制方案。


本节小结

我们已经从主题的使用者,成功进阶为创造者,掌握了深度定制 DaisyUI 的两种核心方法:

  • 创建全新主题: 通过 Theme Generator 生成 @plugin "daisyui/theme" 代码块,并在主配置中设置 themes: false,可以打造一个完全独立的、体积最优的品牌主题。
  • 微调现有主题: 通过为 @plugin "daisyui/theme" 指定一个内置主题的 name,并只提供需要覆盖的 CSS 变量,可以实现对现有主题的快速、局部修改。

8.4. 封装的艺术(上):构建高内聚的原子组件

我们已经为应用注入了强大的“主题灵魂”,现在,是时候开始雕琢它的“血肉筋骨”了。在本节中,我们将亲手创建第一批属于我们项目自己的、可复用的“原子组件”。

这个过程,是从“调用 class”到“创造组件”的思维跃迁,也是衡量项目工程化水平的关键一步。我们的目标是构建出 高内聚(组件只做一件事并做好)、低耦合(组件不依赖外部,易于移植和复用)的 UI 单元。

8.4.1. 最佳实践:规划组件与类型的目录结构

一个清晰的文件结构是项目可维护性的基石。在开始编写任何组件之前,我们先来规划我们的“组件库”和“类型定义”将存放在哪里。

我们将采用一种专业、可扩展的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
src/
├── components/
│ └── ui/ # <-- 1. 用于存放通用的、无业务逻辑的 UI 原子组件
│ ├── Button/
│ │ ├── Button.tsx # 组件实现
│ │ └── index.ts # 用于简化导出的 "barrel" 文件
│ └── Card/
│ ├── Card.tsx
│ └── index.ts
├── layouts/
│ └── AppLayout.tsx # 布局组件
├── pages/ # 页面组件
├── router/
│ └── index.tsx # 路由配置
└── types/
└── ui.ts # <-- 2. 用于存放所有 UI 组件共享的 TypeScript 类型

为什么要这样规划?

  1. components/ui/: 我们将项目中的组件明确区分为两类。ui 目录存放的是像 Button, Card 这样与业务无关、在任何地方都可能用到的“原子”;未来我们还会创建更复杂的“业务组件”(如 UserProfileCard),它们将存放在 components/features/ 等目录中。这种分离让职责更清晰。
  2. Button/index.ts: 为每个组件创建一个文件夹和 index.ts 文件(这被称为“桶文件” Barrel File),能让我们通过 @/components/ui/Button 这样更清晰的路径来导入。同时,未来与这个组件相关的文件(如测试文件 Button.test.tsx、文档文件 Button.stories.mdx)都可以被聚合在这个文件夹内,实现真正的“高内聚”。
  3. types/ui.ts: 将所有 UI 组件的 props 类型定义集中管理,便于查找和复用,确保了整个组件系统在类型层面的一致性。

8.4.2. 第一个组件:封装类型安全的 <Button />

我们的目标:将 <button className="btn btn-primary btn-sm"> 这种“硬编码”的 class 字符串,升级为 <Button variant="primary" size="sm"> 这种声明式、类型安全的 React 组件。

第一步:引入 clsx 工具库
在封装组件时,我们经常需要根据不同的 props 动态地拼接 className。使用模板字符串 () 来拼接虽然可行,但非常繁琐且容易出错。

clsx 是一个极简、高效的工具库,专门用于解决这个问题。

1
pnpm add clsx

现在我们可以用 clsx('btn', isLoading && 'btn-disabled') 这种更优雅的方式来组合 class。

第二步:在 types/ui.ts 中定义 ButtonProps
我们首先来定义我们按钮组件的“API”,即它能接收哪些 props

文件路径: @/types/ui.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
import type { ComponentPropsWithoutRef } from 'react';

// 定义按钮的视觉变体和尺寸
export type ButtonVariant = 'primary' | 'secondary' | 'accent' | 'ghost' | 'link';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';

// 定义 Button 组件的 Props
export type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
isOutline?: boolean; // 是否为 outline 样式
};
深度解析:`ComponentPropsWithoutRef
2025-10-08

ComponentPropsWithoutRef<'button'> 这个类型看起来很复杂,它有什么用?

这是 React 组件封装的一个核心最佳实践。它的意思是“获取原生 <button> 元素所有合法的属性类型,但不包括 ref”。

通过 & 将它与我们自己的 props(如 variant)合并,我们的 <Button /> 组件就自动继承了所有原生按钮的属性,比如 onClick, disabled, type, aria-label 等等。

明白了!这样我们的封装组件就不会丢失原生元素的能力,复用性和灵活性大大增强了。

第三步:引导学习:查阅官方文档
在实现组件之前,让我们先看看 DaisyUI 为 button 提供了哪些“原材料”。了解官方提供了哪些 class 变体,有助于我们更好地设计自己的组件 props

第四步:在 Button.tsx 中实现组件
现在,我们来编写组件的实现代码。

文件路径: @/components/ui/Button/Button.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
import { clsx } from 'clsx';
import type { ButtonProps } from '@/types/ui';

const Button = ({
variant = 'primary',
size = 'md',
loading = false,
isOutline = false,
className,
children,
...props // 将所有其他原生 button 属性传递下去
}: ButtonProps) => {

const classes = clsx(
'btn', // 基础 class
{
// 变体 classes
'btn-primary': variant === 'primary',
'btn-secondary': variant === 'secondary',
'btn-accent': variant === 'accent',
'btn-ghost': variant === 'ghost',
'btn-link': variant === 'link',
// 尺寸 classes
'btn-xs': size === 'xs',
'btn-sm': size === 'sm',
'btn-md': size === 'md',
'btn-lg': size === 'lg',
// 其他状态 classes
'btn-outline': isOutline,
'btn-disabled': loading, // loading 时禁用按钮
},
className, // 允许外部传入额外的 class
);

return (
<button className={classes} {...props}>
{loading && <span className="loading loading-spinner"></span>}
{children}
</button>
);
};

export default Button;

第五步:创建 index.ts 桶文件

文件路径: @/components/ui/Button/index.ts (新建文件)

1
2
export { default } from './Button';
export * from '@/types/ui'; // 同时导出相关的类型,方便使用

现在,我们就可以在应用的任何地方通过 import Button from '@/components/ui/Button'; 来使用这个全新的、类型安全的按钮组件了!


8.4.3. 数据展示基石:用“复合组件”模式封装 <Card />

掌握了 <Button /> 的封装模式后,我们来挑战一个更有趣的组件:<Card />。与按钮不同,卡片通常拥有更复杂的内部结构。为了在封装后依然保持这种结构的灵活性,我们将采用一种更高级的 React 模式——复合组件

核心思想: 我们不再创建一个单一的、万能的 <Card /> 组件,而是创建一个“家族”:一个主 <Card> 组件和一系列“子组件”,如 <Card.Body>, <Card.Title>, <Card.Actions>。这让我们可以像搭积木一样,以声明式的方式自由组合卡片的内部结构。

第一步:引导学习:查阅官方文档
Card 组件的结构比 Button 更丰富,它包含 card-title, card-body, card-actions 等多个部分。

第二步:在 types/ui.ts 中定义 CardProps
这次,我们的类型定义会更简单,因为大部分结构将由子组件承担。

文件路径: @/types/ui.ts (添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... ButtonProps
import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react';

// 主 Card 组件的 Props
export type CardProps = ComponentPropsWithoutRef<'div'> & {
isBordered?: boolean;
isImageFull?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
};

// Card 子组件的通用 Props (只需 children 和 className)
export type CardSubComponentProps = PropsWithChildren<{
className?: string;
}>;

第三步:实现复合组件 <Card />
我们将把所有相关的子组件都定义在 Card.tsx 这同一个文件中。

文件路径: @/components/ui/Card/Card.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
54
55
56
57
58
59
60
61
import { clsx } from 'clsx';
import type { CardProps, CardSubComponentProps } from '@/types/ui';

// --- 子组件定义 ---

const Figure = ({ children, className }: CardSubComponentProps) => (
<figure className={className}>{children}</figure>
);

const Body = ({ children, className }: CardSubComponentProps) => (
<div className={clsx('card-body', className)}>{children}</div>
);

const Title = ({ children, className }: CardSubComponentProps) => (
<h2 className={clsx('card-title', className)}>{children}</h2>
);

const Actions = ({ children, className }: CardSubComponentProps) => (
<div className={clsx('card-actions', className)}>{children}</div>
);


// --- 主组件定义 ---

const Card = ({
isBordered = false,
isImageFull = false,
size,
className,
children,
...props
}: CardProps) => {

const classes = clsx(
'card',
{
'card-bordered': isBordered,
'image-full': isImageFull,
'card-xs': size === 'xs',
'card-sm': size === 'sm',
'card-md': size === 'md',
'card-lg': size === 'lg',
},
'bg-base-100 shadow-xl',
className,
);

return (
<div className={classes} {...props}>
{children}
</div>
);
};

// --- 将子组件附加到主组件上 ---
Card.Figure = Figure;
Card.Body = Body;
Card.Title = Title;
Card.Actions = Actions;

export default Card;
深度解析:复合组件的优势
2025-10-08

这种写法看起来有点像 Form.Item,它和只用一个 <Card> 组件传一堆 props (比如 title, actions) 有什么区别?

问得好!主要区别在于 灵活性 和 所有权。

如果用 props,你就被限制死了,比如 actions 必须在 title 下面。但用复合组件,你可以自由决定结构,甚至可以在 Card.Body 里放两个 Card.Actions,一个居左一个居右。

更重要的是“所有权”。<Card.Title> 只是一个加了 className<h2>,它的 children 完全由你掌控,可以是字符串,也可以是另一个 React 组件。而 title="some string" 这种 prop 则限制了你只能传入字符串。复合组件给了使用者最大的控制权。

明白了,它在“封装”和“灵活”之间找到了完美的平衡。

第四步:更新 index.ts
我们的 index.ts 现在需要导出主组件。

文件路径: @/components/ui/Card/index.ts (修改)

1
2
3
4
// 我们只需要默认导出主 Card 组件
// 子组件会作为 Card 的属性被访问 (Card.Title)
export { default } from './Card';
export * from '@/types/ui';

8.4.4. 即时消费:在页面中验证我们的新组件

学完就要立刻用!我们来创建一个专门的测试页面,立即感受一下我们亲手封装的组件的威力。

第一步:创建测试页面

文件路径: @/pages/ComponentTestPage.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
import Card from "@/components/ui/Card";

const ComponentTestPage = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 卡片测试 */}
<Card>
<Card.Figure>
<img
src="https://bu.dusays.com/2025/10/03/68df32ed02e5d.png"
alt="Shoes"
/>
</Card.Figure>
<Card.Body>
<Card.Title>
封装的复合卡片! <div className="badge badge-secondary">NEW</div>
</Card.Title>
<p>现在我们可以自由组合卡片的各个部分了。</p>
</Card.Body>
<Card.Actions className="justify-end">
<div className="badge badge-accent">Prorise is</div>
<div className="badge badge-primary">Best Student</div>
</Card.Actions>
</Card>
</div>
);
};

export default ComponentTestPage;\

第二步:将测试页面配置为首页
为了能立刻看到效果,我们暂时将这个测试页设置为我们应用的默认首页。

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "@/layouts/AppLayout";
import ComponentTestPage from "@/pages/ComponentTestPage"; // 1. 导入测试页

const router = createBrowserRouter([
{
path: "/",
element: <AppLayout />,
children: [
{
index: true, // 2. 将测试页设置为默认子路由
element: <ComponentTestPage />,
},
{
path: "about",
element: <div>这是关于页面</div>,
},
],
},
]);

export default router;

现在,刷新您的应用首页。您应该能立刻看到我们亲手封装的 <Button /><Card /> 组件正在完美地工作!这个“编码 -> 消费 -> 反馈”的即时闭环,是最高效的学习方式。

image-20251003204504694


本节小结

我们成功地完成了从“class 使用者”到“组件创造者”的关键一步,并建立了一套可复用的封装模式:

  1. 规划结构: 建立了清晰的 components/uitypes/ui.ts 目录结构。
  2. 简单组件封装: 掌握了使用 clsx...props 继承来封装 <Button> 这样的原子组件。
  3. 复合组件封装: 学会了使用 复合组件模式 来封装 <Card /> 这样具有复杂内部结构的组件,在保持灵活性的同时提供了清晰的 API。
  4. 即时消费: 养成了“编码后立即在页面中消费和验证”的良好习惯,形成了高效的学习反馈闭环。

8.5. 布局的构建:搭建 Dashboard 的响应式骨架

我们已经锻造好了第一批“原子零件”(<Button>, <Card>),现在是时候搭建一个“车间”——我们的主仪表盘布局——来容纳和组织它们了。

本节的核心任务是构建一个专业的、带可伸缩侧边栏的响应式布局。在此过程中,我们将掌握一个新的、含金量极高的封装技巧。

8.5.1. 新知识点:受控组件与 Context API

痛点: DaisyUI 的 Drawer (抽屉/侧边栏) 组件,其默认实现是基于一个隐藏的 checkbox<label> 标签。点击 <label> 会改变 checkbox 的选中状态,从而通过 CSS 的 ~+ 选择器来控制抽屉的开关。这种纯 CSS 方案很巧妙,但有一个巨大缺陷:它的状态(开/关)完全由 DOM 控制,我们的 React 组件无法直接获知或改变它。想象一下,如果想在某个子页面的按钮点击时关闭侧边栏,我们将无能为力。

解决方案:

  1. 受控组件: 我们将用 React 的 useState 来接管这个 checkbox 的状态。checkbox 是否选中,将不再由用户直接点击决定,而是完全由我们的 React state 决定。这样,我们就将一个“不受控”的纯 CSS 组件,升级为了一个“受控”的 React 组件。
  2. Context API: 为了避免通过一层层的 props 将“侧边栏状态”和“开关函数”传递给深层的子页面(这被称为“prop drilling”),我们将使用 React 的 Context API。创建一个全局的 DashboardContext,任何嵌套在布局内的子页面,都可以轻松地通过一个自定义 Hook (useDashboard) 来访问和控制侧边栏。

这个模式,是构建高内聚、低耦合复杂布局的基石。

8.5.2. 实战:创建 <DashboardLayout />

第一步:引导学习:查阅官方文档
在动手之前,先来了解一下我们将要使用的“原材料”:Drawer 用于构建侧边栏布局,Navbar 用于顶栏。

第二步:定义 Context 与自定义 Hook
我们为 Dashboard 的共享状态创建一个专属的 Context。

文件路径: @/contexts/DashboardContext.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
import { createContext, useContext, useState, type PropsWithChildren } from 'react';

// 1. 定义 Context 中要共享的数据结构
interface DashboardContextType {
isSidebarOpen: boolean;
toggleSidebar: () => void;
}

// 2. 创建 Context,初始值为 undefined
const DashboardContext = createContext<DashboardContextType | undefined>(undefined);

// 3. 创建 Provider 组件,它将管理状态并提供给子组件
export const DashboardProvider = ({ children }: PropsWithChildren) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const toggleSidebar = () => setSidebarOpen(prev => !prev);

const value = { isSidebarOpen, toggleSidebar };

return (
<DashboardContext.Provider value={value}>
{children}
</DashboardContext.Provider>
);
};

// 4. 创建自定义 Hook,简化 Context 的消费
export const useDashboard = () => {
const context = useContext(DashboardContext);
if (context === undefined) {
throw new Error('useDashboard must be used within a DashboardProvider');
}
return context;
};

第三步:构建受控的 <DashboardLayout /> 组件

文件路径: @/layouts/DashboardLayout.tsx (修改 AppLayout.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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { Outlet, NavLink } from "react-router-dom";
import { DashboardProvider, useDashboard } from "@/contexts/DashboardContext";
import ThemeSwitcher from "@/components/ThemeSwitcher";

// 菜单项组件,为了代码清晰
const MenuItems = () => (
<ul className="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
<li>
<NavLink to="/dashboard">仪表盘首页</NavLink>
</li>
<li>
<NavLink to="/dashboard/settings">系统设置</NavLink>
</li>
<li className="mt-auto">
<NavLink to="/">返回主站</NavLink>
</li>
</ul>
);

// 真正被导出的布局组件
const DashboardLayoutInternal = () => {
const { isSidebarOpen, toggleSidebar } = useDashboard();

return (
// div.drawer: DaisyUI 抽屉布局的根容器
<div className="drawer">
{/* input.drawer-toggle: 隐藏的 checkbox,状态由 React 控制 */}
<input
id="sidebar"
type="checkbox"
className="drawer-toggle"
checked={isSidebarOpen}
onChange={toggleSidebar}
/>
{/* div.drawer-content: 页面主内容区 */}
<div className="drawer-content flex flex-col">
<header className="navbar bg-base-100 shadow-md">
{/* 菜单按钮 - 用于切换侧边栏 */}
<label htmlFor="sidebar" className="btn btn-ghost drawer-button">
<i className="fa-solid fa-bars"></i>
</label>
<div className="flex-1">
<a className="btn btn-ghost text-xl">Dashboard</a>
</div>
{/* 主题切换组件 */}
<div className="flex-none">
<ThemeSwitcher />
</div>
</header>

<main className="flex-grow p-4 bg-base-200">
{/* 子页面将在这里渲染 */}
<Outlet />
</main>
</div>

{/* div.drawer-side: 侧边栏容器 (注意:这里应该和 drawer-content 平级) */}
<div className="drawer-side">
{/* label.drawer-overlay: 抽屉打开时,覆盖主内容区的蒙层,点击可关闭抽屉 */}
<label
htmlFor="sidebar"
aria-label="close sidebar"
className="drawer-overlay"
></label>
{/* 侧边栏菜单 */}
<MenuItems />
</div>
</div>
);
};

export default function DashboardLayout() {
return (
<DashboardProvider>
<DashboardLayoutInternal />
</DashboardProvider>
);
}

8.5.3. 路由集成与消费 Context

第一步:更新路由配置
我们将为 /dashboard 创建一组新的路由,并应用我们全新的布局。

文件路径: @/router/index.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
import { createBrowserRouter } from 'react-router-dom';
import AppLayout from '@/layouts/AppLayout';
import DashboardLayout from '@/layouts/DashboardLayout'; // 1. 导入新布局
import ComponentTestPage from '@/pages/ComponentTestPage';

// 2. 为 Dashboard 创建一个简单的子页面
const DashboardHomePage = () => <div>欢迎来到仪表盘!</div>;
const SettingsPage = () => <div>这里是系统设置页面。</div>;


const router = createBrowserRouter([
// 主站路由
{
path: '/',
element: <AppLayout />,
children: [
{
index: true,
element: <ComponentTestPage />,
},
],
},
// Dashboard 路由组
{
path: '/dashboard',
element: <DashboardLayout />, // 3. 应用 Dashboard 布局
children: [
{
index: true,
element: <DashboardHomePage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
]);

export default router;

第二步(见证奇迹):在子页面中消费 Context
现在,我们来创建一个子页面,它能“隔空”控制父布局的状态。

文件路径: @/pages/dashboard/Settings.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
import { useDashboard } from '@/contexts/DashboardContext';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';

const Settings = () => {
// 1. 无需 props,直接通过自定义 Hook 获取布局的控制权
const { isSidebarOpen, toggleSidebar } = useDashboard();

return (
<Card>
<Card.Body>
<Card.Title>系统设置</Card.Title>
<p>
当前侧边栏状态: <strong>{isSidebarOpen ? '打开' : '关闭'}</strong>
</p>
<Card.Actions>
{/* 2. 在深层子组件中,轻松控制父布局的行为 */}
<Button onClick={toggleSidebar} variant="primary">
切换侧边栏状态
</Button>
</Card.Actions>
</Card.Body>
</Card>
);
};

export default Settings;

现在,访问 /dashboard/settings。您会发现,点击页面中的按钮,可以轻松地打开和关闭外层的侧边栏。我们成功地实现了高内聚、低耦合的布局状态管理!

img


本节小结

我们通过构建一个专业的 Dashboard 布局,掌握了一套含金量极高的封装模式:

  • 受控组件: 学会了如何用 React state 接管纯 CSS 组件(如 DaisyUI Drawer)的内部状态,使其行为完全可被预测和控制。
  • Context API: 掌握了使用 Context 创建一个“状态提供者”(Provider)和一个“状态消费者”(custom Hook),从而优雅地解决了跨层级组件通信的问题,避免了繁琐的属性钻探。
  • 学以致用: 我们将新学的封装模式与之前创建的原子组件 (Button, Card) 结合,构建了一个真实、可交互的复杂布局。

8.6. 封装的艺术(下):组合原子组件,创造“微件” (Widgets)

我们已经拥有了“原子”级别的 UI 砖块(<Button>, <Card>)和“房屋”骨架(<DashboardLayout>)。现在,是时候开始打造内部的“精装家具”了——那些由原子组件组合而成的、具有特定业务含义的“微件”。

一个“微件”是自包含的、功能明确的 UI 模块,例如一个数据统计卡片、一个待办事项列表或一个用户头像组合。它是我们迈向最终 Dashboard 界面的关键一步。

8.6.1. 新知识点:使用 React.ReactNode 创建灵活的“插槽”

在封装一个微件时,我们经常会遇到一个问题:某些部分的内容是多变的。例如,一个统计卡片可能有时需要一个图标,有时不需要;图标本身可能是来自 Font Awesome 的 <i> 标签,也可能是一个自定义的 <svg> 组件,甚至是 Ant Design 的 <Icon />

不良实践: iconName: string。如果我们的 prop 是这样设计的,那么 <StatCard> 组件内部就必须写死对特定图标库(如 Font Awesome)的依赖,这大大降低了它的可复用性,造成了 高耦合

最佳实践: icon: React.ReactNodeReact.ReactNode 是 React 中一个非常宽泛的类型,它可以是 JSX.Element, string, number, null 等任何可以被 React 渲染的东西。

深度解析:`React.ReactNode` 插槽的威力
2025-10-09

所以,将 prop 的类型定义为 React.ReactNode,就相当于为这个组件开了一个“插槽”?

完全正确!你可以把它理解成 Vue 中的 <slot>。父组件可以向这个“插槽”里填充任何它想渲染的内容。

我们的 <StatCard> 组件不再关心“这个图标是什么”,它只负责“在这里为图标留出一个位置,并把它渲染出来”。图标的具体实现完全由使用者决定,可以是 <i>, <img>, <span> 甚至是另一个 React 组件。

明白了!这彻底解耦了组件与其内容的依赖关系,达到了极致的灵活性和低耦合。

8.6.2. 组合的力量:封装 <StatCard /> 微件

目标: 我们要封装一个用于在 Dashboard 首页展示关键指标(KPI)的数据统计卡片。

image-20251004084918280

第一步:引导学习:查阅官方文档
我们将使用 DaisyUI 的 Stat (统计) 组件作为样式基础。

第二步:规划新的文件结构并定义 Props
为了区分“原子”和“微件”,我们在 components 目录下新建一个 widgets 文件夹。

1
2
3
4
5
6
7
src/
└── components/
├── ui/ # 原子组件
└── widgets/ # 微件/业务组件
└── StatCard/
├── StatCard.tsx
└── index.ts

文件路径: @/types/ui.ts (添加)

1
2
3
4
5
6
7
8
9
10
// ... CardProps 等
import type { ReactNode } from 'react';

export type StatCardProps = {
title: string;
value: string;
description?: string;
icon?: ReactNode; // <-- 使用 ReactNode 作为 icon 的类型
className?: string;
};

第三步:实现 <StatCard /> 组件
这个组件将完美地展示如何将我们之前封装的“原子”(Card)和 DaisyUI 的原生 class 组合在一起,并利用 ReactNode 插槽。

文件路径: @/components/widgets/StatCard/StatCard.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
import Card from '@/components/ui/Card';
import type { StatCardProps } from '@/types/ui';
import { clsx } from 'clsx';

const StatCard = ({ title, value, description, icon, className }: StatCardProps) => {
return (
// 1. 使用我们之前封装的 Card 组件作为根容器
<Card className={clsx('shadow-md', className)}>
<Card.Body>
{/* div.stat: DaisyUI 统计组件的容器 */}
<div className="stat">
{/* stat-figure: 用于放置图标或图片的插槽容器 */}
{icon && (
<div className="stat-figure text-secondary">
{icon}
</div>
)}
{/* stat-title: 统计项的标题 */}
<div className="stat-title">{title}</div>
{/* stat-value: 统计项的核心数值 */}
<div className="stat-value">{value}</div>
{/* stat-desc: 统计项的补充描述 */}
{description && <div className="stat-desc">{description}</div>}
</div>
</Card.Body>
</Card>
);
};

export default StatCard;

第四步:创建 index.ts 桶文件

文件路径: @/components/widgets/StatCard/index.ts (新建文件)

1
2
export { default } from "./StatCard";
export type { StatCardProps } from "@/types/ui";

8.6.3. 即时消费:在 Dashboard 首页展示统计数据

现在,让我们立即在 Dashboard 首页使用我们全新的 <StatCard /> 微件。

文件路径: @/pages/dashboard/DashboardHome.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
import StatCard from '@/components/widgets/StatCard';

const DashboardHomePage = () => {
return (
<div>
<h1 className="text-2xl font-bold mb-4">仪表盘概览</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="总销售额"
value="$89,400"
description="比上月增长 21%"
// 演示 icon 插槽的灵活性传入一个 Font Awesome 图标
icon={<i className="fa-solid fa-dollar-sign text-4xl"></i>}
/>
<StatCard
title="新增用户"
value="1,200"
description="日活用户 5k"
// 传入另一个不同的 Font Awesome 图標
icon={<i className="fa-solid fa-users text-4xl"></i>}
/>
<StatCard
title="总订单量"
value="5,600"
// 甚至可以传入 Emoji
icon={<span className="text-4xl">📦</span>}
/>
<StatCard
title="待办任务"
value="32"
description="2个紧急任务"
// 或者不传入 icon
/>
</div>
</div>
);
};

export default DashboardHomePage;

现在,访问您的 /dashboard 页面。一个专业、美观、数据清晰的统计卡片区域已经呈现在您的眼前!您也亲身体会到了 ReactNode 插槽带来的巨大灵活性。

image-20251004082126310


本节小结

我们成功地从“原子”迈向了“分子”,掌握了构建复杂微件的核心技巧:

  • 组件组合: 学会了如何将基础的原子组件 (<Card>) 与 DaisyUI 的原生 class (stat-*) 组合,创造出具有特定业务含义的新组件 (<StatCard>)。
  • ReactNode 插槽: 掌握了一种高级封装模式,通过将 props 类型定义为 ReactNode,为组件创建了高度灵活、低耦合的内容“插槽”。
  • 分层结构: 在文件系统中,我们通过创建 widgets 目录,将业务相关的微件与通用的原子组件进行了物理隔离,使项目结构更加清晰。

8.7. 封装的艺术(续):构建交互式与数据密集型微件

我们已经拥有了“原子”砖块,现在是时候将它们组合成具有特定功能的“模块”了。本节,我们将聚焦于 Dashboard 中最常见的两类微件:一个可交互的任务列表,和一个用于展示订单的数据表格。

8.7.1. 新知识点:状态提升与回调函数

场景: 我们要创建一个任务列表,每个任务项前面都有一个复选框,用户可以勾选来标记任务的完成状态。

image-20251004084852823

思考: “已完成”这个状态,应该由谁来管理?是每个 <TaskItem /> 自己管理自己的 checked 状态,还是由父组件 <TaskList /> 统一管理一个包含所有任务状态的数组?

最佳实践: 状态提升。对于列表类数据,其“唯一数据来源”应该存在于父组件中。父组件负责维护整个数据数组,并向子组件传递:

  1. 子组件自身所需的数据 (task 对象)。
  2. 一个用于“请求”父组件修改状态的 回调函数 (onToggleTask)。

知识转译: 这个模式与 Vue 的父子通信理念异曲同工。在 Vue 中,子组件通过 $emit 向上传递一个事件,父组件监听这个事件并修改数据。在 React 中,父组件直接将一个 能够修改自己状态的函数 作为 prop 向下传递给子组件,子组件在需要时调用这个函数即可。殊途同归,核心都是“让状态的管理者来执行修改操作”。

8.7.2. 实战:封装交互式 <TaskList /> 微件

第一步:引导学习:查阅“原材料”文档
我们将用到 List, Checkbox, BadgeJoin 等组件。

第二步:定义 Task 相关类型

文件路径: @/types/ui.ts (添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ... 其他类型

export type TaskStatus = 'Pending' | 'In Progress' | 'Completed';

export interface Task {
id: number;
text: string;
isCompleted: boolean;
status: TaskStatus;
}

export interface TaskListProps {
tasks: Task[];
onToggleTask: (id: number) => void;
onDeleteTask: (id: number) => void;
}

export interface TaskItemProps {
task: Task;
onToggle: () => void;
onDelete: () => void;
}

第三步:封装“哑”组件 <TaskItem />
子组件 TaskItem 只负责渲染,不包含任何状态逻辑。

文件路径: @/components/widgets/TaskList/TaskItem.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
import type { TaskItemProps } from '@/types/ui';
import { clsx } from 'clsx';

const statusBadgeMap = {
'Pending': 'badge-warning',
'In Progress': 'badge-info',
'Completed': 'badge-success',
};

const TaskItem = ({ task, onToggle, onDelete }: TaskItemProps) => {
return (
<li className="flex items-center justify-between p-2 hover:bg-base-200 rounded-lg">
<div className="flex items-center gap-3">
<input
type="checkbox"
className="checkbox"
checked={task.isCompleted}
onChange={onToggle}
/>
<span className={clsx(task.isCompleted && 'line-through text-base-content/50')}>
{task.text}
</span>
</div>
<div className="flex items-center gap-2">
<div className={clsx('badge', statusBadgeMap[task.status])}>
{task.status}
</div>
<button className="btn btn-xs btn-ghost" onClick={onDelete}>
<i className="fa-solid fa-trash"></i>
</button>
</div>
</li>
);
};

export default TaskItem;

第四步:封装“聪明”组件 <TaskList />
父组件 TaskList 也不管理状态,它只负责接收状态和回调,并循环渲染子组件。

文件路径: @/components/widgets/TaskList/TaskList.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 { TaskListProps } from '@/types/ui';
import Card from '@/components/ui/Card';
import TaskItem from './TaskItem';

const TaskList = ({ tasks, onToggleTask, onDeleteTask }: TaskListProps) => {
return (
<Card>
<Card.Body>
<Card.Title>团队任务列表</Card.Title>
<ul className="space-y-2 mt-4">
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={() => onToggleTask(task.id)}
onDelete={() => onDeleteTask(task.id)}
/>
))}
</ul>
</Card.Body>
</Card>
);
};

export default TaskList;

桶文件: 别忘了在 @/components/widgets/TaskList/index.ts 中导出 TaskList

第五步:在页面中“消费”并管理状态
真正的状态管理发生在页面组件中。

文件路径: @/pages/dashboard/DashboardHome.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
import { useState } from 'react';
import StatCard from '@/components/widgets/StatCard';
import TaskList from '@/components/widgets/TaskList'; // 1. 导入 TaskList
import type { Task } from '@/types/ui';

// 2. 模拟初始任务数据
const initialTasks: Task[] = [
{ id: 1, text: '完成项目周报', isCompleted: false, status: 'In Progress' },
{ id: 2, text: '修复 Bug #1024', isCompleted: false, status: 'Pending' },
{ id: 3, text: '部署到生产环境', isCompleted: true, status: 'Completed' },
];

const DashboardHomePage = () => {
// 3. 在页面级组件中“提升”状态
const [tasks, setTasks] = useState(initialTasks);

// 4. 定义回调函数来修改状态
const handleToggleTask = (id: number) => {
setTasks(tasks.map(t => t.id === id ? { ...t, isCompleted: !t.isCompleted } : t));
};

const handleDeleteTask = (id: number) => {
setTasks(tasks.filter(t => t.id !== id));
};

return (
<div className="space-y-4">
{/* StatCard 部分保持不变 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* ... StatCard instances */}
</div>

{/* 5. 渲染 TaskList,并传入状态和回调函数 */}
<TaskList
tasks={tasks}
onToggleTask={handleToggleTask}
onDeleteTask={handleDeleteTask}
/>
</div>
);
};

export default DashboardHomePage;

现在,您的 Dashboard 首页就有了一个功能完整的、可交互的任务列表!

img

8.7.3. 新知识点:数据驱动的渲染 API

场景: 我们要创建一个展示订单列表的表格。表格的列(显示什么字段、如何格式化)可能会根据不同的页面需求而变化。

image-20251004090126290

不良实践: 在 <OrderTable /> 组件内部硬编码 <thead><tbody> 的所有列。这会导致该组件只能用于展示订单,无法复用。

最佳实践: 设计一个 数据驱动的渲染 API。让我们的表格组件接收两个核心 props

  1. data: T[]: 一个泛型数据数组,可以是任何类型的数据。
  2. columns: ColumnDef<T>[]: 一个列定义数组。每个列定义对象都精确地告诉表格:
    • header: 这一列的表头是什么。
    • accessorKey: 应该从数据对象中取哪个字段的值。
    • cell (可选): 如果需要自定义渲染(比如把状态文字渲染成一个徽章),则提供一个自定义渲染函数。

这种模式将 数据结构定义渲染组件 三者完全解耦,是所有专业级表格、列表组件的基石。

8.7.4. 实战:封装可复用的 <DataTable /> 微件

第一步:定义泛型 Props
我们将使用 TypeScript 的泛型来实现类型的灵活性。

文件路径: @/types/ui.ts (添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... 其他类型
import type { ReactNode } from 'react';

// T 代表传入的任意数据行类型
export type ColumnDef<T> = {
header: string;
accessorKey: keyof T;
cell?: (value: any) => ReactNode; // 自定义渲染函数
};

export type DataTableProps<T> = {
data: T[];
columns: ColumnDef<T>[];
};

第二步:实现泛型组件 <DataTable />

文件路径: @/components/widgets/DataTable/DataTable.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
import type { DataTableProps } from '@/types/ui';

// 使用 <T extends {}> 声明一个泛型组件
function DataTable<T extends {}>({ data, columns }: DataTableProps<T>) {
return (
<div className="overflow-x-auto">
{/* table.table.table-zebra: DaisyUI 表格组件,带斑马条纹 */}
<table className="table table-zebra">
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.accessorKey)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col, colIndex) => {
const value = row[col.accessorKey];
return (
<td key={colIndex}>
{/* 如果定义了 cell 渲染函数,则使用它,否则直接显示值 */}
{col.cell ? col.cell(value) : String(value)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}

export default DataTable;

桶文件: 别忘了在 @/components/widgets/DataTable/index.ts 中导出 DataTable

第三步:在页面中“消费” <DataTable />
我们将为 DashboardHomePage 添加一个“最近订单”表格。

文件路径: @/pages/dashboard/DashboardHome.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
54
55
56
57
58
59
60
61
62
63
// ... imports
import DataTable from '@/components/widgets/DataTable';
import type { ColumnDef } from '@/types/ui';

// 模拟订单数据类型和数据
type Order = {
id: string;
customer: string;
amount: number;
status: 'Pending' | 'Shipped' | 'Cancelled';
};

const ordersData: Order[] = [
{ id: '#001', customer: '张三', amount: 128.00, status: 'Shipped' },
{ id: '#002', customer: '李四', amount: 49.90, status: 'Pending' },
{ id: '#003', customer: '王五', amount: 256.00, status: 'Cancelled' },
];

// 为订单数据定义列
// 为订单数据定义列
const orderColumns: ColumnDef<Order>[] = [
{ header: "订单号", accessorKey: "id" },
{ header: "客户", accessorKey: "customer" },
{
header: "金额",
accessorKey: "amount",
cell: (value) => ${Number(value).toFixed(2)}`,
},
{
header: "状态",
accessorKey: "status",
// 演示强大的自定义渲染
cell: (value) => {
const status = value as Order["status"];
const badgeClass = {
Pending: "badge-warning",
Shipped: "badge-success",
Cancelled: "badge-error",
}[status];
return <div className={`badge ${badgeClass}`}>{status}</div>;
},
},
];

const DashboardHomePage = () => {
// ... tasks state and handlers

return (
<div className="space-y-4">
{/* ... StatCard and TaskList ... */}

{/* 渲染我们的数据表格 */}
<Card>
<Card.Body>
<Card.Title>最近订单</Card.Title>
<DataTable data={ordersData} columns={orderColumns} />
</Card.Body>
</Card>
</div>
);
};

export default DashboardHomePage;

刷新 Dashboard 首页,一个结构清晰、带有自定义状态徽章的订单表格就呈现在您眼前了。最重要的是,我们的 <DataTable /> 组件现在可以用来渲染任何类型的数据!


本节小结

我们通过构建两个核心微件,成功掌握了两种专业级的组件封装模式:

  • 状态提升与回调: 通过在父组件中管理状态,并向子组件传递回调函数,我们构建了可预测、易于维护的交互式列表。
  • 数据驱动渲染 API: 通过 datacolumns props,我们构建了高度可复用、与具体业务逻辑解耦的数据展示组件,并初次体验了 TypeScript 泛型的威力。

image-20251004091551698


8.8. 终极组装:整合动态数据与最终布局

这是我们本章的最终冲刺。我们将把之前精心封装的所有组件和微件组装起来,用 loader 替换掉所有的静态模拟数据,并使用 Tailwind CSS Grid 系统完成专业的页面布局,最终呈现一个数据驱动、外观精美的 Dashboard 首页。

8.8.1. 新知识点:页面 Loader 作为“数据枢纽”

痛点: 目前,我们的 DashboardHomePage 组件内部充满了各种 useState 和模拟数据。如果每个微件(如 <StatCard>, <TaskList>)都自己去 useEffect 获取数据,我们又会回到 7.4 节所批判的“请求瀑布流”的老路,导致页面加载缓慢。

最佳实践: 将页面的 loader 作为一个“数据枢纽” (Data Hub)。由它负责 并行 发起当前页面所需的所有数据请求,然后将结果聚合在一个对象中返回。页面组件则扮演“数据分发者”的角色,从 useLoaderData 中获取全部数据,再将相应的部分传递给各个子微件。

深度解析:数据枢纽模式的优势
2025-10-10

也就是说,/dashboard 页面的 loader 要负责获取统计、任务、订单三份数据?

完全正确。你可以使用 Promise.all 来并行触发这三个 API 请求。这样,所有数据都在一个网络往返周期内开始加载,性能最优。

loader 最终返回一个类似 { stats, tasks, orders } 的对象。DashboardHomePage 组件拿到这个对象后,将 stats 数据传给 <StatCard>,将 tasks 数据传给 <TaskList>,以此类推。

我明白了。这个模式下,数据获取的依赖关系非常清晰,全部集中在路由的 loader 中,而页面和微件本身保持了“纯粹”——只负责接收 props 并渲染。

8.8.2. 实战:创建 dashboardLoader 并注入动态数据

第一步:在页面文件中创建 loader
我们将为 DashboardHomePage 编写一个 loader,用于模拟并行获取所有数据。

文件路径: @/pages/dashboard/DashboardHome.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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { useState } from 'react';
import { useLoaderData } from 'react-router-dom'; // 1. 导入 useLoaderData
import StatCard from '@/components/widgets/StatCard';
import TaskList from '@/components/widgets/TaskList';
import DataTable from '@/components/widgets/DataTable';
import Card from '@/components/ui/Card';
import type { Order, Task, StatCardProps } from "@/types/ui";

// --- 2. 定义 Loader ---
// 模拟 API 调用
const fetchStats = () => new Promise(resolve => setTimeout(() => resolve([
{ title: "总销售额", value: "$89,400", description: "比上月增长 21%", icon: <i className="fa-solid fa-dollar-sign text-4xl"></i> },
{ title: "新增用户", value: "1,200", description: "日活用户 5k", icon: <i className="fa-solid fa-users text-4xl"></i> },
]), 500));

const fetchTasks = () => new Promise(resolve => setTimeout(() => resolve([
{ id: 1, text: '完成项目周报', isCompleted: false, status: 'In Progress' },
{ id: 2, text: '修复 Bug #1024', isCompleted: false, status: 'Pending' },
{ id: 3, text: '部署到生产环境', isCompleted: true, status: 'Completed' },
]), 800));

const fetchOrders = () => new Promise(resolve => setTimeout(() => resolve([
{ id: '#001', customer: '张三', amount: 128.00, status: 'Shipped' },
{ id: '#002', customer: '李四', amount: 49.90, status: 'Pending' },
{ id: '#003', customer: '王五', amount: 256.00, status: 'Cancelled' },
]), 1200));

// 定义 loader 函数,并导出
export const loader = async (): Promise<{
stats: StatCardProps[];
tasks: Task[];
orders: Order[];
}> => {
console.log("Dashboard loader is running...");
const [stats, tasks, orders] = await Promise.all([
fetchStats() as Promise<StatCardProps[]>,
fetchTasks() as Promise<Task[]>,
fetchOrders() as Promise<Order[]>,
]);
return { stats, tasks, orders };
};

// --- 3. 定义组件 ---
// ... Order 类型和 columns 定义 ...

const DashboardHomePage = () => {
// 4. 使用 useLoaderData 获取 loader 返回的所有数据
const { stats, tasks: initialTasks, orders } = useLoaderData() as Awaited<ReturnType<typeof loader>>;

// 对于交互式列表,我们仍需 useState 来管理客户端状态,并用 loader 数据做初始化
const [tasks, setTasks] = useState(initialTasks);

const handleToggleTask = (id: number) => {
setTasks(tasks.map(t => t.id === id ? { ...t, isCompleted: !t.isCompleted } : t));
};

const handleDeleteTask = (id: number) => {
setTasks(tasks.filter(t => t.id !== id));
};

return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">仪表盘概览</h1>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => <StatCard key={index} {...stat} />)}
</div>

<TaskList
tasks={tasks}
onToggleTask={handleToggleTask}
onDeleteTask={handleDeleteTask}
/>

<Card>
<Card.Body>
<Card.Title>最近订单</Card.Title>
<DataTable data={orders} columns={orderColumns} />
</Card.Body>
</Card>
</div>
);
};

注意: 对于像 TaskList 这样既需要服务端初始数据,又需要在客户端进行交互(勾选、删除)的组件,最佳实践是将 loader 的数据作为 useState初始值。用户的后续操作会更新 state,如果需要将变更持久化到后端,则应使用 useFetcher 调用一个 action (如 第七章路由章节 所示)。

第二步:更新路由配置

文件路径: @/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
import DashboardLayout from '@/layouts/DashboardLayout';
// 1. 从页面导入 loader 和 Component
import DashboardHomePage, { loader as dashboardLoader } from '@/pages/dashboard/DashboardHome';

// ...
const router = createBrowserRouter([
// ...
{
path: '/dashboard',
element: <DashboardLayout />,
children: [
{
index: true,
element: <DashboardHomePage />,
loader: dashboardLoader, // 2. 将 loader 关联到路由
},
// ...
],
},
]);

export default router;

本节小结

我们完成了本章的最终目标,将所有知识点融会贯通:

  • 页面 Loader 作为“数据枢纽”: 掌握了在一个路由的 loader 中,通过 Promise.all 并行请求页面所需全部数据的高级模式。
  • 数据分发: 学会了在页面级组件中通过 useLoaderData 接收聚合数据,并将其作为 props 分发给下层的各个业务微件。

至此,我们不仅构建了一个漂亮的界面,更重要的是,我们构建了一个数据流清晰、组件化、高内聚低耦合的、专业级的 React 应用模块。


8.9. 本章核心总结

恭喜您!我们已经完成了一次从“组件库使用者”到“组件系统构建者”的深度旅程。通过以 DaisyUI 为“原材料”,我们不仅成功地构建了一个功能完备、数据驱动的 Dashboard,更重要的是,我们掌握了一套专业、可扩展的 React 组件化思想和工程实践。

8.9.1. 组件封装的核心原则回顾

本章,我们通过封装不同类型的组件,层层递进地学习了四种含金量极高的封装模式:

  1. 原子组件 (<Button />): 学会了使用 clsx 动态拼接类名,并通过继承原生 props (ComponentPropsWithoutRef) 来封装基础的 UI 单元。
  2. 复合组件 (<Card />): 掌握了使用 Card.Body, Card.Title 等子组件的模式,在提供统一封装的同时,赋予了使用者最大的布局灵活性。
  3. 受控组件与 Context (<DashboardLayout />): 学会了用 React state 接管纯 CSS 组件的状态,并通过 Context API 将状态和控制权优雅地共享给深层子组件,实现了布局与页面的解耦。
  4. 数据驱动 API (<DataTable />): 掌握了通过 datacolumns props 来驱动渲染的专业模式,并利用 TypeScript 泛型构建了高度可复用的数据密集型组件。

这四种模式,是您未来面对任何复杂组件需求时的“武功秘籍”。

8.9.2. 我们构建的项目结构盘点

回顾我们的项目,它已经形成了一个清晰、专业、高内聚低耦合的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
src/
├── components/
│ ├── ui/ # 原子组件 (Button, Card)
│ └── widgets/ # 业务微件 (StatCard, TaskList, DataTable)
├── contexts/
│ └── DashboardContext.tsx # 布局状态上下文
├── layouts/
│ ├── AppLayout.tsx # 主站布局
│ └── DashboardLayout.tsx # 仪表盘布局
├── pages/
│ └── dashboard/
│ └── DashboardHome.tsx # 包含 loader 的页面组件
├── router/
│ └── index.tsx # 中心化路由配置
└── types/
└── ui.ts # 统一的 UI 组件类型定义

这套结构兼顾了可维护性与可扩展性,可以直接作为您未来新项目的脚手架。

8.9.3. 核心技术栈总结

我们成功地将一系列现代化工具协同在一起,形成了一套高效的工程流:

  • 样式: Tailwind CSS v4 + DaisyUI v5,享受了“CSS-First”的极速编译和“语义化主题”的强大能力。
  • 组件化: 遵循了从“原子”到“微件”再到“布局”的自下而上的构建思路。
  • 数据流: 复习并应用了 React Routerloader 作为“数据枢纽”,为整个页面提供数据。
  • 工程化: 配置了 Vite 路径别名,并建立了清晰的文件分层。

您现在拥有的,不仅仅是一个 Dashboard 示例,更是一整套经过验证的、可复刻的现代化 React 应用开发方法论。