Добрый день, читатели Хабра, представляю вашему вниманию перевод статьи React, Abort Controller and onClick async calls.

Что такое Abort Controller в JavaScript Web Apps, как его использовать в React для прерывания асинхронных вызовов? Теория и некоторые примеры использования.

Что в статье?

В самом начале мы поговорим о базовой теории асинхронных функций в JavaScript и о том, как они работают.

Затем немного об Abort Controller и о том, зачем его вообще использовать?

По окончании введения я покажу два варианта использования в React: первый - когда получаете данные при монтировании компонента, а второй - для асинхронных запросов, вызываемых пользовательским действием, например, onClick.

Обзор статьи:

  1. Асинхронные (async) и синхронные (sync) функции, Fetch API и AbortController

  2. Жизненный цикл компонента в React и зачем нужно "прибираться" перед размонтированием компонента

  3. Отмена асинхронного сигнала для событий, вызванных монтированием компонента

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

  5. Некоторые мысли и репозиторий с кодом

Асинхронные (async) и синхронные (sync) функции и AbortController

Как вы, вероятно, знаете, JavaScript поддерживает особый вид программирования - асинхронное программирование. Если по какой-то причине вы до сих пор не знаете об этом, вернитесь к этой статье через некоторое время :)

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

Синхронные функции

Прежде чем перейти к асинхронным функциям, несколько слов о синхронных :)

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

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

Асинхронные функции

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

В качестве ответа на этот вызов Web API имеет специальные методы/интерфейсы, которые способны взять под контроль асинхронные операции. Это означает, что пользовательский агент - браузер разблокирует обратный вызов, чтобы можно было выполнить другие действия. Затем, когда любые данные возвращаются с сервера, он возвращает ответ в наш callstack в качестве функции обратного вызова (колбэка).

Fetch API

Одним из интерфейсов, используемых для связи с API сервера, является Fetch API. У него есть как минимум одна замечательная особенность - он может использовать AbortController. Использование метода fetch в вашем приложении указывает браузеру, что он должен использовать Fetch API.

AbortController

AbortController - это специальный объект, который содержит свойство signal. Это свойство можно добавить к асинхронной функции, используя fetch в качестве одной из опций. Это связывает определенный сигнал с определенной функцией. Но зачем это делать?

Еще одним методом AbortController является abort(), который способен отменить выполнение функции. Это означает, что если сервер отвечает, браузер проигнорирует этот ответ, и он не будет передавать колбэк в наш стек вызовов. Хорошо, но как это можно использовать?

Жизненный цикл компонента в React и зачем "прибираться" перед размонтированием компонента?

Все (?) современные JavaScript фреймворки и библиотеки основаны на компонентах. Компоненты - это многократно используемые элементы, которые могут быть использованы в любой части приложения (при условии, что они построены таким образом). Можно легко сказать, что современные веб-приложения построены по модульному принципу.

Каждый компонент имеет свой жизненный цикл - момент, когда он отображается (монтирование), момент, когда он уничтожается (размонтирование) и всё, что происходит между этими моментами (обновление).

Жизненные цикл компонента в React

В классовых компонентах доступ к жизненному циклу может быть предоставлен с помощью методов типа componentDidMount() или componentWillUnmount(). На мой взгляд, эти названия не требуют объяснений :)

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

const [showLoading, setShowLoading] = useState(false)

 useEffect(
    () => {
      let timer1 = setTimeout(() => setShowLoading(true), 1000)

      // здесь происходит очищение таймаута при размонтировании компонента
      // как при componentWillUnmount
      return () => {
        clearTimeout(timer1)
      }
    },
    [] //useEffect сработает один раз
       //если передадим значение в массив, наример так [data], тогда 
       //clearTimeout будет срабатывать каждый раз когда значение date меняется
  )

Зачем "прибираться" перед размонтированием компонента?

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

Что происходит в такой ситуации? Вы можете получить предупреждение об утечке памяти:

Warning: Can only update a mounted or mounting component. Обычно это означает, что вы вызвали setState, replaceState или forceUpdate на размонтированном компоненте. Что является пустой/холостой командой.

А значит, это как-то влияет на производительность приложения. А это не самая лучшая практика :) В этой статье довольно подробно рассматривается этот вопрос.

Чтобы этого избежать, вам нужно отменить все подписки и асинхронные вызовы, когда компонент размонтируется!

Отмена асинхронного сигнала для событий, вызванных монтированием компонента

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

export const Articles = () => {
  const [state, setState] = useState([]);

  useEffect(() => {
    const abortController = new AbortController();
    const {signal} = abortController;

    const apiCall = async path => {
      try {
        const request = await fetch(path, {
          signal: signal,
          method: 'GET',
        });
        const response = await request.json();
        setState([response]);
      } catch (e) {
        if (!signal?.aborted) {
          console.error(e);
        }
      }
    };

    apiCall('https://jsonplaceholder.typicode.com/posts/1');

    return () => {
      abortController.abort();
    };
  }, [setState]);

  return (
    <>
      {state.map(article=> (
        <article key={article?.id} className='article'>
          <h1>{article?.title}</h1>
          <p>{article?.body}</p>
        </article> 
      ))}
    </>
  );
};

На первый взгляд это кажется немного сложным, но на самом деле это, вероятно, один из самых простых примеров использования AbortController :) Просто перед размонтированием компонента я вызываю метод AbortController.abort(). Вот и все!

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

Гипотетически, если пользователь хочет получить некоторые данные с сервера, нажав на кнопку (или любым другим способом)? Если слабое соединение и медленный интернет, пользователь может начать раздражаться и перейти к другому экрану. Как отменить такой сигнал? Сложность здесь заключается в том, что вам нужно передать уникальный сигнал в useEffect, но этот сигнал фактически инициализируется вне useEffect - в функции, которая обрабатывает действие onClick. Я знаю, что это может показаться простым, но есть одна загвоздка...

Насколько я знаю, в React нет встроенного метода, который мог бы справиться с этим сценарием (но возможно, есть какая-то библиотека?), что делать в подобном случае?

Fetch как пользовательский хук

Первым шагом будет создание пользовательского хука, который может принимать сигнал в качестве параметра или просто предоставлять уникальный сигнал. Затем он возвращает как метод fetch, так и метод abort:

import {compile} from 'path-to-regexp';
import {GET_ARTICLE_PATH} from './articles-routes';

export const useGetSingleArticle = ({ articleId, abortController = new AbortController()}) => {
  const baseUrl = 'https://jsonplaceholder.typicode.com';
  const path = baseUrl + compile(GET_ARTICLE_PATH)({articleId});
  const { signal, abort } = abortController || {};
  const articleRequest = fetch(path, {
    signal: signal,
    method: 'GET',
  });

  return [articleRequest, abort?.bind(abortController)];
};

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

React Хуки

Добавим три простых хука внутри нашего компонента:

const [articleId, setArticleId] = useState(2);
const [articleRequest, abortArticleRequest] = useGetSingleArticle({articleId: articleId});
const abortFuncs = useRef([]);

Первый - это просто поставщик уникального параметра для API хук.

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

Третий хук - это массив, в котором хранятся все наши сигналы.

Обработчик нажатия (клика)

Следующий шаг - это асинхронная функция, которая обрабатывает действие onClick:

const fetchOnClick = async () => {
  try {
    abortFuncs.current.unshift(abortArticleRequest);

    const newArticleRequest = await articleRequest;
    const newArticle = await newArticleRequest.json();

    setState([...state, newArticle]);
    setArticleId(articleId +1);
  } catch(e) {
    console.error(e);
  }
}

Ключевым в нашей проблеме является передача нашего метода abort в массив с помощью метода unshift(). Затем я просто получаю данные и обновляю состояние.

Обновление в useEffect

Теперь нужно сделать обновление в хуке useEffect, созданном в предыдущем примере. Я создаём функцию abortClickRequests, которая проходит через массив с сигналами и вызывает abort() для каждого из них.

useEffect(() => {
    const abortController = new AbortController();
    const {signal} = abortController;

    const apiCall = async path => {
      try {
        const request = await fetch(path, {
          signal: signal,
          method: 'GET',
        });
        const response = await request.json();
        setState([response]);
      } catch (e) {
        if (!signal?.aborted) {
          console.error(e);
        }
      }
    };
    const abortClickRequests = () => {
      abortFuncs.current.map(abort => abort());
    }

    apiCall('https://jsonplaceholder.typicode.com/posts/1');

    return () => {
      abortClickRequests();
      abortController.abort();
    };
  }, [setState]);

Когда компонент будет уничтожен, я просто вызываю предопределенную функцию, и всё!

Мысли

В целом эта реализация довольно простая и скорее является представлением концепции :)

Первый недостаток этого кода я вижу в том, что я прерываю все сигналы внутри таблицы, а не только те, которые на 100% не завершены. Я полагаю, что это можно оптимизировать.

Второй момент заключается в том, что на самом деле трудно (?) проверить, действительно ли эти сигналы прерваны. Теоретически все должно быть в порядке, но как мы можем быть уверены на 100%?

Я не знаю, может быть, все эти усилия на самом деле бессмысленны, потому что существует какая-то библиотека с функцией, которая обрабатывает эту ситуацию? Но тогда мы переходим к бесконечному разговору о чрезмерном использовании библиотек…

Вы можете сказать, что эта проблема не является проблемой и не стоит о ней беспокоиться :D

Я не уверен... А у вас есть какие-нибудь мысли?

Дополнительно

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

Вот несколько ресурсов, которые меня вдохновили - RWieruch, Spec, SLorber.

В общем, если вы считаете, что эта статья - полная ерунда, пожалуйста, не стесняйтесь поделиться этим в комментариях :)

Комментарии (3)


  1. noodles
    13.11.2021 12:09

    Я не уверен... А у вас есть какие-нибудь мысли?

    1. перед изменением стейта компонента - проверять не размонтирован ли этот компонент.

    2. Не делать приложения таким образом, что что-то может быть завязано на позднем ответе\ответах сервера или на порядке этих ответов (идемпотентность). Сеть - нестабильная и враждебная штука. Надеятся на abortController не надёжно.


  1. faiwer
    13.11.2021 15:06
    +4

    Кажется человек совсем не понимает как работают promises.


    export const useGetSingleArticle = (...) => {
      ...
      const articleRequest = fetch(path, {...});
      return [articleRequest];
    }

    Вот тут запрос уже запущен. Причём во время рендер-фазы (которая должна быть как можно более pure). Причём он делает новый запрос на каждый render (потому что вызывает useGetSingleArticle каждый рендер.


    const [articleId, setArticleId] = useState(2);
    const [articleRequest, abortArticleRequest] = useGetSingleArticle({articleId: articleId});

    А вот тут:


    const fetchOnClick = async () => {
        const newArticleRequest = await articleRequest;
        const newArticle = await newArticleRequest.json();
        ...
    }

    Он просто дожидается его завершения.


    Зря вы всю эту чушь перевели. Тем кому и правда интересен hook-way я рекомендую почитать Клепова.
    По какой-то необъяснимой причине буквально всё что публикуется на хабре про хуки, имеет настолько низкое качество, что можно смело переименовывать во "вредные советы".


    Касательно AbortController-ов. Тут в целом стоит начать с того чтобы в ваших react-component-ах не было никаких fetch-ей. Это низкоуровневый примитив, которому не место в компоненте. Создайте прослойку для api-вызовов, которая всю мишуру, вроде обработки ошибок, .json(), валидации, преобразования данных и прочее сделает на своей стороне. А наружу пусть торчит условный:


    api.getUserById: (userId: string) => Promise<User>

    Ну или чуть сложнее, если нужно добавить поддержку abortController-ов.


    Hook-Way он во многом о велосипедостроительстве. Вы постоянно пишете один и тот же код, подмечаете это, выносите общую составляющую в кастомный хук, который оптимально решает задачу. И так раз за разом. Потом эти хуки комбинируете между собой. Получается эдакая библиотека обобщённых хуков проекта. В ней наверняка будет пара тройка хуков для выполнения api запросов, в которой уже будут учтены все нюансы, включая уход от race condition, всякие throthle и debounce, кеши и т.д… Ну и мемоизация, само собой.


    1. AlexeyFront Автор
      15.11.2021 11:04

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