Hacknote.js

Состояние гонки

3 мин.
Состояние гонки

При разработке пользовательских интерфейсов мы часто сталкиваемся с обработкой каких-либо асинхронных операций. Зачастую в качестве таких операций выступают сетевые запросы. Сеть зависит от множества разных вещей, что делает её весьма непредсказуемой. Предлагаю рассмотреть на примерах, что я имею в виду.

Гонки клонов

Устроим гонки покемонов: выставим на дуэль Пикачу и… ещё одного Пикачу! Почему бы и нет?

fetch("https://pokeapi.co/api/v2/pokemon/pikachu").then(() => {
console.log("1");
});
fetch("https://pokeapi.co/api/v2/pokemon/pikachu").then(() => {
console.log("2");
});

Вопрос: в каком порядке выведутся цифры в консоли (прямо как на собеседовании) какой из двух Пикачу придёт к финишу первым?


    Какой запрос выполнится быстрее?

    На самом деле на этот вопрос нет однозначного ответа и результаты этого эксперимента могут отличаться в зависимости от стабильности интернета, реализации бекенда или даже браузера. Что же нам даёт эта информация?

    Что не так с моим поиском?

    Рассмотрим довольно распространённый сценарий с поиском, результаты которого перезапрашиваются при каждом изменении пользователем поисковой строки. В случае с React можно написать следующий код:

    // Отправляем запрос
    // на каждое изменение поисковой строки
    useEffect(() => {
    fetchData(searchString).then((data) => {
    setData(data);
    });
    }, [searchString]);

    Функция fetchData в данном примере будет эмулировать нестабильность сетевого запроса:

    export async function fetchData(text: string): Promise<string> {
    // Ждём от 50 до 1000 миллисекунд...
    await sleep(getRandomNumber(50, 1000));
    // ...и просто возвращаем исходный текст
    return text;
    }

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

    Проблемный поиск (демо)

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

    Скорее всего вы уже догадались, в чём дело, но чтобы точно понять, с чем мы имеем дело, предлагаю рассмотреть последовательность происходящих событий на диаграмме:

    Действия пользователя
    Первый запрос
    Второй запрос
    (0.100)
    Пользователь ввёл букву
    (0.300)
    Пользователь ввёл букву
    (0.500)
    Результат запроса применён
    (0.700)
    Результат запроса применён
    Проблемный поиск (диаграмма)

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

    Самый простой способ решить эту проблему — просто проигнорировать результат неактуального запроса.

    Выбрасываем просрочку

    Можно просто создать переменную isIgnored и присваивать ей значение true в момент, когда запрос становится неактуальным:

    useEffect(() => {
    let isIgnored = false;
    fetchData(searchString).then((data) => {
    if (!isIgnored) {
    setData(data);
    }
    });
    return () => {
    // Когда хук размонтируется,
    // результат запроса нам более не интересен
    isIgnored = true;
    };
    }, [searchString]);

    Таким образом проблема действительно будет решена и приложение будет работать более предсказуемо в непредсказуемых условиях:

    Состояние гонки исправлено (демо)

    Посмотрим, что изменилось на диаграмме:

    Действия пользователя
    Первый запрос
    Второй запрос
    (0.100)
    Пользователь ввёл букву
    (0.300)
    Пользователь ввёл букву
    (0.300)
    Запрос помечен как неактуальный
    (0.500)
    Результат запроса применён
    Состояние гонки исправлено (диаграмма)

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

    Изначальная проблема решена, но теперь получается, что браузер тратит ресурсы на бесполезное сетевое взаимодействие. Можно ли как-то сказать самому браузеру, что результат запроса нам больше не нужен, чтобы он отменил его?

    Экономим трафик

    На самом деле создание переменной isIgnored — довольно кустарный способ решения проблемы, ведь в браузере есть более мощный встроенный механизм — AbortController . Попробуем переписать код с его помощью:

    useEffect(() => {
    const abortController = new AbortController();
    fetchData(searchString).then((data) => {
    if (!abortController.signal.aborted) {
    setData(data);
    }
    });
    return () => {
    abortController.abort();
    };
    }, [searchString]);

    Концептуально ничего не изменилось, поскольку AbortController используется только нашим кодом, а функция, осуществляющая запрос, ничего о нём не знает.

    Чтобы увидеть всю мощь AbortController , нам понадобится использовать функцию, которая умеет с ним работать. К счастью обычный fetch как раз умеет это делать — он принимает сигнал отмены (signal ) в качестве одного из необязательных параметров:

    useEffect(() => {
    const abortController = new AbortController();
    fetch(`${apiUrl}?q=${searchString}`, {
    // Передаём источник события отмены
    signal: abortController.signal,
    }).then((data) => {
    setData(data);
    });
    return () => {
    abortController.abort();
    };
    }, [searchString]);

    Внутри браузер подпишется на событие abort у сигнала и прервёт запрос, когда событие произойдёт:

    Действия пользователя
    Первый запрос
    Второй запрос
    (0.100)
    Пользователь ввёл букву
    (0.300)
    Пользователь ввёл букву
    (0.300)
    Запрос отменён
    (0.500)
    Результат запроса применён
    Отмена запроса (диаграмма)

    Убедимся, что это действительно работает:


    Введите поисковый запрос
    Поиск по GitHub-репозиториям

    В DevTools мы увидим нечто подобное:

    Отменённые запросы
    Отменённые запросы

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

    Заключение

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