Даже не знаю с чего начать, это моя первая статья и пишу я ее по причине того что мне не с кем обсудить ее содержимое. Для контекста добавлю, что я самоучка без работы.
Stateful Event Multiplexing Bus
Именно такое название мне дал чат гпт, когда я спросил его о моем подходе, и как он мне сообщил, то что я придумал, это уникально и (цитирую) «Годнота!». Но названия у всей этой истории нету, ибо я не силен в нейминге, но в коде она называется «MEctx». Можете предложить название, мб приживется...
Так кто же такой этот "MEctx"
Если описывать моими мыслями (а я не знаю теорию js), то получается следующее:
Общий хендлер событий — все ивенты обрабатываются в единственном обработчике
Количество ивентов — я подписываюсь только по ОДНОМУ разу на каждый тип ивента, и все они ведут в общий хендлер
строгое распространение ивентов — в общем хендлере хранится объект с ключом названия ивента, в котором лежат ивенты под необходимый режим работы
Режимы — в обработчике хранится переменная хранящая ключ по которому будут вызываться ивенты в их хранилищах
Плюсы данной архитектуры:
Единая точка входа ивентов — большой контроль + легкий дебаг
Режимы позволяют вызывать только требуемые в данный момент ивенты
Не вызывает ререндеры — в моей реализации общий хендлер хранится в глобальном скопе страницы (window), но можно спокойно перенести все в «useContext» и ничего не сломается
Обновление коллбеков — если нужно заменить ивент или убрать или добавить, то нужно просто обратиться в общий хендлер и сделать необходимую операцию с объектом по ключу названия ивента, это не вызовет ререндер
Минимальное взаимодействие с DOM деревом — так как в данной архитектуре мы 1 раз вешаем ивент и направляем его коллбеком в общий хендлер, то на первом рендере все махинации с деревом прекратятся
Возможность создать мидлвар для ивентов
Код
Внесу еще немного контекста, код был написал 11 месяцев назад и в данной реализации он заточен под взаимодействие с картой на базе "react-map-gl", но все можно спокойно переписать под любую задачу. Я не обязан дать вам готовый код, я всего лишь хочу показать вам такой подход.
import { Map, MapLibreEvent, MapMouseEvent } from "maplibre-gl";
import { MapCollection } from "react-map-gl/dist/esm/components/use-map";
// это строки префиксы названий слоев инструментов
import {
IMAGE_PREFIX_GHOST,
PIN_PREFIX,
POLYGON_PREFIX,
ROUTE_PREFIX,
} from "~/components/_store/geometry";
// это строка текущего выбранного инструмента карты
import { MAP_TOOL } from "~/components/_store/project";
// это строка префикс названия слоев датасетов
import { DATASET_PREFIX } from "../../../_store/datasets";
// ____ ___ ____________
// /\ '. / \ /\ ________\
// \ \ '. / \ \ \ \_______/
// \ \ \. './ /\ \ \ \ \_________ ________ ___ __ __
// \ \ \'. /\ \ \ \ \ ________\ | _____| _| |_ \ \ / /
// \ \ \.'._/ \ \ \ \ \ \_______/ | | |_ _| \ \/ /
// \ \ \'./ \ \ \ \ \ \_________ | | | | } {
// \ \__\ \ \__\ \ \___________\ | |____ | |_ / /\ \
// \/__/ \/__/ \/___________/ |______| |___| /_/ \_\
//
//
// MECtx
//
// created: 4.05.24
// successfully applied: 10.05.24
//
// example:
//
// useEffect(() => {
// let handleClick = (str) => {
// console.log(str)
// if (ME.tool) {
// ME.tool = null
// } else {
// ME.tool = "pin"
// }
// }
//
// ME.click.stock = (e) => handleClick("stock event") // call callback only when ME.tool == null
// ME.click.pin = (e) => handleClick("pin event") // call callback only when ME.tool == "pin"
//
// return () => {
// ME.click.stock = () => {}
// ME.click.pin = () => {}
// }
// }, [])
//
// ME can store a lot of callbacks, but always call only one
// MAP_TOOL это строки названия инструментов
type eventsList = MAP_TOOL | "stock" | "cs";
type handlersList =
| "contextmenu"
| "click"
| "mousedown"
| "mouseup"
| "mousemove"
| "mouseenter"
| "mouseleave"
| "mouseout"
| "mouseover"
| "drag"
| "dragend"
| "dragstart"
| "move"
| "moveend"
| "movestart"
| "zoom"
| "zoomend"
| "zoomstart";
export enum METoolModes {
None = 0,
Point = 1,
Fill = 2,
Line = 3,
Between = 4,
}
export type MapEvents = {
/**
* *DO NOT REDECLARE AFTER `inited` PROPERTY: `true`*
*
* shortcut for `click` and `contextmenu` events in `clickChecker` function
* @param e map event
* @param type event from `eventsList`
* @param rc `false` - LeftClick, `true` - RightClick
*/
handleClick: (e: MapMouseEvent, type: eventsList, rc: boolean) => void;
/**
* handles `MapMouseEvent<>`
* @param e map event
* @param handler event from `eventsList`
*/
handleEvent: (e: MapMouseEvent, handler: handlersList) => void;
/**
* handles `MapMouseEvent<MouseEvent | TouchEvent | undefined>`
* @param e map event
* @param handler event from `eventsList`
*/
handleMTEvent: (
e: MapLibreEvent<MouseEvent | TouchEvent | undefined>,
handler: handlersList,
) => void;
/**
* handles `MapMouseEvent<MouseEvent | TouchEvent | WheelEvent | undefined>`
* @param e map event
* @param handler event from `eventsList`
*/
handleMTWEvent: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
handler: handlersList,
) => void;
contextmenu: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
click: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mousedown: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mouseup: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mousemove: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mouseenter: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mouseleave: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mouseout: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
mouseover: {
[key in eventsList]?: (e: MapMouseEvent) => void;
};
drag: {
[key in eventsList]?: (e: MapLibreEvent<MouseEvent | TouchEvent | undefined>) => void;
};
dragend: {
[key in eventsList]?: (e: MapLibreEvent<MouseEvent | TouchEvent | undefined>) => void;
};
dragstart: {
[key in eventsList]?: (e: MapLibreEvent<MouseEvent | TouchEvent | undefined>) => void;
};
move: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
moveend: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
movestart: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
zoom: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
zoomend: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
zoomstart: {
[key in eventsList]?: (
e: MapLibreEvent<MouseEvent | TouchEvent | WheelEvent | undefined>,
) => void;
};
/**
* property for selecting events
*
* if tool equals `null` => call `Stock` callbacks
*
* if tool equals `some_tool` => call `some_tool` callbacks
*/
tool: MAP_TOOL | null;
/**
* RightClick - `true` value allows instrument to show Bbox
*/
rc: boolean;
/**
*
*/
toolMode: METoolModes;
map: {
current: Map & {
/**
* get real map object
*/
getMap: () => Map;
};
};
init: (Map: MapCollection<Map>) => void;
inited: boolean;
};
let runEvent = (e: any, handler: handlersList, ctx: MapEvents) => {
if (ctx.tool) {
if (ctx[handler][ctx.tool]) {
ctx[handler][ctx.tool]!(e);
}
} else {
if (!ctx.toolMode && ctx[handler]["stock"]) {
ctx[handler]["stock"]!(e);
}
}
};
// пример мидлвара
// функция для стоковых ивентов click и contextmenu
let clickChecker = function (e: MapMouseEvent, rc: boolean, ctx: MapEvents) {
// получаем слои под кликом
let featuresUnderClick = ctx.map.current.queryRenderedFeatures(e.point);
if (featuresUnderClick.length) {
let layer = featuresUnderClick[0]?.layer;
let id = layer?.id.split(":")[0];
switch (id) {
case DATASET_PREFIX:
ctx.handleClick(e, "dataset_info", rc);
break;
case POLYGON_PREFIX:
ctx.handleClick(e, "polygon", rc);
break;
case ROUTE_PREFIX:
ctx.handleClick(e, "route", rc);
break;
case PIN_PREFIX:
ctx.handleClick(e, "pin", rc);
break;
case IMAGE_PREFIX_GHOST:
ctx.handleClick(e, "image", rc);
break;
default: {
// ctx.setTool(null);
}
}
}
};
export const MEInitialGlobalObject: MapEvents = {
handleClick: function (e, type, rc) {
if (rc) {
if (this.contextmenu[type]) {
this.tool = type;
this.contextmenu[type](e);
}
} else {
if (this.click[type]) {
this.tool = type;
this.click[type](e);
}
}
},
handleEvent: function (e, handler) {
runEvent(e, handler, this);
},
handleMTEvent: function (e, handler) {
runEvent(e, handler, this);
},
handleMTWEvent: function (e, handler) {
runEvent(e, handler, this);
},
contextmenu: {},
click: {},
mousedown: {},
mouseup: {},
mousemove: {},
mouseenter: {},
mouseleave: {},
mouseout: {},
mouseover: {},
drag: {},
dragend: {},
dragstart: {},
move: {},
moveend: {},
movestart: {},
zoom: {},
zoomend: {},
zoomstart: {},
tool: null,
map: {
// @ts-ignore
current: null,
},
init: function (Map) {
this.map.current = Map.current;
if (!this.inited) {
this.inited = true;
this.click.stock = (e: MapMouseEvent) => {
clickChecker(e, false, this);
};
this.contextmenu.stock = (e: MapMouseEvent) => {
clickChecker(e, true, this);
};
}
},
inited: false,
};
Регистрация ивентов на карте выглядит так:
onClick={(e) => ME.handleEvent(e, "click")}
onContextMenu={(e) => ME.handleEvent(e, "contextmenu")}
onMouseDown={(e) => ME.handleEvent(e, "mousedown")}
onMouseUp={(e) => ME.handleEvent(e, "mouseup")}
onMouseMove={(e) => {
ME.handleEvent(e, "mousemove");
if (ME.inited && !ME.toolMode && ME.mousemove.cs) {
ME.mousemove.cs(e);
}
}}
onMouseEnter={(e) => ME.handleEvent(e, "mouseenter")}
onMouseLeave={(e) => ME.handleEvent(e, "mouseleave")}
onMouseOut={(e) => ME.handleEvent(e, "mouseout")}
onMouseOver={(e) => ME.handleEvent(e, "mouseover")}
onDrag={(e) => ME.handleMTEvent(e, "drag")}
onDragEnd={(e) => ME.handleMTEvent(e, "dragend")}
onDragStart={(e) => ME.handleMTEvent(e, "dragstart")}
onMove={(e) => ME.handleMTWEvent(e, "move")}
onMoveEnd={(e) => ME.handleMTWEvent(e, "moveend")}
onMoveStart={(e) => ME.handleMTWEvent(e, "movestart")}
onZoom={(e) => {
ME.handleMTWEvent(e, "zoom");
setPopupMaxWidth(getMaxWidthFromZoom());
}}
onZoomEnd={(e) => ME.handleMTWEvent(e, "zoomend")}
onZoomStart={(e) => ME.handleMTWEvent(e, "zoomstart")}
Регистрация происходит 1 раз и больше мы не мучаем бедную карту.
Базовое использование выглядит так:
useEffect(() => {
// проверяем необходимость обновить коллбек
if (tool == "pin" && drawmode) {
// создаем простую функцию как и всегда
let handleClick = (e: MapMouseEvent) => {
//
// логика
//
};
// вешаем ивент
ME.click.pin = (e) => handleClick(e);
return () => {
// удаляем по необходимости
ME.click.pin = (e) => () => {};
};
}
}, [...]);
То длинное полотно кода конечно по хорошему было бы разделить как в других статьях, но я приверженец простого копи-паст.
В общем, описываю жизненный цикл ивента в этой структуре:
На первом рендере - создаем обработчик, инициализируем "ME.init(Map)", и вешаем начальные ивенты там где нам необходимо, в моем случае на карте.
Вызов ивента — коллбеком вызываем общий хендлер и передаем оригинальный объект ивента
Анализ ивента — общий обработчик смотрит текущий режим работы, если ивент не поддерживается, игнорирует его, если ивент можно вызвать, но в текущем режиме нет такого слушателя, вызывается «stock» коллбек (это нечто вроде глобального коллбека, который вызывается только когда больше вызывать нечего)
Вызов мидлвара по необходимости
Вызов необходимого ивента
Получается так что мы можем создать сколько угодно каких угодно ивентов, и это не будет вызывать так же много нагрузки на браузер как простое вешание ивентов на все подряд так как ивенты в этой архитектуре - это просто функции в объекте.
Так же можно немного отредактировать код и сделать «режим» массивом строк, что позволит вызывать сразу несколько ивентов, хотя изначально браузер вызвал только один.
Наверное на этом все, надеюсь, я не придумал велосипед...
Комментарии (10)
cupraer
16.05.2025 20:09все ивенты обрабатываются в единственном обработчике
Это решение не выдержит нагрузку больше, чем домашняя страничка Васи Пупкина.
happy-mama Автор
16.05.2025 20:0980к элементов на карте прекрасно отрабатывали без единого подлагивания
cupraer
16.05.2025 20:09Отрабатывали что именно?
happy-mama Автор
16.05.2025 20:09Второй блок кода в статье.
Абсолютно все махинации на карте проходили через этот обработчик, то есть любой клик по элементу или перетаскивание элемента, создание нового, удаление конкретного и так далее, да хоть миллион элементов на карте будет, если у тебя всего 1 глобальный слушатель то буквально нечему лагать
winkyBrain
16.05.2025 20:09я не знаю теорию js
Зато придумал новую архитектуру ивентов. Но
Я не обязан дать вам готовый код, я всего лишь хочу показать вам такой подход
Удивительно) ещё и подход заточен под конкретную библиотеку react, которая создана для работы с картами - а у этого явно есть своя специфика.
Базовое использование выглядит так:
useEffect(() => { // проверяем необходимость обновить коллбек if (tool == "pin" && drawmode) { // создаем простую функцию как и всегда let handleClick = (e: MapMouseEvent) => { // // логика // }; // вешаем ивент ME.click.pin = (e) => handleClick(e); return () => { // удаляем по необходимости ME.click.pin = (e) => () => {}; }; } }, [...]);
Выглядит ужасно. То есть каждый раз, когда нужно повесить новый хедлер на onClick по конкретному элементу, мы вместо этого устанавливаем какой-то глобальный onClick, который будет меняться вот так в каждом useEffect?
На самом деле уже давно придумали такую штуку, как делегирование событий. Когда например вместо того, чтобы вешать 10 хедлеров на 10 одинаковых по функционалу дочерних элементов, мы вешаем 1 хендлер на родителя, вытащив id потомка(или что нам нужно для успешной обработки события) через event.target. Вам возможно именно этого и не хватало? В любом случае незнание базы языка вряд ли может привести к каким-то действительно серьёзным открытиям/новым архитектурам. Скорее наоборот, в данном случае второго буквально вытекает из первого, причём вытекает сомнительного качества
happy-mama Автор
16.05.2025 20:09А зачем вешать ивент на конкретный элемент? как раз от этого я и бежал когда выдумывал эту архитектуру. Вешаешь 1 хендлер для текущего режима управления пользователя и логикой внутри хендлера уже и определяешь действие которое нужно совершить. Этот код себя прекрасно показал на датасете с 80к кустиков в париже, обрабатывая все 1 ивентом.
В том то и прикол этой архитектуры, что если у тебя есть некая задача где тебе нужно часто дергать туда сюда ивенты на большом количестве элементов, то ты не вешаешь по хендлеру на каждый элемент, а слушаешь глобально все элементы и выполняешь одну и ту же логику для всех сразу
VanKrock
16.05.2025 20:09Возможно это специфика js, что всё выглядит странно, но сама задача и подход в её решению, выглядит так, как будто вы хотите применить паттерн медиатор, в js я беглым поиском не нашёл ничего толкового по этой теме, но в С# есть такая библиотечка как MediatR и всё выглядит так, что вы хотите применить нечно подобное в js
Tishka17
16.05.2025 20:09Мне кажется Mediatr называть реализацией паттерна медиатор не вполне корректно. Это все таки центральная шина, которая требует отправитель затачивать под нее и делать встраиваемые обработчики. В то время как классический медиатор просто про наличие источников событий и использование медиатора как их обработчика, делающего полезную работу.
Riim
16.05.2025 20:09Вроде как обычное делегирование событий. Идее одного обработчика на весь документ то же сто лет уже. Ничего нового не рассмотрел.
eliminator123
мне кажется самое интересное - это декларативная разметка отправляемых событий. Это позволило бы условно говоря "программировать мышкой".