第27章 项目二——社交类应用

Chapter-27 - 项目二——社交类应用

27.1 项目架构设计

27.1.1 页面划分:Feed / 详情 / 个人页 / 消息

社交应用的核心页面设计,某种程度上决定了整个应用的用户体验基线。

Feed(动态信息流) 是用户打开 App 后第一个看到的页面,相当于社交应用的脸面。信息流的质量直接决定用户留存——帖子怎么排列(时间序?热度序?)、图片怎么展示(全部加载还是懒加载)、下拉刷新和无限滚动的体验,都是这里要解决的难题。

详情页包括帖子详情和用户详情。当用户点击某条动态或某个头像时,需要有一个"深入"的页面展示完整内容。它与 Feed 的关系是"从总览到细节",要注意详情页的加载状态(骨架屏是标配)和返回后 Feed 状态的保持。

个人页是用户的"名片",除了展示基本信息,还要能看到该用户发布的所有帖子列表。这里通常还需要一个"编辑资料"的功能入口(对自己可见,对他人隐藏或变成"关注/发消息"按钮)。

消息模块一般分两块:通知(谁点赞/评论/关注了你的内容)和私信(一对一的对话)。私信需要有实时能力,可以用 WebSocket 或轮询实现。

27.1.2 数据流设计:状态管理方案

用户操作 → Action → Reducer/Store → UI 更新

27.2 路由规划

27.2.1 React Router v7 路由配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function AppRoutes() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/explore" element={<Explore />} />
      <Route path="/notifications" element={<Notifications />} />
      <Route path="/messages" element={<Messages />} />
      <Route path="/profile/:username" element={<Profile />} />
      <Route path="/post/:id" element={<PostDetail />} />
      <Route path="/login" element={<Login />} />
    </Routes>
  )
}

27.2.2 路由守卫:登录拦截

1
2
3
4
5
6
7
8
import { Navigate } from 'react-router-dom'

function ProtectedRoute({ children, isAuthenticated }) {
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />
  }
  return children
}

27.3 全局状态

27.3.1 Zustand 管理用户状态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useAuthStore = create(
  persist(
    (set) => ({
      user: null,
      token: null,
      isAuthenticated: false,
      login: (user, token) => set({ user, token, isAuthenticated: true }),
      logout: () => set({ user: null, token: null, isAuthenticated: false }),
      updateToken: (token) => set({ token })
    }),
    { name: 'auth-storage' }
  )
)

27.3.2 API 层封装(axios 实例 + 拦截器)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import axios from 'axios'

// 创建 axios 实例,配置公共基础路径
const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL  // .env 文件中定义,如 http://localhost:3000
})

// --------------------------------------------------
// 请求拦截器:每次发请求前自动执行
// --------------------------------------------------
api.interceptors.request.use(config => {
  // 从 zustand store 同步获取当前 token(不用 useStore 是因为这是请求拦截器,非组件内)
  const token = useAuthStore.getState().token
  if (token) {
    // 注入 Bearer Token,用于身份验证
    config.headers.Authorization = `Bearer ${token}`
  }
  return config  // 必须返回,否则请求会卡住
})

// --------------------------------------------------
// 响应拦截器:每次收到响应后自动执行
// --------------------------------------------------
api.interceptors.response.use(
  // 成功时:直接返回 data,省去每个调用处手动 .data
  response => response.data,
  // 失败时:统一处理 401 未授权
  error => {
    if (error.response?.status === 401) {
      // Token 过期或无效:登出并跳转登录页
      useAuthStore.getState().logout()
      window.location.href = '/login'
    }
    // 其他错误:继续抛出,让调用方处理
    return Promise.reject(error)
  }
)

27.4 登录注册

27.4.1 React Hook Form + Zod 表单验证

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const schema = z.object({
  email: z.string().email('邮箱格式不正确'),
  password: z.string().min(6, '密码至少6位')
})

function LoginPage() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema)
  })

  async function onSubmit(data) {
    const { email, password } = data
    await loginApi({ email, password })
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register('password')} />
      {errors.password && <p>{errors.password.message}</p>}
      <button type="submit">登录</button>
    </form>
  )
}

