Недавно в социальных сетях появилась гифка, на которой изображено удивительное произведение искусства, созданное Бьорном Стаалом.
Я хотел повторить его, но, не имея навыков работы с 3D для создания сферы, частиц и физических взаимодействий, пытался понять, как заставить окно реагировать на положение другого окна.
По сути, стоило разделить состояние между несколькими окнами. На мой взгляд, в этом заключалась одна из самых крутых “фишек” проекта Бьорна! Не найдя хорошей статьи или туториала по этой теме, я решил поделиться своими находками.
Попробуем создать упрощенную проверку концепции (POC) на основе работы Бьорна!
Первое, что я сделал, это вспомнил все известные мне способы обмена информацией между несколькими клиентами.
Сервер
Понятно, что наличие сервера (либо с опросом (поллингом), либо с веб-сокетами) упростило бы проблему. Однако, поскольку Бьорн добился результата без использования сервера, об этом не могло быть и речи.
Локальное хранилище
Локальное хранилище — это хранилище браузера для пар “ключ-значение”. Как правило, оно используется для сохранения информации между сессиями браузера. Хотя обычно оно применяется для хранения токенов авторизации или URL-адресов перенаправления, в нем можно хранить все, что поддается сериализации.
Недавно я открыл для себя интересные API локального хранилища, включая событие 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
.
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);
Вы можете найти весь код в этом репозитории. Я внес туда кое-какие абстракции, поскольку много экспериментировал с ним, но суть осталась прежней.
Если вы запустите его на нескольких окнах, надеюсь, получите тот же результат, что и на гифке ниже!
Читайте также:
- Размеченные объединения в TypeScript
- Утилиты, которые повышают эффективность приложения React на Typescript
- Знакомство с SurrealDB с помощью Express.js, Node.js и TypeScript
Читайте нас в Telegram, VK и Дзен
Перевод статьи notachraf: Sharing a state between windows without a server