0%

Shadcn + Tailwind + Vite + React——现代前端开发环境搭建

shadcn/ui 这个宝藏库,真是相见恨晚!
不到半小时帮你搭起了一个漂亮的后台管理系统,这效率简直了~

介绍

  在前端开发中,我们经常需要快速搭建一个美观、现代化的用户界面。传统的 UI 组件库如 Ant Design、Material-UI 虽然功能强大,但往往会带来庞大的包体积和定制化的困难。今天要介绍的 shadcn/ui 采用了一种全新的思路——它不是一个传统的组件库,而是一个可复制粘贴的组件集合,配合 Tailwind Css 实现高度可定制化。

  shadcn/ui 的理念非常简单:把精心设计的组件代码直接复制到你的项目中,你可以完全掌控代码,随意修改样式和逻辑。这种方式既保证了组件的质量,又给了开发者最大的自由度。

为什么选择 shadcn/ui

传统组件库 vs shadcn/ui

特性传统组件库shadcn/ui
安装方式npm install复制粘贴代码
代码控制权有限完全掌控
定制难度需要覆盖样式直接修改源码
包体积较大按需引入
学习成本需要学习 API所见即所得
样式方案内置样式系统Tailwind Css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 传统组件库使用方式 import { Button } from 'antd';
import 'antd/dist/antd.Css';

// 需要通过配置覆盖样式
<Button
type="primary"
style={{ backgroundColor: '#custom-color' }}
>
点击我
</Button>

// shadcn/ui 使用方式
// 组件代码在你的项目中,可以直接修改 import { Button } from '@/components/ui/button';

// 直接在组件文件中修改 Tailwind 类名即可
<Button className="bg-custom-color">点击我</Button>

shadcn/ui 的核心优势

  1. 完全可控: 组件代码在你的项目里,想怎么改就怎么改
  2. 零依赖: 不会锁定你在某个组件库版本
  3. 按需使用: 只复制需要的组件,不会有冗余代码
  4. Tailwind 驱动: 利用 Tailwind Css 的强大功能
  5. 类型安全: 完整的 Typescript 支持
  6. 无障碍: 内置 ARIA 属性,符合可访问性标准

快速开始

创建 Vite + React + Typescript 项目

1
2
3
4
5
# 使用 Vite 创建项目 npm create vite@latest my-app -- --template React-TS

# 进入项目目录 cd my-app

# 安装依赖 npm install

安装 Tailwind Css

1
2
3
# 安装 Tailwind Css 及相关依赖 npm install -D tailwindcss postcss autoprefixer

# 初始化 Tailwind 配置 npx tailwindcss init -p

