Главная / Статьи / Шесть ошибок асинхронного тайминга, которые проявляются как сбои в отрисовке React

Шесть ошибок асинхронного тайминга, которые проявляются как сбои в отрисовке React

Изучите шесть паттернов асинхронного JavaScript, начиная с устаревших захваченных значений и заканчивая отсутствием возврата объектов Promise, которые приводят к тому, что компоненты React отображают неверное или недостижимое состояние.

1485 слов

Многие проблемы, которые относят к «ошибкам React», на самом деле не возникают внутри React. Список результатов старого запроса, индикатор загрузки, который не исчезает, уведомление о успешном выполнении операции, появляющееся до готовности данных: React — это просто место, где эти проблемы становятся видимыми. Настоящая причина обычно находится на уровень ниже, в способе, которым асинхронный JavaScript передает значения со временем.

Асинхронный код вносит элемент времени в программу, и каждый вызов await или .then() является моментом, когда что-то может измениться под поверхностью. Ниже приведены шесть распространенных ошибок, связанных с временем, причины, по которым они вызывают путаницу в интерфейсе, и небольшие изменения, которые их исправляют, чтобы вы могли обнаружить их во время ревью кода.

Ошибка 1: Устаревшие запросы продолжают записывать данные в текущее состояние

Поле поиска — это классический пример. Приведенный ниже код уже задерживает ввод на 300 мс и очищает таймер при завершении работы.

useEffect(() => {
  if (!query) {
    setResults([]);
    return;
  }

  const timeoutId = setTimeout(async () => {
    const results = await search(query);
    setResults(results);
  }, 300);

  return () => {
    clearTimeout(timeoutId);
  };
}, [query]);

Теперь представьте, что кто-то вводит фразу «Async JavaScript Mistakes». Сначала он вводит слово «Async», делает паузу ровно настолько, чтобы сработал механизм дебаунсинга, после чего отправляется запрос. Затем он продолжает вводить текст, и отправляется ещё один запрос с полной фразой. Механизм дебаунсинга сокращает количество запросов, но не предотвращает одновременную отправку двух из них.

Порядок, в котором запросы покидают браузер, находится под вашим контролем. Порядок возврата ответов — нет. Если запрос с более длинным текстом обрабатывается первым, а запрос с словом «Async» — вторым, то побеждает второй вызов функции setResults, и в списке отображаются результаты по запросу на «Async», хотя введённый текст явно гласит «Async JavaScript Mistakes».

Здесь нет ничего не так. Сети могут переупорядочивать результаты, и код никогда не указывал React, какой ответ всё ещё актуален. Обычное решение — отменить уже неактуальные операции с помощью AbortController, созданного внутри функции effect и отменённого при её очистке, так что устаревший ответ либо вообще не поступает, либо игнорируется. Если поток данных уже построен на RxJS, switchMap обеспечивает ту же логику «только самый свежий результат имеет значение». Для более подробного рассмотрения именно этой ситуации см. как устранять ситуации конкуренции, которые нельзя решить с помощью дебаунсинга.

Ошибка 2: Принятие решений на основе значений, полученных до вызова await

Рассматривайте каждый await как границу. То, что было известно до его выполнения, представляет собой моментальную копию состояния; всё, что происходит после его выполнения, происходит позже, возможно, после изменения других состояний.

const handlePublish = async () => {
  const { canPublish } = permissions;

  await saveDraft();

  if (canPublish) {
    publish();
  }
};

Этот обработчик считывает значение canPublish из поля permissions, ждёт сохранения черновика, а затем принимает решение о публикации. Проблема заключается в том, что решение основано на значении, которое было истинным в начале. Если у пользователя были отозваны права во время выполнения функции saveDraft(), локальная константа всё равно будет указывать на возможность публикации.

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

Ошибка 3: Предположение о том, что остальная часть функции всегда выполняется

Вот флаг загрузки, используемый вместе с методом fetch.

setLoading(true);

const dashboard = await getDashboard();

setDashboard(dashboard);
setLoading(false);

