Debouncing событий

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

Если на каждое такое событие выполнять сложные вычисления, сетевые запросы или обновление интерфейса, производительность приложения быстро ухудшается. Для решения этой проблемы применяется техника debouncing.

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

В контексте Mapbox GL JS debouncing особенно важен при работе с событиями карты, поскольку многие из них возникают непрерывно во время взаимодействия пользователя с картой.


Проблема частых событий

Рассмотрим обработчик перемещения карты:

map.on('move', () => {
    console.log(map.getCenter());
});

Событие move вызывается многократно во время перетаскивания карты.

Если вместо простого вывода координат выполнять тяжёлые операции:

map.on('move', () => {
    fetch('/api/search');
});

то при одном перемещении карты могут быть отправлены десятки запросов на сервер.

Подобная ситуация приводит к:

  • перегрузке API;
  • увеличению сетевого трафика;
  • замедлению интерфейса;
  • росту нагрузки на процессор;
  • ухудшению пользовательского опыта.

Принцип работы Debouncing

Пусть задержка составляет 500 миллисекунд.

Последовательность событий выглядит следующим образом:

Событие
|
|---100мс---|
|---100мс---|
|---100мс---|
|---100мс---|
|---100мс---|

Debounce Timer
перезапуск
перезапуск
перезапуск
перезапуск
перезапуск

500мс тишины

Выполнение функции

Каждое новое событие сбрасывает таймер.

Функция выполняется только тогда, когда события перестают поступать.


Реализация Debounce на JavaScript

Классическая реализация:

function debounce(callback, delay) {
    let timeoutId;

    return function (...args) {
        clearTimeout(timeoutId);

        timeoutId = setTimeout(() => {
            callback.apply(this, args);
        }, delay);
    };
}

Разбор компонентов:

timeoutId

Хранит идентификатор таймера.

let timeoutId;

clearTimeout

Удаляет ранее запущенный таймер.

clearTimeout(timeoutId);

Если пользователь продолжает взаимодействовать с картой, выполнение постоянно откладывается.

setTimeout

Создаёт новый таймер.

timeoutId = setTimeout(() => {
    callback();
}, delay);

После завершения периода бездействия вызывается нужная функция.


Debouncing события move

Наиболее распространённый сценарий использования.

Без оптимизации:

map.on('move', () => {
    updateMarkers();
});

С debouncing:

const debouncedUpdate = debounce(() => {
    updateMarkers();
}, 300);

map.on('move', debouncedUpdate);

Теперь обновление маркеров произойдёт только после завершения перемещения карты.


Debouncing поиска по карте

Часто поисковая строка связана с геокодированием.

Без debouncing:

input.addEventListener('input', (event) => {
    searchAddress(event.target.value);
});

При вводе слова из десяти символов будет выполнено десять запросов.

Оптимизированный вариант:

const debouncedSearch = debounce((query) => {
    searchAddress(query);
}, 500);

input.addEventListener('input', (event) => {
    debouncedSearch(event.target.value);
});

Теперь запрос выполняется только после завершения ввода.


Использование Debouncing для получения данных в текущем окне карты

Типичный сценарий:

map.on('move', () => {
    const bounds = map.getBounds();

    loadObjects(bounds);
});

Во время перемещения карты запросы будут отправляться непрерывно.

Лучшее решение:

const loadVisibleObjects = debounce(() => {
    const bounds = map.getBounds();

    loadObjects(bounds);
}, 400);

map.on('move', loadVisibleObjects);

Такой подход широко применяется в:

  • системах отображения недвижимости;
  • геоинформационных системах;
  • картах транспорта;
  • сервисах поиска организаций;
  • системах мониторинга объектов.

Debouncing события zoom

Событие масштабирования также генерируется очень часто.

Пример:

map.on('zoom', () => {
    console.log(map.getZoom());
});

Оптимизированный вариант:

const debouncedZoomHandler = debounce(() => {
    updateClusters();
}, 250);

map.on('zoom', debouncedZoomHandler);

После завершения масштабирования выполняется только одно обновление кластеров.


Debouncing события rotate

При вращении карты возникает аналогичная проблема.

const debouncedRotation = debounce(() => {
    updateCompass();
}, 200);

map.on('rotate', debouncedRotation);

Обновление интерфейса выполняется значительно реже.


Debouncing события pitch

Событие изменения наклона карты:

const debouncedPitch = debounce(() => {
    refreshBuildings();
}, 300);

map.on('pitch', debouncedPitch);

Особенно полезно при работе с трёхмерными слоями.


Debouncing изменения размеров окна

После изменения размеров окна нередко требуется пересчитать отображаемые данные.

Без оптимизации:

window.addEventListener('resize', () => {
    map.resize();
});

Оптимизированный вариант:

const debouncedResize = debounce(() => {
    map.resize();
}, 200);

window.addEventListener('resize', debouncedResize);

Использование стрелочных функций

Современная реализация может выглядеть компактнее:

const debounce = (callback, delay) => {
    let timer;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(() => {
            callback(...args);
        }, delay);
    };
};

