🚀 AI 全栈项目第五天:用 shadcn/ui 打造高颜值 React 应用 & 实战 Notes 项目

437 阅读11分钟

大家好!👋 欢迎回到 AI 全栈项目实战 的第五天。

在过去几天的“特种兵式”训练中,我们已经掌握了 React 的“时空穿梭术”(Router 路由),请来了德国管家(Zustand 状态管理),穿上了 TypeScript 的钢铁侠战衣,还学会了用 NestJS 搭建后端的“精装豪宅”。

今天,我们要把这些零散的宝石串成一条项链。我们要开始实战了!我们将从零开始搭建一个移动端的 Notes(笔记)应用

而且,今天我要向大家介绍一位前端 UI 界的新晋“顶流” —— shadcn/ui。如果你受够了 Material UI 或者 Ant Design 那种“千篇一律”的样式,shadcn/ui 绝对会让你打开新世界的大门。它是目前 React 生态中最火的组件库方案,没有之一。

准备好了吗?我们要发车了!🚗


🛠️ 一、 磨刀不误砍柴工:优雅的项目配置

在开始写代码之前,我们要先解决几个“长期痛点”。你是不是经常在代码里看到这样的路径: import Button from '../../../../components/Button' 这种 ../../ 的地狱不仅难看,而且一旦你移动了文件位置,引用的路径全都要炸。

1.1 路径别名(Path Alias):让 @ 带你回家

我们想要的效果是:无论你在哪个深层目录下,只要输入 @/,就代表回到了 src 根目录。编辑器还能智能提示目录下的文件。

首先,我们需要修改 Vite 的配置文件 vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// 1️⃣ 引入 node 的 path 模块
import path from 'path' 

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    react(),
    tailwindcss()
  ],
  // 2️⃣ 配置 resolve.alias
  resolve: {
    alias: {
      // __dirname 是 Node.js 的超级变量,代表当前文件的绝对路径
      // path.resolve 负责拼接路径
      '@': path.resolve(__dirname, 'src'),
    },
  }
})

💡 知识点解析:

  • path 模块:这是 Node.js 的内置模块,专门用来处理文件路径的。
  • __dirname:这是 Node.js 的全局变量,指向当前文件所在的文件夹目录。
  • TS 报错?:当你写 import path from 'path' 时,TypeScript 可能会报错说找不到模块。这是因为 TS 默认不知道 Node.js 的内置模块。
    • 解决方案:我们需要安装 Node 的类型声明文件。
    npm i -D @types/node
    
    这行命令是告诉 TS:“嘿,别慌,我知道 path 是啥,这是它的说明书。”

配置完 Vite 还没完,我们还得告诉 TypeScript 这个 @ 是什么意思,否则 TS 编译器会报错。 打开 tsconfig.app.json(或者 tsconfig.json)并非或者!!!!!两者都要加上路径配置:

{
  "compilerOptions": {
    // ... 其他配置
    
    /* 路径配置 */
    "baseUrl": ".", // 基准目录是当前目录
    "paths": {
      "@/*": ["src/*"] // 告诉 TS:遇到 @/ 开头的路径,去 src/ 下面找
    }
  }
}

这样,我们就彻底告别了 ../../ 的噩梦,迎来了清爽的 @/components/Header


🎨 二、 UI 界的“乐高”:shadcn/ui 初体验

2.1 为什么是 shadcn/ui?

传统的组件库(如 Antd, MUI)是把组件封装在一个黑盒子里(npm 包),你只能通过修改 props 来改变样式。如果你想大改结构?没门。

shadcn/ui 的理念完全不同。它不是一个组件库,它是一个组件集合。 当你需要一个 Button 时,它不是让你 npm install @shadcn/button,而是直接把 Button 组件的源代码下载到你的项目中(通常是 src/components/ui 目录下)。

这意味着:你拥有这些组件的完全控制权。你可以随意修改它的代码、样式、逻辑。它基于 Tailwind CSS 构建,样式修改极其方便。

