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

c6f9e01a5c6fabb0193e4ae28c3a3195

Интро

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

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

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

© Habrahabr.ru