Использование:

const debouncedMove = debounce(() => {
    updateData();
}, 300);

map.on('move', debouncedMove);

Debouncing с сохранением контекста

Если используется метод класса, необходимо учитывать контекст объекта.

Пример класса:

class MapController {
    constructor(map) {
        this.map = map;

        this.handleMove = debounce(
            this.handleMove.bind(this),
            300
        );

        map.on('move', this.handleMove);
    }

    handleMove() {
        console.log(this.map.getCenter());
    }
}

Метод bind() гарантирует корректную работу this.


Debouncing и асинхронные операции

Очень часто debounce применяется совместно с асинхронными запросами.

const debouncedLoad = debounce(async () => {
    const bounds = map.getBounds();

    const response = await fetch('/api/objects');

    const data = await response.json();

    renderObjects(data);
}, 500);

map.on('move', debouncedLoad);

Пользователь может активно перемещать карту, но запрос будет выполнен только после остановки.


Debouncing и геокодирование

Получение координат по адресу — одна из самых распространённых задач.

const geocode = debounce(async (address) => {
    const response = await fetch(
        `/geocode?q=${address}`
    );

    const result = await response.json();

    showResult(result);
}, 600);

Использование:

searchInput.addEventListener('input', (event) => {
    geocode(event.target.value);
});

Подобный подход существенно снижает количество обращений к сервису геокодирования.


Выбор оптимальной задержки

Подходящее значение зависит от задачи.

Сценарий Рекомендуемая задержка
Поиск адреса 300–700 мс
Перемещение карты 200–500 мс
Масштабирование 150–400 мс
Изменение размеров окна 100–300 мс
Сетевые запросы 300–1000 мс

Слишком маленькая задержка уменьшает эффект оптимизации.

Слишком большая задержка создаёт ощущение медленного интерфейса.


Debouncing против события moveend

Mapbox GL JS предоставляет специальные события завершения действий.

Например:

map.on('moveend', () => {
    updateData();
});

Во многих случаях это предпочтительнее debouncing.

Аналогично существуют:

map.on('zoomend', () => {
    updateClusters();
});

map.on('rotateend', () => {
    updateCompass();
});

Преимущества событий завершения:

  • отсутствует необходимость в таймерах;
  • логика проще;
  • меньше вероятность ошибок;
  • более предсказуемое поведение.

Однако debouncing остаётся полезным, когда:

  • событие завершения отсутствует;
  • требуется собственная задержка;
  • обработчик используется вне Mapbox GL JS;
  • необходимо объединять несколько типов событий.

Совмещение нескольких событий

Иногда один обработчик должен реагировать на разные изменения карты.

const updateData = debounce(() => {
    loadVisibleObjects();
}, 400);

map.on('move', updateData);
map.on('zoom', updateData);
map.on('rotate', updateData);

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


Отмена обработчика

При удалении карты желательно удалить подписку.

const debouncedHandler = debounce(() => {
    updateData();
}, 300);

map.on('move', debouncedHandler);

map.off('move', debouncedHandler);

Важно использовать ту же ссылку на функцию.

Следующий вариант работать не будет:

map.off('move', debounce(updateData, 300));

Создаётся новый объект функции, который отличается от ранее зарегистрированного обработчика.


Типичные ошибки

Создание debounce внутри обработчика

Неправильно:

map.on('move', () => {
    debounce(updateData, 300)();
});

При каждом событии создаётся новый таймер.

Правильно:

const debouncedUpdate = debounce(updateData, 300);

map.on('move', debouncedUpdate);

Слишком большая задержка

const update = debounce(loadData, 5000);

Пять секунд ожидания делают интерфейс неотзывчивым.


Отсутствие очистки старых запросов

Даже при использовании debounce запрос может оказаться устаревшим.

Например:

const controller = new AbortController();

fetch(url, {
    signal: controller.signal
});

В сложных приложениях debouncing часто комбинируется с AbortController, чтобы отменять старые запросы и предотвращать обработку неактуальных данных.


Практический пример загрузки объектов после перемещения карты

function debounce(callback, delay) {
    let timer;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(() => {
            callback(...args);
        }, delay);
    };
}

const loadMarkers = debounce(async () => {
    const bounds = map.getBounds();

    const response = await fetch(
        `/api/markers?bbox=${bounds.toArray()}`
    );

    const markers = await response.json();

    renderMarkers(markers);
}, 400);

map.on('move', loadMarkers);
map.on('zoom', loadMarkers);

Особенности данного решения:

  • не создаёт лишних запросов;
  • уменьшает нагрузку на сервер;
  • сохраняет плавность работы карты;
  • хорошо масштабируется при больших объёмах данных;
  • подходит для большинства приложений на основе Mapbox GL JS.

Техника debouncing является одним из базовых инструментов оптимизации событийной модели в Mapbox GL JS. При грамотном использовании она позволяет эффективно контролировать частоту выполнения обработчиков, снижать нагрузку на клиентскую и серверную части приложения, а также поддерживать высокую отзывчивость интерфейса даже при интенсивном взаимодействии с картой.