5. React Blog
Создадим современный блог с маршрутизацией, state management и возможностью добавлять посты и комментарии.
🎯 Что мы создадим
Заголовок раздела «🎯 Что мы создадим»Функционал блога:
- ✅ Список постов с превью
- ✅ Детальная страница поста
- ✅ Форма создания нового поста
- ✅ Комментарии к постам
- ✅ React Router для навигации
- ✅ Context API для глобального состояния
- ✅ Responsive дизайн
- ✅ Деплой на Vercel
Технологии:
- React 18+
- React Router v6
- Context API
- Vite (bundler)
- CSS Modules
📦 Подготовка
Заголовок раздела «📦 Подготовка»1. Установка инструментов
Заголовок раздела «1. Установка инструментов»Node.js и npm:
- Скачай Node.js с nodejs.org (выбери LTS версию)
- Установи Node.js
- Проверь установку:
Окно терминала node -v # должно вывести v20.x.xnpm -v # должно вывести 10.x.x
VS Code:
- Скачай с code.visualstudio.com
- Установи расширения:
- ES7+ React/Redux/React-Native snippets
- Prettier - Code formatter
- ESLint
2. Создание проекта
Заголовок раздела «2. Создание проекта»Открой терминал и выполни:
# Создаём проект с помощью Vitenpm create vite@latest my-blog -- --template react
# Переходим в папку проектаcd my-blog
# Устанавливаем зависимостиnpm install
# Устанавливаем React Routernpm install react-router-dom
# Запускаем dev-серверnpm run dev✅ Чекпоинт: Браузер откроется на http://localhost:5173, ты увидишь стартовую страницу Vite + React.
🏗️ Структура проекта
Заголовок раздела «🏗️ Структура проекта»Создадим правильную структуру:
my-blog/├── public/├── src/│ ├── components/│ │ ├── Header.jsx│ │ ├── PostList.jsx│ │ ├── PostCard.jsx│ │ ├── PostDetail.jsx│ │ ├── CommentForm.jsx│ │ ├── CommentList.jsx│ │ └── CreatePost.jsx│ ├── context/│ │ └── BlogContext.jsx│ ├── pages/│ │ ├── Home.jsx│ │ ├── Post.jsx│ │ └── NewPost.jsx│ ├── styles/│ │ ├── Header.module.css│ │ ├── PostCard.module.css│ │ ├── PostDetail.module.css│ │ └── App.module.css│ ├── App.jsx│ ├── App.css│ └── main.jsx├── package.json└── vite.config.jsСоздай папки вручную:
mkdir src/componentsmkdir src/contextmkdir src/pagesmkdir src/styles🎨 Шаг 1: Context для глобального состояния
Заголовок раздела «🎨 Шаг 1: Context для глобального состояния»Создадим контекст для хранения постов и комментариев.
src/context/BlogContext.jsx:
import { createContext, useState, useContext } from 'react';
// Создаём контекстconst BlogContext = createContext();
// Хук для удобного доступа к контекстуexport const useBlog = () => { const context = useContext(BlogContext); if (!context) { throw new Error('useBlog должен использоваться внутри BlogProvider'); } return context;};
// Provider компонентexport const BlogProvider = ({ children }) => { // Начальные посты const [posts, setPosts] = useState([ { id: 1, title: 'Привет, React!', content: 'Это мой первый пост в блоге на React. Здесь я буду делиться своими знаниями и опытом.', author: 'Автор блога', date: '2024-01-15', comments: [ { id: 1, author: 'Читатель', text: 'Отличный старт!' } ] }, { id: 2, title: 'Hooks в React', content: 'useState и useEffect — это базовые хуки, которые нужно знать каждому React-разработчику.', author: 'Автор блога', date: '2024-01-20', comments: [] } ]);
// Добавить новый пост const addPost = (post) => { const newPost = { id: Date.now(), ...post, date: new Date().toISOString().split('T')[0], comments: [] }; setPosts([newPost, ...posts]); };
// Добавить комментарий к посту const addComment = (postId, comment) => { setPosts(posts.map(post => { if (post.id === postId) { return { ...post, comments: [ ...post.comments, { id: Date.now(), ...comment } ] }; } return post; })); };
// Найти пост по ID const getPostById = (id) => { return posts.find(post => post.id === parseInt(id)); };
const value = { posts, addPost, addComment, getPostById };
return ( <BlogContext.Provider value={value}> {children} </BlogContext.Provider> );};Что тут происходит:
createContext()— создаём контекстuseState— храним массив постовaddPost— функция для добавления нового постаaddComment— функция для добавления комментарияBlogProvider— оборачиваем приложение, чтобы все компоненты имели доступ к данным
🧭 Шаг 2: Настройка маршрутизации
Заголовок раздела «🧭 Шаг 2: Настройка маршрутизации»src/main.jsx:
import React from 'react';import ReactDOM from 'react-dom/client';import { BrowserRouter } from 'react-router-dom';import { BlogProvider } from './context/BlogContext';import App from './App';import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <BrowserRouter> <BlogProvider> <App /> </BlogProvider> </BrowserRouter> </React.StrictMode>);src/App.jsx:
import { Routes, Route } from 'react-router-dom';import Header from './components/Header';import Home from './pages/Home';import Post from './pages/Post';import NewPost from './pages/NewPost';import styles from './styles/App.module.css';
function App() { return ( <div className={styles.app}> <Header /> <main className={styles.main}> <Routes> <Route path="/" element={<Home />} /> <Route path="/post/:id" element={<Post />} /> <Route path="/new" element={<NewPost />} /> </Routes> </main> </div> );}
export default App;🏠 Шаг 3: Header компонент
Заголовок раздела «🏠 Шаг 3: Header компонент»src/components/Header.jsx:
import { Link } from 'react-router-dom';import styles from '../styles/Header.module.css';
const Header = () => { return ( <header className={styles.header}> <div className={styles.container}> <Link to="/" className={styles.logo}> 📝 Мой Блог </Link> <nav className={styles.nav}> <Link to="/" className={styles.link}>Главная</Link> <Link to="/new" className={styles.button}>Создать пост</Link> </nav> </div> </header> );};
export default Header;src/styles/Header.module.css:
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1rem 0; box-shadow: 0 2px 10px rgba(0,0,0,0.1);}
.container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; justify-content: space-between; align-items: center;}
.logo { font-size: 1.5rem; font-weight: bold; color: white; text-decoration: none; transition: opacity 0.3s;}
.logo:hover { opacity: 0.9;}
.nav { display: flex; gap: 1.5rem; align-items: center;}
.link { color: white; text-decoration: none; font-weight: 500; transition: opacity 0.3s;}
.link:hover { opacity: 0.8;}
.button { background: white; color: #667eea; padding: 0.5rem 1rem; border-radius: 8px; text-decoration: none; font-weight: 600; transition: transform 0.3s, box-shadow 0.3s;}
.button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(255,255,255,0.3);}📄 Шаг 4: Страница Home (список постов)
Заголовок раздела «📄 Шаг 4: Страница Home (список постов)»src/pages/Home.jsx:
import PostList from '../components/PostList';import styles from '../styles/App.module.css';
const Home = () => { return ( <div className={styles.container}> <h1>Последние посты</h1> <PostList /> </div> );};
export default Home;src/components/PostList.jsx:
import PostCard from './PostCard';import { useBlog } from '../context/BlogContext';
const PostList = () => { const { posts } = useBlog();
if (posts.length === 0) { return <p style={{ textAlign: 'center', marginTop: '2rem' }}>Постов пока нет. Создайте первый!</p>; }
return ( <div> {posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> );};
export default PostList;src/components/PostCard.jsx:
import { Link } from 'react-router-dom';import styles from '../styles/PostCard.module.css';
const PostCard = ({ post }) => { return ( <article className={styles.card}> <Link to={`/post/${post.id}`} className={styles.link}> <h2 className={styles.title}>{post.title}</h2> </Link> <div className={styles.meta}> <span className={styles.author}>👤 {post.author}</span> <span className={styles.date}>📅 {post.date}</span> <span className={styles.comments}>💬 {post.comments.length} комментариев</span> </div> <p className={styles.preview}> {post.content.substring(0, 150)}... </p> <Link to={`/post/${post.id}`} className={styles.readMore}> Читать далее → </Link> </article> );};
export default PostCard;src/styles/PostCard.module.css:
.card { background: white; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: transform 0.3s, box-shadow 0.3s;}
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,0.12);}
.link { text-decoration: none; color: inherit;}
.title { font-size: 1.5rem; margin: 0 0 1rem 0; color: #2d3748; transition: color 0.3s;}
.link:hover .title { color: #667eea;}
.meta { display: flex; gap: 1.5rem; margin-bottom: 1rem; font-size: 0.9rem; color: #718096;}
.preview { color: #4a5568; line-height: 1.6; margin-bottom: 1rem;}
.readMore { display: inline-block; color: #667eea; font-weight: 600; text-decoration: none; transition: transform 0.3s;}
.readMore:hover { transform: translateX(4px);}✅ Чекпоинт: На главной странице видишь 2 начальных поста с превью и мета-информацией.
📖 Шаг 5: Страница поста (детальный вид)
Заголовок раздела «📖 Шаг 5: Страница поста (детальный вид)»src/pages/Post.jsx:
import { useParams, Navigate } from 'react-router-dom';import { useBlog } from '../context/BlogContext';import PostDetail from '../components/PostDetail';import CommentList from '../components/CommentList';import CommentForm from '../components/CommentForm';import styles from '../styles/App.module.css';
const Post = () => { const { id } = useParams(); const { getPostById } = useBlog();
const post = getPostById(id);
if (!post) { return <Navigate to="/" replace />; }
return ( <div className={styles.container}> <PostDetail post={post} /> <CommentList comments={post.comments} /> <CommentForm postId={post.id} /> </div> );};
export default Post;src/components/PostDetail.jsx:
import { useNavigate } from 'react-router-dom';import styles from '../styles/PostDetail.module.css';
const PostDetail = ({ post }) => { const navigate = useNavigate();
return ( <article className={styles.post}> <button onClick={() => navigate(-1)} className={styles.backButton}> ← Назад </button> <h1 className={styles.title}>{post.title}</h1> <div className={styles.meta}> <span className={styles.author}>👤 {post.author}</span> <span className={styles.date}>📅 {post.date}</span> </div> <div className={styles.content}> {post.content} </div> </article> );};
export default PostDetail;src/styles/PostDetail.module.css:
.post { background: white; border-radius: 12px; padding: 2rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08);}
.backButton { background: #f7fafc; border: 1px solid #e2e8f0; color: #4a5568; padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem; transition: all 0.3s; margin-bottom: 1rem;}
.backButton:hover { background: #edf2f7; border-color: #cbd5e0;}
.title { font-size: 2rem; margin: 1rem 0; color: #2d3748;}
.meta { display: flex; gap: 1.5rem; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0; font-size: 0.9rem; color: #718096;}
.content { font-size: 1.1rem; line-height: 1.8; color: #2d3748;}💬 Шаг 6: Комментарии
Заголовок раздела «💬 Шаг 6: Комментарии»src/components/CommentList.jsx:
import styles from '../styles/PostDetail.module.css';
const CommentList = ({ comments }) => { if (comments.length === 0) { return <p className={styles.noComments}>Комментариев пока нет. Будьте первым!</p>; }
return ( <div className={styles.comments}> <h3 className={styles.commentsTitle}>Комментарии ({comments.length})</h3> {comments.map(comment => ( <div key={comment.id} className={styles.comment}> <div className={styles.commentAuthor}>👤 {comment.author}</div> <div className={styles.commentText}>{comment.text}</div> </div> ))} </div> );};
export default CommentList;src/components/CommentForm.jsx:
import { useState } from 'react';import { useBlog } from '../context/BlogContext';import styles from '../styles/PostDetail.module.css';
const CommentForm = ({ postId }) => { const { addComment } = useBlog(); const [author, setAuthor] = useState(''); const [text, setText] = useState('');
const handleSubmit = (e) => { e.preventDefault();
if (!author.trim() || !text.trim()) { alert('Заполните все поля!'); return; }
addComment(postId, { author, text }); setAuthor(''); setText(''); };
return ( <form onSubmit={handleSubmit} className={styles.form}> <h3 className={styles.formTitle}>Добавить комментарий</h3> <input type="text" placeholder="Ваше имя" value={author} onChange={(e) => setAuthor(e.target.value)} className={styles.input} /> <textarea placeholder="Ваш комментарий" value={text} onChange={(e) => setText(e.target.value)} className={styles.textarea} rows="4" /> <button type="submit" className={styles.submitButton}> Отправить </button> </form> );};
export default CommentForm;Добавим стили для комментариев в PostDetail.module.css:
/* ...предыдущие стили... */
.noComments { text-align: center; color: #a0aec0; font-style: italic; margin: 2rem 0;}
.comments { background: white; border-radius: 12px; padding: 2rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08);}
.commentsTitle { font-size: 1.5rem; margin: 0 0 1.5rem 0; color: #2d3748;}
.comment { padding: 1rem; background: #f7fafc; border-radius: 8px; margin-bottom: 1rem; border-left: 4px solid #667eea;}
.commentAuthor { font-weight: 600; color: #4a5568; margin-bottom: 0.5rem;}
.commentText { color: #2d3748; line-height: 1.6;}
.form { background: white; border-radius: 12px; padding: 2rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08);}
.formTitle { font-size: 1.5rem; margin: 0 0 1.5rem 0; color: #2d3748;}
.input,.textarea { width: 100%; padding: 0.75rem; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 1rem; margin-bottom: 1rem; transition: border-color 0.3s;}
.input:focus,.textarea:focus { outline: none; border-color: #667eea;}
.submitButton { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 0.75rem 2rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.3s, box-shadow 0.3s;}
.submitButton:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);}✅ Чекпоинт: При клике на пост открывается детальная страница, видны комментарии (или сообщение “Комментариев пока нет”), есть форма для добавления комментария.
✍️ Шаг 7: Создание нового поста
Заголовок раздела «✍️ Шаг 7: Создание нового поста»src/pages/NewPost.jsx:
import CreatePost from '../components/CreatePost';import styles from '../styles/App.module.css';
const NewPost = () => { return ( <div className={styles.container}> <h1>Создать новый пост</h1> <CreatePost /> </div> );};
export default NewPost;src/components/CreatePost.jsx:
import { useState } from 'react';import { useNavigate } from 'react-router-dom';import { useBlog } from '../context/BlogContext';import styles from '../styles/PostDetail.module.css';
const CreatePost = () => { const { addPost } = useBlog(); const navigate = useNavigate();
const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [author, setAuthor] = useState('');
const handleSubmit = (e) => { e.preventDefault();
if (!title.trim() || !content.trim() || !author.trim()) { alert('Заполните все поля!'); return; }
addPost({ title, content, author }); navigate('/'); };
return ( <form onSubmit={handleSubmit} className={styles.form}> <input type="text" placeholder="Заголовок поста" value={title} onChange={(e) => setTitle(e.target.value)} className={styles.input} /> <input type="text" placeholder="Автор" value={author} onChange={(e) => setAuthor(e.target.value)} className={styles.input} /> <textarea placeholder="Содержание поста" value={content} onChange={(e) => setContent(e.target.value)} className={styles.textarea} rows="10" /> <div style={{ display: 'flex', gap: '1rem' }}> <button type="submit" className={styles.submitButton}> Опубликовать </button> <button type="button" onClick={() => navigate(-1)} className={styles.cancelButton} > Отмена </button> </div> </form> );};
export default CreatePost;Добавим стиль для кнопки “Отмена” в PostDetail.module.css:
/* ...предыдущие стили... */
.cancelButton { background: #f7fafc; color: #4a5568; padding: 0.75rem 2rem; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s;}
.cancelButton:hover { background: #edf2f7; border-color: #cbd5e0;}✅ Чекпоинт: При клике на “Создать пост” открывается форма. После заполнения и отправки пост добавляется в список, происходит редирект на главную.
🎨 Шаг 8: Общие стили приложения
Заголовок раздела «🎨 Шаг 8: Общие стили приложения»src/styles/App.module.css:
.app { min-height: 100vh; background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf6 100%);}
.main { min-height: calc(100vh - 64px); padding: 2rem 0;}
.container { max-width: 900px; margin: 0 auto; padding: 0 1rem;}
.container h1 { font-size: 2rem; margin-bottom: 2rem; color: #2d3748;}src/index.css: (базовые стили)
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}🚀 Шаг 9: Деплой на Vercel
Заголовок раздела «🚀 Шаг 9: Деплой на Vercel»1. Подготовка проекта
Заголовок раздела «1. Подготовка проекта»Убедись, что проект запускается локально:
npm run dev # должно работать без ошибокСоздай production build:
npm run build✅ Чекпоинт: В папке dist/ появились файлы для production.
2. Деплой на Vercel
Заголовок раздела «2. Деплой на Vercel»Вариант A: Через интерфейс (для начинающих):
- Зарегистрируйся на vercel.com
- Нажми “Add New Project”
- Импортируй свой GitHub репозиторий
- Vercel автоматически определит Vite проект
- Нажми “Deploy”
- Получи ссылку вида
https://my-blog-xyz.vercel.app
Вариант B: Через CLI (для продвинутых):
# Установи Vercel CLI глобальноnpm install -g vercel
# Выполни деплойvercel
# Следуй инструкциям в терминале✅ Чекпоинт: Твой блог доступен онлайн! Открой ссылку в браузере — всё работает!
🐛 Типичные ошибки и решения
Заголовок раздела «🐛 Типичные ошибки и решения»Ошибка 1: “Cannot find module ‘react-router-dom’”
Заголовок раздела «Ошибка 1: “Cannot find module ‘react-router-dom’”»Причина: Не установлен React Router.
Решение:
npm install react-router-domОшибка 2: “404 Not Found” при переходе по прямой ссылке на /post/1
Заголовок раздела «Ошибка 2: “404 Not Found” при переходе по прямой ссылке на /post/1»Причина: Vercel не знает, как обрабатывать клиентские маршруты.
Решение: Создай файл vercel.json в корне проекта:
{ "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } ]}Ошибка 3: “useBlog must be used within BlogProvider”
Заголовок раздела «Ошибка 3: “useBlog must be used within BlogProvider”»Причина: Компонент пытается использовать useBlog() вне <BlogProvider>.
Решение: Убедись, что в main.jsx всё приложение обёрнуто в <BlogProvider>.
Ошибка 4: Стили не применяются
Заголовок раздела «Ошибка 4: Стили не применяются»Причина: CSS Modules не работают без правильного импорта.
Решение: Импортируй стили как объект:
import styles from './styles/Header.module.css';// Используй: className={styles.header}🎉 Финальный чекпоинт
Заголовок раздела «🎉 Финальный чекпоинт»Проверь, что у тебя работает:
- ✅ Главная страница с постами
- ✅ Детальная страница поста
- ✅ Комментарии к постам
- ✅ Форма создания нового поста
- ✅ Навигация между страницами
- ✅ Responsive дизайн (проверь на мобильном)
- ✅ Деплой на Vercel
🚀 Что дальше?
Заголовок раздела «🚀 Что дальше?»Улучшения для блога:
-
LocalStorage: Сохраняй посты в localStorage, чтобы они не пропадали при перезагрузке:
// В BlogContext.jsxuseEffect(() => {localStorage.setItem('posts', JSON.stringify(posts));}, [posts]); -
Markdown редактор: Добавь поддержку Markdown для контента:
Окно терминала npm install react-markdown -
Фильтрация и поиск: Добавь поиск по заголовкам постов.
-
Пагинация: Если постов много, добавь постраничную навигацию.
-
Backend интеграция: Подключи реальный API (JSON Server, Firebase, или свой Node.js сервер).
-
Авторизация: Добавь логин/регистрацию (Firebase Auth, Auth0).
-
Лайки/дизлайки: Добавь возможность голосовать за посты.
-
Категории и теги: Группируй посты по темам.
📚 Ресурсы для изучения
Заголовок раздела «📚 Ресурсы для изучения»- React Router Docs — официальная документация
- Context API Guide — подробный гайд от React
- Vite Docs — документация Vite
- Vercel Deployment Guide — гайд по деплою
Поздравляем! Ты создал полноценный блог на React с маршрутизацией, state management и комментариями! 🎉