Throttling и debouncing

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

Для решения подобных проблем используются две распространённые техники оптимизации:

  • Throttling (дросселирование) — ограничение частоты выполнения функции.
  • Debouncing (отложенный вызов) — выполнение функции только после завершения серии событий.

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


Проблема частых событий в Mapbox GL JS

Mapbox GL JS предоставляет большое количество событий:

map.on('move', () => {
    console.log('Карта перемещается');
});

map.on('zoom', () => {
    console.log('Изменение масштаба');
});

map.on('rotate', () => {
    console.log('Вращение карты');
});

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

Например:

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

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

Последствия:

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

Для предотвращения подобных ситуаций применяются throttling и debouncing.


Концепция Throttling

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

Предположим, установлен интервал 500 миллисекунд.

Если событие возникает постоянно:

События:
| | | | | | | | | | | | |

Выполнение:
|-----500ms-----|-----500ms-----|

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


Реализация Throttling

Простейшая реализация:

function throttle(callback, delay) {
    let lastCall = 0;

    return function (...args) {
        const now = Date.now();

        if (now - lastCall >= delay) {
            lastCall = now;
            callback.apply(this, args);
        }
    };
}

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

const throttledHandler = throttle(() => {
    console.log('Обработчик выполнен');
}, 1000);

window.addEventListener('scroll', throttledHandler);

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


Throttling для события move

Одно из самых распространённых применений в Mapbox GL JS — обновление данных во время перемещения карты.

Без ограничения:

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

С throttling:

const throttledUpdate = throttle(() => {
    updateMarkers();
}, 300);

map.on('move', throttledUpdate);

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


Получение текущих границ карты

Часто требуется получать данные в пределах текущей области просмотра.

Пример:

const throttledFetch = throttle(() => {
    const bounds = map.getBounds();

    loadData({
        west: bounds.getWest(),
        east: bounds.getEast(),
        south: bounds.getSouth(),
        north: bounds.getNorth()
    });
}, 500);

map.on('move', throttledFetch);

Во время перемещения карта остаётся отзывчивой, а сервер получает ограниченное количество запросов.


Использование Throttling для resize

При изменении размеров окна браузера событие resize генерируется очень часто.

Пример:

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

Такой подход предотвращает лишние перерасчёты интерфейса.


Концепция Debouncing

Debouncing работает иначе.

Функция выполняется только тогда, когда серия событий завершилась и прошло определённое время без новых вызовов.

Схема работы:

События:
||||||||||||||||||||

Пауза:
------500ms------

Выполнение:
             X

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


Реализация Debouncing

Базовый вариант:

