Fullstack-разработка: Node.js и React

Практический курс по созданию веб-приложений, объединяющий серверную логику на Node.js и пользовательские интерфейсы на React [[it-dev-journal.ru](https://it-dev-journal.ru/articles/node-js-i-react-kak-napisat-fulstek-prilozhenie-polnoe-rukovodstvo)]. Вы изучите архитектуру клиент-серверного взаимодействия, настройку окружения и реализацию обмена данными через API [[purpleschool.ru](https://purpleschool.ru/knowledge-base/article/express-react-js)].

Настройка окружения и архитектура приложения на Node.js и React

Настройка окружения и архитектура приложения на Node.js и React

Добро пожаловать в курс по Fullstack-разработке. В этой статье мы заложим фундамент вашего будущего приложения. Мы не просто установим программы, но и разберем, как именно взаимодействуют две ключевые технологии: Node.js на сервере и React на клиенте.

Архитектура Fullstack-приложения

Прежде чем писать код, важно понять, как устроена система. Fullstack-приложение на стеке MERN (MongoDB, Express, React, Node.js) или PERN (PostgreSQL) работает по клиент-серверной модели.

  1. Клиент (Frontend): Это ваше React-приложение. Оно работает в браузере пользователя. Его задача — отрисовать интерфейс и реагировать на действия пользователя (клики, ввод текста).
  2. Сервер (Backend): Это Node.js с фреймворком Express. Он работает на удаленном компьютере (или локально при разработке). Его задача — обрабатывать бизнес-логику, работать с базой данных и отправлять данные клиенту.
  3. API (Application Programming Interface): Это набор правил, по которым клиент и сервер общаются. Обычно они обмениваются данными в формате JSON.

Почему Node.js и React?

Главное преимущество этой связки — единый язык программирования. И фронтенд, и бэкенд пишутся на JavaScript. Это позволяет:

  • Переиспользовать код (например, функции валидации данных).
  • Использовать общий менеджер пакетов (npm).
  • Легче переключаться между задачами сервера и клиента.

Установка и проверка Node.js

Основой всего окружения является Node.js. Это среда выполнения, которая позволяет запускать JavaScript вне браузера. Вместе с ней устанавливается npm (Node Package Manager) — инструмент для управления библиотеками.

Шаг 1: Установка

Перейдите на официальный сайт nodejs.org и скачайте версию LTS (Long Term Support). Это наиболее стабильная версия, рекомендуемая для большинства проектов.

Шаг 2: Проверка версий

После установки откройте терминал (Command Prompt, PowerShell или Terminal на macOS) и введите следующие команды:

node -v
npm -v

Вы должны увидеть номера версий, например v20.11.0 и 10.2.4. Если вы видите ошибки, попробуйте перезагрузить компьютер или переустановить Node.js.

Структура проекта

Для учебного проекта мы будем использовать простую и понятную структуру, разделяющую код клиента и сервера. Создайте главную папку для вашего проекта, например my-fullstack-app.

Внутри неё мы создадим две подпапки:

  • client — здесь будет жить React.
  • server — здесь будет жить Node.js API.

Такой подход называется монорепозиторием (в упрощенном виде), так как весь код хранится в одном месте.

Настройка серверной части (Node.js + Express)

Начнем с бэкенда, так как он будет поставлять данные.

  1. Откройте терминал и перейдите в папку проекта:

    mkdir server
    cd server
    
  2. Инициализируйте проект. Флаг -y автоматически ответит «да» на все вопросы конфигурации:

    npm init -y
    

    В папке появится файл package.json — паспорт вашего проекта, где хранятся список зависимостей и скрипты запуска.

  3. Установите Express — минималистичный веб-фреймворк для Node.js:

    npm install express
    

Создание базового сервера

Создайте в папке server файл index.js и добавьте следующий код:

const express = require('express');
const app = express();
const PORT = 5000;

// Базовый маршрут (endpoint)
app.get('/api/hello', (req, res) => {
  res.json({ message: 'Привет от сервера Node.js!' });
});

app.listen(PORT, () => {
  console.log(`Сервер запущен на порту ${PORT}`);
});

Запустите сервер командой node index.js. В консоли вы увидите сообщение о запуске.

Настройка клиентской части (React)

Теперь настроим фронтенд. Раньше стандартом был Create React App, но сейчас индустрия перешла на Vite — он значительно быстрее и современнее.

  1. Вернитесь в корень проекта и создайте папку клиента:
    cd ..
    npm create vite@latest client -- --template react
    
  2. Перейдите в папку и установите зависимости:
    cd client
    npm install
    
  3. Запустите React-приложение:
    npm run dev
    

Теперь у вас запущены два процесса: сервер на порту 5000 и клиент (обычно на порту 5173).

Связывание клиента и сервера (CORS)

Если вы попробуете сделать запрос с React (порт 5173) на Node.js (порт 5000), браузер заблокирует его из-за политики безопасности. Это называется CORS (Cross-Origin Resource Sharing).

Чтобы разрешить браузеру получать данные с другого порта, нужно настроить сервер.

  1. В папке server остановите сервер (Ctrl+C) и установите пакет cors:
    npm install cors
    
  2. Обновите server/index.js:
const express = require('express');
const cors = require('cors'); // Импорт cors
const app = express();

app.use(cors()); // Разрешаем запросы с других доменов/портов

// ... остальной код ...

Теперь React сможет свободно получать данные от вашего API.

Оценка производительности (Теория)

При проектировании архитектуры важно понимать, как данные передаются по сети. Одним из ключевых показателей является время загрузки данных. Рассмотрим упрощенную формулу расчета времени загрузки ресурса (например, JSON-ответа или JS-бандла):

Ttotal=SV+LT_{total} = \frac{S}{V} + L

где TtotalT_{total} — общее время загрузки (секунды), SS — размер передаваемых данных (биты), VV — пропускная способность канала (биты в секунду), LL — задержка сети (latency, секунды).

Пример: Если ваш API возвращает JSON размером 50 Кб (50×8×1024=40960050 \times 8 \times 1024 = 409600 бит), скорость интернета пользователя 10 Мбит/с (10×10610 \times 10^6 бит/с), а пинг (задержка) составляет 50 мс (0.05 с), то:

Ttotal=40960010000000+0.05=0.04096+0.05≈0.09 секT_{total} = \frac{409600}{10000000} + 0.05 = 0.04096 + 0.05 \approx 0.09 \text{ сек}

Это показывает, что даже при быстром интернете задержка (LL) играет огромную роль. Именно поэтому в архитектуре Node.js мы стараемся минимизировать количество запросов и объем передаваемых данных.

Итоги

Мы успешно подготовили рабочее место для Fullstack-разработки. Вот ключевые моменты:

  • Архитектура: React отвечает за интерфейс, Node.js — за логику и данные.
  • Окружение: Node.js и npm необходимы для запуска и управления зависимостями.
  • Структура: Разделение на client и server упрощает поддержку кода.
  • Связь: Для взаимодействия между портами (5173 и 5000) необходима настройка CORS на сервере.
  • Инструменты: Vite — современный стандарт для сборки React-приложений.

Разработка REST API сервера на платформе Node.js

Разработка REST API сервера на платформе Node.js

REST (Representational State Transfer) — это архитектурный стиль взаимодействия компонентов распределенного приложения в сети. В контексте Node.js и Express это набор правил, определяющих, как клиент (React) и сервер обмениваются данными. Понимание этих принципов критически важно для создания масштабируемых приложений.

Принципы REST-архитектуры

Основная идея REST заключается в том, что все данные на сервере считаются ресурсами. У каждого ресурса есть уникальный идентификатор (URI) и стандартные методы управления им.

Ключевые характеристики REST API:

  1. Клиент-серверная архитектура: Четкое разделение ответственности. Сервер хранит данные, клиент занимается отображением.
  2. Stateless (Отсутствие состояния): Сервер не хранит информацию о состоянии сессии клиента между запросами. Каждый запрос должен содержать всю необходимую информацию для его обработки (например, токен авторизации).
  3. Единообразие интерфейса: Использование стандартных HTTP-методов (GET, POST, PUT, DELETE).

HTTP-методы и CRUD

В основе любого API лежат операции CRUD (Create, Read, Update, Delete). В протоколе HTTP им соответствуют определенные глаголы (методы).

Операция CRUD HTTP Метод Описание Пример URL
Create (Создание) POST Создает новый ресурс /api/users
Read (Чтение) GET Получает список или один ресурс /api/users или /api/users/1
Update (Обновление) PUT / PATCH Обновляет существующий ресурс /api/users/1
Delete (Удаление) DELETE Удаляет ресурс /api/users/1

Разработка REST API на Node.js требует понимания не только синтаксиса, но и архитектурных паттернов, чтобы приложение было поддерживаемым. habr.com

Настройка Express для работы с JSON

По умолчанию Express не умеет читать JSON-данные, приходящие в теле запроса (req.body). Для этого необходимо подключить встроенный middleware.

Откройте файл server/index.js (созданный в предыдущей статье) и добавьте следующую строку перед объявлением маршрутов:

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors());
app.use(express.json()); // Обязательно для парсинга JSON в POST-запросах

// ... маршруты

Без app.use(express.json()) при попытке отправить данные на сервер вы получите undefined вместо объекта с данными.

Реализация маршрутов (Routes)

Рассмотрим создание полноценного API для управления списком задач (Tasks). Поскольку мы пока не подключили базу данных, будем использовать массив в оперативной памяти для имитации хранилища.

1. Получение данных (GET)

Метод GET используется для запроса данных. Он не должен изменять состояние сервера.

let tasks = [
  { id: 1, title: 'Изучить Node.js', completed: false },
  { id: 2, title: 'Настроить React', completed: false },
];

// Получить все задачи
app.get('/api/tasks', (req, res) => {
  res.json(tasks);
});

2. Создание данных (POST)

Метод POST принимает данные в теле запроса (req.body) и создает новый ресурс.

// Создать новую задачу
app.post('/api/tasks', (req, res) => {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Название задачи обязательно' });
  }

  const newTask = {
    id: tasks.length + 1,
    title,
    completed: false
  };

  tasks.push(newTask);
  res.status(201).json(newTask); // 201 Created
});

