Даже не знаю с чего начать, это моя первая статья и пишу я ее по причине того что мне не с кем обсудить ее содержимое. Для контекста добавлю, что я самоучка без работы.
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» коллбек (это нечто вроде глобального коллбека, который вызывается только когда больше вызывать нечего)
-
Вызов мидлвара по необходимости
-
Вызов необходимого ивента
Получается так что мы можем создать сколько угодно каких угодно ивентов, и это не будет вызывать так же много нагрузки на браузер как простое вешание ивентов на все подряд так как ивенты в этой архитектуре - это просто функции в объекте.
Так же можно немного отредактировать код и сделать «режим» массивом строк, что позволит вызывать сразу несколько ивентов, хотя изначально браузер вызвал только один.
Наверное на этом все, надеюсь, я не придумал велосипед...
Автор: happy-mama
