Skip to content

第16章:企业级实战:简易博客系统(综合应用)

博客系统是一个非常适合学习 React 综合应用的项目,它涉及到路由、状态管理、网络请求、表单处理等多个方面的知识。本章将详细介绍如何构建一个简易的企业级博客系统,帮助你巩固和应用之前学习的 React 知识。

16.1 项目初始化与架构搭建(路由、状态管理、接口封装)

16.1.1 项目创建

首先,我们使用 Vite 创建一个新的 React 项目:

bash
# 使用 Vite 创建 React 项目
npm create vite@latest blog-system -- --template react

# 进入项目目录
cd blog-system

# 安装依赖
npm install

16.1.2 安装必要的依赖

我们需要安装以下依赖:

  • react-router-dom:用于路由管理
  • @reduxjs/toolkitreact-redux:用于状态管理
  • axios:用于网络请求
  • styled-components:用于样式管理
  • react-markdown:用于渲染 Markdown 内容
  • react-syntax-highlighter:用于代码高亮
bash
npm install react-router-dom @reduxjs/toolkit react-redux axios styled-components react-markdown react-syntax-highlighter

16.1.3 项目目录结构

blog-system/
├── public/
│   └── favicon.ico
├── src/
│   ├── assets/          # 静态资源
│   ├── components/      # 公共组件
│   ├── pages/           # 页面组件
│   ├── store/           # Redux 状态管理
│   ├── api/             # API 接口封装
│   ├── hooks/           # 自定义 Hooks
│   ├── utils/           # 工具函数
│   ├── App.jsx          # 应用根组件
│   ├── main.jsx         # 应用入口
│   └── index.css        # 全局样式
├── .gitignore
├── index.html
├── package.json
├── vite.config.js
└── README.md

16.1.4 路由配置

创建路由配置文件:

jsx
// src/App.jsx
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import PostDetail from './pages/PostDetail';
import EditPost from './pages/EditPost';
import Login from './pages/Login';
import { useSelector } from 'react-redux';

function App() {
  const { isAuthenticated } = useSelector(state => state.auth);

  // 私有路由组件
  const PrivateRoute = ({ children }) => {
    return isAuthenticated ? children : <Navigate to="/login" />;
  };

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="post/:id" element={<PostDetail />} />
          <Route 
            path="edit/:id?" 
            element={
              <PrivateRoute>
                <EditPost />
              </PrivateRoute>
            } 
          />
          <Route path="login" element={<Login />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;

16.1.5 状态管理配置

创建 Redux store:

js
// src/store/store.js
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import postsReducer from './postsSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
    posts: postsReducer,
  },
});

创建认证状态切片:

js
// src/store/authSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  isAuthenticated: false,
  user: null,
  token: localStorage.getItem('token') || null,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    loginSuccess: (state, action) => {
      state.isAuthenticated = true;
      state.user = action.payload.user;
      state.token = action.payload.token;
      localStorage.setItem('token', action.payload.token);
    },
    logout: (state) => {
      state.isAuthenticated = false;
      state.user = null;
      state.token = null;
      localStorage.removeItem('token');
    },
  },
});

export const { loginSuccess, logout } = authSlice.actions;
export default authSlice.reducer;

创建文章状态切片:

js
// src/store/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { postApi } from '../api/api';

export const fetchPosts = createAsyncThunk('posts/fetchPosts', async (params) => {
  const response = await postApi.getPosts(params);
  return response;
});

export const fetchPostById = createAsyncThunk('posts/fetchPostById', async (id) => {
  const response = await postApi.getPostById(id);
  return response;
});

export const createPost = createAsyncThunk('posts/createPost', async (postData) => {
  const response = await postApi.createPost(postData);
  return response;
});

export const updatePost = createAsyncThunk('posts/updatePost', async ({ id, postData }) => {
  const response = await postApi.updatePost(id, postData);
  return response;
});

export const deletePost = createAsyncThunk('posts/deletePost', async (id) => {
  await postApi.deletePost(id);
  return id;
});

const initialState = {
  posts: [],
  currentPost: null,
  loading: false,
  error: null,
  total: 0,
};