function debounce(callback, delay) {
    let timer;

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

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

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

const debouncedHandler = debounce(() => {
    console.log('Выполнено');
}, 500);

window.addEventListener('scroll', debouncedHandler);

Функция выполнится только после остановки прокрутки.


Debouncing для поиска объектов

Распространённая задача — поиск объектов на карте.

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

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

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

С debouncing:

input.addEventListener(
    'input',
    debounce(event => {
        search(event.target.value);
    }, 500)
);

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


Debouncing для moveend-подобного поведения

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

Вместо использования события moveend можно реализовать аналогичное поведение через debouncing.

const debouncedLoad = debounce(() => {
    loadVisibleFeatures();
}, 400);

map.on('move', debouncedLoad);

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

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


Сравнение Throttling и Debouncing

Throttling

Особенности:

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

Пример:

map.on('move', throttle(updateMarkers, 300));

Результат:

Движение карты → обновление каждые 300 мс

Debouncing

Особенности:

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

Пример:

map.on('move', debounce(loadData, 500));

Результат:

Движение карты → ожидание остановки → загрузка данных

Когда использовать Throttling

Throttling хорошо подходит для задач, требующих постоянного обновления данных.

Примеры:

  • отображение координат курсора;
  • обновление статистики;
  • изменение пользовательского интерфейса;
  • обновление счётчиков;
  • динамическое отображение информации на карте.

Пример отображения центра карты:

const updateCenter = throttle(() => {
    const center = map.getCenter();

    document.getElementById('info').textContent =
        `${center.lng}, ${center.lat}`;
}, 200);

map.on('move', updateCenter);

Когда использовать Debouncing

Debouncing эффективен для операций, которые должны происходить после завершения действий пользователя.

Примеры:

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

Пример:

const saveMapState = debounce(() => {
    const state = {
        center: map.getCenter(),
        zoom: map.getZoom()
    };

    localStorage.setItem(
        'map-state',
        JSON.stringify(state)
    );
}, 1000);

map.on('move', saveMapState);

Состояние сохранится только после окончания взаимодействия с картой.


Использование библиотеки Lodash

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

Подключение:

import throttle from 'lodash/throttle';
import debounce from 'lodash/debounce';

Throttling:

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

Debouncing:

map.on(
    'move',
    debounce(() => {
        loadVisibleObjects();
    }, 500)
);

Преимущества готовых реализаций:

  • высокая надёжность;
  • обработка сложных сценариев;
  • дополнительные настройки;
  • хорошая производительность.

Параметры leading и trailing

Lodash позволяет управлять моментом вызова функции.

Пример:

throttle(handler, 1000, {
    leading: true,
    trailing: false
});

Параметры:

Параметр Описание
leading запуск в начале интервала
trailing запуск в конце интервала

Для debouncing:

debounce(handler, 500, {
    leading: true,
    trailing: false
});

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


Комбинирование с запросами GeoJSON

Частая задача в Mapbox GL JS — загрузка данных в зависимости от текущей области просмотра.

Пример с throttling:

const updateData = throttle(async () => {
    const bounds = map.getBounds();

    const response = await fetch(
        `/api/data?west=${bounds.getWest()}
        &east=${bounds.getEast()}
        &south=${bounds.getSouth()}
        &north=${bounds.getNorth()}`
    );

    const geojson = await response.json();

    map.getSource('objects').setData(geojson);
}, 500);

map.on('move', updateData);

Карта получает новые данные постепенно во время перемещения.


Защита от гонки запросов

Даже при использовании throttling и debouncing возможно получение ответов в неправильном порядке.

Пример решения:

let requestId = 0;

const loadData = debounce(async () => {
    const currentId = ++requestId;

    const response = await fetch('/api/data');
    const data = await response.json();

    if (currentId !== requestId) {
        return;
    }

    map.getSource('objects').setData(data);
}, 400);

Устаревшие ответы автоматически игнорируются.


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

Современный способ отмены старых запросов:

let controller;

const loadData = debounce(async () => {
    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    try {
        const response = await fetch('/api/data', {
            signal: controller.signal
        });

        const data = await response.json();

        map.getSource('objects').setData(data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            throw error;
        }
    }
}, 300);

map.on('move', loadData);

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


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

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

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

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

Каждый вызов создаёт новую функцию.

Правильно:

const throttledUpdate =
    throttle(updateData, 300);

map.on('move', throttledUpdate);

Слишком маленький интервал

Плохо:

throttle(loadData, 10);

Интервал в 10 миллисекунд практически не снижает нагрузку.

Чаще используются значения:

100–300 мс  — интерфейс
300–1000 мс — запросы данных
500–2000 мс — сохранение состояния

Использование Debouncing там, где требуется постоянное обновление

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

map.on(
    'move',
    debounce(updateCoordinates, 500)
);

Координаты будут обновляться только после остановки карты.

Лучше:

map.on(
    'move',
    throttle(updateCoordinates, 100)
);

Практическая схема выбора

Сценарий Решение
Отображение координат курсора Throttling
Обновление пользовательского интерфейса Throttling
Получение данных во время перемещения Throttling
Поиск по вводу текста Debouncing
Геокодирование адресов Debouncing
Сохранение состояния карты Debouncing
Фильтрация объектов Debouncing
Массовые сетевые запросы Debouncing
Отслеживание текущего центра карты Throttling
Загрузка данных после окончания перемещения Debouncing

Производительность картографических приложений

В крупных проектах на Mapbox GL JS throttling и debouncing становятся обязательными инструментами оптимизации. События move, zoom, drag, rotate, pitch, resize и пользовательский ввод способны генерировать огромный поток вызовов. Грамотное ограничение частоты выполнения обработчиков позволяет:

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

Выбор между throttling и debouncing определяется характером задачи: необходимы ли регулярные обновления во время действия пользователя или единственное выполнение после завершения серии событий. Именно это различие лежит в основе большинства решений по оптимизации событийной модели в приложениях на Mapbox GL JS.