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