2.2 初始化 shadcn/ui

首先,确保你的项目已经安装并配置好了 Tailwind CSS(如果不记得了,请复习之前的文章)。 然后,在项目根目录下运行初始化命令:

npx shadcn@latest init

💡 知识点解析:npx 是什么?

  • npm 是包管理工具(安装、卸载)。
  • npx 是包执行工具。
  • 优势:它不需要你全局安装 shadcn。它会临时下载最新版本的 shadcn,执行完命令后就删除。非常适合这种“一次性”的初始化操作,而且永远用的都是最新版。

执行过程中,它会问你一些问题(比如用什么颜色主题、CSS 变量存在哪),一路回车选择默认即可。它会自动检测你的 tsconfig.json 里的别名配置(刚才我们配的 @),非常智能。

2.3 添加你的第一个组件

现在,我们想要一个漂亮的按钮。 在终端运行:

npx shadcn@latest add button

你会发现,你的 src/components/ui 目录下多了一个 button.tsx 文件。 这就是 shadcn 的魔力!代码就在你手里,想怎么改就怎么改。

让我们在 App.tsx 里试一下(伪代码):

import { Button } from '@/components/ui/button'

export default function App() {
  return (
    <div>
      <Button variant="default">我是默认按钮</Button>
      <Button variant="destructive">我是危险按钮</Button>
      <Button variant="outline">我是描边按钮</Button>
    </div>
  )
}

简直太优雅了!你不需要写一行 CSS,就拥有了企业级设计的按钮。


🏗️ 三、 项目实战:Notes 应用架构设计

接下来,我们正式开始搭建 Notes 项目。

3.1 目录结构

有了路径别名,我们的目录结构可以非常清晰:

src/
├── components/     # 公共组件
│   ├── ui/         # shadcn 下载的组件
│   ├── Header.tsx  # 自己写的业务组件
│   └── ...
├── layouts/        # 布局组件
│   └── MainLayout.tsx
├── pages/          # 页面组件
│   ├── Home.tsx
│   ├── Login.tsx
│   ├── Mine.tsx
│   └── ...
├── router/         # 路由配置
│   └── index.tsx
├── store/          # Zustand 状态管理
│   └── useUserStore.ts
├── types/          # TS 类型定义
├── App.tsx         # 根组件
└── main.tsx        # 入口文件

3.2 路由架构:App 还是 Main?

这里有个关键的架构设计点。 通常我们会把 <Router> 包裹在最外层。在本项目中,我们选择在 main.tsx 中包裹 App 组件。

为什么? 因为我们希望在 App.tsx 中使用 useNavigateuseLocation 等 Hooks 来做全局的权限控制(比如未登录跳转)。这些 Hooks 必须在 Router 的上下文(Context)内部才能运行。 如果 App.tsx 自己渲染 <Router>,那它自己就不在 Router 内部,就用不了这些 Hooks。

所以,我们在 main.tsx 这样做:

import { createRoot } from 'react-dom/client'
import RouterConfig from '@/router/index.tsx' // 引入我们封装的 Router 组件
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  // RouterConfig 内部包含了 <BrowserRouter>
  // App 作为 children 传入,这样 App 就在 Router 内部了
  <RouterConfig>
    <App />
  </RouterConfig>,
)

3.3 路由配置:懒加载与 Suspense

打开 src/router/index.tsx,这里是我们应用的交通枢纽。

import { Suspense, lazy } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Loading from '@/components/Loading'; // 自定义的 Loading 组件
import MainLayout from '@/layouts/MainLayout';

// 🚀 路由懒加载 (Lazy Loading)
// 只有当用户访问这个页面时,浏览器才会去下载这个页面的 JS 代码。
// 这对于移动端应用至关重要,能极大提升首屏加载速度。
const Home = lazy(() => import('@/pages/Home'));
const Mine = lazy(() => import('@/pages/Mine'));
const Login = lazy(() => import('@/pages/Login'));
const Order = lazy(() => import('@/pages/Order'))
const Chat = lazy(() => import('@/pages/Chat'))

