Обзор
Технология Server-Sent Events(SSE) позволяющая отправлять информацию с сервера клиенту в реальном времени, основана на HTTP протоколе.
На клиентской стороне server-sent events API предоставляет EventSource интерфейс (часть HTML5 стандарта), посредством которого открывается постоянное соединение с HTTP сервером.
HTTP сервер отправляет события в text/event-stream формате. Соединение остаётся открытым до тех пор пока не будет вызван метод EventSource.close().
Ограничения:
- Возможен только приём данных с сервера (однонаправленный поток данных, в отличии от WebSockets);
- Данные могут передаваться только в
UTF-8формате (небинарные данные).
Возможные преимущества:
- Работает через
HTTP, а значит у клиентов не возникнет проблем с подключением в случае, когда они подключены через прокси, не поддерживающие другие соединения (такие как WebSockets); - Если соединение устанавливается через
HTTPS, то трафик SSE защищён шифрованием.
Поддержка браузерами: https://caniuse.com/eventsource.
В этой статье мы разработаем приложение Todo List, которое позволяет пользователям добавлять, удалять, помечать как выполенные задания в списке.
Обратите внимание, состояние списка с помощью Server-sent Events будет общим для всех пользователей:
Шаг 1 - Разработка Express Backend
# Создаём и заходим в каталог проекта mkdir sse cd sse # Создаём и заходим в подкаталог для для backend части проекта mkdir server cd server # Инициализируем проект и устанавливаем необходимые пакеты npm init -y npm install express@^4.18.1 body-parser@^1.20.0 compression@^1.7.4 cors@^2.8.5 --save После установки пакетов в файл package.json необходимо добавить строку "type": "module" для того чтобы NodeJS мог работать с модулями.
{ "name": "server", "type": "module" ... } Создаём файл server.js и добавляем каркас приложения:
import express from 'express'; import compression from 'compression'; import bodyParser from 'body-parser'; import cors from 'cors'; const app = express(); app.use(compression()); app.use(cors()); app.use(bodyParser.json()); let clients = []; let todoState = []; app.get('/state', (req, res) => { res.json(todoState); }); const PORT = process.env.PORT || 3005; app.listen(PORT, () => { console.log(`Shared todo list server listening at http://localhost:${PORT}`); }); Запускаем сервер командой npm start. Если всё сделано правильно, то, сделав запрос curl http://localhost:3005/state вы увидете [] - пустой список todo листа.
Далее перед декларацией порта const PORT = process.env.PORT || 3005; добавляем код для подключения клиента через SSE:
app.get('/events', (req, res) => { const headers = { // Тип соединения 'text/event-stream' необходим для SSE 'Content-Type': 'text/event-stream', 'Access-Control-Allow-Origin': '*', // Отставляем соединение открытым 'keep-alive' 'Connection': 'keep-alive', 'Cache-Control': 'no-cache' }; // Записываем в заголовок статус успешного ответа 200 res.writeHead(200, headers); /* Формирование данных: Когда EventSource получает множество последовательных строк, начинающихся с data: они объединяются, вставляя символ новой строки между ними. Завершающие символы новой строки удаляются. Двойные символы конца строки \n\n обозначают конец события. */ const sendData = `data: ${JSON.stringify(todoState)}\n\n`; res.write(sendData); // Если используется compression middleware, то необходимо // добавить res.flush() для отправки данных пользователю res.flush(); // Создаём уникальный идентификатор клиента const clientId = genUniqId(); const newClient = { id: clientId, res, }; clients.push(newClient); console.log(`${clientId} - Connection opened`); req.on('close', () => { console.log(`${clientId} - Connection closed`); clients = clients.filter(client => client.id !== clientId); }); }); function genUniqId(){ return Date.now() + '-' + Math.floor(Math.random() * 1000000000); } Итак, мы написали код, позволяющий клиенту подключиться, установив постоянное соединение, а так же сохранили id и res в массиве клиентов, чтобы в дальнейшем мы могли отправлять данные подключенным клиентам.
Чтобы проверить, что всё работает, добавим код для передачи уникальных id подключенных пользователей.
app.get('/clients', (req, res) => { res.json(clients.map((client) => client.id)); }); Запускаем сервер npm start.
Подключаемся в новом терминале к серверу:
curl -H Accept:text/event-stream http://localhost:3005/events В разных терминалах можно несколько раз повторить команду, чтобы сымитировать подключение нескольких клиентов.
Проверяем список подключившихся:
curl http://localhost:3005/clients В терминале вы должны увидеть массив id подключенных клиентов:
["1652948725022-121572961","1652948939397-946425533"] Теперь приступим к написанию бизнес логики приложения Todo List, нам нужно:
a) Добавлять задачу в todo список;
b) Удалять задачу из todo списка;
c) Устанавливать/снимать галочку выполнения задачи;
d) После каждого действия отправлять состояние всем подключенным клиентам.
Состояние todo списка будет выглядеть следующим образом:
[ { id: "1652980545287-628967479", text: "Task 1", checked: true }, { id: "1652980542043-2529066", text: "Task 2", checked: false }, ... ] Где id - уникальный идентификатор, генерируемый сервером, text - текст задачи, checked - состояние галочки задачи.
Начнём с d) - после каждого действия отправлять состояние всем подключенным клиентам:
function sendToAllUsers() { for(let i=0; i<clients.length; i++){ clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`); clients[i].res.flush(); } } Затем реализуем a) b) и c):
// Добавляем новую задачу в список и отправляем // состояние всем клиентам app.post('/add-task', (req, res) => { const addedText = req.body.text; todoState = [ { id: genUniqId(), text: addedText, checked: false }, ...todoState ]; res.json(null); sendToAllUsers(); }); // Изменяем состояние выполнения задачи в списке // и отправляем результат всем клиентам app.post('/check-task', (req, res) => { const id = req.body.id; const checked = req.body.checked; todoState = todoState.map((item) => { if(item.id === id){ return { ...item, checked }; } else{ return item; } }); res.json(null); sendToAllUsers(); }); // Удаляем задачу из списка и отправляем новое // состояние списка всем клиентам app.post('/del-task', (req, res) => { const id = req.body.id; todoState = todoState.filter((item) => { return item.id !== id; }); res.json(null); sendToAllUsers(); }); Итак, серверная часть готова. Полный код серверной части:
import express from 'express'; import compression from 'compression'; import bodyParser from 'body-parser'; import cors from 'cors'; const app = express(); app.use(compression()); app.use(cors()); app.use(bodyParser.json()); let clients = []; let todoState = []; app.get('/state', (req, res) => { res.json(todoState); }); app.get('/events', (req, res) => { const headers = { 'Content-Type': 'text/event-stream', 'Access-Control-Allow-Origin': '*', 'Connection': 'keep-alive', 'Cache-Control': 'no-cache' }; res.writeHead(200, headers); const sendData = `data: ${JSON.stringify(todoState)}\n\n`; res.write(sendData); res.flush(); const clientId = genUniqId(); const newClient = { id: clientId, res, }; clients.push(newClient); console.log(`${clientId} - Connection opened`); req.on('close', () => { console.log(`${clientId} - Connection closed`); clients = clients.filter(client => client.id !== clientId); }); }); function genUniqId(){ return Date.now() + '-' + Math.floor(Math.random() * 1000000000); } function sendToAllUsers() { for(let i=0; i<clients.length; i++){ clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`); clients[i].res.flush(); } } app.get('/clients', (req, res) => { res.json(clients.map((client) => client.id)); }); app.post('/add-task', (req, res) => { const addedText = req.body.text; todoState = [ { id: genUniqId(), text: addedText, checked: false }, ...todoState ]; res.json(null); sendToAllUsers(); }); app.post('/check-task', (req, res) => { const id = req.body.id; const checked = req.body.checked; todoState = todoState.map((item) => { if(item.id === id){ return { ...item, checked }; } else{ return item; } }); res.json(null); sendToAllUsers(); }); app.post('/del-task', (req, res) => { const id = req.body.id; todoState = todoState.filter((item) => { return item.id !== id; }); res.json(null); sendToAllUsers(); }); const PORT = process.env.PORT || 3005; app.listen(PORT, () => { console.log(`Shared todo list server listening at http://localhost:${PORT}`); }); Приступаем ко второму шагу - клиентской части.
Шаг 2 - Клиентская часть: React приложение
Перейдём в ранее созданную папку проекта sse, затем запустим команду создания шаблона react приложения:
npx create-react-app client Далее перейдём в папку созданного приложения и запустим его:
cd client npm start После чего в браузере должна открыться страница клиентского приложения http://localhost:3000.
Далее переходим к файлу src/index.js и удаляем React.StrictMode из приложения.
// Было root.render( <React.StrictMode> <App /> </React.StrictMode> ); // Стало root.render( <App /> ); Дело в том что React StrictMode дважды рендерит компоненты в режиме development для обнаружения возможных проблем. Но в нашем случае это не нужно, иначе клиент будет дважды подключаться к серверу и устанавливать постоянное соединение.
Удалим из файла App.css всё содержимое и вставим свои стили:
h1 { text-align: center; } main { display: flex; justify-content: center; } .l-todo { max-width: 31.25rem; } form { margin-bottom: 1rem; } form input[type="submit"] { margin-left: 0.5rem; } .task-group { margin-bottom: 0.125rem; display: flex; flex-wrap: nowrap; justify-content: space-between; } .task-group button { padding: 0.25rem 0.5rem; margin-left: 0.5rem; border: none; background-color: white; } Подготовим каркас приложения, удалим из файла App.js всё содержимое и вставим свой код:
import './App.css'; import { useState, useEffect, useRef } from 'react'; function App(){ return( <main> </main> ); } export default App; Добавим в наш App компонент хук состояния списка:
const [tasks, setTasks] = useState([]); Теперь добавим хук useEffect в котором мы будем устанавливать постоянное SSE соединение:
useEffect(() => { let mount = true; let events; let timer; let createEvents = () => { // Закрываем соединение если открыто if(events){ events.close(); } // Устанавливаем SSE соединение events = new EventSource(`http://localhost:3005/events`); events.onmessage = (event) => { // Если компонент смонтирован, устанавливаем // полученными данными состояние списка if(mount){ let parsedData = JSON.parse(event.data); setTasks(parsedData); } }; // Если возникает ошибка - ждём секунду и // снова вызываем функцию подключения events.onerror = (err) => { timer = setTimeout(() => { createEvents(); }, 1000); }; }; createEvents(); // Перед размонтированием компонента отчищаем // таймер и закрываем соединение return () => { mount = false; clearTimeout(timer); events.close(); } }, []); Теперь при открытии клиентского сайта http://localhost:3000 произойдёт соединение с сервером и сервер отправит состояние todo списка подключившемуся клиенту. Клиент, получив данные установит состояние todo списка.
Разработаем компонент интерфейса для добавления новой задачи в список.

Добавим в проект файл src/AddTask.js
function AddTask(props){ const { text, onTextChange, onSubmit, textRef } = props; return( <form onSubmit={onSubmit}> <input type="text" name="add" value={text} onChange={onTextChange} ref={textRef} /> <input type="submit" value="Добавить" /> </form> ); } export default AddTask; Создадим элемент списка:

Добавим в проект файл src/Task.js:
function Task(props){ const { id, text, checked, onCheck, onDel } = props; return( <div className="task-group"> <div> <input type="checkbox" name={`chk${id}`} id={`chk${id}`} checked={checked} onChange={onCheck} /> <label htmlFor={`chk${id}`}>{text}</label> </div> <button id={`btn${id}`} onClick={onDel}>x </button> </div> ); } export default Task; Подключим файлы созданные файлы в App.js:
import AddTask from './AddTask'; import Task from './Task'; В нашем приложении мы будем передавать данные на сервер в JSON формате, поэтому прежде чем двигаться дальше мы напишем небольшую обёртку для javascript fetch API для упрощения клиентского кода. Создадим файл /src/jsonFetch.js:
function jsonFetch(url, data){ return new Promise(function(resolve, reject){ fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then(function(res){ if(res.ok){ const contentType = res.headers.get('content-type'); if(contentType && contentType.includes('application/json')){ return res.json(); } return reject(`Не JSON, content-type: ${contentType}`); } return reject(`Статус: ${res.status}`); }) .then(function(res){ resolve(res); }) .catch(function(error){ reject(error); }); }); } export default jsonFetch; Подключим созданный файл в App.js:
import jsonFetch from './jsonFetch'; Теперь добавим наши компоненты AddTask и Task в App компонент:
function App(){ const [addTaskText, setAddTaskText] = useState(''); const [tasks, setTasks] = useState([]); const addTextRef = useRef(null); useEffect(() => { // Код SSE ... },[]); const tasksElements = tasks.map((item) => { return( <Task key={item.id} id={item.id} text={item.text} checked={item.checked} onCheck={handleTaskCheck} onDel={handleTaskDel} /> ); }); return ( <main> <div className="l-todo"> <h1>Todo List</h1> <AddTask text={addTaskText} onSubmit={handleAddTaskSubmit} onTextChange={handleAddTaskTextChange} textRef={addTextRef} /> {tasksElements} </div> </main> ); } Напишем обработчики событий пользователя в App компоненте:
function handleAddTaskTextChange(event){ setAddTaskText(event.target.value); } function handleAddTaskSubmit(event){ event.preventDefault(); let addedText = addTaskText.trim(); if(!addedText){ return setAddTaskText(''); } jsonFetch('http://localhost:3005/add-task', {text: addedText}) .then(() => { setAddTaskText(''); }) .catch((err) => { console.log(err); }) .finally(() => { addTextRef.current.focus(); }); } function handleTaskCheck(event){ const checked = event.target.checked; const targetId = event.target.id.substring(3); jsonFetch('http://localhost:3005/check-task', {id: targetId, checked}) .catch((err) => { console.log(err); }); } function handleTaskDel(event){ let targetId = event.target.id.substring(3); jsonFetch('http://localhost:3005/del-task', {id: targetId}) .catch((err) => { console.log(err); }); } Итак, логика работы приложения: при монтировании компонента создаётся SSE подключение к серверу, который при подключении передаёт состояние списка. После получения состояние списка с сервера оно устанавливается клиенту setTasks(parsedData).
Далее, при добавлении, удалении и установке/снятии галочки задачи - изменения отправляются на сервер, там записываются в todoState и передаются всем подключенным пользователям.
Полный код клиентского приложения:
import './App.css'; import { useState, useEffect, useRef } from 'react'; import AddTask from './AddTask'; import Task from './Task'; import jsonFetch from './jsonFetch'; function App(){ const [addTaskText, setAddTaskText] = useState(''); const [tasks, setTasks] = useState([]); const addTextRef = useRef(null); useEffect(() => { let mount = true; let events; let timer; let createEvents = () => { if(events){ events.close(); } events = new EventSource(`http://localhost:3005/events`); events.onmessage = (event) => { if(mount){ let parsedData = JSON.parse(event.data); setTasks(parsedData); } }; events.onerror = (err) => { timer = setTimeout(() => { createEvents(); }, 1000); }; }; createEvents(); return () => { mount = false; clearTimeout(timer); events.close(); } }, []); const tasksElements = tasks.map((item) => { return( <Task key={item.id} id={item.id} text={item.text} checked={item.checked} onCheck={handleTaskCheck} onDel={handleTaskDel} /> ); }); return ( <main> <div className="l-todo"> <h1>Todo List</h1> <AddTask text={addTaskText} onSubmit={handleAddTaskSubmit} onTextChange={handleAddTaskTextChange} textRef={addTextRef} /> {tasksElements} </div> </main> ); function handleAddTaskTextChange(event){ setAddTaskText(event.target.value); } function handleAddTaskSubmit(event){ event.preventDefault(); let addedText = addTaskText.trim(); if(!addedText){ return setAddTaskText(''); } jsonFetch('http://localhost:3005/add-task', {text: addedText}) .then(() => { setAddTaskText(''); }) .catch((err) => { console.log(err); }) .finally(() => { addTextRef.current.focus(); }); } function handleTaskCheck(event){ const checked = event.target.checked; const targetId = event.target.id.substring(3); jsonFetch('http://localhost:3005/check-task', {id: targetId, checked}) .catch((err) => { console.log(err); }); } function handleTaskDel(event){ let targetId = event.target.id.substring(3); jsonFetch('http://localhost:3005/del-task', {id: targetId}) .catch((err) => { console.log(err); }); } } export default App; Ставьте лайки, пишите комментарии.

Top comments (0)