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 ( <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

await

yield*

async function

function*

Promise

Operation

new Promise()

action()

Promise.all()

all()

Promise.race()

race()

for await

for yield* each

AsyncIterable

Stream

AsyncIterator

Subscription

Запуск и отмена генераторов

Для того чтобы создать генератор, нужно только указать его тип (использовав 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


ссылка на оригинал статьи https://habr.com/ru/articles/819005/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *