Повний код з матеріалу можна знайти за посиланням.
Почнемо з визначень.
Вебсокети
Вебсокети дозволяють клієнтському браузеру та серверу обмінюватись інформацією в реальному часі. На відміну від HTTP, клієнт отримує дані не лише після запиту на сервер, а й сервер сам може надсилати дані клієнту.

Проблема
Щоб описаний механізм працював, клієнт має відкрити з'єднання з сервером та тримати його активним, поки не закриється вкладка чи не зникне інтернет. Тобто утворюється постійне з'єднання. Така передача даних зберігає стан, тобто і клієнт, і сервер зберігають хоча б якісь дані в пам'яті на WebSocket-сервері для кожного відкритого клієнтського з'єднання.
Якщо клієнт відкриває 15 вкладок, у нього буде 15 відкритих з'єднань із сервером.
Тож з'ясуємо, як ми можемо зменшити навантаження в таких випадках.

WebWorkers
, SharedWorkers
та BroadcastChannels
приходять на допомогу
Вебворкери — просте рішення для вебконтенту під час запуску скриптів у фонових потоках. Потік воркера може виконувати завдання, не перериваючи користувацький інтерфейс. Сформований один раз воркер може надсилати повідомлення JavaScript-коду, з якого і був утворений. Повідомлення перехоплюють спеціальні обробники.
Розділювані воркери — тип вебворкерів, до яких можна отримати доступ з декількох контекстів браузера, тобто вікон, iframe або навіть воркерів подій.
Широкомовні канали дозволяють простий обмін даними між контекстами браузера (вікнами, вкладками, фреймами чи iframes) одного походження.
Всі наведені визначення з MDN.
Зменшуємо навантаження на сервер за допомогою розділюваних воркерів
Коли один клієнт має декілька з'єднань, відкритих одним браузером, на допомогу приходять розділювані воркери. Замість відкривати з'єднання з кожної вкладки/браузера, ми можемо використати SharedWorker для з'єднання з сервером.
Це з'єднання буде відкритим, поки всі вкладки з сайтом не закриються. Всі відкриті вкладки можуть використовувати єдине з'єднання для обміну даними з сервером.
Ми використаємо API широкомовних каналів для трансляції зміни стану вебсокета всім контекстам (вкладкам).
Налаштування базового Web Socket сервера
Перейдемо до коду. Для наочності ми налаштуємо дуже простий вебсервер, який підтримуватиме socket-з'єднання за допомогою npm-модуля ws
. Ініціалізуємо npm-проєкт, використовуючи:
$ npm init
Одразу як отримаєте файл package.json
, додайте ws
-модуль та express
для створення базового http-сервера:
$ npm install --save ws express
Далі створіть файл index.js
з таким кодом, щоб налаштувати статичний сервер, який обслуговує файли з директорії public
, використовуючи порт 3000, а для ws
-сервера — порт 3001.
const express = require("express");
const path = require("path");
const WebSocket = require("ws");
const app = express();
// Використовуємо директорію public для запитів статичних файлів
app.use(express.static("public"));
// Запускаємо WS-сервер на порту 3001
const wss = new WebSocket.Server({ port: 3001 });
wss.on("connection", ws => {
console.log('A new client connected!');
ws.on("message", data => {
console.log(`Message from client: ${data}`);
// Модифікуємо вхідні дані та відправляємо
const parsed = JSON.parse(data);
ws.send(
JSON.stringify({
...parsed.data,
// Додаткове поле field встановлюється сервером
// Розглянемо цей момент детальніше далі
messageFromServer: `Hello tab id: ${parsed.data.from}`
})
);
});
ws.on("close", () => {
console.log("Sad to see you go :(");
});
});
// Відстежуємо запити статичних сторінок на порту 3000
const server = app.listen(3000, function() {
console.log("The server is running on http://localhost:" + 3000);
});
Створюємо SharedWorker
Аби створити будь-який вид воркера в JavaScript, необхідно створити окремий файл, котрий визначає його обов'язки.
Всередині цього файлу треба визначити, що робити з воркером, коли його проініціалізовано. Цей код виконається лише раз, коли SharedWorker запуститься вперше. Доки ми не закриємо останню вкладку, з'єднану з воркером, ініціалізація не відбудеться повторно.
Ми можемо визначити обробника події onconnect
, аби обробити процес з'єднання кожної вкладки до SharedWorker. Розглянемо файл worker.js
:
// Відкриваємо з'єднання. Це спільне з'єднання
//Тому буде відкритим лише один раз
const ws = new WebSocket("ws://localhost:3001");
// Створюємо широкомовний канал, аби сповіщати про зміни стану
const broadcastChannel = new BroadcastChannel("WebSocketChannel");
// Робимо мапінг, щоб відстежувати порти. Ви можете розглядати порти
// як посередників, через які ми можемо спілкуватись з вкладками
// Тут ми мапимо uuid кожного контексту (вкладки)
// до його порту. Ми вимушені робити так, адже Port API
// не має ідентифікатора, щоб визначити, звідки приходить повідомлення
const idToPortMap = {};
// Дамо знати всім з'єднаним контекстам (вкладкам) про зміни стану
ws.onopen = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });
ws.onclose = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });
// Коли ми отримуємо дані з сервера
ws.onmessage = ({ data }) => {
console.log(data);
// Створюємо об'єкти, аби передати їх обробникам
const parsedData = { data: JSON.parse(data), type: "message" }
if (!parsedData.data.from) {
// Транслюємо всім контекстам (вкладкам). Усе тому, що
// не було встановлено певного id полю from.
// Ми використовуємо це поле, аби визначити, яка вкладка надсилає повідомлення
broadcastChannel.postMessage(parsedData);
} else {
// Отримуємо порт, куди відправлятимемо повідомлення, використовуючи uuid
// тобто до певної вкладки
idToPortMap[parsedData.data.from].postMessage(parsedData);
}
};
// Обробник події викликається, коли вкладка намагається з'єднатися з воркером
onconnect = e => {
// Отримуємо MessagePort з об'єкта event. Він буде каналом зв'язку між
// SharedWorker та вкладкою
const port = e.ports[0];
port.onmessage = msg => {
// Розміщуємо інформацію про port у map
idToPortMap[msg.data.from] = port;
// Перенаправляємо це повідомлення до ws-з'єднання.
ws.send(JSON.stringify({ data: msg.data }));
};
// Тепер треба сповістити щойно створений контекст про поточний стан
// WS-з'єднання.
port.postMessage({ state: ws.readyState, type: "WSState"});
};
У фрагменті коду вище деякі речі могли здатися вам не дуже очевидними на початку. Роз'яснимо їх покроково:
- Ми використовуємо Broadcast Channel API, аби транслювати зміни стану з сокета;
- Ми використовуємо метод
postMessage
, аби встановити початковий стан контексту (вкладки); - Ми використовуємо поле
from
, яке передається вкладками (контекстами), щоб з'ясувати куди перенаправляти відповідь. - Якщо ж такого поля немає, ми транслюємо повідомлення всім з'єднаним контекстам.
Зверніть увагу: console.log
тут не показуватимуть результат в консолі браузера. Для цього необхідно відкрити консоль SharedWorker. Аби відкрити інструменти розробника для SharedWorker, перейдіть на chrome://inspect
.
Звертаємось до SharedWorker
Спочатку створимо HTML-сторінку, куди додамо скрипти для зв'язку з розділюваним воркером.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Web Sockets</title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/node-uuid/1.4.8/uuid.min.js"></script>
<script src="main.js"></script>
</body>
</html>
Отже, ми визначили наш воркер у файлі worker.js
та створили HTML-сторінку. Розглянемо, як ми можемо використати спільне вебсокет-з'єднання з будь-якого контексту (вкладки). Створимо файл main.js
з таким вмістом:
// Створюємо екземпляр SharedWorker, використовуючи файл worker.js.
// Нам потрібно, щоб він був в усіх JS-файлах, які матимуть доступ до сокета
const worker = new SharedWorker("worker.js");
// Створюємо унікальний ідентифікатор, використовуючи бібліотеку uuid.
// Так ми зможемо визначити вкладку, з якої було відправлено повідомлення
// А якщо відповідь надсилається з сервера на цю вкладку, ми можемо
// перенаправити її, використавши вже згаданий ідентифікатор.
const id = uuid.v4();
// Встановлюємо початковий стан вебсокета як з'єднання.
// Модифікуватимемо його залежно від подій
let webSocketState = WebSocket.CONNECTING;
console.log(`Initializing the web worker for user: ${id}`);
// З'єднуємось з shared worker
worker.port.start();
// Встановлюємо слухача подій, який або встановлює стан вебсокета
// Або обробляє дані, які приходять лише з поточної вкладки
worker.port.onmessage = event => {
switch (event.data.type) {
case "WSState":
webSocketState = event.data.state;
break;
case "message":
handleMessageFromPort(event.data);
break;
}
};
// Налаштовуємо широкомовний канал на прослуховування подій вебсокета.
// Код тут подібний до обробника вище.
// Але тепер обробник слухає події всіх вкладок.
const broadcastChannel = new BroadcastChannel("WebSocketChannel");
broadcastChannel.addEventListener("message", event => {
switch (event.data.type) {
case "WSState":
webSocketState = event.data.state;
break;
case "message":
handleBroadcast(event.data);
break;
}
});
// Обробляємо широкомовні повідомлення сервера
function handleBroadcast(data) {
console.log("This message is meant for everyone!");
console.log(data);
}
// Обробляємо події, призначені лише для визначеної вкладки
function handleMessageFromPort(data) {
console.log(`This message is meant only for user with id: ${id}`);
console.log(data);
}
// Використовуємо цей метод, аби відправити дані на сервер
function postMessageToWSServer(input) {
if (webSocketState === WebSocket.CONNECTING) {
console.log("Still connecting to the server, try again later!");
} else if (
webSocketState === WebSocket.CLOSING ||
webSocketState === WebSocket.CLOSED
) {
console.log("Connection Closed!");
} else {
worker.port.postMessage({
// Додаємо інформацію про відправника як uuid, щоб отримати відповідь назад
from: id,
data: input
});
}
}
// Відправляємо повідомлення серверу після приблизно 2,5 секунд.
// Цього часу достатньо, аби було створено сокет-з'єднання.
setTimeout(() => postMessageToWSServer("Initial message"), 2500);```
Надсилання повідомлень SharedWorker
Як ми бачили вище, надіслати повідомлення можна за допомогою метода worker.port.postMessage()
. Ви можете передати будь-який JS-об'єкт, масив, примітив.
Вдалим рішенням вважається передача об'єкта, що визначає, з якого контексту прийшло повідомлення, тож воркер може здійснити відповідні дії. Наприклад, якби у нас був чат і на одній з вкладок ми хотіли б надіслати повідомлення, то могли б використовувати подібний об'єкт:
{
type: 'message',
from: 'Tab1'
value: {
text: 'Hello',
createdAt: new Date()
}
}
Якби у нас був застосунок для обміну файлами, то при видаленні файлу, надсилаємо ту саму структуру, проте з іншим типом та значеннями:
{
type: 'deleteFile',
from: 'Tab2'
value: {
fileName: 'a.txt',
deletedBy: 'testUser'
}
}
Так воркер знатиме, що саме робити з даними.
Відстежуємо повідомлення воркера
На початку ми створили вкладку, щоб відстежувати MessagePorts
різних вкладок. Тоді ми визначили обробник worker.port.onmessage
для подій одразу з SharedWorker
до вкладки.
Коли сервер не встановлює поле from
, ми просто транслюємо повідомлення всім вкладкам, використовуючи широкомовний канал. Всі вкладки матимуть слухача повідомлень для WebSocketChannel
, який оброблятиме усі трансляції повідомлень.
Подібні налаштування можуть використовуватись за двох сценаріїв:
- Якщо ви граєте в гру на певній вкладці. Ви хочете, щоб повідомлення приходили лише на активну вкладку. Іншим вкладкам не потрібна буде ця інформація. Для такого випадку ви можете використовувати перший об'єкт;
- Якщо ж ви граєте в гру на фейсбуці й отримали повідомлення, ця інформація має поширитись на всі вкладки, оскільки індикатор повідомлень повинен змінитись.
Остаточна діаграма
Отже, ми використали розділювані воркери для оптимізації вебсокетів. Поглянемо на фінальну послідовність процесів:

Ще немає коментарів