export default function RouterConfig({children}: {children?: React.ReactNode}) {
  return (
    <Router>
      {/* Suspense 是 React 的内置组件,用于处理异步加载的状态 */}
      {/* fallback 属性接受一个组件,在页面还没加载出来时显示(比如转圈圈) */}
      <Suspense fallback={<Loading/>}>
        <Routes>
          <Route path="/login" element={<Login />}/>
          
          {/* 📦 嵌套路由布局 */}
          {/* MainLayout 包含了底部的导航栏,所有子路由都会显示在这个布局里 */}
          <Route path="/" element={<MainLayout/>}>
            <Route path="" element={<Home />} />
            <Route path="mine" element={<Mine />} />
            <Route path="order" element={<Order />} />
            <Route path="chat" element={<Chat />} />
          </Route>
        </Routes>
      </Suspense>
      {/* 渲染传入的子组件 (App.tsx) */}
      {children}
    </Router>
  )
}

这里我们复习了几个核心知识点:

  1. lazy: 动态导入组件。
  2. Suspense: 必须配合 lazy 使用,提供加载时的占位符。我们这里写了一个 Loading 组件(虽然代码简单,但提升了体验)。
  3. 嵌套路由: <Route path="/" element={<MainLayout/>}>。这意味着 Home, Mine 等页面都会作为 MainLayout 的子元素渲染。

🧭 四、 导航系统:MainLayout 与 BottomNav

4.1 布局容器 (MainLayout)

src/layouts/MainLayout.tsx 充当了页面的骨架。

import { Outlet } from 'react-router-dom'
import BottomNav from '@/components/BottomNav';

export default function MainLayout() {
  return (
    <div className="min-h-screen bg-gray-50 pb-16">
      {/* 📺 Outlet 是子路由的出口 */}
      {/* 当 URL 是 /mine 时,<Mine /> 就会显示在这里 */}
      <div className="h-full w-full">
        <Outlet />
      </div>
      
      {/* 底部导航栏,永远固定在底部 */}
      <BottomNav/>
    </div>
  )
}

4.2 智能底栏 (BottomNav)

这是本项目的亮点之一。我们没有把导航写死,而是用配置化的方式。 打开 src/components/BottomNav.tsx

