await vs yield на примере Effection 3.0 и React
Интро
Одним из недостатков промисов является отмена, точнее ее отсутствие. Соответственно цепочка промисов или асинхронных функций будет выполняться до самого конца
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 (
{data.map(res => (
{res.name}
))}
);
}
Если пользователь введет новое значение, пока грузятся данные, то после загрузки он может увидеть как результаты от предыдущего запроса, так и от последнего.
А как же дебаунс, abortController, обработка ошибок, загрузка?
Все верно. Чтобы решить проблему гонки, нам нужен AbortController и правильная обработка ошибок. Добавим дебаунс, чтобы не спамить запросами на каждый символ:
const Autosuggest = () => {
const [input, setInput] = useState('');
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const abortRef = useRef()
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 (
{data.map(res => (
{res.name}
))}
);
}
В целом, если не считать сильного разрастания кода, то все проблемы решены. Но есть ряд моментов, с которыми просто придется смириться:
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 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 {
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 (
{data.map(res => (
{res.name}
))}
);
}
Реализацию useTaskCallback
можно посмотреть тут. Уже можно заметить, как значительно уменьшился код. Как же решились все эти проблемы?
import { call, Operation, useAbortSignal } from 'effection';
function* getData(value): Operation {
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
может принимать параметры, но проблема в том, что TNext
— это тип для всех переменных, полученных через yield
. Т.е. указав тип TNext, мы решим проблему только для генератора с 1 yield, а в случае разных типов мы просто получим ошибку. Пример.
Поэтому зачастую в таких подходах можно увидеть что-то такое:
function* handleData(): Generator {
const result: Data = yield call(getData);
console.log(result);
}
или даже такое:
function* handleData(): Generator {
const result: ReturnType = yield call(getData);
console.log(result);
}
Что, во-первых, дает дополнительный оверхед, во-вторых, не помогает на 100% избавиться от проблем с типами.
А как effection добились полной типизации?
type Operation = Generator;
function* getData(): Operation {
// response имеет тип Response
const response = yield* call(fetch('/url'));
// json имеет тип Data
const json = yield* call(response.json() as Promise);
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 {
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 {
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 {
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