javascript

Кажется, я придумал новую архитектуру ивентов и мне она нравится

  • суббота, 17 мая 2025 г. в 00:00:03
https://habr.com/ru/articles/910100/

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

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» коллбек (это нечто вроде глобального коллбека, который вызывается только когда больше вызывать нечего)

  • Вызов мидлвара по необходимости

  • Вызов необходимого ивента

Получается так что мы можем создать сколько угодно каких угодно ивентов, и это не будет вызывать так же много нагрузки на браузер как простое вешание ивентов на все подряд так как ивенты в этой архитектуре - это просто функции в объекте.

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

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