React разработка
Полное руководство по разработке современных React приложений с лучшими практиками.
Создание проекта
Vite + React
# Создание нового проекта
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
# Дополнительные зависимости
npm install @types/node
npm install -D tailwindcss postcss autoprefixer
Структура проекта
src/
├── components/ # Переиспользуемые компоненты
│ ├── ui/ # UI компоненты
│ └── layout/ # Компоненты макета
├── pages/ # Страницы
├── hooks/ # Кастомные хуки
├── utils/ # Утилиты
├── types/ # TypeScript типы
├── api/ # API клиенты
├── store/ # Состояние (Redux/Zustand)
└── styles/ # Стили
Компоненты
Функциональные компоненты
import React, { useState, useEffect } from 'react';
interface UserProps {
id: number;
name: string;
email: string;
}
const UserCard: React.FC<UserProps> = ({ id, name, email }) => {
const [isLoading, setIsLoading] = useState(false);
const [userData, setUserData] = useState<UserProps | null>(null);
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
setUserData(data);
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, [id]);
if (isLoading) {
return <div className="animate-pulse">Загрузка...</div>;
}
return (
<div className="bg-white shadow-md rounded-lg p-6">
<h3 className="text-lg font-semibold">{name}</h3>
<p className="text-gray-600">{email}</p>
</div>
);
};
export default UserCard;
Компоненты высшего порядка (HOC)
import React, { ComponentType } from 'react';
interface WithLoadingProps {
isLoading: boolean;
}
function withLoading<T extends object>(
WrappedComponent: ComponentType<T>
) {
return (props: T & WithLoadingProps) => {
const { isLoading, ...otherProps } = props;
if (isLoading) {
return (
<div className="flex justify-center items-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
);
}
return <WrappedComponent {...(otherProps as T)} />;
};
}
// Использование
const UserListWithLoading = withLoading(UserList);
Хуки
Кастомные хуки
import { useState, useEffect } from 'react';
// Хук для API запросов
export function useApi<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
// Хук для локального хранилища
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue] as const;
}
// Хук для дебаунса
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
Управление состоянием
Context API
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
loading: boolean;
}
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_FAILURE' }
| { type: 'LOGOUT' };
const initialState: AuthState = {
user: null,
isAuthenticated: false,
loading: false,
};
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loading: true };
case 'LOGIN_SUCCESS':
return {
...state,
user: action.payload,
isAuthenticated: true,
loading: false,
};
case 'LOGIN_FAILURE':
return { ...state, loading: false };
case 'LOGOUT':
return { ...initialState };
default:
return state;
}
}
const AuthContext = createContext<{
state: AuthState;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
} | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
const login = async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const user = await response.json();
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE' });
}
};
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
return (
<AuthContext.Provider value={{ state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Zustand
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
removeTodo: (id: number) => void;
}
interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
export const useTodoStore = create<TodoState>()(
persist(
(set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{
id: Date.now(),
text,
completed: false,
createdAt: new Date(),
},
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
}),
{
name: 'todo-storage',
}
)
);
Роутинг
React Router
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Suspense, lazy } from 'react';
// Ленивая загрузка компонентов
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Profile = lazy(() => import('./pages/Profile'));
const NotFound = lazy(() => import('./pages/NotFound'));
// Защищенный маршрут
interface ProtectedRouteProps {
children: React.ReactNode;
}
function ProtectedRoute({ children }: ProtectedRouteProps) {
const { state } = useAuth();
if (!state.isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-100">
<Suspense fallback={<div>Загрузка...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</div>
</BrowserRouter>
);
}
export default App;
Формы
React Hook Form
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, 'Имя должно содержать минимум 2 символа'),
email: z.string().email('Неверный формат email'),
age: z.number().min(18, 'Возраст должен быть не менее 18 лет'),
terms: z.boolean().refine(val => val === true, 'Необходимо согласие с условиями'),
});
type UserFormData = z.infer<typeof userSchema>;
function UserForm() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: '',
email: '',
age: 18,
terms: false,
},
});
const onSubmit = async (data: UserFormData) => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
reset();
alert('Пользователь создан успешно!');
}
} catch (error) {
console.error('Ошибка при создании пользователя:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Имя</label>
<input
{...register('name')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
{...register('email')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Возраст</label>
<Controller
name="age"
control={control}
render={({ field }) => (
<input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
)}
/>
{errors.age && (
<p className="mt-1 text-sm text-red-600">{errors.age.message}</p>
)}
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
{...register('terms')}
className="rounded border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700">
Согласен с условиями использования
</span>
</label>
{errors.terms && (
<p className="mt-1 text-sm text-red-600">{errors.terms.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Отправка...' : 'Создать пользователя'}
</button>
</form>
);
}
Стилизация
Tailwind CSS
# Установка
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
// tailwind.config.js
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
},
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
Styled Components
import styled, { ThemeProvider } from 'styled-components';
const theme = {
colors: {
primary: '#3b82f6',
secondary: '#64748b',
danger: '#ef4444',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
},
};
const Button = styled.button<{ variant?: 'primary' | 'secondary' | 'danger' }>`
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background-color: ${props =>
props.theme.colors[props.variant || 'primary']};
color: white;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: ${props => props.theme.breakpoints.sm}) {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`;
const Card = styled.div`
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
padding: 1.5rem;
margin-bottom: 1rem;
`;
function App() {
return (
<ThemeProvider theme={theme}>
<Card>
<h2>Styled Components Example</h2>
<Button variant="primary">Primary Button</Button>
<Button variant="danger">Danger Button</Button>
</Card>
</ThemeProvider>
);
}
Тестирование
Jest + React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserCard from './UserCard';
// Мокирование fetch
global.fetch = jest.fn();
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
describe('UserCard', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
test('отображает данные пользователя', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render(<UserCard {...mockUser} />);
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
test('обрабатывает ошибку загрузки', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<UserCard {...mockUser} />);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Error fetching user:', expect.any(Error));
});
consoleSpy.mockRestore();
});
});
Оптимизация
React.memo и useMemo
import React, { memo, useMemo, useCallback } from 'react';
interface ExpensiveComponentProps {
items: Array<{ id: number; name: string; value: number }>;
multiplier: number;
onItemClick: (id: number) => void;
}
const ExpensiveComponent = memo<ExpensiveComponentProps>(({
items,
multiplier,
onItemClick
}) => {
const processedItems = useMemo(() => {
return items.map(item => ({
...item,
processedValue: item.value * multiplier,
}));
}, [items, multiplier]);
const handleClick = useCallback((id: number) => {
onItemClick(id);
}, [onItemClick]);
return (
<div>
{processedItems.map(item => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}: {item.processedValue}
</div>
))}
</div>
);
});
ExpensiveComponent.displayName = 'ExpensiveComponent';
Виртуализация списков
import { FixedSizeList as List } from 'react-window';
interface VirtualizedListProps {
items: Array<{ id: number; name: string }>;
}
const VirtualizedList: React.FC<VirtualizedListProps> = ({ items }) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className="flex items-center px-4 border-b">
{items[index].name}
</div>
);
return (
<List
height={400}
itemCount={items.length}
itemSize={50}
itemData={items}
>
{Row}
</List>
);
};
Деплой
Vite сборка
// package.json
{
"scripts": {
"build": "tsc && vite build",
"preview": "vite preview",
"build:staging": "vite build --mode staging",
"build:production": "vite build --mode production"
}
}
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
});
Docker
# Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]