Обратите внимание на коды ответов: 200 — успешно (по умолчанию), 201 — успешно создано, 400 — ошибка клиента (неверные данные).

3. Параметры маршрута и удаление (DELETE)

Для действий с конкретным элементом используются параметры маршрута. В Express они обозначаются через двоеточие, например :id. Доступ к ним осуществляется через req.params.

// Удалить задачу по ID
app.delete('/api/tasks/:id', (req, res) => {
  const taskId = parseInt(req.params.id);
  const taskIndex = tasks.findIndex(t => t.id === taskId);

  if (taskIndex === -1) {
    return res.status(404).json({ message: 'Задача не найдена' });
  }

  tasks.splice(taskIndex, 1);
  res.status(200).json({ message: 'Задача удалена' });
});

Теория производительности: Пропускная способность

При разработке API важно оценивать, какую нагрузку способен выдержать сервер. Node.js работает в однопоточном режиме, используя событийный цикл (Event Loop). Теоретический предел пропускной способности (RPS — Requests Per Second) для CPU-зависимых задач можно оценить по формуле:

RPSmax=1Tcpu RPS_{max} = \frac{1}{T_{cpu}}

где RPSmaxRPS_{max} — максимальное количество запросов в секунду, а TcpuT_{cpu} — время, которое процессор тратит на обработку одного запроса (в секундах).