配置 Tailwind 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
46
47
48
49
50
51
52
53
54
// tailwind.config.JS
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.Html',
'./src/**/*.{JS,TS,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
}
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
/* src/index.Css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}

.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}

@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

安装 shadcn/ui CLI

1
2
# 安装 shadcn-ui CLI
npx shadcn-ui@latest init

在交互式提示中选择配置:

1
2
3
4
5
6
7
8
9
✔ Would you like to use Typescript (recommended)? … yes
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global Css file? … src/index.Css
✔ Would you like to use Css variables for colors? … yes
✔ Where is your tailwind.config.JS located? … tailwind.config.JS
✔ Configure the import alias for components: … @/components
✔ Configure the import alias for utils: … @/lib/utils
✔ Are you using React Server Components? … no

添加组件

1
2
3
4
5
6
7
8
9
# 添加 Button 组件 npx shadcn-ui@latest add button

# 添加 Card 组件 npx shadcn-ui@latest add card

# 添加 Input 组件 npx shadcn-ui@latest add input

# 添加 Dialog 组件 npx shadcn-ui@latest add dialog

# 一次添加多个组件 npx shadcn-ui@latest add button card input dialog table

核心组件使用

Button 按钮组件

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
// src/components/ui/button.tsx
import * as React from 'React';
import { Slot } from '@radix-ui/React-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HtmlButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

const Button = React.forwardRef<HtmlButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

export { Button, buttonVariants };

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Button } from '@/components/ui/button';

function App() {
return (
<div className="flex gap-4 p-4">
<Button>默认按钮</Button>
<Button variant="destructive">危险按钮</Button>
<Button variant="outline">边框按钮</Button>
<Button variant="secondary">次要按钮</Button>
<Button variant="ghost">幽灵按钮</Button>
<Button variant="link">链接按钮</Button>

<Button size="sm">小按钮</Button>
<Button size="lg">大按钮</Button>
<Button size="icon">+</Button>
</div>
);
}

Card 卡片组件

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
// 使用 Card 组件 import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';

function UserCard() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>用户信息</CardTitle>
<CardDescription>管理你的个人资料</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div>
<label className="text-sm font-medium">姓名</label>
<p className="text-sm text-muted-foreground">张三</p>
</div>
<div>
<label className="text-sm font-medium">邮箱</label>
<p className="text-sm text-muted-foreground">zhangsan@example.com</p>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">取消</Button>
<Button>保存</Button>
</CardFooter>
</Card>
);
}

Form 表单组件

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
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'React-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

// 定义表单验证规则 const formSchema = z.object({
username: z.string().min(2, {
message: '用户名至少 2 个字符',
}),
email: z.string().email({
message: '请输入有效的邮箱地址',
}),
});

function ProfileForm() {
// 初始化表单 const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
},
});

// 提交处理 function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>用户名</FormLabel>
<FormControl>
<Input placeholder="请输入用户名" {...field} />
</FormControl>
<FormDescription>
这是你的公开显示名称
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input placeholder="请输入邮箱" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">提交</Button>
</form>
</Form>
);
}

Dialog 对话框组件

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
import { useState } from 'React';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

function UserDialog() {
const [open, setOpen] = useState(false);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>打开对话框</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>
在这里修改你的个人资料,点击保存来应用更改。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
姓名
</Label>
<Input
id="name"
defaultValue="张三"
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="email" className="text-right">
邮箱
</Label>
<Input
id="email"
defaultValue="zhangsan@example.com"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={() => setOpen(false)}>
保存更改
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

Table 表格组件

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
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';

const users = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', role: '管理员' },
{ id: 2, name: '李四', email: 'lisi@example.com', role: '用户' },
{ id: 3, name: '王五', email: 'wangwu@example.com', role: '用户' },
];

function UserTable() {
return (
<Table>
<TableCaption>用户列表</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>姓名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead className="text-right">角色</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-right">{user.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

实战:搭建后台管理系统

创建布局组件

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// src/components/Layout/Sidebar.tsx
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Home, Users, Settings, FileText } from 'lucide-React';

interface SidebarProps extends React.HtmlAttributes<HtmlDivElement> {}

export function Sidebar({ className }: SidebarProps) {
const routes = [
{
label: '首页',
icon: Home,
href: '/',
},
{
label: '用户管理',
icon: Users,
href: '/users',
},
{
label: '文章管理',
icon: FileText,
href: '/posts',
},
{
label: '设置',
icon: Settings,
href: '/settings',
},
];

return (
<div className={cn('pb-12 w-64', className)}>
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold">
管理后台
</h2>
<div className="space-y-1">
{routes.map((route) => (
<Button
key={route.href}
variant="ghost"
className="w-full justify-start"
>
<route.icon className="mr-2 h-4 w-4" />
{route.label}
</Button>
))}
</div>
</div>
</div>
</div>
);
}

// src/components/Layout/Header.tsx
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { User } from 'lucide-React';

export function Header() {
return (
<header className="border-b">
<div className="flex h-16 items-center px-4">
<div className="ml-auto flex items-center space-x-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<User className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">张三</p>
<p className="text-xs leading-none text-muted-foreground">
zhangsan@example.com
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>个人资料</DropdownMenuItem>
<DropdownMenuItem>设置</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
}

// src/components/Layout/index.tsx
import { Sidebar } from './Sidebar';
import { Header } from './Header';

export function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar className="border-r" />
<div className="flex-1 overflow-auto">
<Header />
<main className="p-6">{children}</main>
</div>
</div>
);
}

创建数据表格页面

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// src/pages/Users.tsx
import { useState } from 'React';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Plus } from 'lucide-React';

const mockUsers = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', role: '管理员', status: '激活' },
{ id: 2, name: '李四', email: 'lisi@example.com', role: '用户', status: '激活' },
{ id: 3, name: '王五', email: 'wangwu@example.com', role: '用户', status: '禁用' },
];

export function UsersPage() {
const [search, setSearch] = useState('');

const filteredUsers = mockUsers.filter(user =>
user.name.toLowerCase().includes(search.toLowerCase()) ||
user.email.toLowerCase().includes(search.toLowerCase())
);

return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">用户管理</h1>
<Button>
<Plus className="mr-2 h-4 w-4" />
添加用户
</Button>
</div>

<div className="flex items-center space-x-2">
<Input
placeholder="搜索用户..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
</div>

<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>姓名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>角色</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
user.status === '激活'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.status}
</span>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>编辑</DropdownMenuItem>
<DropdownMenuItem>查看详情</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

主题切换

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
84
85
86
87
88
89
// src/components/ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from 'React';

type Theme = 'dark' | 'light' | 'system';

type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};

type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};

const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
undefined
);

export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);

useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');

if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}

root.classList.add(theme);
}, [theme]);

const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};

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

export const useTheme = () => {
const context = useContext(ThemeProviderContext);

if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');

return context;
};

// 使用主题切换 import { Moon, Sun } from 'lucide-React';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/components/ThemeProvider';

export function ThemeToggle() {
const { theme, setTheme } = useTheme();

return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">切换主题</span>
</Button>
);
}

响应式设计

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
// Tailwind 响应式断点
// sm: 640px
// md: 768px
// lg: 1024px
// xl: 1280px
// 2xl: 1536px

function ResponsiveExample() {
return (
<div className="
grid
grid-cols-1 {/* 移动端 1 列 */}
sm:grid-cols-2 {/* 平板 2 列 */}
lg:grid-cols-3 {/* 桌面 3 列 */}
xl:grid-cols-4 {/* 大屏 4 列 */}
gap-4
p-4
">
<Card className="p-4">内容 1</Card>
<Card className="p-4">内容 2</Card>
<Card className="p-4">内容 3</Card>
<Card className="p-4">内容 4</Card>
</div>
);
}

