• Событийная архитектура веб-приложения

    Одной из самых плохо расширяемых частей любого веб-приложения является его клиентский код, как правило, написанный на javascript. Во многих проектах он представляет собой джунгли из функций, принимающих коллбеки — и это в лучшем случае. Многие склонны винить в таком положении дел непосредственно сам язык, припоминая его «низкое» происхождение, странное поведение и отсутствие синтаксического сахара. Несомненно, в этом есть своя правда. Но я полагаю, что основная причина такой запутанности заключается в том, что построить для взаимодействия с интерфейсом стройную и расширяемую архитектуру, руководствуясь только принципами императивного программирования — невозможно. И хотя модель реализации событий в браузере сама подводит к идее организации кода декларативно, почему-то немногие на это отваживаются.

    Приведу пример простейшего API корзины интернет-магазина, а для событий используем уже имеющуюся «шину» — элемент body.

    function add(item_id, quantity) {
      $.ajax({
        type: 'POST',
        url: '/api/cart/add',
        dataType: 'json',
        data: { item_id: item_id, quantity: quantity },
      }).done(function (data) {
        $('body').trigger('QuantityChanged', [data.item_id, data.quantity]);
        $('body').trigger('PriceChanged', [data.item_id, data.sum]);
      });
    }

    Теперь после добавления товара отправляется два события: изменилось количество, изменилась цена. Можно было бы реализовать это и одним событием с большим количеством параметров, но так лучше не делать: если слушателю события интересно только изменение цены, нет нужды передавать ему и все остальные тридцать три параметра. Это повышает шанс ошибки и ведет к неразберихе в коде. События должны быть предельно атомарными, чтобы и слушателей можно было делать небольшими, выполняющими лишь одну роль — но хорошо. Можно также расширить количество событий, добавив, например, своё событие для ошибки, начала запроса и т.д.

    /**
     * Обновить количество товара в корзине
     */
    $('body').on('QuantityChanged', function (e, item_id, quantity) {
      $('[data-item-quantity="' + item_id + '"]').html(quantity);
    });
    
    /**
     * Обновить цену товара в корзине
     */
    $('body').on('PriceChanged', function (e, item_id, sum) {
      $('[data-item-sum="' + item_id + '"]').html(sum);
    });

    Никто теперь не мешает нам расширить систему, добавив на какой-то отдельной странице дополнительное действие при добавлении товара в корзину. И это всё без изменения API, без изменения старых событий и без условных операторов.

    Почему это работает

    Секрет в инверсии зависимостей. Вместо того чтобы функция add() знала обо всех компонентах, которые нужно обновить, она просто объявляет: «что-то произошло». Компоненты сами решают, интересно им это или нет.

    Это основа паттерна pub/sub (издатель-подписчик), который сегодня встречается повсюду:

    • Redux dispatch actions
    • Vue emit events
    • RxJS observables
    • Node.js EventEmitter

    Что делать сегодня

    В 2014 году jQuery был стандартом, и $('body').trigger() — разумным решением. Сегодня есть варианты получше:

    // Нативный EventEmitter
    const events = new EventTarget();
    events.dispatchEvent(
      new CustomEvent('QuantityChanged', { detail: { itemId, quantity } }),
    );
    
    // Или просто Redux/Zustand/Pinia — они построены на этих же принципах

    Концепция осталась прежней: разделяй «что произошло» и «как на это реагировать». Императивный код — для бизнес-логики. Декларативные подписки — для UI.