Доброго времени суток, друзья!
В этом туториале мы рассмотрим Server Sent Events: встроенный класс EventSource, который позволяет поддерживать соединение с сервером и получать от него события.
О том, что такое SSE и для чего он используется можно почитать здесь.
Что конкретно мы будем делать?
Мы напишем простой сервер, который будет по запросу клиента отправлять ему данные 10 случайных пользователей, а клиент с помощью этих данных будет формировать карточки пользователей.
Сервер будет реализован на Node.js, клиент — на JavaScript. Для стилизации будет использоваться Bootstrap, в качестве API — Random User Generator.
Код проекта находится здесь.
Поиграть с кодом можно здесь.
Если вам это интересно, прошу следовать за мной.
Подготовка
Создаем директорию
sse-tut
:mkdir sse-tut
Заходим в нее и инициализируем проект:
cd sse-tut
yarn 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="https://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 || 3000
const getUserData = async () => {
const response = await axios.get('https://randomuser.me/api')
return response.data.results[0]
}
let i = 1
const 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%:
Надеюсь, статья вам понравилась. Благодарю за внимание.
mayorovp
И в чём же отличие от https://habr.com/ru/post/519980/?
iit
Эмм в том что в отличии от него это пока не закрыто НЛО или автором?
mayorovp
На момент написания комментария оба поста были открыты. Но мне почему-то казалось, что автор спрячет именно этот...
iit
Только сейчас заметил что автор один и тот-же.
Это было А/Б тестирование? )))
aio350 Автор
Дублирование произошло случайно