import { Home, User, ListOrdered, MessageCircle } from 'lucide-react'; // 漂亮的图标库
import { useNavigate, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils'; // shadcn 提供的类名合并工具
import { useUserStore } from '@/store/useUserStore'
import { needsLoginPath } from '@/App';

export default function BottomNav() {
  const navigate  = useNavigate();
  const { pathname } = useLocation(); // 获取当前路径
  const { isLogin } = useUserStore((state) => state); // 从 Zustand 获取登录状态

  // 📝 配置数组:想加新菜单?在这里加一个对象就行了!
  const tabs = [
    { label: "首页", path: "/", icon: Home },
    { label: "聊天", path: "/chat", icon: MessageCircle },
    { label: "订单", path: "/order", icon: ListOrdered },
    { label: "我的", path: "/mine", icon: User }
  ]

  const handleNav = (path: string) => {
    // 1. 如果点击的是当前页面,啥也不做
    if (path === pathname) return;
    
    // 2. 🛡️ 权限守卫:如果目标页面需要登录,且用户未登录
    if (needsLoginPath.includes(path) && !isLogin) {
      navigate("/login"); // 踢去登录页
      return;
    }
    
    // 3. 正常跳转
    navigate(path);
  }

  return (
    <div className="fixed bottom-0 left-0 right-0 h-16 border-t bg-background flex items-center justify-around z-50 safe-area-bottom">
    {
      tabs.map((tab) => {
        const Icon = tab.icon;
        // 判断当前 tab 是否被激活
        const isActive = pathname === tab.path;

        return (
          <button 
            key={tab.path}
            onClick={() => handleNav(tab.path)}
            className="flex flex-col items-center justify-center w-full h-full space-y-1"
          >
            {/* 图标变色逻辑 */}
            <Icon 
              size={24} 
              className={cn("transition-colors", 
                isActive ? "text-primary" : "text-muted-foreground"
              )}
            />
            {/* 文字变色逻辑 */}
            <span className={cn("text-xs transition-colors", 
              isActive ? "text-primary font-medium": "text-muted-foreground")}>
              {tab.label}
            </span>
          </button>
        )
      })
    }
    </div>
  )
}

💡 核心逻辑解析:

  1. Icon Component: const Icon = tab.icon; React 允许我们将组件作为变量传递。
  2. cn() 函数: 这是 shadcn 提供的一个神级工具(基于 clsxtailwind-merge)。它可以让你动态拼接类名,并且自动解决 Tailwind 类名冲突的问题。
    • 比如:cn("bg-red-500", isActive && "bg-blue-500")
  3. 权限控制: 我们引入了 needsLoginPath 数组(稍后在 App.tsx 定义),如果用户没登录想点“我的”,直接拦截。

🔐 五、 全局状态与权限守卫

5.1 状态仓库 (Zustand)

复习一下 Zustand。在 src/store/useUserStore.ts

import { create } from 'zustand';
import type { User } from '@/types'; // 引入类型定义

// 1. 定义 Store 的形状 (Interface)
interface UserStore {
    isLogin: boolean;
    user: User | null; // User 或者是 null
}

// 2. 创建 Store
// create<UserStore> 使用泛型约束,保证代码提示和类型安全
export const useUserStore = create<UserStore>((set) => ({
    isLogin: false, // 默认未登录
    user: null
}))

简单、纯粹。没有 Redux 那些复杂的样板代码。

5.2 全局路由守卫 (App.tsx)

虽然我们在 BottomNav 里做了拦截,但如果用户直接在浏览器地址栏输入 /mine 怎么办? 我们需要在 App.tsx 里做一个全局的“安检门”。

import { useEffect } from 'react';
import { useUserStore } from '@/store/useUserStore'
import { useNavigate, useLocation } from 'react-router-dom';
import BackToTop from '@/components/BackToTop';

// 导出需要登录才能访问的路径列表,供其他组件(如 BottomNav)使用
export const needsLoginPath = ['/mine','/order','/chat']

function App() {
  const { isLogin } = useUserStore();
  const navigate = useNavigate();
  const { pathname } = useLocation();

  // 🛡️ useEffect 监听器
  useEffect(() => {
    // 如果未登录 且 当前路径在受保护列表里
    if(!isLogin && needsLoginPath.includes(pathname)) {
      navigate('/login') // 强制跳转登录页
    }
  }, [isLogin, navigate, pathname]) // 依赖项:任何一个变化都会触发检查

  return (
    <>
      {/* 这里的 BackToTop 是全局组件,所有页面都有 */}
      <BackToTop/>
    </>
  )
}

export default App

💡 为什么用 useEffect? React 的组件渲染是纯函数,不能直接在渲染过程中执行跳转(Side Effect)。必须放在 useEffect 中,等组件挂载或更新后再执行逻辑。


🏠 六、 首页开发:shadcn 实战

终于到了画页面的时候了!我们将使用 shadcn 的 Card 组件来快速搭建首页。

6.1 通用头部组件 (Header)

先写一个通用的 Header.tsx

import React from 'react'; 
import { Button } from '@/components/ui/button'; // 复用 shadcn 的 Button
import { ArrowLeft } from 'lucide-react';

interface HeaderProps {
  title: string;
  showBackBtn?: boolean; // 可选属性
  onBackClick?:() => void;
}

const Header: React.FC<HeaderProps> = ({
  title,
  showBackBtn = false, // 默认不显示返回按钮
  onBackClick = () => window.history.back() // 默认行为:浏览器后退
}) => {
  return (
    <header className="flex items-center justify-center h-16 px-4 border-b bg-white sticky top-0 z-40">
      <div className="absolute left-4">
      {
        showBackBtn && (
          <Button variant="ghost" size="icon" onClick={onBackClick}>
            <ArrowLeft size={20}/>
          </Button>
        )
      }
      </div>
      <h1 className="text-lg font-semibold truncate max-w-[60%] text-center">
        {title}
      </h1>
    </header>
  )
}
export default Header

6.2 首页 (Home.tsx)

import Header from '@/components/Header';
// 引入 shadcn 的 Card 组件系列
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';

export default function Home() {
    return (
        <>
          <Header title="首页" showBackBtn={true} />
          
          <div className="p-4 space-y-4"> {/* space-y-4 给所有子元素之间添加垂直间距 */}
            
            {/* 这里的 Card 直接使用了我们项目里的组件代码 */}
            <Card>
              <CardHeader>
                <CardTitle>欢迎来到 React Mobile</CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-muted-foreground">这是基于 shadcn/ui 构建的现代化应用</p>
              </CardContent>
            </Card>

            {/* Grid 布局展示列表 */}
            <div className="grid grid-cols-2 gap-4">
            {
              [1,2,3,4,5,6,7,8,9,10,11,12].map((i,index) => (
                <div 
                  key={index} 
                  className="h-32 bg-white rounded-lg shadow-sm flex items-center justify-center border hover:shadow-md transition-shadow"
                >
                  Item {i}
                </div>
              ))
            }
            </div>
          </div>
        </>
    )
}

6.3 回到顶部 (BackToTop)

最后,给用户一个小惊喜。当页面滑动超过一定距离时,显示“回到顶部”按钮。

import React, { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button';
import { ArrowUp } from 'lucide-react';

interface BackToTopProps {
    threshould?: number; // 阈值:滚动多少像素显示
}

const BackToTop:React.FC<BackToTopProps> = ({ threshould = 400 }) =>{
  const [isVisible, setIsVisible] = useState<boolean>(false);

  useEffect (() => {
    const toggleVisibility = () => {
        // window.scrollY 获取当前垂直滚动距离
        setIsVisible(window.scrollY > threshould);
    }
    // 添加滚动监听
    window.addEventListener('scroll', toggleVisibility);
    
    // 🧹 清理函数:组件卸载时移除监听,防止内存泄漏!
    return () => window.removeEventListener('scroll', toggleVisibility);
  }, [threshould])

  if(!isVisible) return null;

  const scrollToTop = () => {
      window.scrollTo({ top: 0, behavior: 'smooth' }); // 平滑滚动
  }

  return (
    <Button
      variant="outline"
      size="icon"
      onClick={scrollToTop}
      className="fixed bottom-20 right-6 rounded-full shadow-lg z-50 bg-white/80 backdrop-blur-sm"
    >
        <ArrowUp className="w-4 h-4"/>
    </Button>
  )
}
export default BackToTop;

📝 总结与预告

今天,我们干了一件大事!

  1. 工程化:配置了 @ 路径别名,让项目结构更清晰。
  2. UI 革命:引入了 shadcn/ui,体验了“拥有组件代码”的快感。
  3. 架构实战:搭建了完整的移动端路由架构,实现了 MainLayout + BottomNav 的经典布局。
  4. 权限控制:利用 Zustand + Router Guard 实现了全局登录拦截。
  5. Hooks 运用:深入使用了 useEffect, useState, useLocation 等 React 核心技能。

现在的应用虽然长得挺好看,但数据都是假的(Mock data)。 下一章预告:我们将回到后端,完善 NestJS 接口,打通前后端任督二脉,实现真正的用户登录文章列表获取

我们要把“全栈”进行到底!大家加油!🚀