Совместное использование состояний между окнами без задействования сервера

Недавно в социальных сетях появилась гифка, на которой изображено удивительное произведение искусства, созданное Бьорном Стаалом.

Произведение Бьорна Стаала

Я хотел повторить его, но, не имея навыков работы с 3D для создания сферы, частиц и физических взаимодействий, пытался понять, как заставить окно реагировать на положение другого окна.

По сути, стоило разделить состояние между несколькими окнами. На мой взгляд, в этом заключалась одна из самых крутых “фишек” проекта Бьорна! Не найдя хорошей статьи или туториала по этой теме, я решил поделиться своими находками.

Попробуем создать упрощенную проверку концепции (POC) на основе работы Бьорна!

Вот что мы попытаемся создать (конечно, это менее привлекательно, чем творение Бьорна)

Первое, что я сделал, это вспомнил все известные мне способы обмена информацией между несколькими клиентами.


Сервер

Понятно, что наличие сервера (либо с опросом (поллингом), либо с веб-сокетами) упростило бы проблему. Однако, поскольку Бьорн добился результата без использования сервера, об этом не могло быть и речи.

Локальное хранилище

Локальное хранилище  —  это хранилище браузера для пар “ключ-значение”. Как правило, оно используется для сохранения информации между сессиями браузера. Хотя обычно оно применяется для хранения токенов авторизации или URL-адресов перенаправления, в нем можно хранить все, что поддается сериализации.

Недавно я открыл для себя интересные API локального хранилища, включая событие storage, которое срабатывает, когда локальное хранилище изменяется другой сессией того же сайта.

Так работает событие storage (разумеется, это упрощенная версия)

Мы можем использовать эту схему, сохраняя состояние каждого окна в локальном хранилище. Когда окно изменит свое состояние, другие окна будут обновляться посредством события storage.

В этом состояла моя первоначальная идея, и, похоже, именно такое решение выбрал Бьорн, поскольку он поделился своим кодом LocalStorage Manager вместе с примером использования с ThreeJs здесь.

Но как только я узнал, что есть код, решающий эту задачу, я захотел проверить, есть ли другой способ… и (внимание, спойлер!) да, он есть!

Общие воркеры

За броским термином “веб-воркеры” скрывается интересная концепция.

Говоря простым языком, воркер  —  это второй скрипт, выполняющийся в другом потоке. Хотя у воркеров нет доступа к DOM, поскольку они существуют вне HTML-документа, они все равно могут взаимодействовать с основным скриптом.

В основном они используются для разгрузки основного скрипта, выполняя фоновые задания, такие как предварительная выборка информации или обработка менее важных задач, таких как журналы потоков и опросы.

Упрощенное объяснение механизмов взаимодействия между скриптом и воркером

Общие воркеры  —  это особый вид веб-воркеров. Они могут взаимодействовать с несколькими экземплярами одного и того же скрипта, и поэтому представляют интерес в нашем случае! Итак, погрузимся в код!

Общие воркеры могут отправлять информацию в несколько сессий одного и того же скрипта

Настройка воркера

Как уже говорилось, воркер  —  это “второй скрипт” со своими точками входа. В зависимости от конфигурации (TypeScript, бандлер, сервер разработки), вам может потребоваться настроить tsconfig, добавить директивы или использовать особый синтаксис импорта.

Не могу здесь описать все возможные способы использования веб-воркера, но вы можете найти информацию об этом на платформе MDN или в интернете.

В моем случае я использую Vite и TypeScript, поэтому мне нужен файл worker.ts. Я также устанавливаю @types/sharedworker в качестве dev-зависимости. Мы можем создать соединение в основном скрипте, используя следующий синтаксис:

new SharedWorker(new URL("worker.ts", import.meta.url));

Итак, нам нужно:

  • идентифицировать каждое окно;
  • отслеживать все состояния окна;
  • предупреждать другие окна о необходимости перерисовки, когда окно меняет свое состояние.

Состояние будет довольно простым:

type WindowState = {
screenX: number; // window.screenX
screenY: number; // window.screenY
width: number; // window.innerWidth
height: number; // window.innerHeight
};

Самая важная информация содержится, конечно, в window.screenX и window.screenY, поскольку они указывают, где находится окно относительно левого верхнего угла монитора.

У нас будет два типа сообщений:

  • Каждое окно при изменении состояния будет публиковать сообщение windowStateChanged с новым состоянием.
  • Воркер будет отправлять обновления всем остальным окнам, чтобы предупредить их о том, что одно из них изменилось. Воркер отправит сообщение sync с состоянием всех окон.

Начнем с простого воркера, который выглядит примерно так:

// worker.ts 
let windows: { windowState: WindowState; id: number; port: MessagePort }[] = [];

onconnect = ({ ports }) => {
const port = ports[0];

port.onmessage = function (event: MessageEvent<WorkerMessage>) {
console.log("We'll do something");
};
};

Базовое подключение к SharedWorker будет выглядеть приблизительно так, как показано. У меня есть несколько базовых функций, которые сгенерируют идентификатор и вычислят текущее состояние окна, а также я немного поработал над типом сообщения, которое мы можем использовать (оно называется WorkerMessage):

// main.ts
import { WorkerMessage } from "./types";
import {
generateId,
getCurrentWindowState,
} from "./windowState";

const sharedWorker = new SharedWorker(new URL("worker.ts", import.meta.url));
let currentWindow = getCurrentWindowState();
let id = generateId();