Если getDashboard() возвращает ошибку, выполнение покидает функцию на строке await, и код setLoading(false) так и не выполняется. Индикатор загрузки остается на экране бесконечно.

Когда какая-либо часть модели состояния отвечает за срок жизни асинхронной операции, её сброс должен происходить как в случае успеха, так и при ошибке. Блок finally прямо отражает эту цель:

setLoading(true);

try {
  const dashboard = await getDashboard();
  setDashboard(dashboard);
} finally {
  setLoading(false);
}

Обратите внимание, что блок try по-прежнему не содержит блока catch. Ошибка продолжает передаваться тому, кто вызвал этот код, что часто и нужно, в то время как флаг загрузки гарантированно сбрасывается. Добавляйте блок catch только в том случае, если это подходящее место для преобразования ошибки в элемент интерфейса, например, сообщение об ошибках.

Ошибка 4: Независимые запросы, приводящие к несогласованным состояниям экрана

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

getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);

Каждый ответ появляется в отдельном состоянии системы в момент своего прибытия. Это означает, что React может отрисовать любую комбинацию элементов: профиль без прав и настроек, права и настройки без профиля и так далее — в любом порядке, который может возникнуть из-за работы сети.

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

Ошибка 5: Создание следующего состояния на основе устаревшей копии

Эта ошибка легко скрывается в обработчиках событий.

const handleAdd = async () => {
  await saveItem(newItem);

  setItems([...items, newItem]);
};

items — это то, что хранил компонент в момент начала выполнения функции handleAdd. Пока выполнялась функция saveItem(), другая операция могла добавить или удалить элементы. Когда обработчик возобновляет работу, он использует старый массив и перезаписывает более свежие изменения.

Когда следующее значение зависит от предыдущего, пусть React передаст предыдущее значение через функцию обновления:

setItems(current => [...current, newItem]);

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

Ошибка 6: нарушение цепочки обещаний из-за отсутствия возврата

Эта цепочка кажется строго последовательной: сохранить, затем обновить виджеты, затем отметить как сохраненные.

saveDashboard()
  .then(() => refreshWidgets())
  .then(() => setSaved(true));

Теперь посмотрим, как может быть написана функция refreshWidgets:

const refreshWidgets = () => {
  getWidgets().then(setWidgets);
};

Функция инициирует запрос, но возвращает undefined, а не Promise. С точки зрения внешней цепочки вызовов функция refreshWidgets() завершается мгновенно, поэтому следующий метод .then выполняется сразу, и метод setSaved(true) может быть вызван во время загрузки виджетов.

Решение заключается в использовании одного ключевого слова для возврата Promise, чтобы цепочка могла на него дождаться:

const refreshWidgets = () => {
  return getWidgets().then(setWidgets);
};

Функция async косвенно достигает того же результата, поскольку она всегда возвращает Promise, который считывается после завершения её тела:

const refreshWidgets = async () => {
  const widgets = await getWidgets();
  setWidgets(widgets);
};

В интерфейсе пользовательского интерфейса этот баг может проявляться в виде слишком раннего появления сообщения об успехе, оставшихся на экране устаревших данных или перенаправления, происходящего до того, как завершится обновление. Ни один из этих случаев явно не указывает на отсутствие оператора return. Правила проверки кода TypeScript, такие как @typescript-eslint/no-floating-promises, могут автоматически выявлять многие из этих ситуаций.

Основные выводы

Граница между React и JavaScript не всегда очевидна. React отрисовывает состояние, но асинхронный JavaScript определяет, когда это состояние поступит и будет ли оно актуальным, устаревшим или несогласованным к моменту его поступления. При анализе асинхронного кода в компонентах для выявления большинства таких багов возникают три вопроса:

  • Когда было зафиксировано это значение, и могло ли оно измениться во время выполнения операции await?
  • Какая асинхронная операция отвечает за обновление этого состояния, и может ли более старая операция перезаписать более новую?
  • Остаётся ли операция актуальной после завершения, и оставляет ли каждый сценарий, включая сбои, интерфейс в допустимом состоянии?