Головна / Статті / Шість помилок асинхронного таймінгу, які проявляються як баги у відображенні в 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“, зупиняється на достатньо довгий час, щоб ефект debounce закінчився, і надсилається запит. Потім він продовжує вводити текст, і надсилається ще один запит з повним фразою. Ефект debounce зменшив кількість запитів, але не завадив тому, щоб два з них відправлялися одночасно.

Порядок, у якому запити виходять з браузера, знаходиться під вашим контролем. Порядок, у якому повертаються відповіді, — ні. Якщо довший запит обробиться першим, а запит з „Async“ — другим, то переможе другий виклик setResults, і список покаже результати для „Async“, хоча на введенні чітко видно „Async JavaScript Mistakes“.

Тут нічого не пішло не так. Мережі можуть переупорядковувати результати, а код ніколи не вказує React, яка відповідь все ще актуальна. Зазвичай вирішенням є скасування застарілих операцій за допомогою AbortController, створеного всередині функції effect та скасованого під час її очищення, так що застаріла відповідь або ніколи не надходить, або ігнорується. Якщо потік даних вже побудований на RxJS, switchMap забезпечує ту саму семантику „лише найновіші дані є актуальними“. Для більш детального розгляду саме цього сценарію дивіться як вирішувати проблеми конкурентних умов, які не можна вирішити за допомогою debouncing.

Помилка 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 та зберігаючи все в одному оновленні стану, або ж моделюючи екран як редьюсер із чітко визначеними станами завантаження, готовності та помилок. Паралельне отримання даних та незалежний стан UI — це два окремі рішення.

Помилка 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?
  • Яка асинхронна операція відповідає за це оновлення стану, і чи може старіша операція перезаписати новішу?
  • Чи залишається операція актуальною після завершення, і чи залишається інтерфейс користувача у правильному стані після будь-якого сценарію, включаючи збої?