06-高级模式与实战项目——21. 实战项目五:社交媒体
📅 2026/7/9 1:47:15
👁️ 阅读次数
📝 编程学习
21. 实战项目五:社交媒体
概述
社交媒体应用是一个综合性的全栈项目,涵盖动态发布、点赞评论、用户关注、实时消息等核心功能。通过这个项目,你将掌握实时交互、乐观更新、无限滚动等高级技能。
| 维度 | 内容 |
|---|---|
| What | 完整社交媒体应用,包含动态发布、点赞、评论 |
| Why | 掌握实时交互、乐观更新、无限滚动 |
| When | 综合实战练习 |
| Where | 完整全栈项目 |
| Who | 需要复杂交互经验的开发者 |
| How | 实现动态流、点赞、评论、用户关注等 |
1. 项目需求
1.1 功能列表
- ✅ 用户注册/登录
- ✅ 动态发布(文字/图片)
- ✅ 动态列表(无限滚动)
- ✅ 点赞/取消点赞(乐观更新)
- ✅ 评论功能
- ✅ 关注/取消关注用户
- ✅ 个人主页
- ✅ 消息通知
- ✅ 搜索用户
1.2 技术栈
- React 19
- TypeScript
- React Router v6
- Zustand (状态管理)
- React Query (数据获取)
- Tailwind CSS
- JSON Server (Mock API)
2. 项目结构
social-media/ ├── src/ │ ├── components/ │ │ ├── Layout/ │ │ │ ├── Header.tsx │ │ │ ├── Sidebar.tsx │ │ │ └── Layout.tsx │ │ ├── PostCard.tsx │ │ ├── PostForm.tsx │ │ ├── CommentList.tsx │ │ ├── CommentForm.tsx │ │ ├── UserCard.tsx │ │ ├── NotificationBell.tsx │ │ └── InfiniteScroll.tsx │ ├── pages/ │ │ ├── HomePage.tsx │ │ ├── ProfilePage.tsx │ │ ├── ExplorePage.tsx │ │ ├── NotificationsPage.tsx │ │ ├── LoginPage.tsx │ │ └── RegisterPage.tsx │ ├── stores/ │ │ ├── authStore.ts │ │ └── notificationStore.ts │ ├── hooks/ │ │ ├── usePosts.ts │ │ ├── usePost.ts │ │ ├── useComments.ts │ │ ├── useUser.ts │ │ └── useInfiniteScroll.ts │ ├── api/ │ │ └── client.ts │ ├── types/ │ │ └── index.ts │ ├── App.tsx │ └── main.tsx ├── db.json └── package.json3. 代码实现
3.1 类型定义
// src/types/index.ts export interface User { id: string; username: string; name: string; email: string; avatar: string; bio: string; followersCount: number; followingCount: number; postsCount: number; isFollowed?: boolean; } export interface Post { id: string; userId: string; user: User; content: string; images: string[]; likesCount: number; commentsCount: number; isLiked?: boolean; createdAt: string; } export interface Comment { id: string; postId: string; userId: string; user: User; content: string; createdAt: string; } export interface Notification { id: string; userId: string; type: 'like' | 'comment' | 'follow'; fromUser: User; postId?: string; read: boolean; createdAt: string; }3.2 状态管理
// src/stores/authStore.ts import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { User } from '../types'; interface AuthState { user: User | null; token: string | null; isLoading: boolean; login: (email: string, password: string) => Promise<void>; register: (username: string, email: string, password: string) => Promise<void>; logout: () => void; updateUser: (updates: Partial<User>) => void; } export const useAuthStore = create<AuthState>()( persist( (set) => ({ user: null, token: null, isLoading: false, login: async (email, password) => { set({ isLoading: true }); // 模拟登录 await new Promise(resolve => setTimeout(resolve, 1000)); set({ user: { id: '1', username: 'john', name: 'John Doe', email, avatar: '', bio: '', followersCount: 0, followingCount: 0, postsCount: 0 }, token: 'fake-token', isLoading: false, }); }, register: async (username, email, password) => { set({ isLoading: true }); await new Promise(resolve => setTimeout(resolve, 1000)); set({ user: { id: '1', username, name: username, email, avatar: '', bio: '', followersCount: 0, followingCount: 0, postsCount: 0 }, token: 'fake-token', isLoading: false, }); }, logout: () => set({ user: null, token: null }), updateUser: (updates) => set((state) => ({ user: state.user ? { ...state.user, ...updates } : null, })), }), { name: 'auth' } ) );3.3 自定义 Hooks
// src/hooks/usePosts.ts import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Post } from '../types'; const fetchPosts = async ({ pageParam = 1 }) => { const response = await fetch(`/api/posts?_page=${pageParam}&_limit=10&_sort=createdAt&_order=desc`); const data = await response.json(); return { data, nextPage: data.length === 10 ? pageParam + 1 : undefined, }; }; export function usePosts() { return useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.nextPage, }); } export function useLikePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ postId, isLiked }: { postId: string; isLiked: boolean }) => { const method = isLiked ? 'DELETE' : 'POST'; await fetch(`/api/posts/${postId}/like`, { method }); }, onMutate: async ({ postId, isLiked }) => { await queryClient.cancelQueries({ queryKey: ['posts'] }); const previousPosts = queryClient.getQueryData(['posts']); queryClient.setQueryData(['posts'], (old: any) => { if (!old) return old; return { ...old, pages: old.pages.map((page: any) => ({ ...page, data: page.data.map((post: Post) => post.id === postId ? { ...post, isLiked: !isLiked, likesCount: isLiked ? post.likesCount - 1 : post.likesCount + 1, } : post ), })), }; }); return { previousPosts }; }, onError: (err, variables, context) => { queryClient.setQueryData(['posts'], context?.previousPosts); }, }); } // src/hooks/useComments.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; export function useComments(postId: string) { return useQuery({ queryKey: ['comments', postId], queryFn: () => fetch(`/api/comments?postId=${postId}&_sort=createdAt&_order=asc`).then(res => res.json()), enabled: !!postId, }); } export function useCreateComment(postId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (content: string) => fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ postId, content, userId: '1' }), }).then(res => res.json()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['comments', postId] }); queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); }3.4 PostCard 组件
// src/components/PostCard.tsx import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Post } from '../types'; import { useLikePost } from '../hooks/usePosts'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import CommentList from './CommentList'; import CommentForm from './CommentForm'; interface PostCardProps { post: Post; } export default function PostCard({ post }: PostCardProps) { const [showComments, setShowComments] = useState(false); const likeMutation = useLikePost(); const handleLike = () => { likeMutation.mutate({ postId: post.id, isLiked: !!post.isLiked }); }; return ( <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 mb-4"> {/* 用户信息 */} <div className="flex items-center gap-3 mb-3"> <Link to={`/profile/${post.user.id}`}> <img src={post.user.avatar || 'https://via.placeholder.com/40'} alt={post.user.name} className="w-10 h-10 rounded-full" /> </Link> <div> <Link to={`/profile/${post.user.id}`} className="font-semibold hover:underline"> {post.user.name} </Link> <p className="text-xs text-gray-500"> {formatDistanceToNow(new Date(post.createdAt), { addSuffix: true, locale: zhCN })} </p> </div> </div> {/* 内容 */} <p className="mb-3">{post.content}</p> {/* 图片 */} {post.images.length > 0 && ( <div className={`grid gap-2 mb-3 ${post.images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}> {post.images.map((img, i) => ( <img key={i} src={img} alt="" className="rounded-lg w-full aspect-square object-cover" /> ))} </div> )} {/* 操作按钮 */} <div className="flex gap-6 border-t border-b py-2 my-2"> <button onClick={handleLike} className={`flex items-center gap-1 ${post.isLiked ? 'text-red-500' : 'text-gray-500'} hover:text-red-500`} > <span>❤️</span> <span>{post.likesCount}</span> </button> <button onClick={() => setShowComments(!showComments)} className="flex items-center gap-1 text-gray-500 hover:text-blue-500" > <span>💬</span> <span>{post.commentsCount}</span> </button> </div> {/* 评论区域 */} {showComments && ( <div className="mt-3"> <CommentForm postId={post.id} /> <CommentList postId={post.id} /> </div> )} </div> ); }3.5 PostForm 组件
// src/components/PostForm.tsx import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../stores/authStore'; interface PostFormProps { onSuccess?: () => void; } export default function PostForm({ onSuccess }: PostFormProps) { const { user } = useAuthStore(); const [content, setContent] = useState(''); const [isLoading, setIsLoading] = useState(false); const queryClient = useQueryClient(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!content.trim()) return; setIsLoading(true); // 乐观更新 const newPost = { id: Date.now().toString(), userId: user!.id, user: user!, content, images: [], likesCount: 0, commentsCount: 0, isLiked: false, createdAt: new Date().toISOString(), }; queryClient.setQueryData(['posts'], (old: any) => { if (!old) return { pages: [{ data: [newPost] }] }; return { ...old, pages: [{ data: [newPost, ...old.pages[0].data] }, ...old.pages.slice(1)], }; }); setContent(''); try { await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, userId: user!.id }), }); onSuccess?.(); } catch (error) { console.error('发布失败', error); } finally { setIsLoading(false); } }; return ( <form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 mb-4"> <div className="flex gap-3"> <img src={user?.avatar || 'https://via.placeholder.com/40'} alt="" className="w-10 h-10 rounded-full" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="分享你的想法..." className="flex-1 p-3 border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700" rows={3} /> </div> <div className="flex justify-end mt-3"> <button type="submit" disabled={!content.trim() || isLoading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" > {isLoading ? '发布中...' : '发布'} </button> </div> </form> ); }3.6 主页组件
// src/pages/HomePage.tsx import { useRef, useCallback } from 'react'; import { usePosts } from '../hooks/usePosts'; import PostCard from '../components/PostCard'; import PostForm from '../components/PostForm'; export default function HomePage() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = usePosts(); const observerRef = useRef<IntersectionObserver | null>(null); const lastPostRef = useCallback( (node: HTMLDivElement | null) => { if (isLoading) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }); if (node) observerRef.current.observe(node); }, [isLoading, hasNextPage, isFetchingNextPage, fetchNextPage] ); if (isLoading) { return <div className="text-center py-12">加载中...</div>; } const posts = data?.pages.flatMap(page => page.data) || []; return ( <div className="max-w-2xl mx-auto"> <PostForm /> {posts.map((post, index) => ( <div key={post.id} ref={index === posts.length - 1 ? lastPostRef : undefined} > <PostCard post={post} /> </div> ))} {isFetchingNextPage && ( <div className="text-center py-4 text-gray-500">加载更多...</div> )} {!hasNextPage && posts.length > 0 && ( <div className="text-center py-4 text-gray-500">没有更多了</div> )} </div> ); }3.7 个人主页
// src/pages/ProfilePage.tsx import { useParams } from 'react-router-dom'; import { useUser, useFollowUser } from '../hooks/useUser'; import { usePostsByUser } from '../hooks/usePosts'; import PostCard from '../components/PostCard'; import { useAuthStore } from '../stores/authStore'; export default function ProfilePage() { const { id } = useParams(); const { user: currentUser } = useAuthStore(); const { data: user, isLoading: userLoading } = useUser(id!); const { data: posts, isLoading: postsLoading } = usePostsByUser(id!); const followMutation = useFollowUser(); const isOwnProfile = currentUser?.id === id; if (userLoading) return <div className="text-center py-12">加载中...</div>; if (!user) return <div className="text-center py-12">用户不存在</div>; return ( <div className="max-w-2xl mx-auto"> {/* 用户信息 */} <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-4"> <div className="flex items-center gap-6"> <img src={user.avatar || 'https://via.placeholder.com/100'} alt={user.name} className="w-24 h-24 rounded-full" /> <div className="flex-1"> <h1 className="text-2xl font-bold">{user.name}</h1> <p className="text-gray-500">@{user.username}</p> <p className="mt-2">{user.bio}</p> <div className="flex gap-4 mt-3 text-sm"> <span><strong>{user.postsCount}</strong> 帖子</span> <span><strong>{user.followersCount}</strong> 粉丝</span> <span><strong>{user.followingCount}</strong> 关注</span> </div> </div> {!isOwnProfile && ( <button onClick={() => followMutation.mutate(user.id)} className={`px-4 py-2 rounded-lg ${ user.isFollowed ? 'bg-gray-200 text-gray-700 hover:bg-gray-300' : 'bg-blue-600 text-white hover:bg-blue-700' }`} > {user.isFollowed ? '已关注' : '关注'} </button> )} </div> </div> {/* 帖子列表 */} {postsLoading ? ( <div className="text-center py-12">加载中...</div> ) : ( posts?.map(post => <PostCard key={post.id} post={post} />) )} </div> ); }3.8 无限滚动 Hook
// src/hooks/useInfiniteScroll.ts import { useEffect, useRef, useCallback } from 'react'; export function useInfiniteScroll( isLoading: boolean, hasNextPage: boolean | undefined, fetchNextPage: () => void ) { const observerRef = useRef<IntersectionObserver | null>(null); const loadMoreRef = useCallback( (node: HTMLDivElement | null) => { if (isLoading) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasNextPage) { fetchNextPage(); } }); if (node) observerRef.current.observe(node); }, [isLoading, hasNextPage, fetchNextPage] ); return loadMoreRef; }3.9 Mock 数据
// db.json{"users":[{"id":"1","username":"john","name":"John Doe","email":"john@example.com","avatar":"","bio":"前端开发者","followersCount":120,"followingCount":85,"postsCount":15}],"posts":[{"id":"1","userId":"1","content":"React 19 真是太棒了!","images":[],"likesCount":45,"commentsCount":12,"createdAt":"2024-06-15T10:00:00Z"}],"comments":[],"likes":[],"follows":[]}4. 项目运行
# 安装依赖npminstall@tanstack/react-query date-fns# 启动项目npmrun dev5. 总结
核心知识点
| 知识点 | 应用 |
|---|---|
| 无限滚动 | 动态流加载 |
| 乐观更新 | 点赞、发布动态 |
| 实时交互 | 评论、关注 |
| 状态管理 | Zustand + React Query |
| 用户认证 | 登录/注册 |
🎉 阶段六全部完成!
完成统计
| 分类 | 文档数量 | 状态 |
|---|---|---|
| 高级组件模式 | 4篇 | ✅ |
| 设计模式 | 4篇 | ✅ |
| 测试 | 4篇 | ✅ |
| TypeScript 集成 | 4篇 | ✅ |
| 实战项目 | 5篇 | ✅ |
| 总计 | 21篇 | ✅ 全部完成 |
编程学习
技术分享
实战经验