React, AbortController и асинхронные onClick вызовы

Kate

Administrator
Команда форума

Что в статье?​

В самом начале мы поговорим о базовой теории асинхронных функций в 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 в качестве функции обратного вызова (колбэка).

3048c5b15551927d3b43c487310bbcb1.jpg

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.

 
Сверху