Что такое "реальное время" в интернете и зачем оно нужно
Представьте ситуацию. Вы пишете сообщение другу в мессенджере. Нажимаете "Отправить" - и друг видит его мгновенно. Не через секунду, не через пять - а прямо сейчас. Это и есть "реальное время" (real-time) в веб-приложениях.
А теперь представьте другую ситуацию. Вы отправили обычное письмо по почте. Написали, положили в конверт, отнесли на почту. Через три дня адресат получил, прочитал, написал ответ, отнёс на почту. Ещё через три дня вы получили ответ. Вот так работает обычный HTTP - протокол, на котором построено большинство сайтов.
Разница между "обычным" и "реальным временем" - это как разница между обычной почтой и телефонным звонком. По почте вы отправляете запрос и ждёте ответ. По телефону вы разговариваете одновременно, в обе стороны, без задержек.
В этой статье мы разберёмся, как работает real-time в веб-приложениях. Объясним всё простыми словами, с аналогиями и подробными примерами кода. А в конце - покажем, как построить свой чат с нуля.
Как работает обычный HTTP (и почему он не подходит для чатов)
Когда вы открываете обычный сайт, происходит примерно следующее:
- Ваш браузер отправляет запрос на сервер: "Дай мне страницу about.html"
- Сервер получает запрос, находит страницу и отправляет её обратно
- Браузер показывает страницу
- Соединение закрывается. Всё. Разговор окончен.
Это как заказ в ресторане: вы попросили - вам принесли. Но если вы хотите узнать, не появилось ли новое блюдо в меню, вам придётся снова подозвать официанта и спросить. И снова. И снова. Каждые 5 секунд.
Именно так раньше делали "почти реальное время" в вебе - это называлось polling (опрос):
// Polling - "допотопный" способ получать обновления
// Браузер каждые 3 секунды спрашивает сервер: "Есть новые сообщения?"
// Эта функция запускается каждые 3 секунды (3000 миллисекунд)
setInterval(async () => {
// fetch - это встроенная функция браузера для отправки HTTP-запросов
// Мы отправляем GET-запрос на адрес /api/messages
const response = await fetch('/api/messages')
// Преобразуем ответ сервера из JSON-формата в обычный JavaScript-объект
const messages = await response.json()
// Если сервер вернул новые сообщения - показываем их на экране
if (messages.length > 0) {
// Для каждого нового сообщения вызываем функцию показа
messages.forEach(msg => showMessage(msg))
}
// Проблема: даже если новых сообщений нет, мы всё равно
// отправляем запрос каждые 3 секунды. Это как звонить другу
// каждые 3 секунды и спрашивать: "Ну что, написал мне?"
// Сервер тратит ресурсы на обработку пустых запросов.
// При 1000 пользователей - это 333 запроса В СЕКУНДУ впустую!
}, 3000)
У polling есть две серьёзные проблемы:
- Задержка - сообщение может прийти сразу после опроса, и вы узнаете о нём только через 3 секунды (или сколько вы выставили интервал)
- Нагрузка на сервер - тысячи пользователей шлют запросы каждые несколько секунд, даже когда ничего не происходит. Это пустая трата ресурсов
WebSocket - постоянная линия связи
WebSocket решает обе проблемы. Вместо того чтобы каждый раз "звонить и вешать трубку", WebSocket устанавливает постоянное соединение между браузером и сервером. Как будто вы подняли трубку и не кладёте её - разговариваете, когда нужно, молчите, когда нечего сказать.
Вот как это работает:
- Браузер говорит серверу: "Привет, давай перейдём на WebSocket?" (это называется "рукопожатие" - handshake)
- Сервер отвечает: "Давай!"
- Теперь между ними открыт постоянный канал. Сервер может отправить сообщение браузеру в любой момент, не дожидаясь запроса
- Браузер тоже может отправить сообщение серверу в любой момент
- Канал остаётся открытым, пока кто-то не закроет его или не пропадёт интернет
// WebSocket - простейший пример на стороне браузера (клиента)
// Создаём WebSocket-соединение с сервером
// ws:// - это протокол WebSocket (как http://, но для постоянного соединения)
// wss:// - то же самое, но с шифрованием (как https://)
const socket = new WebSocket('wss://example.com/chat')
// Это событие срабатывает, когда соединение успешно установлено
// Можно представить это как: "трубка поднята, связь установлена"
socket.onopen = () => {
console.log('Соединение установлено!')
// Теперь можно отправлять сообщения на сервер
// JSON.stringify превращает JavaScript-объект в текстовую строку
// потому что WebSocket передаёт только текст или бинарные данные
socket.send(JSON.stringify({
type: 'join', // Тип сообщения - "присоединиться"
room: 'general', // К какому чату присоединяемся
username: 'Иван' // Наше имя
}))
}
// Это событие срабатывает, когда СЕРВЕР отправляет нам сообщение
// Обратите внимание: мы ничего не запрашивали!
// Сервер сам решил, что нам нужно это знать
socket.onmessage = (event) => {
// event.data - это текст сообщения от сервера
// JSON.parse превращает текстовую строку обратно в JavaScript-объект
const data = JSON.parse(event.data)
console.log('Получено сообщение:', data)
// Например: { type: 'message', from: 'Мария', text: 'Привет!' }
// Показываем сообщение в интерфейсе чата
showMessage(data)
}
// Это событие срабатывает при разрыве соединения
// Например, если пропал интернет или сервер перезагрузился
socket.onclose = (event) => {
console.log('Соединение закрыто:', event.reason)
// Здесь обычно пытаемся переподключиться (об этом ниже)
}
// Это событие срабатывает при ошибке
socket.onerror = (error) => {
console.log('Ошибка WebSocket:', error)
}
Socket.IO - WebSocket "на стероидах"
Чистый WebSocket - это как сырой телефонный провод. Он работает, но для реального приложения нужно многое доделать: переподключение при обрыве, "комнаты" для разных чатов, подтверждение доставки и так далее.
Socket.IO - это библиотека, которая берёт WebSocket и добавляет все эти "удобства". Представьте: WebSocket - это труба для воды, а Socket.IO - это целая система водоснабжения с кранами, фильтрами и датчиками давления.
Вот что Socket.IO даёт "из коробки" (то есть без дополнительной настройки):
- Автопереподключение - если интернет пропал и вернулся, соединение восстановится автоматически
- Комнаты (rooms) - можно группировать пользователей. Например, отдельная комната для каждого чата
- Подтверждение доставки - можно узнать, дошло ли сообщение до получателя
- Fallback - если WebSocket по какой-то причине не работает (старый браузер, корпоративный прокси), Socket.IO автоматически переключится на polling
// Серверная часть Socket.IO - то, что работает на вашем сервере
// Для запуска нужно установить: npm install socket.io express
// Подключаем необходимые библиотеки
import express from 'express' // Express - фреймворк для веб-сервера
import { createServer } from 'http' // http - встроенный модуль Node.js
import { Server } from 'socket.io' // Socket.IO - наша библиотека для real-time
// Создаём веб-сервер
const app = express() // Express-приложение
const httpServer = createServer(app) // HTTP-сервер на базе Express
// Создаём Socket.IO сервер, "прикрепляя" его к HTTP-серверу
const io = new Server(httpServer, {
cors: {
// Разрешаем подключаться с этих адресов
// (нужно для безопасности - чтобы не любой сайт мог подключиться)
origin: ['http://localhost:3000', 'https://mysite.com'],
}
})
// Хранилище пользователей онлайн
// Map - это коллекция "ключ-значение", как словарь
// Ключ - ID соединения, значение - имя пользователя
const onlineUsers = new Map()
// Это событие срабатывает при каждом новом подключении
// Каждый пользователь, открывший сайт, создаёт своё "соединение" (socket)
io.on('connection', (socket) => {
console.log('Новый пользователь подключился! ID:', socket.id)
// --- Событие: пользователь присоединяется к чату ---
// socket.on() - слушаем конкретное событие от этого пользователя
// 'join' - имя события (мы придумали его сами)
socket.on('join', (data) => {
// data - это объект, который отправил клиент
// Например: { room: 'general', username: 'Иван' }
// Запоминаем имя пользователя
onlineUsers.set(socket.id, data.username)
// Присоединяем пользователя к "комнате"
// Комната - это группа пользователей, которые видят одни и те же сообщения
socket.join(data.room)
// Отправляем сообщение ВСЕМ пользователям в этой комнате
// (кроме того, кто только что присоединился)
socket.to(data.room).emit('system', {
text: `${data.username} присоединился к чату`
})
// Отправляем список онлайн-пользователей всем в комнате
// Array.from() превращает Map в обычный массив
io.to(data.room).emit('online-users', {
users: Array.from(onlineUsers.values())
})
console.log(`${data.username} зашёл в комнату "${data.room}"`)
})
// --- Событие: пользователь отправил сообщение ---
socket.on('message', (data) => {
// data: { room: 'general', text: 'Привет всем!' }
const username = onlineUsers.get(socket.id)
// io.to(room).emit() - отправить ВСЕМ в комнате (включая отправителя)
// socket.to(room).emit() - отправить всем КРОМЕ отправителя
io.to(data.room).emit('message', {
from: username, // Кто написал
text: data.text, // Текст сообщения
time: new Date().toISOString() // Время отправки
})
})
// --- Событие: пользователь печатает ---
// Это те самые "Иван печатает..." которые вы видите в мессенджерах
socket.on('typing', (data) => {
const username = onlineUsers.get(socket.id)
// Отправляем всем в комнате КРОМЕ того, кто печатает
socket.to(data.room).emit('typing', { username })
})
// --- Событие: пользователь отключился ---
// Срабатывает автоматически при закрытии вкладки или потере соединения
socket.on('disconnect', () => {
const username = onlineUsers.get(socket.id)
onlineUsers.delete(socket.id)
if (username) {
// Сообщаем всем, что пользователь вышел
io.emit('system', { text: `${username} покинул чат` })
io.emit('online-users', {
users: Array.from(onlineUsers.values())
})
}
console.log('Пользователь отключился:', socket.id)
})
})
// Запускаем сервер на порту 3001
httpServer.listen(3001, () => {
console.log('Сервер чата запущен на порту 3001')
})
Клиентская часть - интерфейс чата
Теперь напишем то, что видит пользователь в браузере. Это простая HTML-страница с окном чата:
// Клиентская часть Socket.IO - то, что работает в браузере
// Для подключения нужно добавить в HTML:
// <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
// Подключаемся к серверу Socket.IO
// io() - это функция из библиотеки socket.io-client
const socket = io('http://localhost:3001', {
// Если соединение оборвётся, Socket.IO будет пытаться
// переподключиться автоматически. Эти настройки управляют тем, как именно:
reconnection: true, // Включить автопереподключение
reconnectionAttempts: 10, // Максимум 10 попыток
reconnectionDelay: 1000, // Первая попытка через 1 секунду
reconnectionDelayMax: 30000, // Максимальная задержка - 30 секунд
})
// Текущая комната (чат)
let currentRoom = 'general'
let myUsername = ''
// Функция для присоединения к чату
// Вызывается, когда пользователь вводит имя и нажимает "Войти"
function joinChat(username) {
myUsername = username
// Отправляем серверу событие 'join'
// Сервер получит это в socket.on('join', ...)
socket.emit('join', {
room: currentRoom,
username: username
})
}
// Функция отправки сообщения
// Вызывается при нажатии кнопки "Отправить" или клавиши Enter
function sendMessage(text) {
// Не отправляем пустые сообщения
if (!text.trim()) return
// Отправляем событие 'message' на сервер
socket.emit('message', {
room: currentRoom,
text: text
})
// Очищаем поле ввода после отправки
document.getElementById('message-input').value = ''
}
// Индикатор "печатает..."
// Переменная typingTimer нужна, чтобы не спамить событиями
let typingTimer = null
// Эта функция вызывается при каждом нажатии клавиши в поле ввода
function handleTyping() {
// Если таймер не запущен - отправляем событие "я печатаю"
if (!typingTimer) {
socket.emit('typing', { room: currentRoom })
}
// Сбрасываем таймер: если пользователь перестал печатать
// на 2 секунды, мы сможем снова отправить событие
clearTimeout(typingTimer)
typingTimer = setTimeout(() => {
typingTimer = null
}, 2000)
}
// --- Получаем события от сервера ---
// Новое сообщение в чате
socket.on('message', (data) => {
// data: { from: 'Мария', text: 'Привет!', time: '2026-...' }
const messagesDiv = document.getElementById('messages')
const msgElement = document.createElement('div')
// Если сообщение от нас - стилизуем по-другому (справа, другой цвет)
const isMe = data.from === myUsername
msgElement.className = isMe ? 'message mine' : 'message'
// Показываем имя отправителя и текст
msgElement.innerHTML = `
${data.from}
${data.text}
${new Date(data.time).toLocaleTimeString()}
`
messagesDiv.appendChild(msgElement)
// Прокручиваем чат вниз, чтобы видеть новое сообщение
messagesDiv.scrollTop = messagesDiv.scrollHeight
})
// Системное сообщение (кто-то зашёл/вышел)
socket.on('system', (data) => {
const messagesDiv = document.getElementById('messages')
const msgElement = document.createElement('div')
msgElement.className = 'system-message'
msgElement.textContent = data.text
messagesDiv.appendChild(msgElement)
})
// Кто-то печатает
socket.on('typing', (data) => {
const indicator = document.getElementById('typing-indicator')
indicator.textContent = `${data.username} печатает...`
// Убираем индикатор через 3 секунды
setTimeout(() => {
indicator.textContent = ''
}, 3000)
})
// Обновление списка онлайн-пользователей
socket.on('online-users', (data) => {
const onlineDiv = document.getElementById('online-users')
onlineDiv.innerHTML = data.users
.map(name => `${name}`)
.join('')
})
// --- Обработка переподключения ---
socket.on('connect', () => {
console.log('Подключено к серверу')
// Если мы переподключились после обрыва - заново входим в комнату
if (myUsername) {
socket.emit('join', { room: currentRoom, username: myUsername })
}
})
socket.on('disconnect', (reason) => {
console.log('Соединение потеряно:', reason)
// Socket.IO сам попытается переподключиться
})
SSE - когда данные идут только в одну сторону
WebSocket - это двусторонний канал: и сервер, и клиент могут отправлять сообщения. Но иногда вам нужен только "односторонний поток": сервер отправляет данные, а клиент только слушает.
Примеры: лента новостей, биржевые котировки, уведомления, прогресс загрузки. Для таких случаев есть SSE (Server-Sent Events) - более простая технология, чем WebSocket.
Аналогия: WebSocket - это телефонный разговор (оба говорят). SSE - это радио (станция вещает, вы слушаете).
// SSE - серверная часть (Node.js + Express)
// Сервер отправляет обновления клиенту через обычный HTTP
app.get('/api/notifications', (req, res) => {
// Устанавливаем специальные заголовки, которые говорят браузеру:
// "Это не обычный ответ. Это поток данных, который будет приходить постепенно"
res.setHeader('Content-Type', 'text/event-stream') // Формат SSE
res.setHeader('Cache-Control', 'no-cache') // Не кэшировать
res.setHeader('Connection', 'keep-alive') // Держать соединение
// Функция для отправки события клиенту
// Формат SSE: "data: текст\n\n" (два перевода строки в конце - обязательно!)
function sendEvent(eventName, data) {
res.write(`event: ${eventName}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
// Отправляем приветственное сообщение
sendEvent('connected', { message: 'Вы подключены к уведомлениям' })
// Пример: каждые 5 секунд отправляем какое-нибудь обновление
const interval = setInterval(() => {
sendEvent('notification', {
text: 'Новый заказ #' + Math.floor(Math.random() * 1000),
time: new Date().toISOString()
})
}, 5000)
// Когда клиент закрывает вкладку - прекращаем отправку
req.on('close', () => {
clearInterval(interval)
console.log('Клиент отключился от SSE')
})
})
// SSE - клиентская часть (браузер)
// EventSource - встроенный API браузера, ничего устанавливать не нужно
const source = new EventSource('/api/notifications')
// Слушаем конкретное событие 'notification'
source.addEventListener('notification', (event) => {
// event.data - текстовые данные от сервера
const data = JSON.parse(event.data)
console.log('Новое уведомление:', data.text)
// Показываем уведомление пользователю
showNotification(data.text)
})
// Слушаем событие подключения
source.addEventListener('connected', (event) => {
console.log('Подключены к серверу уведомлений')
})
// Обработка ошибок (SSE автоматически переподключается!)
source.onerror = () => {
console.log('Ошибка SSE, переподключение...')
// Браузер сам попытается переподключиться
}
Сравнение: что когда использовать
Вот простая таблица, которая поможет выбрать технологию:
| Задача | Технология | Почему |
|---|---|---|
| Чат, мессенджер | WebSocket / Socket.IO | Двусторонний обмен, низкая задержка |
| Уведомления | SSE | Односторонний поток, проще настроить |
| Биржевые котировки | WebSocket | Двусторонний (подписка + отписка), частые обновления |
| Прогресс загрузки | SSE | Сервер сообщает клиенту о прогрессе |
| Онлайн-игра | WebSocket | Минимальная задержка, двусторонний обмен |
| Лента новостей | SSE или Polling | Обновления редкие, простота важнее скорости |
| Совместное редактирование | WebSocket | Синхронизация изменений между пользователями |
Правило большого пальца: если данные идут только от сервера к клиенту - используйте SSE. Если нужен обмен в обе стороны - используйте WebSocket (или Socket.IO для удобства).
Практический пример: система уведомлений
Чат - это хорошо, но уведомления нужны почти каждому сайту. Вот как сделать систему уведомлений, которая мгновенно показывает новые события:
// Система уведомлений на Socket.IO
// Серверная часть
// Когда в системе происходит что-то важное,
// мы отправляем уведомление нужному пользователю
// Пример 1: новый заказ - уведомляем менеджера
async function onNewOrder(order) {
// Сохраняем уведомление в базу данных
// (чтобы пользователь увидел его, даже если был оффлайн)
await db.query(
'INSERT INTO notifications (user_id, type, text, data) VALUES (?, ?, ?, ?)',
[
order.managerId,
'new_order',
`Новый заказ #${order.id} на сумму ${order.total} руб.`,
JSON.stringify({ orderId: order.id })
]
)
// Отправляем уведомление в реальном времени
// io.to() отправляет сообщение в "комнату" конкретного пользователя
io.to(`user:${order.managerId}`).emit('notification', {
type: 'new_order',
text: `Новый заказ #${order.id} на сумму ${order.total} руб.`,
orderId: order.id,
time: new Date().toISOString()
})
}
// Пример 2: комментарий к задаче - уведомляем автора задачи
async function onNewComment(comment, task) {
// Не уведомляем, если автор комментария = автор задачи
if (comment.authorId === task.authorId) return
await db.query(
'INSERT INTO notifications (user_id, type, text, data) VALUES (?, ?, ?, ?)',
[
task.authorId,
'new_comment',
`${comment.authorName} прокомментировал задачу "${task.title}"`,
JSON.stringify({ taskId: task.id, commentId: comment.id })
]
)
io.to(`user:${task.authorId}`).emit('notification', {
type: 'new_comment',
text: `${comment.authorName} прокомментировал задачу "${task.title}"`,
taskId: task.id
})
}
// При подключении пользователя - присоединяем его к персональной "комнате"
io.on('connection', (socket) => {
// Здесь userId берётся из токена авторизации (JWT)
const userId = socket.handshake.auth.userId
if (userId) {
// Пользователь теперь "слушает" свою персональную комнату
socket.join(`user:${userId}`)
console.log(`Пользователь ${userId} подключён к уведомлениям`)
}
})
Масштабирование: когда пользователей становится много
Один сервер Node.js может держать 10,000-50,000 WebSocket-соединений. Но что делать, когда пользователей больше?
Представьте, что у вас два сервера. Пользователь А подключён к серверу 1, пользователь Б - к серверу 2. Они в одном чате, но когда А отправляет сообщение, сервер 1 не знает про пользователя Б на сервере 2.
Решение - Redis (быстрая база данных в оперативной памяти) как "посредник" между серверами:
// Масштабирование Socket.IO с помощью Redis
// Redis работает как "переговорная комната" между серверами
// Установка: npm install @socket.io/redis-adapter redis
import { createClient } from 'redis'
import { createAdapter } from '@socket.io/redis-adapter'
// Подключаемся к Redis
// Redis - это сверхбыстрая база данных, которая хранит данные в памяти
// Все серверы подключаются к одному Redis
const pubClient = createClient({ url: 'redis://localhost:6379' })
const subClient = pubClient.duplicate()
// Ждём подключения обоих клиентов
await Promise.all([pubClient.connect(), subClient.connect()])
// Подключаем Redis-адаптер к Socket.IO
// Теперь когда сервер 1 отправляет сообщение в комнату,
// Redis передаёт это сообщение серверу 2, и тот доставляет
// его своим подключённым пользователям
io.adapter(createAdapter(pubClient, subClient))
// Всё! Код вашего приложения не меняется.
// io.to('room').emit('message', data) теперь работает
// через все серверы автоматически.
Безопасность WebSocket-соединений
WebSocket-соединения нужно защищать так же, как обычные HTTP-запросы. Вот основные правила:
// Безопасность Socket.IO
// 1. Аутентификация - проверяем, кто подключается
io.use((socket, next) => {
// socket.handshake.auth - данные, которые клиент передал при подключении
const token = socket.handshake.auth.token
if (!token) {
// Если токена нет - отклоняем подключение
return next(new Error('Нужна авторизация'))
}
try {
// jwt.verify проверяет, что токен настоящий и не просрочен
const user = jwt.verify(token, process.env.JWT_SECRET)
socket.userId = user.id // Запоминаем ID пользователя
socket.userRole = user.role // Запоминаем роль
next() // Разрешаем подключение
} catch (err) {
next(new Error('Недействительный токен'))
}
})
// 2. Rate limiting - ограничение частоты сообщений
// Без этого злоумышленник может отправить миллион сообщений в секунду
const messageCount = new Map()
io.on('connection', (socket) => {
socket.on('message', (data) => {
// Считаем сообщения за последнюю минуту
const count = messageCount.get(socket.id) || 0
if (count > 30) {
// Больше 30 сообщений в минуту - блокируем
socket.emit('error', { text: 'Слишком много сообщений. Подождите.' })
return
}
messageCount.set(socket.id, count + 1)
// Сбрасываем счётчик через минуту
setTimeout(() => {
messageCount.set(socket.id, Math.max(0, (messageCount.get(socket.id) || 0) - 1))
}, 60000)
// Обрабатываем сообщение...
})
})
// 3. Валидация данных - проверяем, что прислал клиент
socket.on('message', (data) => {
// Проверяем, что data - это объект с нужными полями
if (typeof data !== 'object') return
if (typeof data.text !== 'string') return
if (data.text.length > 5000) return // Не больше 5000 символов
if (data.text.trim().length === 0) return // Не пустое
// Очищаем HTML-теги (защита от XSS)
// Без этого злоумышленник может отправить
const cleanText = data.text
.replace(//g, '>')
// Теперь безопасно отправлять другим пользователям
io.to(data.room).emit('message', {
from: socket.username,
text: cleanText,
time: new Date().toISOString()
})
})
Когда НЕ нужен real-time
Real-time - это здорово, но не всегда нужно. Вот ситуации, когда обычный HTTP лучше:
- Блог или статический сайт - контент меняется редко, нет смысла держать соединение
- Интернет-магазин - для большинства действий (просмотр каталога, оформление заказа) обычных запросов достаточно. Real-time может пригодиться только для чата поддержки
- Формы и опросы - отправил форму, получил ответ. Одноразовое действие
- Аналитические дашборды - если данные обновляются раз в минуту, проще обновлять страницу по таймеру, чем держать WebSocket
Не усложняйте без необходимости. WebSocket добавляет сложность: нужно думать о переподключении, масштабировании, безопасности. Если polling каждые 30 секунд решает вашу задачу - используйте polling.
Итого
Real-time в вебе - это не магия, а конкретные технологии: WebSocket (постоянный двусторонний канал), SSE (односторонний поток от сервера), и Socket.IO (удобная обёртка над WebSocket с кучей полезных фич).
Для чата и мессенджера - берите Socket.IO. Для уведомлений и лент - берите SSE. Для всего остального - скорее всего, хватит обычного HTTP.
Главное помните: real-time добавляет сложность. Начните с простого решения и усложняйте только когда появится реальная потребность. А если вам нужна помощь с внедрением real-time функций - чат, уведомления, live-данные - напишите нам, мы поможем спроектировать и реализовать.