Клиент-серверное взаимодействие
Зачем нужно
Каждое SPA-приложение общается с сервером: загружает данные, отправляет формы, проверяет авторизацию. Понимание полного цикла запрос/ответ, форматов данных и паттернов обработки ошибок — основа клиент-серверной разработки.
Где используется
- Любой запрос к API из браузера
- Отправка форм (регистрация, оплата)
- Загрузка/скачивание файлов
- Real-time данные (polling, SSE, WebSocket)
Цикл запрос/ответ
Клиент (браузер) Сервер (API)
│ │
│── 1. DNS-резолвинг ──────────── │
│── 2. TCP-соединение ─────────── │
│── 3. TLS handshake (HTTPS) ──── │
│ │
│── 4. HTTP-запрос ──────────────→ │
│ POST /api/users │
│ Content-Type: application/json │
│ {"name": "Антон"} │
│ │
│←─ 5. HTTP-ответ ──────────────── │
│ 201 Created │
│ {"id": 1, "name": "Антон"} │
│ │
Fetch API
// === GET-запрос ===
const response = await fetch('/api/users');
const users = await response.json();
// === POST с JSON ===
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Антон', email: 'a@mail.ru' }),
});
// === С авторизацией ===
const response = await fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${token}` },
});
// === С cookies ===
const response = await fetch('/api/profile', {
credentials: 'include', // Отправить cookies
});
Форматы данных
JSON (основной)
// Отправка
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Антон',
email: 'a@mail.ru',
tags: ['developer', 'admin'],
}),
});
// Получение
const data = await response.json();
FormData (файлы, формы)
// Загрузка файла
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
formData.append('name', 'Антон');
const response = await fetch('/api/upload', {
method: 'POST',
// НЕ ставить Content-Type — browser установит boundary автоматически
body: formData,
});
// Из HTML-формы
const form = document.querySelector('form');
const formData = new FormData(form);
await fetch('/api/submit', {
method: 'POST',
body: formData,
});
URLSearchParams (query string)
// Отправка как x-www-form-urlencoded
const params = new URLSearchParams();
params.append('username', 'anton');
params.append('password', '12345');
await fetch('/api/login', {
method: 'POST',
body: params,
// Content-Type: application/x-www-form-urlencoded — автоматически
});
API-клиент (обёртка над fetch)
class ApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.token = null;
}
setToken(token) {
this.token = token;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options.headers,
},
...options,
};
if (config.body && typeof config.body === 'object'
&& !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}
// FormData — убрать Content-Type (browser установит сам)
if (options.body instanceof FormData) {
delete config.headers['Content-Type'];
}
const response = await fetch(url, config);
// Обработка ошибок
if (!response.ok) {
const error = await response.json().catch( => ({}));
const err = new Error(error.message || `HTTP ${response.status}`);
err.status = response.status;
err.data = error;
throw err;
}
if (response.status === 204) return null;
return response.json();
}
get(endpoint, params) {
const query = params ? '?' + new URLSearchParams(params).toString() : '';
return this.request(endpoint + query);
}
post(endpoint, body) {
return this.request(endpoint, { method: 'POST', body });
}
put(endpoint, body) {
return this.request(endpoint, { method: 'PUT', body });
}
patch(endpoint, body) {
return this.request(endpoint, { method: 'PATCH', body });
}
delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
}
// Использование
const api = new ApiClient('http://localhost:4000/api');
api.setToken(localStorage.getItem('token'));
const users = await api.get('/users', { page: 1, limit: 10 });
const user = await api.post('/users', { name: 'Антон' });
await api.patch('/users/42', { name: 'Новое имя' });
await api.delete('/users/42');
Паттерны обработки ошибок
// === Retry с exponential backoff ===
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Retry только для серверных ошибок
if (response.status >= 500 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
continue;
}
return response;
} catch (error) {
// Сетевая ошибка
if (attempt === maxRetries) throw error;
const delay = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
}
// === Abort Controller (отмена запроса) ===
const controller = new AbortController();
// Таймаут 5 секунд
const timeoutId = setTimeout( => controller.abort(), 5000);
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('Запрос отменён');
}
}
// === Отмена при уходе со страницы ===
// В SPA — отменять запросы при смене маршрута
let currentController = null;
function loadPage(url) {
// Отменить предыдущий запрос
currentController?.abort();
currentController = new AbortController();
return fetch(url, { signal: currentController.signal });
}
Loading / Error / Data паттерн
class DataLoader {
constructor {
this.state = {
data: null,
loading: false,
error: null,
};
}
async load(fetchFn) {
this.state = { data: null, loading: true, error: null };
this.render;
try {
const data = await fetchFn;
this.state = { data, loading: false, error: null };
} catch (error) {
this.state = { data: null, loading: false, error: error.message };
}
this.render;
}
render {
const container = document.getElementById('content');
if (this.state.loading) {
container.innerHTML = '<div class="spinner">Загрузка...</div>';
return;
}
if (this.state.error) {
container.innerHTML = `
<div class="error">
<p>Ошибка: ${this.state.error}</p>
<button onclick="loader.retry">Повторить</button>
</div>
`;
return;
}
// Рендер данных
container.innerHTML = JSON.stringify(this.state.data, null, 2);
}
}
Частые ошибки
- Не проверяют
response.ok— fetch не бросает ошибку при 4xx/5xx - Content-Type для FormData — нельзя ставить вручную, browser добавит boundary
- Нет отмены запросов — при быстрой навигации приходят ответы от старых запросов
- Нет retry — один таймаут = приложение «сломалось»
- Нет loading state — пользователь не понимает, загружаются данные или нет
- Забывают
JSON.stringify— body:{ name: 'Антон' }→ отправляется[object Object]
Практика
- Написать API-клиент с методами get/post/put/patch/delete
- Добавить обработку ошибок с retry и abort
- Реализовать загрузку файла через FormData
- Реализовать паттерн loading/error/data для отображения
- Добавить interceptor для автоматического refresh token при 401
Связанные темы
- HTTP протокол — методы, заголовки, статус-коды
- REST API — архитектура API
- CORS — кросс-доменные запросы
- URL и URLSearchParams — работа с URL и query-параметрами