Пример расчета: Если обработка одного запроса (парсинг JSON, логика, сериализация ответа) занимает у процессора 2 миллисекунды (0.002 с), то:

RPSmax=10.002=500 запросов/сек RPS_{max} = \frac{1}{0.002} = 500 \text{ запросов/сек}

Это означает, что один процесс Node.js может обработать до 500 таких запросов в секунду. Если добавить тяжелые вычисления, TcpuT_{cpu} увеличится, а RPSRPS упадет. Именно поэтому в Node.js нельзя блокировать поток долгими вычислениями.

Структурирование проекта

Хранить весь код в index.js — плохая практика. В профессиональной разработке используется разделение на слои:

  1. Routes (Маршруты): Определяют URL и методы (routes/taskRoutes.js).
  2. Controllers (Контроллеры): Содержат логику обработки запроса (controllers/taskController.js).
  3. Models (Модели): Отвечают за работу с данными (будет актуально при подключении БД).

Пример выноса логики в контроллер:

// controllers/taskController.js
const getTasks = (req, res) => {
  // Логика получения задач
  res.json(tasks);
};

module.exports = { getTasks };

Такой подход упрощает тестирование и поддержку кода, особенно когда проект разрастается до десятков эндпоинтов. ru.hexlet.io

Коды состояния HTTP

Правильное использование статус-кодов помогает клиенту (React) понять, что произошло на сервере.

  • 2xx (Успех):
    • 200 OK: Запрос выполнен успешно.
    • 201 Created: Ресурс успешно создан.
  • 4xx (Ошибка клиента):
    • 400 Bad Request: Сервер не может обработать запрос из-за ошибки клиента (например, невалидный JSON).
    • 401 Unauthorized: Требуется аутентификация.
    • 403 Forbidden: Доступ запрещен.
    • 404 Not Found: Ресурс не найден.
  • 5xx (Ошибка сервера):
    • 500 Internal Server Error: Непредвиденная ошибка на стороне сервера.

Итоги

В этой статье мы реализовали базовый REST API сервер. Ключевые моменты:

  1. REST — это стиль архитектуры, использующий HTTP-методы (GET, POST, PUT, DELETE) для управления ресурсами.
  2. Middleware express.json() необходим для обработки входящих JSON-данных в POST и PUT запросах.
  3. Маршрутизация в Express позволяет обрабатывать динамические параметры URL (например, :id) через req.params.
  4. Статус-коды HTTP являются важной частью ответа сервера, сообщая клиенту о результате операции (успех или ошибка).
  5. Производительность Node.js сервера напрямую зависит от времени обработки одного запроса процессором, поэтому важно избегать блокирующих операций.

Создание интерфейса пользователя с помощью библиотеки React

Создание интерфейса пользователя с помощью библиотеки React

В предыдущих статьях мы настроили окружение и создали REST API на Node.js, который умеет отдавать список задач. Теперь пришло время оживить наше приложение, создав клиентскую часть. Мы будем использовать React — библиотеку для создания пользовательских интерфейсов, которая позволяет строить сложные приложения из небольших изолированных кусочков кода, называемых компонентами.

Компонентный подход и JSX

React предлагает мыслить не страницами, а компонентами. Компонент — это JavaScript-функция, которая возвращает разметку. Это позволяет повторно использовать код и делает его структуру понятной.