27.4.2 JWT token 管理与刷新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
api.interceptors.response.use(
  response => response,
  async error => {
    if (error.config && error.response?.status === 401) {
      try {
        // Token 过期,尝试用 refreshToken 获取新 token
        const newToken = await refreshToken()
        // 更新 store 中的 token(不改变登录状态)
        useAuthStore.getState().updateToken(newToken)
        // 用新 token 重新执行失败的那个请求
        error.config.headers.Authorization = `Bearer ${newToken}`
        return api.request(error.config)
      } catch {
        // refreshToken 也失败了(如 refreshToken 也过期),强制登出
        useAuthStore.getState().logout()
      }
    }
    return Promise.reject(error)
  }
)

27.4.3 登录状态持久化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const useAuthStore = create(
  persist(
    (set) => ({ /* ... */ }),
    {
      name: 'auth-storage',  // localStorage 的 key 名
      // partialize:指定哪些 state 字段要持久化
      // 这里只存 user、token、isAuthenticated,不存其他临时状态
      // 好处:避免把不相关的数据(如 UI 状态、loading 等)也存进 localStorage
      partialize: (state) => ({
        user: state.user,
        token: state.token,
        isAuthenticated: state.isAuthenticated
      })
    }
  )
)

27.5 响应式设计

27.5.1 移动优先设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* 移动优先 */
.layout { padding: 16px; }

@media (min-width: 768px) {
  .layout { padding: 24px; max-width: 960px; margin: 0 auto; }
}

@media (min-width: 1200px) {
  .layout { max-width: 1200px; }
}

27.5.2 触摸交互优化

1
2
3
4
5
/* 触摸友好的按钮大小 */
button { min-height: 44px; min-width: 44px; }

/* 触摸时的高亮反馈 */
button:active { opacity: 0.8; transform: scale(0.98); }

27.5.3 图片懒加载

图片懒加载是社交类应用必备的优化手段——一个 Feed 可能有几十张图,如果一次性全加载,用户等半天看不到任何东西,还可能把浏览器搞崩。

实现思路:图片初始用占位图(或者透明图),等真实图片加载完成后再显示。常见做法有两种:

方案一:CSS 占位 + onLoad 切换(推荐)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useState } from 'react'

function LazyImage({ src, alt }) {
  const [isLoaded, setIsLoaded] = useState(false)

  return (
    <div className="relative w-full h-full">
      {/* 占位层:图片加载完之前显示 */}
      {!isLoaded && (
        <div className="absolute inset-0 bg-gray-200 animate-pulse" />
      )}
      <img
        src={src}
        alt={alt}
        loading="lazy"  {/* 浏览器原生懒加载 */}
        onLoad={() => setIsLoaded(true)}
        className={`w-full h-full object-cover transition-opacity duration-300 ${
          isLoaded ? 'opacity-100' : 'opacity-0'
        }`}
      />
    </div>
  )
}

方案二:Intersection Observer(更精细的控制)

如果需要"滚动到视口才加载",可以用 Intersection Observer API,在图片进入可视区之前连请求都不发。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useState, useEffect, useRef } from 'react'

function LazyImage({ src, alt }) {
  const [isVisible, setIsVisible] = useState(false)
  const [isLoaded, setIsLoaded] = useState(false)
  const imgRef = useRef(null)

  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) {
        setIsVisible(true)  // 进入视口,加载图片
        observer.disconnect()
      }
    })
    observer.observe(imgRef.current)
    return () => observer.disconnect()
  }, [])

  return (
    <div ref={imgRef} className="relative">
      {!isLoaded && <div className="absolute inset-0 bg-gray-200 animate-pulse" />}
      {isVisible && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          className={`transition-opacity ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
        />
      )}
    </div>
  )
}

方案一简单够用,方案二更省流量。社交 Feed 场景下方案一就足够了,除非你的列表特别长、图特别多。


本章小结

本章我们完成了社交类应用的架构设计:

  • 路由规划:React Router v7 路由守卫实现登录拦截
  • 全局状态:Zustand 管理用户状态,持久化到 localStorage
  • API 层封装:axios 实例 + 拦截器统一处理 token
  • 登录注册:React Hook Form + Zod 表单验证
  • 响应式设计:移动优先、触摸交互优化、图片懒加载

下一个项目我们将实现一个 电商后台管理系统!📊