Разделение состояния между окнами без сервера

Перевод статьи «Sharing a state between windows without a server».

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

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

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

По сути, нужно разделить состояние между несколькими окнами, что, на мой взгляд, является одним из самых крутых аспектов проекта Бьорна!

Не найдя хорошей статьи или руководства по этой теме, я решил поделиться с вами своими находками.

Давайте попробуем создать упрощенный проект на основе работы Бьорна!

Четыре окна, в каждом по окружности. Эти окружности соединены отрезками. При перемещении окон отрезки смещаются, чтобы связь между ними сохранялась.

Обмен информацией между окнами

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

Сервер

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

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

Локальное хранилище (Local Storage) — это, по сути, хранилище ключевых значений браузера, обычно используемое для сохранения информации между сессиями. Стандартно его используют для хранения Auth-токенов или URL-адресов перенаправления, но в нем можно хранить все, что поддается сериализации. Подробнее можно почитать на MDN.

От редакции Techrocks: у нас тоже есть статья на эту тему — «LocalStorage — локальное хранилище в JavaScript».

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

Схема использования локального хранилища

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

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

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

Разделяемые воркеры

WebWorkers — интересная концепция.

Говоря простым языком, worker (рабочий, воркер) — это, по сути, второй скрипт, выполняющийся в другом потоке. У него нет доступа к DOM, поскольку он существует вне HTML-документа, но он все равно может взаимодействовать с вашим основным скриптом.

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

Схема работы воркера

Разделяемые воркеры (shared workers) — это особый вид WebWorkers. Они могут взаимодействовать с несколькими экземплярами одного и того же скрипта, что делает их интересными для нашего случая использования.

Схема работы разделяемых воркеров

Итак, давайте погрузимся в код!

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

Как уже говорилось, воркеры — это «второй скрипт» со своими собственными точками входа. В зависимости от вашей конфигурации (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, поскольку они сообщают нам, где находится окно относительно левого верхнего угла монитора.

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

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

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

// 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) {
        // old one changed
        windows[oldWindowIndex].windowState = newWindow;
      } else {
        // new window 
        windows.push({ id, windowState: newWindow, port });
      }
      windows.forEach((w) =>
        // send sync here 
      );
      break;
    }
  }
};

Чтобы отправить синхронизацию, мне понадобился небольшой трюк. Дело в том, что свойство 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);

Весь код вы можете найти в этом репозитории. Я сделал его немного более абстрактным, поскольку много экспериментировал с ним, но суть его та же. И если вы запустите его на нескольких окнах, надеюсь, вы получите то же, что и здесь!

Спасибо за чтение!

[customscript]techrocks_custom_after_post_html[/customscript]

[customscript]techrocks_custom_script[/customscript]

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх