大家好!👋 欢迎回到 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 的类型声明文件。
这行命令是告诉 TS:“嘿,别慌,我知道 path 是啥,这是它的说明书。”npm i -D @types/node
配置完 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 中使用 useNavigate 或 useLocation 等 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>
)
}
这里我们复习了几个核心知识点:
lazy: 动态导入组件。Suspense: 必须配合lazy使用,提供加载时的占位符。我们这里写了一个Loading组件(虽然代码简单,但提升了体验)。- 嵌套路由:
<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>
)
}
💡 核心逻辑解析:
- Icon Component:
const Icon = tab.icon;React 允许我们将组件作为变量传递。 cn()函数: 这是 shadcn 提供的一个神级工具(基于clsx和tailwind-merge)。它可以让你动态拼接类名,并且自动解决 Tailwind 类名冲突的问题。- 比如:
cn("bg-red-500", isActive && "bg-blue-500")。
- 比如:
- 权限控制: 我们引入了
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;
📝 总结与预告
今天,我们干了一件大事!
- 工程化:配置了
@路径别名,让项目结构更清晰。 - UI 革命:引入了 shadcn/ui,体验了“拥有组件代码”的快感。
- 架构实战:搭建了完整的移动端路由架构,实现了
MainLayout+BottomNav的经典布局。 - 权限控制:利用 Zustand + Router Guard 实现了全局登录拦截。
- Hooks 运用:深入使用了
useEffect,useState,useLocation等 React 核心技能。
现在的应用虽然长得挺好看,但数据都是假的(Mock data)。 下一章预告:我们将回到后端,完善 NestJS 接口,打通前后端任督二脉,实现真正的用户登录和文章列表获取!
我们要把“全栈”进行到底!大家加油!🚀