18. 实战项目二:博客系统
概述
博客系统是一个完整的全栈项目,涵盖文章列表、详情页、创建/编辑文章、评论功能等。通过这个项目,你将掌握数据获取、路由、状态管理、表单处理等核心技能。
| 维度 | 内容 |
|---|
| What | 完整博客系统,包含文章管理和评论功能 |
| Why | 掌握数据获取、路由、状态管理 |
| When | 中级实战练习 |
| Where | 前后端完整项目 |
| Who | 有一定基础的开发者 |
| How | 实现文章列表、详情、创建、评论等 |
1. 项目需求
1.1 功能列表
- ✅ 文章列表展示
- ✅ 文章详情页面
- ✅ 创建新文章
- ✅ 编辑文章
- ✅ 删除文章
- ✅ 文章分类/标签
- ✅ 评论功能
- ✅ 搜索功能
- ✅ 分页/无限滚动
1.2 技术栈
- React 19
- TypeScript
- React Router v6
- React Query (TanStack Query)
- Tailwind CSS
- JSON Server (Mock API)
2. 项目结构
blog-system/ ├── src/ │ ├── components/ │ │ ├── Layout/ │ │ │ ├── Header.tsx │ │ │ ├── Footer.tsx │ │ │ └── Layout.tsx │ │ ├── PostCard.tsx │ │ ├── PostForm.tsx │ │ ├── CommentList.tsx │ │ ├── CommentForm.tsx │ │ ├── SearchBar.tsx │ │ └── Pagination.tsx │ ├── pages/ │ │ ├── HomePage.tsx │ │ ├── PostPage.tsx │ │ ├── CreatePostPage.tsx │ │ ├── EditPostPage.tsx │ │ └── AboutPage.tsx │ ├── hooks/ │ │ ├── usePosts.ts │ │ ├── usePost.ts │ │ └── useComments.ts │ ├── api/ │ │ └── client.ts │ ├── types/ │ │ └── index.ts │ ├── App.tsx │ └── main.tsx ├── db.json └── package.json
3. 代码实现
3.1 类型定义
// src/types/index.ts export interface Post { id: string; title: string; content: string; excerpt: string; author: string; category: string; tags: string[]; coverImage?: string; createdAt: string; updatedAt?: string; viewCount: number; } export interface Comment { id: string; postId: string; author: string; content: string; createdAt: string; } export interface Category { id: string; name: string; slug: string; count: number; } export interface ApiResponse<T> { data: T; total?: number; page?: number; pageSize?: number; }
3.2 API 客户端
// src/api/client.ts const API_BASE = 'http://localhost:3001'; async function request<T>( endpoint: string, options?: RequestInit ): Promise<T> { const response = await fetch(`${API_BASE}${endpoint}`, { headers: { 'Content-Type': 'application/json', }, ...options, }); if (!response.ok) { throw new Error(`API Error: ${response.status}`); } return response.json(); } // Posts export const getPosts = (params?: { category?: string; search?: string; page?: number; limit?: number }) => { const queryParams = new URLSearchParams(); if (params?.category) queryParams.append('category', params.category); if (params?.search) queryParams.append('q', params.search); if (params?.page) queryParams.append('_page', String(params.page)); if (params?.limit) queryParams.append('_limit', String(params.limit)); const query = queryParams.toString(); return request<Post[]>(`/posts${query ? `?${query}` : ''}`); }; export const getPost = (id: string) => request<Post>(`/posts/${id}`); export const createPost = (post: Omit<Post, 'id' | 'createdAt' | 'viewCount'>) => request<Post>('/posts', { method: 'POST', body: JSON.stringify({ ...post, createdAt: new Date().toISOString(), viewCount: 0, }), }); export const updatePost = (id: string, post: Partial<Post>) => request<Post>(`/posts/${id}`, { method: 'PATCH', body: JSON.stringify(post), }); export const deletePost = (id: string) => request<void>(`/posts/${id}`, { method: 'DELETE' }); // Comments export const getComments = (postId: string) => request<Comment[]>(`/comments?postId=${postId}&_sort=createdAt&_order=desc`); export const createComment = (comment: Omit<Comment, 'id' | 'createdAt'>) => request<Comment>('/comments', { method: 'POST', body: JSON.stringify({ ...comment, createdAt: new Date().toISOString(), }), }); // Categories export const getCategories = () => request<Category[]>('/categories');
3.3 自定义 Hooks
// src/hooks/usePosts.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getPosts, createPost, deletePost } from '../api/client'; import { Post } from '../types'; export function usePosts(filters?: { category?: string; search?: string; page?: number }) { return useQuery({ queryKey: ['posts', filters], queryFn: () => getPosts(filters), }); } export function useCreatePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (post: Omit<Post, 'id' | 'createdAt' | 'viewCount'>) => createPost(post), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); } export function useDeletePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => deletePost(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); }
// src/hooks/usePost.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getPost, updatePost } from '../api/client'; export function usePost(id: string) { return useQuery({ queryKey: ['post', id], queryFn: () => getPost(id), enabled: !!id, }); } export function useUpdatePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: Partial<Post> }) => updatePost(id, data), onSuccess: (_, { id }) => { queryClient.invalidateQueries({ queryKey: ['post', id] }); queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); }
// src/hooks/useComments.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getComments, createComment } from '../api/client'; export function useComments(postId: string) { return useQuery({ queryKey: ['comments', postId], queryFn: () => getComments(postId), enabled: !!postId, }); } export function useCreateComment(postId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (comment: Omit<Comment, 'id' | 'createdAt' | 'postId'>) => createComment({ ...comment, postId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['comments', postId] }); }, }); }
3.4 布局组件
// src/components/Layout/Header.tsx import { Link } from 'react-router-dom'; import SearchBar from '../SearchBar'; export default function Header() { return ( <header className="bg-white shadow-md"> <div className="container mx-auto px-4 py-4"> <div className="flex justify-between items-center"> <Link to="/" className="text-2xl font-bold text-blue-600"> 技术博客 </Link> <div className="flex items-center gap-6"> <SearchBar /> <Link to="/about" className="hover:text-blue-600">关于</Link> <Link to="/posts/new" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 写文章 </Link> </div> </div> </div> </header> ); } // src/components/Layout/Footer.tsx export default function Footer() { return ( <footer className="bg-gray-800 text-white mt-12"> <div className="container mx-auto px-4 py-8 text-center"> <p>© 2024 技术博客 - 分享知识和经验</p> </div> </footer> ); } // src/components/Layout/Layout.tsx import { Outlet } from 'react-router-dom'; import Header from './Header'; import Footer from './Footer'; export default function Layout() { return ( <div className="min-h-screen flex flex-col"> <Header /> <main className="flex-1 container mx-auto px-4 py-8"> <Outlet /> </main> <Footer /> </div> ); }
3.5 PostCard 组件
// src/components/PostCard.tsx import { Link } from 'react-router-dom'; import { Post } from '../types'; interface PostCardProps { post: Post; } export default function PostCard({ post }: PostCardProps) { return ( <article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"> {post.coverImage && ( <img src={post.coverImage} alt={post.title} className="w-full h-48 object-cover" /> )} <div className="p-6"> <div className="flex items-center gap-2 text-sm text-gray-500 mb-2"> <span>{post.category}</span> <span>•</span> <span>{new Date(post.createdAt).toLocaleDateString()}</span> </div> <Link to={`/posts/${post.id}`}> <h2 className="text-xl font-bold mb-2 hover:text-blue-600"> {post.title} </h2> </Link> <p className="text-gray-600 mb-4">{post.excerpt}</p> <div className="flex justify-between items-center"> <span className="text-sm text-gray-500">作者: {post.author}</span> <div className="flex gap-2"> {post.tags.map(tag => ( <span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"> {tag} </span> ))} </div> </div> </div> </article> ); }
3.6 页面组件
// src/pages/HomePage.tsx import { useState } from 'react'; import { usePosts } from '../hooks/usePosts'; import PostCard from '../components/PostCard'; import Pagination from '../components/Pagination'; const POSTS_PER_PAGE = 6; export default function HomePage() { const [page, setPage] = useState(1); const { data: posts, isLoading, error } = usePosts({ page, limit: POSTS_PER_PAGE }); if (isLoading) { return ( <div className="flex justify-center py-12"> <div className="text-gray-500">加载中...</div> </div> ); } if (error) { return ( <div className="text-center py-12 text-red-500"> 加载失败: {error.message} </div> ); } return ( <div> <h1 className="text-3xl font-bold mb-8">最新文章</h1> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {posts?.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> <Pagination currentPage={page} totalPages={Math.ceil((posts?.length || 0) / POSTS_PER_PAGE)} onPageChange={setPage} /> </div> ); }
// src/pages/PostPage.tsx import { useParams } from 'react-router-dom'; import { usePost } from '../hooks/usePost'; import { useComments, useCreateComment } from '../hooks/useComments'; import CommentList from '../components/CommentList'; import CommentForm from '../components/CommentForm'; export default function PostPage() { const { id } = useParams<{ id: string }>(); const { data: post, isLoading, error } = usePost(id!); const { data: comments, isLoading: commentsLoading } = useComments(id!); const createComment = useCreateComment(id!); if (isLoading) { return <div className="text-center py-12">加载中...</div>; } if (error || !post) { return <div className="text-center py-12 text-red-500">文章不存在</div>; } const handleAddComment = async (data: { author: string; content: string }) => { await createComment.mutateAsync(data); }; return ( <article className="max-w-3xl mx-auto"> <h1 className="text-4xl font-bold mb-4">{post.title}</h1> <div className="flex items-center gap-4 text-gray-500 mb-8 pb-4 border-b"> <span>作者: {post.author}</span> <span>{new Date(post.createdAt).toLocaleDateString()}</span> <span>分类: {post.category}</span> <span>阅读: {post.viewCount}</span> </div> <div className="prose max-w-none mb-8"> {post.content} </div> <div className="flex gap-2 mb-8"> {post.tags.map(tag => ( <span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm"> #{tag} </span> ))} </div> <section className="border-t pt-8"> <h2 className="text-2xl font-bold mb-4"> 评论 ({comments?.length || 0}) </h2> <CommentForm onSubmit={handleAddComment} isLoading={createComment.isPending} /> {commentsLoading ? ( <div className="text-center py-8">加载评论中...</div> ) : ( <CommentList comments={comments || []} /> )} </section> </article> ); }
3.7 Mock 数据 (db.json)
{"posts":[{"id":"1","title":"React 19 新特性详解","content":"React 19 带来了许多激动人心的新特性...","excerpt":"React 19 带来了许多激动人心的新特性...","author":"张三","category":"React","tags":["React","前端"],"createdAt":"2024-01-15T10:00:00Z","viewCount":1234}],"comments":[{"id":"1","postId":"1","author":"李四","content":"非常棒的文章!","createdAt":"2024-01-15T12:00:00Z"}],"categories":[{"id":"1","name":"React","slug":"react","count":10},{"id":"2","name":"TypeScript","slug":"typescript","count":8},{"id":"3","name":"前端工程化","slug":"engineering","count":6}]}
4. 项目运行
# 安装依赖npminstallreact-router-dom @tanstack/react-query# 安装 JSON Servernpminstall-gjson-server# 启动 Mock APItimeout/t5/nobreak# 或使用 Concurrencynpminstall-gconcurrently# 启动项目json-server--watchdb.json--port3001npmrun dev
5. 总结
核心知识点
| 知识点 | 应用 |
|---|
| React Router | 页面路由、嵌套路由 |
| React Query | 数据获取、缓存、状态管理 |
| 自定义 Hooks | usePosts、usePost、useComments |
| 表单处理 | 创建/编辑文章、评论 |
| 类型安全 | TypeScript 完整类型定义 |