06-高级模式与实战项目——21. 实战项目五:社交媒体
21. 实战项目五社交媒体概述社交媒体应用是一个综合性的全栈项目涵盖动态发布、点赞评论、用户关注、实时消息等核心功能。通过这个项目你将掌握实时交互、乐观更新、无限滚动等高级技能。维度内容What完整社交媒体应用包含动态发布、点赞、评论Why掌握实时交互、乐观更新、无限滚动When综合实战练习Where完整全栈项目Who需要复杂交互经验的开发者How实现动态流、点赞、评论、用户关注等1. 项目需求1.1 功能列表✅ 用户注册/登录✅ 动态发布文字/图片✅ 动态列表无限滚动✅ 点赞/取消点赞乐观更新✅ 评论功能✅ 关注/取消关注用户✅ 个人主页✅ 消息通知✅ 搜索用户1.2 技术栈React 19TypeScriptReact Router v6Zustand (状态管理)React Query (数据获取)Tailwind CSSJSON 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) Promisevoid; register: (username: string, email: string, password: string) Promisevoid; logout: () void; updateUser: (updates: PartialUser) void; } export const useAuthStore createAuthState()( 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}_limit10_sortcreatedAt_orderdesc); 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}_sortcreatedAt_orderasc).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 classNamebg-white dark:bg-gray-800 rounded-lg shadow-md p-4 mb-4 {/* 用户信息 */} div classNameflex 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} classNamew-10 h-10 rounded-full / /Link div Link to{/profile/${post.user.id}} classNamefont-semibold hover:underline {post.user.name} /Link p classNametext-xs text-gray-500 {formatDistanceToNow(new Date(post.createdAt), { addSuffix: true, locale: zhCN })} /p /div /div {/* 内容 */} p classNamemb-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 classNamerounded-lg w-full aspect-square object-cover / ))} /div )} {/* 操作按钮 */} div classNameflex 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)} classNameflex items-center gap-1 text-gray-500 hover:text-blue-500 span/span span{post.commentsCount}/span /button /div {/* 评论区域 */} {showComments ( div classNamemt-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} classNamebg-white dark:bg-gray-800 rounded-lg shadow-md p-4 mb-4 div classNameflex gap-3 img src{user?.avatar || https://via.placeholder.com/40} alt classNamew-10 h-10 rounded-full / textarea value{content} onChange{(e) setContent(e.target.value)} placeholder分享你的想法... classNameflex-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 classNameflex justify-end mt-3 button typesubmit disabled{!content.trim() || isLoading} classNamepx-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 useRefIntersectionObserver | 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 classNametext-center py-12加载中.../div; } const posts data?.pages.flatMap(page page.data) || []; return ( div classNamemax-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 classNametext-center py-4 text-gray-500加载更多.../div )} {!hasNextPage posts.length 0 ( div classNametext-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 classNametext-center py-12加载中.../div; if (!user) return div classNametext-center py-12用户不存在/div; return ( div classNamemax-w-2xl mx-auto {/* 用户信息 */} div classNamebg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-4 div classNameflex items-center gap-6 img src{user.avatar || https://via.placeholder.com/100} alt{user.name} classNamew-24 h-24 rounded-full / div classNameflex-1 h1 classNametext-2xl font-bold{user.name}/h1 p classNametext-gray-500{user.username}/p p classNamemt-2{user.bio}/p div classNameflex gap-4 mt-3 text-sm spanstrong{user.postsCount}/strong 帖子/span spanstrong{user.followersCount}/strong 粉丝/span spanstrong{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 classNametext-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 useRefIntersectionObserver | 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:johnexample.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. 项目运行# 安装依赖npminstalltanstack/react-query date-fns# 启动项目npmrun dev5. 总结核心知识点知识点应用无限滚动动态流加载乐观更新点赞、发布动态实时交互评论、关注状态管理Zustand React Query用户认证登录/注册 阶段六全部完成完成统计分类文档数量状态高级组件模式4篇✅设计模式4篇✅测试4篇✅TypeScript 集成4篇✅实战项目5篇✅总计21篇✅ 全部完成