При запуске приложения мы должны предупредить воркера о появлении нового окна, поэтому сразу же отправляем сообщение:

// main.ts 
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow: currentWindow,
},
} satisfies WorkerMessage);

Мы можем прослушать это сообщение на стороне воркера и соответствующим образом изменить onmessage. Как только воркер получает сообщение windowStateChanged, либо появляется новое окно, и мы добавляем его к состоянию, либо изменяется старое окно. Тогда мы должны оповестить всех о том, что состояние изменилось:

// worker.ts
port.onmessage = function (event: MessageEvent<WorkerMessage>) {
const msg = event.data;
switch (msg.action) {
case "windowStateChanged": {
const { id, newWindow } = msg.payload;
const oldWindowIndex = windows.findIndex((w) => w.id === id);
if (oldWindowIndex !== -1) {
// старое окно изменилось
windows[oldWindowIndex].windowState = newWindow;
} else {
// новое окно
windows.push({ id, windowState: newWindow, port });
}
windows.forEach((w) =>
// отправить сюда sync
);
break;
}
}
};

Чтобы отправить sync, мне нужно немного поработать, потому что свойство “port” не может быть сериализовано. Поэтому я перевожу его в строковый формат и делаю обратный парсинг. Я ленив, так что не отображаю окна в более сериализуемом массиве:

w.port.postMessage({
action: "sync",
payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
} satisfies WorkerMessage);

Пришло время рисовать!

Самая веселая часть работы: рисование

Разумеется, мы не будем создавать сложные 3D-сферы. Просто нарисуем круг в центре каждого окна и линию, соединяющую сферы.

Для рисования я буду использовать базовый 2D Context HTML Canvas, но вы можете выбрать что-либо другое. Нарисовать круг очень просто:

const drawCenterCircle = (ctx: CanvasRenderingContext2D, center: Coordinates) => {
const { x, y } = center;
ctx.strokeStyle = "#eeeeee";
ctx.lineWidth = 10;
ctx.beginPath();
ctx.arc(x, y, 100, 0, Math.PI * 2, false);
ctx.stroke();
ctx.closePath();
};

Чтобы нарисовать линии, нужно выполнить некоторые математические действия, преобразовав относительное положение центра другого окна в координаты текущего окна.

Мы меняем базы. Для этого используем следующий математический прием. Сначала изменим базу, чтобы на мониторе появились координаты, и произведем смещение на текущее окно screenX/screenY.

Ищем целевую позицию (target position) после изменения базы
const baseChange = ({
currentWindowOffset,
targetWindowOffset,
targetPosition,
}: {
currentWindowOffset: Coordinates;
targetWindowOffset: Coordinates;
targetPosition: Coordinates;
}) => {
const monitorCoordinate = {
x: targetPosition.x + targetWindowOffset.x,
y: targetPosition.y + targetWindowOffset.y,
};

const currentWindowCoordinate = {
x: monitorCoordinate.x - currentWindowOffset.x,
y: monitorCoordinate.y - currentWindowOffset.y,
};

return currentWindowCoordinate;
};

Как вы знаете, теперь у нас есть две точки в одной относительной системе координат. Можем провести линию.

const drawConnectingLine = ({
ctx,
hostWindow,
targetWindow,
}: {
ctx: CanvasRenderingContext2D;
hostWindow: WindowState;
targetWindow: WindowState;
}) => {
ctx.strokeStyle = "#ff0000";
ctx.lineCap = "round";
const currentWindowOffset: Coordinates = {
x: hostWindow.screenX,
y: hostWindow.screenY,
};
const targetWindowOffset: Coordinates = {
x: targetWindow.screenX,
y: targetWindow.screenY,
};

const origin = getWindowCenter(hostWindow);
const target = getWindowCenter(targetWindow);

const targetWithBaseChange = baseChange({
currentWindowOffset,
targetWindowOffset,
targetPosition: target,
});

ctx.strokeStyle = "#ff0000";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.lineTo(targetWithBaseChange.x, targetWithBaseChange.y);
ctx.stroke();
ctx.closePath();
};

Теперь остается только реагировать на изменения состояния.

// main.ts
sharedWorker.port.onmessage = (event: MessageEvent<WorkerMessage>) => {
const msg = event.data;
switch (msg.action) {
case "sync": {
const windows = msg.payload.allWindows;
ctx.reset();
drawMainCircle(ctx, center);
windows
.forEach(({ windowState: targetWindow }) => {
drawConnectingLine({
ctx,
hostWindow: currentWindow,
targetWindow,
});
});
}
}
};

И последнее: нам нужно периодически проверять, не изменилось ли окно, и отправлять сообщение, если это так:

setInterval(() => {
const newWindow = getCurrentWindowState();
if (
didWindowChange({
newWindow,
oldWindow: currentWindow,
})
) {
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow,
},
} satisfies WorkerMessage);
currentWindow = newWindow;
}
}, 100);

Вы можете найти весь код в этом репозитории. Я внес туда кое-какие абстракции, поскольку много экспериментировал с ним, но суть осталась прежней.

Если вы запустите его на нескольких окнах, надеюсь, получите тот же результат, что и на гифке ниже!

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи notachraf: Sharing a state between windows without a server

Предыдущая статья21 лайфхак для новичков в JavaScript
Следующая статьяРазработка масштабируемых фронтендов с помощью Feature-Sliced Design