Интро
Одним из недостатков промисов является отмена, точнее ее отсутствие. Соответственно цепочка промисов или асинхронных функций будет выполняться до самого конца
async function getData() { const response = await fetch('/url'); const json = await response.json(); console.log(json.data); }
Исключение: промис, который никогда не зарезолвится (к этому мы еще вернемся)
const neverResolve = new Promise(resolve => { // resolve(value); }) async function test() { try { await neverResolve; } finally { console.log('end'); // этот код не будет вызван НИКОГДА } }
Ну и что?
А то, что пользовательский интерфейс — прерываем. Данные, которые будут загружены позже, могут уже не понадобится. Пользователь может уйти на другой раздел, ввести новое значение в инпут, начать новый поиск.
Поэтому в лучшем случае, если асинхронная функция - чистая функция, то мы просто потратим ресурсы на вычисления, а если функция изменяет внешние данные или пользовательский интерфейс, то вызов такой функции в следующий раз может вызвать состояние гонки (когда предыдущая еще не выполнилась и может выполниться позже той, которая вызвана после нее).
Самый популярный пример — autosuggest
async function getData(name: string) { const response = await fetch(`/search?name=${name}`); const json = await response.json(); return json.data; } const Autosuggest = () => { const [input, setInput] = useState(''); const [data, setData] = useState([]); const onChange = async (e) => { const value = e.target.value; setInput(value); const data = await getData(value); setData(data); } return ( <div> <input type="text" value={input} onChange={onChange}/> {data.map(res => ( <div key={res.id}>{res.name}</div> ))} </div> ); }
Если пользователь введет новое значение, пока грузятся данные, то после загрузки он может увидеть как результаты от предыдущего запроса, так и от последнего.
А как же дебаунс, abortController, обработка ошибок, загрузка?
Все верно. Чтобы решить проблему гонки, нам нужен AbortController и правильная обработка ошибок. Добавим дебаунс, чтобы не спамить запросами на каждый символ:
const Autosuggest = () => { const [input, setInput] = useState(''); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const abortRef = useRef<AbortController>() useEffect(() => { // отмена запроса при размонтировании компонента return () => abortRef.current?.abort(); }, []); const search = useCallback( async (value: string) => { setLoading(true); // отмена предыдущего запроса abortRef.current?.abort(); // создание нового сигнала для запроса abortRef.current = new AbortController(); try { const data = await getData(value, { signal: abortRef.current.signal }); setLoading(false); setData(data); setError(null); } catch (e) { // выход из функции, если запрос был отменен if ((e as Error)?.name === "AbortError") return; // обработка остальных ошибок setError(e); setLoading(false); } }, [] ); // отдельная обертка для дебаунса const debouncedSearch = useDebounced(search, 300); const onChange = async (e) => { const value = e.target.value; setInput(value); debouncedSearch(value); } return ( <div> <input type="text" value={input} onChange={onChange}/> <div className={classNames({ 'is-loading': loading, 'has-error': error, })}> {data.map(res => ( <div key={res.id}>{res.name}</div> ))} </div> </div> ); }
В целом, если не считать сильного разрастания кода, то все проблемы решены. Но есть ряд моментов, с которыми просто придется смириться:
abortController тут используется через useRef для того, чтобы отменять предыдущие запросы при новом поиске и при размо��тировании компонента. Т.е. этот ref становится частью компонента и любой другой компонент, реализующий похожую логику, должен всегда тащить за собой этот ref. Можно чуть сократить код и вынести это в отдельный хук, и будет как-то так
const getAbortSignal = useAbortSignal(); const search = useCallback( async (value: string) => { ... const signal = getAbortSignal(); try { const data = await getData(value, { signal }); ... } ... } );
Уже лучше, но опять же, это дополнительный бойлерплейт в каждом компоненте. Плюс нужно не забывать про независимые запросы. Так как в этом подходе при получении нового сигнала предыдущий отменяется, то в случае независимых запросов нужно создавать по дополнительному сигналу и, соответственно, по дополнительному хуку.
обработка отмены запроса. При отмене запроса fetch бросает ошибку с name = 'AbortError', это значит, что такую ошибку нужно обрабатывать отдельно от остальных. В примере с autosuggest мы просто выходим из функции, чтобы случайно не обновить какой-то state. В целом все ок, главное не забывать про блок finally.
try { const data = await getData(value, { signal: abortRef.current.signal }); setData(data); setError(null); } catch (e) { // выход из функции, если запрос был отменен if ((e as Error)?.name === "AbortError") return; // обработка остальных ошибок setError(e); } finally { setLoading(false); }
Если у вас возникло желание вынести setLoading(false) в блок finally (ну он же есть после загрузки данных и в блоке catch), то вы попались. Блок finally выполняется в любом случае после успешного try или catch, даже если там стоит return. А это значит, что в случае отмены запроса после выхода из условия catch произойдет вызов setLoading(false) из finally. В итоге пользователь не увидит индикатор загрузки.
debounce. В данном случае мы использовали отдельный хук
useDebounce, который ждет 300 секунд прежде, чем сделать вызов. Проблема в самом хуке, потому что от того, как мы его определим, будет зависеть многое, а в случае с хуками вариантов у нас несколько. Например, его можно реализовать так:
import debounce from 'lodash/debounce'; export default function useDebounced<T extends (...args: any[]) => any>( memoizedCallback: T, wait: number ) { const debounced = useMemo( () => debounce(memoizedCallback, wait), [memoizedCallback, wait] ); useEffect(() => { return () => debounced.cancel(); }, [debounced]) return debounced; }
Тут мы используем useMemo + lodash, чтобы создать новую debounced-функцию при изменении аргумента memoizedCallback или wait. Это подразумевает, что функция перед этим должна быть обернута в useCallback.
А что делать, если memoizedCallback изменился? Например, если эта функция (созданная через useCallback) использует какие-то данные из пропсов или стейта. Мы можем отменить предыдущую debounced функцию, для этого и написан useEffect. Вроде логично, мы же не хотим вызвать функцию с устаревшими данными. Мы отменяем предыдущую debounced-функцию, создаем новую, и вызываем ее.
А что, если новую функцию мы не вызовем? Например, если передадим ее дальше в дочерний компонент через props, а она вызывается только внутри какой-то сложной логики. В таком случае мы просто потеряем вызов. Поэтому debounce и хуки требуют очень пристального внимания. Иногда их объявляют через useRef, что, конечно, тоже не всегда правильно.
Генераторы + effection
Теперь давайте посмотрим на то, как будет выглядеть тот же самый компонент с подходом через генераторы
const Autosuggest = () => { const [input, setInput] = useState(''); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [debouncedSearch] = useTaskCallback(function* (value: string): Operation<void> { yield* sleep(300); setLoading(true); try { const data = yield* getData(value); setLoading(false); setData(data); } catch (e) { // обработка обычных ошибок setError(e); setLoading(false); } }, []); const onChange = async (e) => { const value = e.target.value; setInput(value); debouncedSearch(value); } return ( <div> <input type="text" value={input} onChange={onChange}/> {data.map(res => ( <div key={res.id}>{res.name}</div> ))} </div> ); }
Реализацию useTaskCallback можно посмотреть тут. Уже можно заметить, как значительно уменьшился код. Как же решились все эти проблемы?
abortController теперь ушел из компонента. Он теперь находится внутри вложенного генератора:
import { call, Operation, useAbortSignal } from 'effection'; function* getData(value): Operation<Data> { const signal = yield* useAbortSignal(); const response = yield* call(fetch(`/search?name=${value}`, { signal, })); if (response.ok) { return yield* call(response.json()); } else { throw new Error(response.statusText); } }
Как же такое возможно? Это все благодаря механизму отмены. Генераторы позволяют завершить исполнение в любой момент https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/return, благодаря чему можно написать clean-up логику в блоке finally в любом месте генератора. Непосредственно за отмену запроса отвечает effection/useAbortSignal, который отменяет запрос в случае, если текущий генератор завершается (или отменяется). Это позволяет перенести логику запроса в самый низкоуровневый блок (например, так — fetchTask) и работать с запросами уже не задумываясь о создании сигналов и обработке отмены.
debounce. Вся его суть — подождать определенное время перед вызовом функции и отменять предыдущие таймауты при последующих вызовах. Все это отлично ложится на логику генераторов, в которых исполнение можно прекратить в любой момент. Поэтому можно написать
yield* sleep(ms);в любом месте и добиться логики debounce/throttle без необходимости определять дополнительные хуки.
Пару слов про блок finally
Для начало посмотрим на обычную функцию:
function test() { try { fn(); } catch (e) { // этот код выполнится в случае, если fn бросит ошибку } finally { // этот код ГАРАНТИРОВАННО выполнится } } test();
Вспомним пример с промисом, который никогда не завершится:
const neverResolve = new Promise(resolve => { // resolve(value); }) async function test() { try { await neverResolve(1); } finally { console.log('end'); // этот код не будет вызван НИКОГДА } } test();
Теперь тоже самое с генераторами:
import { call } from "effection"; const neverResolve = new Promise(resolve => { // resolve(value); }) function* test() { try { yield* call(neverResolve); } finally { // этот код ГАРАНТИРОВАННО выполнится console.log('end'); } } const g = test(); g.next(); // отменяем генератор через 5 секунд setTimeout(() => g.return(), 5000); // через 5 секунд получим console.log('end')
А что с типизацией?
Effection добились полной типизации генераторов.
Если вы вспоминаете другие библиотеки, которые использовали генераторы, например redux-saga, то могли заметить проблему с типизацией.
import { call } from 'redux-saga/effects' function* getData(): Generator { // response имеет тип unkown const response = yield call(fetch, '/url'); // json имеет тип unkown const json = yield call(response.json); // ошибка типизации return json.data; // ошибка типизации } function* handleData(): Generator { // result имеет тип unkown const result = yield call(getData); console.log(result); }
По умолчанию TS не знает тип, который вернется из конструкции yield something. И это проблема не TS, в действительности, если мы посмотрим на низкоуровневый код, то result действительно может иметь что угодно, и это "что угодно" зависит от внешнего источника, того, кто вызывает функцию-генератор:
const gen = handleData(); gen.next(); gen.next('anything'); // выведет anything
Можно поиграться в плейграунде тут (нажать кнопку run).
Встроенный в typescript тип Generator<T, TReturn, TNext> может принимать параметры, но проблема в том, что TNext - это тип для всех переменных, полученных через yield. Т.е. указав тип TNext, мы решим проблему только для генератора с 1 yield, а в случае разных типов мы просто получим ошибку. Пример.
Поэтому зачастую в таких подходах можно увидеть что-то такое:
function* handleData(): Generator { const result: Data = yield call(getData); console.log(result); }
или даже такое:
function* handleData(): Generator { const result: ReturnType<typeof getData> = yield call(getData); console.log(result); }
Что, во-первых, дает дополнительный оверхед, во-вторых, не помогает на 100% избавиться от проблем с типами.
А как effection добились полной типизации?
type Operation<T> = Generator<unknown, T, unknown>; function* getData(): Operation<string> { // response имеет тип Response const response = yield* call(fetch('/url')); // json имеет тип Data const json = yield* call(response.json() as Promise<Data>); return json.data; } function* handleData(): Operation<'ok'> { // result имеет тип string const result = yield* getData(); console.log(result); return 'ok'; }
Такой способ использует только конструкцию yield*, что дает возможность использовать только результат генератора и избавиться от промежуточных сложнотипизируемых yield
Пример, чтобы поиграться — тут (yield используется только низкоуровневыми вызовами - они скрыты за реализацией).
Заметка про yield*
Этот оператор делегирует выполнение другому вызываемому оператору. Другими словами, мы проваливаемся внутрь другого генератора, как при обычном вызове функции. Если провести аналогию с промисами, то await <=> yield*
В случае с yield getData() текущий генератор создает новый итератор из getData и отдает его через next() источнику (а источник должен отдельно обработать новый итератор, именно поэтому невозможно корректно типизировать его результат), а в случае с yield* getData() текущий генератор проваливается внутрь генератора getData, отдает источнику все внутренние вызовы yield из getData через next(), а затем возвращает результат в переменную. Пример
Effection
Сам по себе Effection не пытается создать фреймворк или библиотеку для фреймворка, как это делали, например, redux-saga или ember-concurrency. Их главная мысль - "if you know how to do it in JavaScript, you know how to do it in Effection".
Все, что нужно для понимания, это вот эта сравнительная табличка между async и генераторами. Я взял ее отсюда и немного дополнил. По этой ссылке можно также посмотреть примеры
Async/Await | Effection |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Запуск и отмена генераторов
Для того чтобы создать генератор, нужно только указать его тип (использовав effection/Operation)
import { sleep, Operation } from 'effection'; function* gen(): Operation<string> { yield* sleep(500); return "hello world"; }
Для запуска генератора используется функция effection/run, которая возвращает задачу (тип Task). Этот тип одновременно является промисом и итератором (в Effection это тип Opertaion)
import { run } from 'effection'; const task = run(gen); // можно получить результат как промис const result = await task; // можно отменить задачу run(() => task.halt());
Clean-up
Стандартный способ для всех генераторов написать clean-up логику — это блок finally. Например, в случае с WebSocket это можно сделать так:
import { main, once } from 'effection'; function* gen(): Operation<string> { const socket = new WebSocket('wss://ws.ifelse.io'); try { yield* once(socket, 'open'); console.log('сокет открыт'); socket.send('hello'); const message = yield* once(socket, 'message'); return message.data; } finally { // закрываем сокет при выходе из функции или отмены генератора socket.close(); console.log('сокет закрыт') } }
Кроме стандартного способа Effection предлагает еще один интересный вариант — ensure
import { main, once, ensure } from 'effection'; function* gen(): Operation<string> { const socket = new WebSocket('wss://ws.ifelse.io'); yield* ensure(() => { // этот код выполнится только после выхода из генератора или отмены socket.close(); console.log('сокет закрыт') }); yield* once(socket, 'open'); console.log('сокет открыт'); socket.send('hello'); const message = yield* once(socket, 'message'); return message.data; }
Напоминает конструкцию defer из go. Такой подход позволяет написать cleanUp логику сразу после объявления переменной, что помогает избежать непредвиденных ситуаций с необработанными ошибками
FAQ
Где можно посмотреть рабочие примеры?
https://github.com/Atrue/react-concurrency-examples/tree/main
Должен ли я переписать теперь все асинхронные функции на генераторы и effection?
Нет. Так же как и с хуками в реакте, в какой-то момент времени у вас могли быть и классовые и функциональные компоненты, и никто не заставлял переписывать сразу все. Можно начинать использовать в тех местах, где важно прерывание и перезапуск задач
Можно ли иметь одновременно асинхронные функции и генераторы?
Да. Более того, effection предоставляет специальный интерфейс Future, который одновременно является Операцией (исполняемым генератором) и Промисом. Генератор за пределами effection запускается через
run(generator), а дождаться промиса внутри генератора можно черезcall(promise). Конечно, в идеале не стоит злоупотреблять этим, так как теряется главная их особенность - отмена. В таком случае приходится самому следить за этим и отменять задачи с помощьюrun(() => task.halt())Это замена redux-saga?
Нет. Но опять же, это не значит, что у вас нет логики в компонентах, а если вы собираетесь рефакторить и выносить часть логики из стора в локальный стейт, то в некоторых задачах effection может вам помочь.
А при чём тут React?
Ни при чём. Effection можно использовать где угодно, в браузере, на бэкенде, на Deno