Пользовательские интерфейсы можно разбить на небольшие строительные блоки, называемые компонентами. Компоненты позволяют создавать самодостаточные, повторно используемые фрагменты кода.

Основы React: Построение UI с помощью компонентов

Для описания внешнего вида компонентов используется синтаксис JSX (JavaScript XML). Он выглядит как HTML, но работает внутри JavaScript.

Основные отличия JSX от HTML:

  1. Вложенность: Компонент должен возвращать только один родительский элемент (обычно div или фрагмент <>...</>).
  2. Атрибуты: Вместо class используется className (так как class — зарезервированное слово в JS), а свойства пишутся в camelCase (например, onClick вместо onclick).
  3. Интерполяция: Внутри фигурных скобок {} можно писать любое валидное выражение JavaScript.

Пример простого компонента:

function Header() {
  const title = "Список задач";
  return (
    <header className="app-header">
      <h1>{title}</h1>
    </header>
  );
}

Пропсы (Props): Передача данных

Чтобы компоненты были динамическими, мы передаем в них данные. В React эти данные называются props (свойства). Пропсы передаются от родителя к ребенку и доступны внутри компонента как объект.

Важно помнить: пропсы доступны только для чтения. Компонент не должен изменять свои пропсы.

// Дочерний компонент
function TaskItem(props) {
  return (
    <div className="task-item">
      {/* Отображаем переданный заголовок */}
      <span>{props.title}</span>
    </div>
  );
}

// Родительский компонент
function TaskList() {
  return (
    <div>
      <TaskItem title="Изучить React" />
      <TaskItem title="Подключить Node.js" />
    </div>
  );
}

Состояние (State): Интерактивность

Пропсы позволяют передавать данные, но не позволяют их изменять внутри компонента. Для данных, которые меняются со временем (например, ввод пользователя в форму или список загруженных задач), используется состояние (State).

Для работы с состоянием используется хук useState.

import { useState } from 'react';

function Counter() {
  // count - текущее значение, setCount - функция для его изменения
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Кликов: {count}
    </button>
  );
}

Интеграция с Node.js API (useEffect)

Теперь самое интересное: свяжем наш React-клиент с сервером Node.js, который мы создали в прошлой статье. Для выполнения побочных эффектов (запросов к серверу) используется хук useEffect.

Мы будем использовать встроенную функцию fetch для получения данных с адреса http://localhost:5000/api/tasks.

Создание компонента App

Откройте файл client/src/App.jsx и замените его содержимое на следующий код:

import { useState, useEffect } from 'react';