const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      // 获取文章列表
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.loading = false;
        state.posts = action.payload.posts;
        state.total = action.payload.total;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      })
      // 获取文章详情
      .addCase(fetchPostById.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchPostById.fulfilled, (state, action) => {
        state.loading = false;
        state.currentPost = action.payload;
      })
      .addCase(fetchPostById.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      })
      // 创建文章
      .addCase(createPost.fulfilled, (state, action) => {
        state.posts.unshift(action.payload);
        state.total += 1;
      })
      // 更新文章
      .addCase(updatePost.fulfilled, (state, action) => {
        const index = state.posts.findIndex(post => post.id === action.payload.id);
        if (index !== -1) {
          state.posts[index] = action.payload;
        }
        if (state.currentPost && state.currentPost.id === action.payload.id) {
          state.currentPost = action.payload;
        }
      })
      // 删除文章
      .addCase(deletePost.fulfilled, (state, action) => {
        state.posts = state.posts.filter(post => post.id !== action.payload);
        state.total -= 1;
      });
  },
});

export default postsSlice.reducer;

16.1.6 接口封装

创建 API 接口封装:

js
// src/api/request.js
import axios from 'axios';

const request = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器
request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
request.interceptors.response.use(
  (response) => {
    return response.data;
  },
  (error) => {
    if (error.response && error.response.status === 401) {
      // 未授权,跳转到登录页
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default request;
js
// src/api/api.js
import request from './request';

export const postApi = {
  getPosts: (params) => request.get('/posts', { params }),
  getPostById: (id) => request.get(`/posts/${id}`),
  createPost: (data) => request.post('/posts', data),
  updatePost: (id, data) => request.put(`/posts/${id}`, data),
  deletePost: (id) => request.delete(`/posts/${id}`),
};

export const authApi = {
  login: (credentials) => request.post('/auth/login', credentials),
  register: (userData) => request.post('/auth/register', userData),
};

16.2 页面布局(头部、侧边栏、内容区、底部)

16.2.1 布局组件

创建布局组件:

jsx
// src/components/Layout.jsx
import React from 'react';
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { logout } from '../store/authSlice';
import styled from 'styled-components';

const LayoutContainer = styled.div`
  display: flex;
  flex-direction: column;
  min-height: 100vh;
`;

const Header = styled.header`
  background-color: #333;
  color: white;
  padding: 1rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
`;

const Logo = styled.h1`
  font-size: 1.5rem;
  margin: 0;
`;

const Nav = styled.nav`
  display: flex;
  gap: 1rem;
`;

const NavLink = styled(Link)`
  color: white;
  text-decoration: none;
  &:hover {
    text-decoration: underline;
  }
`;

const Main = styled.main`
  flex: 1;
  padding: 2rem;
  display: flex;
  gap: 2rem;
`;

const Content = styled.div`
  flex: 1;
`;

const Sidebar = styled.aside`
  width: 300px;
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 8px;
`;

const Footer = styled.footer`
  background-color: #333;
  color: white;
  padding: 1rem;
  text-align: center;
  margin-top: auto;
`;

const Button = styled.button`
  background-color: #f44336;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
  &:hover {
    background-color: #d32f2f;
  }
`;

function Layout() {
  const { isAuthenticated } = useSelector(state => state.auth);
  const dispatch = useDispatch();
  const navigate = useNavigate();

  const handleLogout = () => {
    dispatch(logout());
    navigate('/login');
  };

  return (
    <LayoutContainer>
      <Header>
        <Logo>简易博客系统</Logo>
        <Nav>
          <NavLink to="/">首页</NavLink>
          {isAuthenticated ? (
            <>
              <NavLink to="/edit">写文章</NavLink>
              <Button onClick={handleLogout}>退出登录</Button>
            </>
          ) : (
            <NavLink to="/login">登录</NavLink>
          )}
        </Nav>
      </Header>
      
      <Main>
        <Content>
          <Outlet />
        </Content>
        <Sidebar>
          <h3>关于博客</h3>
          <p>这是一个使用 React 构建的简易博客系统,用于展示 React 的综合应用。</p>
          <h3>最近文章</h3>
          <ul>
            <li>文章1</li>
            <li>文章2</li>
            <li>文章3</li>
          </ul>
        </Sidebar>
      </Main>
      
      <Footer>
        <p>&copy; 2023 简易博客系统</p>
      </Footer>
    </LayoutContainer>
  );
}

export default Layout;

16.3 核心功能实现

16.3.1 首页:博客列表渲染、分页、搜索

创建首页组件:

jsx
// src/pages/Home.jsx
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts, deletePost } from '../store/postsSlice';
import { Link, useNavigate } from 'react-router-dom';
import styled from 'styled-components';

const HomeContainer = styled.div`
  max-width: 800px;
`;

const PostList = styled.ul`
  list-style: none;
  padding: 0;
`;

const PostItem = styled.li`
  padding: 1.5rem;
  border-bottom: 1px solid #eee;
  &:last-child {
    border-bottom: none;
  }
`;

const PostTitle = styled.h2`
  margin: 0 0 0.5rem 0;
`;

const PostMeta = styled.div`
  font-size: 0.9rem;
  color: #666;
  margin-bottom: 1rem;
`;

const PostExcerpt = styled.p`
  margin: 0 0 1rem 0;
`;

const PostActions = styled.div`
  display: flex;
  gap: 1rem;
`;

const Button = styled.button`
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &.primary {
    background-color: #2196F3;
    color: white;
  }
  &.danger {
    background-color: #f44336;
    color: white;
  }
`;

const Pagination = styled.div`
  display: flex;
  justify-content: center;
  margin-top: 2rem;
  gap: 0.5rem;
`;

const SearchContainer = styled.div`
  margin-bottom: 2rem;
`;

const SearchInput = styled.input`
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
`;

function Home() {
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const { posts, loading, error, total } = useSelector(state => state.posts);
  const { isAuthenticated } = useSelector(state => state.auth);
  
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');
  const pageSize = 10;

  useEffect(() => {
    dispatch(fetchPosts({ page, pageSize, search }));
  }, [dispatch, page, search]);

  const handleDelete = (id) => {
    if (window.confirm('确定要删除这篇文章吗?')) {
      dispatch(deletePost(id));
    }
  };

  const handleSearch = (e) => {
    setSearch(e.target.value);
    setPage(1);
  };

  const totalPages = Math.ceil(total / pageSize);

  return (
    <HomeContainer>
      <h1>博客首页</h1>
      
      <SearchContainer>
        <SearchInput
          type="text"
          placeholder="搜索文章..."
          value={search}
          onChange={handleSearch}
        />
      </SearchContainer>

      {loading && <p>加载中...</p>}
      {error && <p style={{ color: 'red' }}>错误:{error}</p>}

      <PostList>
        {posts.map(post => (
          <PostItem key={post.id}>
            <PostTitle>
              <Link to={`/post/${post.id}`}>{post.title}</Link>
            </PostTitle>
            <PostMeta>
              作者:{post.author} | 发布时间:{new Date(post.createdAt).toLocaleString()}
            </PostMeta>
            <PostExcerpt>{post.excerpt}</PostExcerpt>
            <PostActions>
              <Link to={`/post/${post.id}`}>
                <Button className="primary">查看详情</Button>
              </Link>
              {isAuthenticated && (
                <>
                  <Link to={`/edit/${post.id}`}>
                    <Button className="primary">编辑</Button>
                  </Link>
                  <Button className="danger" onClick={() => handleDelete(post.id)}>
                    删除
                  </Button>
                </>
              )}
            </PostActions>
          </PostItem>
        ))}
      </PostList>

      <Pagination>
        <Button 
          className="primary"
          onClick={() => setPage(prev => Math.max(prev - 1, 1))}
          disabled={page === 1}
        >
          上一页
        </Button>
        <span>第 {page} 页,共 {totalPages} 页</span>
        <Button 
          className="primary"
          onClick={() => setPage(prev => Math.min(prev + 1, totalPages))}
          disabled={page === totalPages}
        >
          下一页
        </Button>
      </Pagination>
    </HomeContainer>
  );
}

export default Home;

16.3.2 详情页:博客内容展示、评论列表

创建文章详情页组件:

jsx
// src/pages/PostDetail.jsx
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPostById } from '../store/postsSlice';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import styled from 'styled-components';

const PostContainer = styled.div`
  max-width: 800px;
`;

const PostTitle = styled.h1`
  margin-bottom: 1rem;
`;

const PostMeta = styled.div`
  font-size: 0.9rem;
  color: #666;
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid #eee;
`;

const PostContent = styled.div`
  margin-bottom: 2rem;
  line-height: 1.6;
  h2 {
    margin-top: 2rem;
    margin-bottom: 1rem;
  }
  p {
    margin-bottom: 1rem;
  }
  code {
    background-color: #f5f5f5;
    padding: 0.2rem 0.4rem;
    border-radius: 3px;
    font-family: 'Courier New', Courier, monospace;
  }
  pre {
    background-color: #2d2d2d;
    padding: 1rem;
    border-radius: 4px;
    overflow-x: auto;
    margin-bottom: 1rem;
  }
`;

const CommentSection = styled.div`
  margin-top: 3rem;
  padding-top: 2rem;
  border-top: 1px solid #eee;
`;

const CommentList = styled.ul`
  list-style: none;
  padding: 0;
`;

const CommentItem = styled.li`
  padding: 1rem;
  border-bottom: 1px solid #eee;
  &:last-child {
    border-bottom: none;
  }
`;

const CommentAuthor = styled.div`
  font-weight: bold;
  margin-bottom: 0.5rem;
`;

const CommentContent = styled.div`
  margin-bottom: 0.5rem;
`;

const CommentTime = styled.div`
  font-size: 0.8rem;
  color: #666;
`;

function PostDetail() {
  const { id } = useParams();
  const dispatch = useDispatch();
  const { currentPost, loading, error } = useSelector(state => state.posts);

  useEffect(() => {
    dispatch(fetchPostById(id));
  }, [dispatch, id]);

  if (loading) return <p>加载中...</p>;
  if (error) return <p style={{ color: 'red' }}>错误:{error}</p>;
  if (!currentPost) return <p>文章不存在</p>;

  const renderers = {
    code: ({ language, value }) => {
      return (
        <SyntaxHighlighter style={vscDarkPlus} language={language}>
          {value}
        </SyntaxHighlighter>
      );
    },
  };

  return (
    <PostContainer>
      <PostTitle>{currentPost.title}</PostTitle>
      <PostMeta>
        作者:{currentPost.author} | 发布时间:{new Date(currentPost.createdAt).toLocaleString()}
      </PostMeta>
      <PostContent>
        <ReactMarkdown renderers={renderers}>
          {currentPost.content}
        </ReactMarkdown>
      </PostContent>
      
      <CommentSection>
        <h2>评论</h2>
        <CommentList>
          {currentPost.comments && currentPost.comments.map(comment => (
            <CommentItem key={comment.id}>
              <CommentAuthor>{comment.author}</CommentAuthor>
              <CommentContent>{comment.content}</CommentContent>
              <CommentTime>{new Date(comment.createdAt).toLocaleString()}</CommentTime>
            </CommentItem>
          ))}
        </CommentList>
      </CommentSection>
    </PostContainer>
  );
}

export default PostDetail;

16.3.3 编辑页:表单提交、富文本编辑

创建文章编辑页组件:

jsx
// src/pages/EditPost.jsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPostById, createPost, updatePost } from '../store/postsSlice';
import styled from 'styled-components';

const EditContainer = styled.div`
  max-width: 800px;
`;

const Form = styled.form`
  display: flex;
  flex-direction: column;
  gap: 1rem;
`;

const FormGroup = styled.div`
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
`;

const Label = styled.label`
  font-weight: bold;
`;

const Input = styled.input`
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
`;

const Textarea = styled.textarea`
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
  min-height: 300px;
  font-family: 'Courier New', Courier, monospace;
`;

const Button = styled.button`
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  &.primary {
    background-color: #4CAF50;
    color: white;
  }
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

function EditPost() {
  const { id } = useParams();
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const { currentPost, loading } = useSelector(state => state.posts);
  
  const [formData, setFormData] = useState({
    title: '',
    content: '',
    excerpt: '',
    author: '',
  });

  useEffect(() => {
    if (id) {
      dispatch(fetchPostById(id));
    }
  }, [dispatch, id]);

  useEffect(() => {
    if (currentPost) {
      setFormData({
        title: currentPost.title,
        content: currentPost.content,
        excerpt: currentPost.excerpt,
        author: currentPost.author,
      });
    }
  }, [currentPost]);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (id) {
      dispatch(updatePost({ id, postData: formData })).then(() => {
        navigate(`/post/${id}`);
      });
    } else {
      dispatch(createPost(formData)).then((action) => {
        navigate(`/post/${action.payload.id}`);
      });
    }
  };

  if (loading && id) return <p>加载中...</p>;

  return (
    <EditContainer>
      <h1>{id ? '编辑文章' : '创建文章'}</h1>
      <Form onSubmit={handleSubmit}>
        <FormGroup>
          <Label htmlFor="title">标题</Label>
          <Input
            type="text"
            id="title"
            name="title"
            value={formData.title}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        <FormGroup>
          <Label htmlFor="excerpt">摘要</Label>
          <Input
            type="text"
            id="excerpt"
            name="excerpt"
            value={formData.excerpt}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        <FormGroup>
          <Label htmlFor="author">作者</Label>
          <Input
            type="text"
            id="author"
            name="author"
            value={formData.author}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        <FormGroup>
          <Label htmlFor="content">内容(支持 Markdown)</Label>
          <Textarea
            id="content"
            name="content"
            value={formData.content}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        <Button type="submit" className="primary" disabled={loading}>
          {loading ? '提交中...' : (id ? '更新文章' : '创建文章')}
        </Button>
      </Form>
    </EditContainer>
  );
}

export default EditPost;

16.3.4 登录页:表单验证、登录状态管理

创建登录页组件:

jsx
// src/pages/Login.jsx
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate, Link } from 'react-router-dom';
import { loginSuccess } from '../store/authSlice';
import { authApi } from '../api/api';
import styled from 'styled-components';

const LoginContainer = styled.div`
  max-width: 400px;
  margin: 50px auto;
  padding: 2rem;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
`;

const Form = styled.form`
  display: flex;
  flex-direction: column;
  gap: 1rem;
`;

const FormGroup = styled.div`
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
`;

const Label = styled.label`
  font-weight: bold;
`;

const Input = styled.input`
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
`;

const Button = styled.button`
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  background-color: #4CAF50;
  color: white;
  &:hover {
    background-color: #45a049;
  }
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

const ErrorMessage = styled.div`
  color: #f44336;
  font-size: 0.9rem;
  margin-top: 0.5rem;
`;

const Title = styled.h1`
  text-align: center;
  margin-bottom: 2rem;
`;

function Login() {
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    username: '',
    password: '',
  });
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,
    }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setLoading(true);
    
    try {
      const response = await authApi.login(formData);
      dispatch(loginSuccess(response));
      navigate('/');
    } catch (err) {
      setError(err.response?.data?.message || '登录失败,请检查用户名和密码');
    } finally {
      setLoading(false);
    }
  };

  return (
    <LoginContainer>
      <Title>登录</Title>
      <Form onSubmit={handleSubmit}>
        <FormGroup>
          <Label htmlFor="username">用户名</Label>
          <Input
            type="text"
            id="username"
            name="username"
            value={formData.username}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        <FormGroup>
          <Label htmlFor="password">密码</Label>
          <Input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required
          />
        </FormGroup>
        
        {error && <ErrorMessage>{error}</ErrorMessage>}
        
        <Button type="submit" disabled={loading}>
          {loading ? '登录中...' : '登录'}
        </Button>
      </Form>
    </LoginContainer>
  );
}

