Как стать автором
Обновить

await vs yield на примере Effection 3.0 и React

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров6.5K

Интро

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

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

Теги:
Хабы:
+20
Комментарии33

Публикации

Истории

Работа

Ближайшие события

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область