function App() {
  const [tasks, setTasks] = useState([]); // Состояние для хранения задач
  const [loading, setLoading] = useState(true); // Состояние загрузки

  useEffect(() => {
    // Функция для получения данных
    const fetchTasks = async () => {
      try {
        const response = await fetch('http://localhost:5000/api/tasks');
        const data = await response.json();
        setTasks(data); // Сохраняем данные в состояние
      } catch (error) {
        console.error('Ошибка при загрузке:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchTasks();
  }, []); // Пустой массив зависимостей означает запуск 1 раз при монтировании

  if (loading) return <p>Загрузка...</p>;

  return (
    <div className="container">
      <h1>Мои задачи</h1>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            {task.title} {task.completed ? '✅' : '❌'}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Обратите внимание на атрибут key в методе map. Он критически важен для алгоритма согласования React, чтобы понимать, какие элементы списка изменились, добавились или удалились.

Теория: Виртуальный DOM и сложность обновлений

React работает быстро благодаря концепции Virtual DOM. Вместо того чтобы перерисовывать весь экран при каждом изменении, React создает легкую копию DOM-дерева в памяти, сравнивает её с предыдущей версией и вносит только необходимые изменения.

Классический алгоритм сравнения двух деревьев имеет сложность O(n3)O(n^3). Однако React использует эвристический алгоритм, который снижает сложность до линейной:

Cdiff=O(n)C_{diff} = O(n)

где CdiffC_{diff} — вычислительная сложность алгоритма сравнения (diffing algorithm), а nn — количество элементов в дереве.

Если бы React использовал стандартный алгоритм, то для отображения 1000 элементов потребовалось бы порядка одного миллиарда операций (100031000^3). Благодаря оптимизации и использованию ключей (key), React выполняет это за тысячи операций, что делает интерфейс отзывчивым.

DOM — это объектное представление HTML-элементов. Он выступает в качестве моста между вашим кодом и пользовательским интерфейсом.

Основы React: Рендеринг пользовательских интерфейсов (UI)

Добавление новых задач (POST запрос)

Чтобы наше приложение стало полноценным, реализуем форму добавления задачи. Нам нужно отправить POST-запрос на сервер и обновить состояние списка задач.

Добавьте этот код внутрь компонента App (перед return):

  const [newTaskTitle, setNewTaskTitle] = useState('');

  const addTask = async (e) => {
    e.preventDefault();
    if (!newTaskTitle) return;

    const response = await fetch('http://localhost:5000/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: newTaskTitle })
    });

    const newTask = await response.json();

    // Обновляем список задач, добавляя новую в конец
    setTasks([...tasks, newTask]);
    setNewTaskTitle(''); // Очищаем поле ввода
  };

И добавьте форму в JSX разметку:

<form onSubmit={addTask}>
  <input
    type="text"
    value={newTaskTitle}
    onChange={(e) => setNewTaskTitle(e.target.value)}
    placeholder="Новая задача..."
  />
  <button type="submit">Добавить</button>
</form>

Теперь, когда вы нажимаете «Добавить», React отправляет данные на Node.js, получает ответ с созданной задачей (включая id) и обновляет интерфейс без перезагрузки страницы.

Итоги

Мы успешно создали клиентскую часть Fullstack-приложения. Вот что мы изучили:

  1. Компоненты и JSX: React позволяет строить интерфейс из независимых блоков, используя синтаксис, похожий на HTML.
  2. Props и State: Пропсы передают данные вниз по дереву, а состояние (useState) позволяет компонентам реагировать на изменения и хранить данные.
  3. Связь с бэкендом: Хук useEffect идеально подходит для выполнения первичной загрузки данных с API при монтировании компонента.
  4. Оптимизация: Использование атрибута key в списках помогает алгоритму React эффективно обновлять DOM за время O(n)O(n).

Интеграция клиентской и серверной частей: HTTP-запросы и CORS

Интеграция клиентской и серверной частей: HTTP-запросы и CORS

В предыдущих статьях мы создали сервер на Node.js и клиентское приложение на React. Однако, пока они существуют изолированно друг от друга, наше приложение не может считаться полноценным. В этой статье мы детально разберем механизмы, позволяющие этим двум частям обмениваться данными: протокол HTTP, использование Fetch API и решение проблем безопасности с помощью CORS.

Анатомия HTTP-запроса

Взаимодействие между React (клиентом) и Node.js (сервером) происходит по протоколу HTTP. Это протокол типа «запрос-ответ». Клиент всегда инициализирует общение, отправляя запрос, а сервер обрабатывает его и возвращает ответ.

Согласно Tproger, HTTP-запрос состоит из нескольких ключевых компонентов:

  1. Метод (Method): Указывает на действие (GET, POST, PUT, DELETE).
  2. URL (Uniform Resource Locator): Адрес ресурса (например, http://localhost:5000/api/tasks).
  3. Заголовки (Headers): Метаданные запроса (например, тип контента или информация о браузере).
  4. Тело (Body): Данные, передаваемые на сервер (актуально для POST и PUT).

Жизненный цикл запроса в React

Когда вы вызываете fetch в React-компоненте, происходит следующее:

  1. Браузер формирует HTTP-пакет.
  2. Пакет отправляется через сеть на указанный IP-адрес и порт сервера.
  3. Node.js (Express) перехватывает запрос, маршрутизирует его и выполняет логику.
  4. Сервер формирует ответ (статус-код, заголовки, тело) и отправляет его обратно.
  5. Браузер получает ответ и передает его в JavaScript-код.

Fetch API: Современный стандарт

Для выполнения запросов в современных браузерах используется Fetch API. Это встроенный инструмент, работающий на основе промисов (Promises), что позволяет писать асинхронный код чисто и понятно.

Рассмотрим пример сложного запроса с отправкой данных:

const createTask = async (taskData) => {
  try {
    const response = await fetch('http://localhost:5000/api/tasks', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json', // Сообщаем серверу, что отправляем JSON
      },
      body: JSON.stringify(taskData), // Превращаем объект JS в строку JSON
    });

    if (!response.ok) {
      throw new Error(`Ошибка HTTP: ${response.status}`);
    }

    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Не удалось создать задачу:', error);
  }
};

Обратите внимание на проверку response.ok. Fetch API считает запрос успешным, даже если сервер вернул ошибку 404 или 500. Свойство ok истинно только для статус-кодов в диапазоне 200–299.

CORS: Политика одного источника

Самая частая проблема, с которой сталкиваются новички при соединении React и Node.js — это ошибка CORS (Cross-Origin Resource Sharing).

Выглядит она в консоли браузера примерно так: Access to fetch at '...' from origin '...' has been blocked by CORS policy.

Почему это происходит?

Браузеры следуют строгой политике безопасности, называемой Same-Origin Policy (Политика одного источника). Она запрещает скриптам на одной странице обращаться к ресурсам другого источника (Origin), если тот явно этого не разрешил.

Источник (Origin) определяется комбинацией трех элементов:

  1. Протокол (http/https)
  2. Домен (localhost, google.com)
  3. Порт (3000, 5000)

В нашем случае:

  • Клиент: http://localhost:5173
  • Сервер: http://localhost:5000

Порты отличаются, поэтому браузер считает их разными источниками и блокирует запрос по умолчанию.

Cross-Origin Resource Sharing (CORS) — механизм, использующий дополнительные HTTP-заголовки, чтобы дать возможность агенту пользователя получать разрешения на доступ к выбранным ресурсам с сервера на источнике (домене), отличном от того, что сайт использует в данный момент.

MDN Web Docs

Preflight-запросы (Предварительные запросы)

Для методов, отличных от простых GET или POST (например, если вы добавляете кастомные заголовки или используете метод DELETE), браузер сначала отправляет «разведывательный» запрос методом OPTIONS.

Этот запрос спрашивает сервер: «Могу ли я отправить тебе DELETE-запрос с этого домена?». Если сервер отвечает утвердительно (заголовком Access-Control-Allow-Methods), браузер отправляет основной запрос.

Настройка CORS на сервере Node.js

Чтобы разрешить React-приложению доступ к API, мы должны настроить сервер Express. Самый надежный способ — использовать middleware cors.

Установка:

npm install cors

Подключение в server/index.js:

const cors = require('cors');

// Базовая настройка (разрешает всем)
app.use(cors());

// Продвинутая настройка (для продакшена)
const corsOptions = {
  origin: 'http://localhost:5173', // Разрешаем только нашему фронтенду
  methods: ['GET', 'POST', 'DELETE', 'UPDATE'], // Разрешенные методы
  allowedHeaders: ['Content-Type', 'Authorization'], // Разрешенные заголовки
};

app.use(cors(corsOptions));

Математика сетевых задержек

При интеграции клиента и сервера важно понимать, что каждый HTTP-запрос стоит времени. Это время складывается не только из обработки данных сервером, но и из физической передачи сигнала.

Время передачи данных по сети можно оценить по следующей формуле:

T_{total} = RTT + rac{S}{B}

где:

  • TtotalT_{total} — общее время, необходимое для доставки данных (секунды).
  • RTTRTT (Round Trip Time) — время прохождения сигнала от клиента к серверу и обратно (секунды). Зависит от физического расстояния и качества сети.
  • SS — размер передаваемых данных (биты).
  • BB — пропускная способность канала (биты в секунду).

Пример расчета: Предположим, мы загружаем список задач размером 50 КБ (50×8×1024=409 60050 \times 8 \times 1024 = 409\,600 бит). Пользователь находится далеко от сервера, поэтому RTT=100RTT = 100 мс (0,10{,}1 с). Скорость интернета пользователя — 10 Мбит/с (10 000 00010\,000\,000 бит/с).

Ttotal=0,1+409 60010 000 000=0,1+0,04096≈0,14 сT_{total} = 0{,}1 + \frac{409\,600}{10\,000\,000} = 0{,}1 + 0{,}04096 \approx 0{,}14 \text{ с}

Из этого примера видно, что даже при высокой скорости интернета (BB), задержка (RTTRTT) вносит существенный вклад. Именно поэтому в Fullstack-разработке важно минимизировать количество запросов, объединяя данные, где это возможно.

Обработка ошибок и статусов ответа

Сервер сообщает о результате операции с помощью HTTP статус-кодов. Ваше React-приложение должно уметь правильно их интерпретировать.

Код Значение Действие на клиенте
200 OK Показать данные
201 Created Обновить список, показать уведомление об успехе
400 Bad Request Показать ошибку валидации (например, «Заполните поле»)
401 Unauthorized Перенаправить на страницу входа
404 Not Found Показать заглушку «Ничего не найдено»
500 Server Error Показать сообщение «Попробуйте позже»

Пример обработки в React:

const deleteItem = async (id) => {
  const res = await fetch(`http://localhost:5000/api/items/${id}`, {
    method: 'DELETE'
  });

  if (res.status === 200) {
    // Удаляем из стейта
    setItems(prev => prev.filter(item => item.id !== id));
  } else if (res.status === 404) {
    alert('Элемент уже удален');
  } else {
    alert('Ошибка сервера');
  }
};

Итоги

В этой статье мы объединили клиент и сервер в единую систему.

  1. HTTP-протокол является основой обмена данными. Клиент отправляет запросы (GET, POST и др.), сервер возвращает ответы со статус-кодами.
  2. Fetch API — это стандартный способ выполнения асинхронных запросов в React. Не забывайте проверять свойство response.ok.
  3. CORS — механизм безопасности браузера, блокирующий запросы между разными источниками (портами). Для решения проблемы необходимо настроить сервер (Express) с помощью пакета cors.
  4. Сетевая задержка (RTTRTT) играет критическую роль в производительности. Формула Ttotal=RTT+S/BT_{total} = RTT + S/B напоминает нам, что лишние запросы замедляют приложение.
  5. Обработка ошибок на клиенте должна базироваться на HTTP статус-кодах для обеспечения качественного пользовательского опыта.

Реализация полноценного CRUD-функционала в связке React и Node.js

Реализация полноценного CRUD-функционала в связке React и Node.js

Мы уже прошли большой путь: настроили окружение, запустили сервер на Node.js, создали клиент на React и даже научили их обмениваться данными для получения (Read) и создания (Create) задач. Но полноценное приложение должно уметь больше. В этой статье мы замкнем круг CRUD (Create, Read, Update, Delete), реализовав возможность редактирования и удаления данных.

Мы разберем, как правильно обрабатывать PUT и DELETE запросы на сервере и как эффективно обновлять состояние React-компонентов, не перезагружая страницу.

Расширение API на Node.js

На данный момент наш сервер умеет отдавать список задач и добавлять новые. Чтобы управлять существующими данными, нам нужно научить его понимать, какую именно задачу мы хотим изменить или удалить. Для этого используются параметры маршрута (URL parameters).

Реализация удаления (DELETE)

Метод DELETE идемпотентен — это значит, что повторный вызов того же запроса не должен менять состояние сервера (если объект уже удален, повторный запрос просто вернет тот же результат или ошибку 404).

Откройте файл server/index.js и добавьте следующий маршрут:

// Удаление задачи по ID
app.delete('/api/tasks/:id', (req, res) => {
  const id = parseInt(req.params.id);

  // Находим индекс элемента в массиве
  const index = tasks.findIndex(task => task.id === id);

  if (index !== -1) {
    // Удаляем элемент из массива
    tasks.splice(index, 1);
    res.status(200).json({ message: 'Задача удалена' });
  } else {
    res.status(404).json({ message: 'Задача не найдена' });
  }
});

Здесь :id — это динамический параметр. Express автоматически помещает его значение в объект req.params.

Реализация обновления (PUT)

Для обновления данных обычно используют методы PUT (полная замена ресурса) или PATCH (частичное обновление). Для простоты мы будем использовать PUT для переключения статуса выполнения задачи и изменения названия.

Добавьте этот код в server/index.js:

// Обновление задачи
app.put('/api/tasks/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const { title, completed } = req.body;

  const task = tasks.find(t => t.id === id);

  if (task) {
    // Обновляем поля, если они были переданы
    if (title !== undefined) task.title = title;
    if (completed !== undefined) task.completed = completed;

    res.json(task); // Возвращаем обновленную задачу
  } else {
    res.status(404).json({ message: 'Задача не найдена' });
  }
});

Алгоритмическая сложность операций

Поскольку мы используем массив для хранения данных в памяти, поиск элемента для удаления или обновления выполняется линейно. Время выполнения зависит от количества элементов.

Среднее время поиска в неупорядоченном массиве можно выразить формулой:

Tavg=n+12×topT_{avg} = \frac{n + 1}{2} \times t_{op}

где TavgT_{avg} — среднее время поиска, nn — количество элементов в массиве, а topt_{op} — время выполнения одной операции сравнения.

Если у вас 1000 задач, то в среднем серверу придется проверить 500 элементов, чтобы найти нужный. Для маленьких приложений это мгновенно, но в реальных высоконагруженных системах именно поэтому используются базы данных с индексами (B-Tree или Hash Map), которые снижают сложность поиска с O(n)O(n) до O(log⁡n)O(\log n) или O(1)O(1).

В реальных проектах использование базы данных, такой как MySQL или MongoDB, является стандартом для обеспечения надежности и скорости доступа к данным.

CRUD-приложение на React, Node.js, Express и MySQL

Модернизация Frontend на React

Теперь, когда бэкенд готов, перейдем к React. Нам нужно научить интерфейс отправлять новые типы запросов и обновлять DOM без перезагрузки страницы.

Рефакторинг: Выделение компонента TaskItem

В предыдущей статье мы рендерили список прямо в App.jsx. Чтобы добавить кнопки редактирования и удаления для каждой задачи, лучше выделить отдельный компонент. Это улучшит читаемость и производительность.

Создайте файл client/src/components/TaskItem.jsx (не забудьте создать папку components):

import { useState } from 'react';

function TaskItem({ task, onDelete, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false);
  const [newTitle, setNewTitle] = useState(task.title);

  const handleSave = () => {
    onUpdate(task.id, { title: newTitle });
    setIsEditing(false);
  };

  const toggleComplete = () => {
    onUpdate(task.id, { completed: !task.completed });
  };

  return (
    <li className="task-item">
      <input
        type="checkbox"
        checked={task.completed}
        onChange={toggleComplete}
      />

      {isEditing ? (
        <>
          <input
            value={newTitle}
            onChange={(e) => setNewTitle(e.target.value)}
          />
          <button onClick={handleSave}>Сохранить</button>
          <button onClick={() => setIsEditing(false)}>Отмена</button>
        </>
      ) : (
        <>
          <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
            {task.title}
          </span>
          <button onClick={() => setIsEditing(true)}>✏️</button>
          <button onClick={() => onDelete(task.id)}>🗑️</button>
        </>
      )}
    </li>
  );
}

export default TaskItem;

Этот компонент имеет собственное локальное состояние isEditing, которое управляет режимом отображения: либо текст задачи, либо поле ввода.

Обновление главного компонента App.jsx

Теперь подключим логику в App.jsx. Нам нужно написать функции, которые будут отправлять запросы на сервер и обновлять состояние tasks.

Обратите внимание на принцип иммутабельности (неизменяемости) в React. Мы никогда не меняем массив tasks напрямую (например, через push или splice). Мы всегда создаем новый массив.

import { useState, useEffect } from 'react';
import TaskItem from './components/TaskItem';

function App() {
  const [tasks, setTasks] = useState([]);

  // ... (код загрузки данных из прошлой статьи)
  // useEffect(() => { ... }, []);

  // Функция удаления
  const deleteTask = async (id) => {
    try {
      const res = await fetch(`http://localhost:5000/api/tasks/${id}`, {
        method: 'DELETE',
      });

      if (res.ok) {
        // Фильтруем массив: оставляем все задачи, кроме удаленной
        setTasks(prevTasks => prevTasks.filter(task => task.id !== id));
      }
    } catch (error) {
      console.error('Ошибка удаления:', error);
    }
  };

  // Функция обновления
  const updateTask = async (id, updates) => {
    try {
      const res = await fetch(`http://localhost:5000/api/tasks/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });

      if (res.ok) {
        const updatedTask = await res.json();
        // Проходим по массиву и заменяем обновленную задачу
        setTasks(prevTasks => prevTasks.map(task =>
          task.id === id ? updatedTask : task
        ));
      }
    } catch (error) {
      console.error('Ошибка обновления:', error);
    }
  };

  return (
    <div className="container">
      <h1>Мои задачи</h1>
      {/* Форма добавления задачи (из прошлой статьи) */}

      <ul>
        {tasks.map(task => (
          <TaskItem
            key={task.id}
            task={task}
            onDelete={deleteTask}
            onUpdate={updateTask}
          />
        ))}
      </ul>
    </div>
  );
}

export default App;

Оптимистичный UI (Optimistic UI)

В коде выше мы сначала ждем ответа от сервера (await fetch), и только потом обновляем интерфейс (setTasks). Это надежно, но создает небольшую задержку для пользователя. Это называется пессимистичным обновлением.

Для улучшения UX (User Experience) часто используют оптимистичное обновление. Идея в том, чтобы обновить интерфейс мгновенно, предполагая, что сервер ответит успешно. Если же произойдет ошибка, мы откатываем изменения назад.

Пример оптимистичного удаления:

const deleteTaskOptimistic = (id) => {
  // 1. Сразу обновляем UI
  const previousTasks = [...tasks]; // Сохраняем копию на случай ошибки
  setTasks(tasks.filter(task => task.id !== id));

  // 2. Отправляем запрос в фоне
  fetch(`http://localhost:5000/api/tasks/${id}`, {
    method: 'DELETE',
  }).then(res => {
    if (!res.ok) {
      // 3. Если ошибка — откатываем изменения
      setTasks(previousTasks);
      alert('Не удалось удалить задачу');
    }
  });
};

Такой подход делает приложение визуально очень быстрым, так как исчезает задержка сети (RTTRTT).

Создание масштабируемого бэкенда и интеграция его с фронтендом на ReactJS требует понимания техник развертывания и управления состоянием.

Как создать CRUD-приложение на ReactJS?

Математика производительности обновлений

При обновлении списка в React мы используем метод .map(). Это операция с линейной сложностью O(n)O(n). Однако React использует Virtual DOM для минимизации реальных операций с браузером.

Количество операций перерисовки (RopsR_{ops}) можно приблизительно оценить так:

Rops≈Ndiff+CoverheadR_{ops} \approx N_{diff} + C_{overhead}

где RopsR_{ops} — количество реальных изменений в DOM браузере, NdiffN_{diff} — количество элементов, которые реально изменились (в нашем случае 1 элемент при обновлении), а CoverheadC_{overhead} — накладные расходы на сравнение деревьев Virtual DOM.

Благодаря использованию уникального атрибута key (который мы передали как key={task.id}), React понимает, что остальные элементы списка не изменились, и не трогает их в реальном DOM. Если бы мы использовали индекс массива в качестве ключа (key={index}), при удалении элемента React мог бы ошибочно перерисовать весь список, что значительно увеличило бы RopsR_{ops} и нагрузку на браузер.

Итоги

Мы завершили создание полноценного Fullstack-приложения с CRUD-функционалом. Вот ключевые выводы:

  1. Маршрутизация: Для изменения конкретных ресурсов на сервере используются динамические параметры в URL (например, :id), доступные через req.params.
  2. Методы HTTP: PUT используется для обновления данных, а DELETE — для их удаления. Это семантически верно и соответствует стандартам REST.
  3. Иммутабельность: При обновлении состояния в React (удаление или изменение элемента) мы всегда создаем новые копии массивов с помощью методов .filter() и .map(), а не мутируем старые.
  4. UX: Разделение логики на компоненты (TaskItem) и использование оптимистичных обновлений делает интерфейс отзывчивым и удобным для пользователя.
  5. Производительность: Правильное использование ключей (key) в списках критически важно для минимизации операций с DOM при обновлении данных.