Разработка фронтенда сайта на React
React — библиотека для построения пользовательского интерфейса через декларативные компоненты. Сайт на React — это SPA или SSR-приложение с маршрутизацией, состоянием, типизацией, оптимизацией сборки и CI/CD. Здесь нет «Hello World» — только продакшн-архитектура для проекта, которому предстоит расти.
Стек и выбор фреймворка
Чистый Vite + React — для SPA без SEO-требований (панели управления, порталы):
React 18+ + Vite 5 + TypeScript + React Router v6 + Tanstack Query + Zustand
Next.js App Router — для сайтов с SEO, смешанным контентом, ISR:
Next.js 15 + TypeScript + React Server Components + Tanstack Query (client) + Zustand (client)
Remix — для форм, мутаций, fullstack без API-слоя:
Remix 2 + TypeScript + Prisma + Zod
Этот материал — о Vite + React SPA, Next.js — в отдельном разделе.
Инициализация и структура проекта
npm create vite@latest my-site -- --template react-ts
cd my-site
npm install
Расширить базовую установку:
# Маршрутизация
npm install react-router-dom
# Серверное состояние
npm install @tanstack/react-query @tanstack/react-query-devtools
# Глобальное состояние
npm install zustand
# Формы + валидация
npm install react-hook-form zod @hookform/resolvers
# UI
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tooltip
# Утилиты
npm install clsx tailwind-merge class-variance-authority
# Стили
npm install -D tailwindcss @tailwindcss/vite @tailwindcss/typography
# Иконки
npm install lucide-react
# Dev
npm install -D @types/node eslint @typescript-eslint/eslint-plugin prettier
Структура директорий
src/
app/ ← Инициализация приложения
App.tsx
Router.tsx
QueryProvider.tsx
assets/ ← Статика (изображения, шрифты)
components/
ui/ ← Примитивы (Button, Input, Card, Dialog)
layout/ ← Header, Footer, Sidebar, Section
shared/ ← Переиспользуемые составные компоненты
features/ ← Feature-слайсы (слабая связность)
home/
components/
hooks/
index.ts
about/
contact/
hooks/ ← Глобальные хуки
lib/ ← Утилиты, конфигурации
utils.ts ← cn() и прочее
api.ts ← Axios/Fetch конфигурация
queryClient.ts
pages/ ← Страницы (используют features)
HomePage.tsx
AboutPage.tsx
ContactPage.tsx
NotFoundPage.tsx
store/ ← Zustand stores
styles/
globals.css
types/ ← Глобальные TypeScript типы
Маршрутизация
// src/app/Router.tsx
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { AppShell } from '@/components/layout/AppShell';
import { PageLoader } from '@/components/ui/PageLoader';
// Ленивая загрузка страниц — каждая страница в отдельный chunk
const HomePage = lazy(() => import('@/pages/HomePage'));
const AboutPage = lazy(() => import('@/pages/AboutPage'));
const ServicesPage = lazy(() => import('@/pages/ServicesPage'));
const BlogPage = lazy(() => import('@/pages/BlogPage'));
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
const ContactPage = lazy(() => import('@/pages/ContactPage'));
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
const router = createBrowserRouter([
{
element: (
<AppShell>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</AppShell>
),
children: [
{ path: '/', element: <HomePage /> },
{ path: '/about', element: <AboutPage /> },
{ path: '/services', element: <ServicesPage /> },
{
path: '/blog',
children: [
{ index: true, element: <BlogPage /> },
{ path: ':slug', element: <BlogPostPage /> },
],
},
{ path: '/contact', element: <ContactPage /> },
],
errorElement: <NotFoundPage />,
},
]);
export const Router = () => <RouterProvider router={router} />;
Серверное состояние: Tanstack Query
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 10 * 60 * 1000, // 10 минут
retry: (failureCount, error: any) => {
if (error?.response?.status === 404) return false;
return failureCount < 2;
},
refetchOnWindowFocus: false,
},
},
});
// src/features/blog/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
export interface Post {
id: number;
slug: string;
title: string;
excerpt: string;
category: string;
publishedAt: string;
coverImage: string;
}
const POSTS_QUERY_KEY = ['posts'] as const;
export const usePosts = (params?: { category?: string; page?: number }) => {
return useQuery({
queryKey: [...POSTS_QUERY_KEY, params],
queryFn: () => api.get<Post[]>('/posts', { params }),
placeholderData: (previousData) => previousData, // Предотвратить мигание при пагинации
});
};
export const usePost = (slug: string) => {
return useQuery({
queryKey: [...POSTS_QUERY_KEY, slug],
queryFn: () => api.get<Post>(`/posts/${slug}`),
enabled: Boolean(slug),
});
};
Компоненты UI: Button с CVA
// src/components/ui/Button.tsx
import { ButtonHTMLAttributes, FC, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-blue-600 text-white shadow hover:bg-blue-700',
destructive: 'bg-red-600 text-white shadow hover:bg-red-700',
outline: 'border border-gray-200 bg-white shadow-sm hover:bg-gray-50',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'hover:bg-gray-100 hover:text-gray-900',
link: 'text-blue-600 underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
ref={ref}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Форма обратной связи
// src/features/contact/ContactForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { Button } from '@/components/ui/Button';
import { api } from '@/lib/api';
const contactSchema = z.object({
name: z.string().min(2, 'Минимум 2 символа').max(100),
email: z.string().email('Некорректный email'),
phone: z.string().optional(),
subject: z.string().min(5, 'Минимум 5 символов'),
message: z.string().min(20, 'Минимум 20 символов').max(2000),
consent: z.literal(true, { errorMap: () => ({ message: 'Необходимо согласие' }) }),
});
type ContactFormData = z.infer<typeof contactSchema>;
export const ContactForm = () => {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitSuccessful },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const { mutate, isPending, isSuccess, isError } = useMutation({
mutationFn: (data: ContactFormData) => api.post('/contact', data),
onSuccess: () => reset(),
});
if (isSuccess) {
return (
<div className="rounded-lg bg-green-50 p-6 text-green-800">
<p className="font-medium">Сообщение отправлено</p>
<p className="mt-1 text-sm">Мы ответим в течение 24 часов.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit((data) => mutate(data))} className="space-y-5" noValidate>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700" htmlFor="name">
Имя <span className="text-red-500">*</span>
</label>
<input
id="name"
className={cn(
'w-full rounded-md border px-3 py-2 text-sm shadow-sm',
'focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
errors.name ? 'border-red-500' : 'border-gray-300'
)}
placeholder="Иван Иванов"
{...register('name')}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600">{errors.name.message}</p>
)}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<input
id="email"
type="email"
className={cn(
'w-full rounded-md border px-3 py-2 text-sm shadow-sm',
'focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
errors.email ? 'border-red-500' : 'border-gray-300'
)}
placeholder="[email protected]"
{...register('email')}
/>
{errors.email && (
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
)}
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700" htmlFor="message">
Сообщение <span className="text-red-500">*</span>
</label>
<textarea
id="message"
rows={5}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm shadow-sm',
'focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
errors.message ? 'border-red-500' : 'border-gray-300'
)}
{...register('message')}
/>
{errors.message && (
<p className="mt-1 text-xs text-red-600">{errors.message.message}</p>
)}
</div>
{isError && (
<p className="text-sm text-red-600">Ошибка отправки. Попробуйте ещё раз.</p>
)}
<Button type="submit" loading={isPending} size="lg" className="w-full sm:w-auto">
Отправить сообщение
</Button>
</form>
);
};
Оптимизация производительности
Сборка Vite: code splitting и tree-shaking
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
build: {
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
// Разделить vendor на отдельные чанки
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-query': ['@tanstack/react-query'],
'vendor-forms': ['react-hook-form', 'zod', '@hookform/resolvers'],
},
},
},
// Не предупреждать о чанках до 700 KB (после — оптимизировать)
chunkSizeWarningLimit: 700,
},
});
React.memo и useMemo
// Тяжёлый список — мемоизировать
const PostCard = React.memo<{ post: Post }>(({ post }) => {
return (
<article className="...">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
);
}, (prevProps, nextProps) => prevProps.post.id === nextProps.post.id);
// Тяжёлые вычисления
const sortedPosts = useMemo(
() => [...posts].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()),
[posts]
);
Intersection Observer для lazy rendering
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
const LazySection: FC<{ children: ReactNode }> = ({ children }) => {
const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.1, once: true });
return (
<div ref={ref}>
{isIntersecting ? children : <div style={{ minHeight: 400 }} />}
</div>
);
};
SEO и мета-теги
Для SPA используется react-helmet-async:
npm install react-helmet-async
import { Helmet } from 'react-helmet-async';
const HomePage = () => (
<>
<Helmet>
<title>Разработка сайтов под ключ — Company Name</title>
<meta name="description" content="Создаём сайты и веб-приложения..." />
<meta property="og:title" content="Разработка сайтов — Company Name" />
<meta property="og:description" content="..." />
<meta property="og:image" content="https://example.com/og-home.jpg" />
<meta property="og:type" content="website" />
<link rel="canonical" href="https://example.com/" />
</Helmet>
{/* Контент страницы */}
</>
);
Для проектов с серьёзными SEO-требованиями — переход на Next.js App Router с Server Components.
Тестирование
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// src/components/ui/__tests__/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../Button';
describe('Button', () => {
it('отображает текст', () => {
render(<Button>Нажми меня</Button>);
expect(screen.getByRole('button', { name: 'Нажми меня' })).toBeInTheDocument();
});
it('вызывает onClick', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick}>Кнопка</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('disabled при loading', () => {
render(<Button loading>Загрузка</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Сроки
| Этап | Срок |
|---|---|
| Настройка проекта, структура, конфигурации | 1–2 дня |
| UI Kit (Button, Input, Card, Dialog, Form) | 2–3 дня |
| Маршрутизация и лейаут | 1 день |
| Главная страница | 2–3 дня |
| Внутренние страницы (О нас, Услуги, Блог) | 1–2 дня каждая |
| Форма контактов с валидацией и API | 1 день |
| SEO-разметка, robots.txt, sitemap | 0.5 дня |
| Оптимизация (Lighthouse 90+) | 1–2 дня |
| Тесты (E2E + unit для ключевых компонентов) | 2–3 дня |
Итого: посадочный сайт 5–7 страниц на React с нуля — 10–16 рабочих дней с учётом ревью и итераций.







