Доброго времени суток, друзья!
В этом туториале мы рассмотрим Server Sent Events: встроенный класс EventSource, который позволяет поддерживать соединение с сервером и получать от него события.
О том, что такое SSE и для чего он используется можно почитать здесь.
Что конкретно мы будем делать?
Мы напишем простой сервер, который будет по запросу клиента отправлять ему данные 10 случайных пользователей, а клиент с помощью этих данных будет формировать карточки пользователей.
Сервер будет реализован на Node.js, клиент на JavaScript. Для стилизации будет использоваться Bootstrap, в качестве API Random User Generator.
Код проекта находится здесь.
Поиграть с кодом можно здесь.
Если вам это интересно, прошу следовать за мной.
Подготовка
Создаем директорию
sse-tut
:
mkdir sse-tut
Заходим в нее и инициализируем проект:
cd sse-tutyarn init -y// илиnpm init -y
Устанавливаем
axios
:
yarn add axios// илиnpm i axios
axios будет использоваться для получения данных пользователей.
Редактируем
package.json
:
"main": "server.js","scripts": { "start": "node server.js"},
Структура проекта:
sse-tut --node_modules --client.js --index.html --package.json --server.js --yarn.lock
Содержание
index.html
:
<head> <!-- Bootstrap CSS --> <link rel="stylesheet" href="http://personeltest.ru/aways/stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous"> <style> .card { margin: 0 auto; max-width: 512px; } img { margin: 1rem; max-width: 320px; } p { margin: 1rem; } </style></head><body> <main class="container text-center"> <h1>Server-Sent Events Tutorial</h1> <button class="btn btn-primary" data-type="start-btn">Start</button> <button class="btn btn-danger" data-type="stop-btn" disabled>Stop</button> <p data-type="event-log"></p> </main> <script src="client.js"></script></body>
Сервер
Приступаем к реализации сервера.
Открываем
server.js
.Подключаем http и axios, определяем порт:
const http = require('http')const axios = require('axios')const PORT = process.env.PORT || 3000
Создаем функцию получения данных пользователя:
const getUserData = async () => { const response = await axios.get('https://randomuser.me/api') // проверяем полученные данные console.log(response) return response.data.results[0]}
Создаем счетчик количества отправленных пользователей:
let i = 1
Пишем функцию отправки данных клиенту:
const sendUserData = (req, res) => { // статус ответа - 200 ок // соединение должно оставаться открытым // тип содержимого - поток событий // не кэшировать res.writeHead(200, { Connection: 'keep-alive', 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }) // данные будут отправляться каждые 2 секунды const timer = setInterval(async () => { // если отправлено 10 пользователей if (i > 10) { // останавливаем таймер clearInterval(timer) // сообщаем о том, что было отправлено 10 пользователей console.log('10 users has been sent.') // отправляем клиенту идентификатор со значением -1 // для того, чтобы клиент закрыл соединение res.write('id: -1\ndata:\n\n') // закрываем соединение res.end() return } // получаем данные const data = await getUserData() // записываем данные в ответ // event - название события // id - идентификатор события; используется при повторном подключении // retry - время повторного подключения // data - данные res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`) // сообщаем о том, что данные отправлены console.log('User data has been sent.') // увеличиваем значение счетчика i++ }, 2000) // обрабатываем закрытие соединения клиентом req.on('close', () => { clearInterval(timer) res.end() console.log('Client closed the connection.') })}
Создаем и запускаем сервер:
http.createServer((req, res) => { // обязательный заголовок для преодоления CORS res.setHeader('Access-Control-Allow-Origin', '*') // если адрес запроса - getUser if (req.url === '/getUsers') { // отправляем данные sendUserData(req, res) } else { // иначе, сообщаем о том, что запрашиваемая страница не найдена, // и закрываем соединение res.writeHead(404) res.end() }}).listen(PORT, () => console.log('Server ready.'))
Полный код сервера:
const http = require('http')const axios = require('axios')const PORT = process.env.PORT || 3000const getUserData = async () => { const response = await axios.get('https://randomuser.me/api') return response.data.results[0]}let i = 1const sendUserData = (req, res) => { res.writeHead(200, { Connection: 'keep-alive', 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }) const timer = setInterval(async () => { if (i > 10) { clearInterval(timer) console.log('10 users has been sent.') res.write('id: -1\ndata:\n\n') res.end() return } const data = await getUserData() res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`) console.log('User data has been sent.') i++ }, 2000) req.on('close', () => { clearInterval(timer) res.end() console.log('Client closed the connection.') })}http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') if (req.url === '/getUsers') { sendUserData(req, res) } else { res.writeHead(404) res.end() }}).listen(PORT, () => console.log('Server ready.'))
Выполняем команду
yarn start
или npm
start
. В терминале появляется сообщение Server ready..
Открываем http://localhost:3000
:С сервером закончили, переходим к клиентской части приложения.
Клиент
Открываем файл
client.js
.Создаем функцию генерации шаблона пользовательской карточки:
const getTemplate = user => `<div class="card"> <div class="row"> <div class="col-md-4"> <img src="${user.img}" class="card-img" alt="user-photo"> </div> <div class="col-md-8"> <div class="card-body"> <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5> <p class="card-text">Name: ${user.name}</p> <p class="card-text">Username: ${user.username}</p> <p class="card-text">Email: ${user.email}</p> <p class="card-text">Age: ${user.age}</p> </div> </div> </div></div>`
Шаблон генерируется с использованием следующих данных: идентификатор пользователя (если имеется), имя, логин, адрес электронной почты и возраст пользователя.
Начинаем реализовывать основной функционал:
class App { constructor(selector) { // основной элемент - контейнер this.$ = document.querySelector(selector) // запускаем частный метод this.#init() } #init() { this.startBtn = this.$.querySelector('[data-type="start-btn"]') this.stopBtn = this.$.querySelector('[data-type="stop-btn"]') // контейнер для сообщений о событиях this.eventLog = this.$.querySelector('[data-type="event-log"]') // устанавливаем контекст для обработчика this.clickHandler = this.clickHandler.bind(this) // делегируем обработку события this.$.addEventListener('click', this.clickHandler) } clickHandler(e) { // если кликнули по кнопке if (e.target.tagName === 'BUTTON') { // получаем тип кнопки // и либо начинаем получать события от сервера, // либо закрываем соединение const { type } = e.target.dataset if (type === 'start-btn') { this.startEvents() } else if (type === 'stop-btn') { this.stopEvents() } // управление состоянием кнопок this.changeDisabled() } } changeDisabled() { if (this.stopBtn.disabled) { this.stopBtn.disabled = false this.startBtn.disabled = true } else { this.stopBtn.disabled = true this.startBtn.disabled = false } }//...
Сначала реализуем закрытие соединения:
stopEvents() { this.eventSource.close() // сообщаем о том, что соединение закрыто пользователем this.eventLog.textContent = 'Event stream closed by client.'}
Переходим к открытию потока событий:
startEvents() { // создаем экземпляр для получения данных по запросу на указанный адрес this.eventSource = new EventSource('http://localhost:3000/getUsers') // сообщаем о том, что соединение открыто this.eventLog.textContent = 'Accepting data from the server.' // обрабатываем получение от сервера идентификатора со значением -1 this.eventSource.addEventListener('message', e => { if (e.lastEventId === '-1') { // закрываем соединение this.eventSource.close() // сообщаем об этом this.eventLog.textContent = 'End of stream from the server.' this.changeDisabled() } // мы можем получить такой идентификатор лишь раз }, {once: true})}
Обрабатываем кастомное событие randomUser:
this.eventSource.addEventListener('randomUser', e => { // парсим полученные данные const userData = JSON.parse(e.data) // проверяем их console.log(userData) // извлекаем данные с помощью деструктуризации const { id, name, login, email, dob, picture } = userData // продолжаем формировать данные, необходимые для генерации пользовательской карточки const i = id.value const fullName = `${name.first} ${name.last}` const username = login.username const age = dob.age const img = picture.large const user = { id: i, name: fullName, username, email, age, img } // генерируем шаблон const template = getTemplate(user) // рендерим карточку на странице this.$.insertAdjacentHTML('beforeend', template)})
Не забываем реализовать обработку ошибок:
this.eventSource.addEventListener('error', e => { this.eventSource.close() this.eventLog.textContent = `Got an error: ${e}` this.changeDisabled()}, {once: true})
Наконец, инициализируем приложение:
const app = new App('main')
Полный код клиента:
const getTemplate = user => `<div class="card"> <div class="row"> <div class="col-md-4"> <img src="${user.img}" class="card-img" alt="user-photo"> </div> <div class="col-md-8"> <div class="card-body"> <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5> <p class="card-text">Name: ${user.name}</p> <p class="card-text">Username: ${user.username}</p> <p class="card-text">Email: ${user.email}</p> <p class="card-text">Age: ${user.age}</p> </div> </div> </div></div>`class App { constructor(selector) { this.$ = document.querySelector(selector) this.#init() } #init() { this.startBtn = this.$.querySelector('[data-type="start-btn"]') this.stopBtn = this.$.querySelector('[data-type="stop-btn"]') this.eventLog = this.$.querySelector('[data-type="event-log"]') this.clickHandler = this.clickHandler.bind(this) this.$.addEventListener('click', this.clickHandler) } clickHandler(e) { if (e.target.tagName === 'BUTTON') { const { type } = e.target.dataset if (type === 'start-btn') { this.startEvents() } else if (type === 'stop-btn') { this.stopEvents() } this.changeDisabled() } } changeDisabled() { if (this.stopBtn.disabled) { this.stopBtn.disabled = false this.startBtn.disabled = true } else { this.stopBtn.disabled = true this.startBtn.disabled = false } } startEvents() { this.eventSource = new EventSource('http://localhost:3000/getUsers') this.eventLog.textContent = 'Accepting data from the server.' this.eventSource.addEventListener('message', e => { if (e.lastEventId === '-1') { this.eventSource.close() this.eventLog.textContent = 'End of stream from the server.' this.changeDisabled() } }, {once: true}) this.eventSource.addEventListener('randomUser', e => { const userData = JSON.parse(e.data) console.log(userData) const { id, name, login, email, dob, picture } = userData const i = id.value const fullName = `${name.first} ${name.last}` const username = login.username const age = dob.age const img = picture.large const user = { id: i, name: fullName, username, email, age, img } const template = getTemplate(user) this.$.insertAdjacentHTML('beforeend', template) }) this.eventSource.addEventListener('error', e => { this.eventSource.close() this.eventLog.textContent = `Got an error: ${e}` this.changeDisabled() }, {once: true}) } stopEvents() { this.eventSource.close() this.eventLog.textContent = 'Event stream closed by client.' }}const app = new App('main')
На всякий случай перезапускаем сервер. Открываем
http://localhost:3000
. Нажимаем на кнопку Start:Начинаем получать данные от сервера и рендерить карточки пользователей.
Если нажать на кнопку Stop, отправка данных будет приостановлена:
Снова нажимаем Start, отправка данных продолжается.
При достижении лимита (10 пользователей) сервер отправляет идентификатор со значением -1 и закрывает соединение. Клиент, в свою очередь, также закрывает поток событий:
Как видите, SSE очень похож на веб-сокеты. Недостатком является однонаправленность сообщений: сообщения могут отправляться только сервером. Преимущество состоит в автоматическом переподключении и простоте реализации.
Поддержка данной технологии на сегодняшний день составляет 95%:
Надеюсь, статья вам понравилась. Благодарю за внимание.