export default Login;

16.4 性能优化(memo、懒加载)

16.4.1 使用 memo 优化组件渲染

jsx
// src/components/PostItem.jsx
import React, { memo } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';

const PostItemContainer = styled.li`
  padding: 1.5rem;
  border-bottom: 1px solid #eee;
  &:last-child {
    border-bottom: none;
  }
`;

const PostTitle = styled.h2`
  margin: 0 0 0.5rem 0;
`;

const PostMeta = styled.div`
  font-size: 0.9rem;
  color: #666;
  margin-bottom: 1rem;
`;

const PostExcerpt = styled.p`
  margin: 0 0 1rem 0;
`;

const PostActions = styled.div`
  display: flex;
  gap: 1rem;
`;

const Button = styled.button`
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &.primary {
    background-color: #2196F3;
    color: white;
  }
  &.danger {
    background-color: #f44336;
    color: white;
  }
`;

const PostItem = memo(({ post, onDelete, isAuthenticated }) => {
  return (
    <PostItemContainer>
      <PostTitle>
        <Link to={`/post/${post.id}`}>{post.title}</Link>
      </PostTitle>
      <PostMeta>
        作者:{post.author} | 发布时间:{new Date(post.createdAt).toLocaleString()}
      </PostMeta>
      <PostExcerpt>{post.excerpt}</PostExcerpt>
      <PostActions>
        <Link to={`/post/${post.id}`}>
          <Button className="primary">查看详情</Button>
        </Link>
        {isAuthenticated && (
          <>
            <Link to={`/edit/${post.id}`}>
              <Button className="primary">编辑</Button>
            </Link>
            <Button className="danger" onClick={() => onDelete(post.id)}>
              删除
            </Button>
          </>
        )}
      </PostActions>
    </PostItemContainer>
  );
});

export default PostItem;

16.4.2 使用懒加载优化页面加载速度

jsx
// src/App.jsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import { useSelector } from 'react-redux';

// 懒加载页面组件
const Home = lazy(() => import('./pages/Home'));
const PostDetail = lazy(() => import('./pages/PostDetail'));
const EditPost = lazy(() => import('./pages/EditPost'));
const Login = lazy(() => import('./pages/Login'));

function App() {
  const { isAuthenticated } = useSelector(state => state.auth);

  // 私有路由组件
  const PrivateRoute = ({ children }) => {
    return isAuthenticated ? children : <Navigate to="/login" />;
  };

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route 
            index 
            element={
              <Suspense fallback={<div>加载中...</div>}>
                <Home />
              </Suspense>
            } 
          />
          <Route 
            path="post/:id" 
            element={
              <Suspense fallback={<div>加载中...</div>}>
                <PostDetail />
              </Suspense>
            } 
          />
          <Route 
            path="edit/:id?" 
            element={
              <PrivateRoute>
                <Suspense fallback={<div>加载中...</div>}>
                  <EditPost />
                </Suspense>
              </PrivateRoute>
            } 
          />
          <Route 
            path="login" 
            element={
              <Suspense fallback={<div>加载中...</div>}>
                <Login />
              </Suspense>
            } 
          />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;

16.5 项目打包与部署(Netlify、Vercel 简易部署)

16.5.1 项目打包

在部署之前,我们需要先打包项目:

bash
# 执行打包命令
npm run build

打包完成后,会生成一个 dist 目录,包含了所有静态文件。

16.5.2 部署到 Netlify

  1. 注册或登录 Netlify 账号
  2. 点击 "Add new site" -> "Import an existing project"
  3. 选择你的代码仓库(GitHub、GitLab 或 Bitbucket)
  4. 配置构建设置:
    • Build command: npm run build
    • Publish directory: dist
  5. 点击 "Deploy site"
  6. 等待部署完成,Netlify 会生成一个随机的域名

16.5.3 部署到 Vercel

  1. 注册或登录 Vercel 账号
  2. 点击 "New Project"
  3. 选择你的代码仓库(GitHub、GitLab 或 Bitbucket)
  4. 配置构建设置(Vercel 会自动检测 React 项目的配置)
  5. 点击 "Deploy"
  6. 等待部署完成,Vercel 会生成一个随机的域名

16.5.4 环境变量配置

如果你的项目需要环境变量(例如 API 基础 URL),可以在 Netlify 或 Vercel 的控制台中配置:

Netlify:Settings -> Build & deploy -> Environment variables Vercel:Settings -> Environment Variables

小结

本章通过实现一个简易的企业级博客系统,我们学习了以下内容:

  • 项目架构搭建:使用 Vite 创建项目,配置路由和状态管理
  • 核心功能实现
    • 首页:博客列表渲染、分页、搜索
    • 详情页:博客内容展示、评论列表
    • 编辑页:表单提交、Markdown 编辑
    • 登录页:表单验证、登录状态管理
  • 性能优化:使用 memo 优化组件渲染,使用懒加载优化页面加载速度
  • 项目部署:部署到 Netlify 和 Vercel

这个博客系统涵盖了 React 开发中的许多常见场景,包括路由管理、状态管理、网络请求、表单处理、Markdown 渲染等。通过这个项目的实践,你应该对 React 的综合应用有了更深入的理解,为后续开发更复杂的 React 应用打下了基础。

© 2026 编程马·菜鸟教程 版权所有