// 隐藏/显示组件 function ResponsiveNav() {
return (
<nav>
{/* 移动端显示汉堡菜单,桌面端隐藏 */}
<Button className="lg:hidden">
菜单
</Button>

{/* 移动端隐藏,桌面端显示完整导航 */}
<ul className="hidden lg:flex gap-4">
<li>首页</li>
<li>关于</li>
<li>联系</li>
</ul>
</nav>
);
}

性能优化建议

按需导入组件

1
2
3
4
5
// 只导入需要的组件,减少包体积 import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';

// 不要这样做
// import * as UI from '@/components/ui';

使用 React.lazy 懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { lazy, Suspense } from 'React';
import { Skeleton } from '@/components/ui/skeleton';

// 懒加载页面组件 const UsersPage = lazy(() => import('@/pages/Users'));
const PostsPage = lazy(() => import('@/pages/Posts'));

function App() {
return (
<Suspense fallback={<Skeleton className="h-full w-full" />}>
<Routes>
<Route path="/users" element={<UsersPage />} />
<Route path="/posts" element={<PostsPage />} />
</Routes>
</Suspense>
);
}

Tailwind Css 优化

1
2
3
4
5
6
7
8
9
10
11
// tailwind.config.JS
export default {
content: [
'./index.Html',
'./src/**/*.{JS,TS,jsx,tsx}',
],
// 生产环境移除未使用的样式 purge: {
enabled: process.env.NODE_ENV === 'production',
content: ['./src/**/*.{JS,jsx,TS,tsx}'],
},
};

总结

  • shadcn/ui 采用复制粘贴的方式,让开发者完全掌控组件代码
  • 基于 Tailwind Css,样式定制灵活且高效
  • 配合 Vite 实现极速的开发体验
  • 内置 Typescript 支持和无障碍功能
  • 适合快速搭建现代化、可定制的 React 应用
  • 组件质量高,设计优雅,开箱即用

周末用 shadcn/ui 搭了个后台系统,效率高到让我怀疑人生。以前要花一天的活,现在半小时就搞定了。果然选对工具很重要,就像选对锅一样,炒菜都香!

扩展阅读

  • shadcn/ui 官方文档
  • Tailwind Css 官方文档
  • Radix UI - shadcn/ui 的底层组件库
  • Vite 官方文档
  • React Hook Form - 推荐的表单解决方案

参考资料

  • Building Beautiful UIs with shadcn/ui
  • Tailwind Css Best Practices
  • React 性能优化指南
bulb