Appearance
第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 install16.1.2 安装必要的依赖
我们需要安装以下依赖:
react-router-dom:用于路由管理@reduxjs/toolkit和react-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-highlighter16.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.md16.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>© 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
- 注册或登录 Netlify 账号
- 点击 "Add new site" -> "Import an existing project"
- 选择你的代码仓库(GitHub、GitLab 或 Bitbucket)
- 配置构建设置:
- Build command:
npm run build - Publish directory:
dist
- Build command:
- 点击 "Deploy site"
- 等待部署完成,Netlify 会生成一个随机的域名
16.5.3 部署到 Vercel
- 注册或登录 Vercel 账号
- 点击 "New Project"
- 选择你的代码仓库(GitHub、GitLab 或 Bitbucket)
- 配置构建设置(Vercel 会自动检测 React 项目的配置)
- 点击 "Deploy"
- 等待部署完成,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 应用打下了基础。
