Оси асинхронности · гранулярность
Слово “асинхронность” склеивает в один пучок несколько независимых вопросов. Сколько значений принесёт операция. Когда они появятся: сейчас, потом или по событию. Кто двигает поток: продюсер пушит или потребитель тянет. Можно ли прервать работу извне. Запускается она сразу или только когда подключился потребитель. На каждый из этих вопросов в JavaScript есть свой набор контрактов, и одна задача может задеть сразу несколько осей. Этот урок про первую из них, гранулярность, сколько значений и в каких порциях. Остальные оси (направление, время, отмена, жадность) разберём в следующих уроках раздела, а здесь зафиксируем общую карту, на которую они потом ложатся.
Ниже шесть основных контрактов асинхронности и пять осей, по которым они различаются. Выбери контракт в верхнем ряду и посмотри, где он живёт на каждой оси. Этот урок дальше идёт по первой строке (сколько значений), но видно сразу, что контракт никогда не “одна точка”: он сразу набор из пяти координат.
После урока ты сможешь по запросу “получить пять страниц с поисковика”
сразу прикинуть, какой контракт уместен: массив, Promise,
AsyncIterable, ReadableStream или поток событий. И главное, почему.
1. Гранулярность как ось
Гранулярность ответа, это ответ на простой вопрос: “сколько значений принесёт эта функция и в каком виде”. В JavaScript есть шесть точек на этой оси, которые встречаются в реальной работе.
| Контракт | Сколько | Когда | Пуш или пулл | Отмена |
|---|---|---|---|---|
T | ровно одно | сейчас | пулл | нет |
T | undefined, Result<T, E>, Option<T> | 0 или 1 | сейчас | пулл | нет |
Promise<T> | ровно одно | потом | пулл | через AbortSignal |
AsyncIterable<T> | 0..N | потом, по запросу | пулл | через return() плюс signal |
ReadableStream<T> | 0..N | потом, с back-pressure | гибрид | cancel() плюс signal |
EventTarget / EventEmitter | 0..бесконечность | по событию | пуш | отписка плюс signal |
Запомнить лучше через одну метафору. Синхронное значение, это лежит у тебя в кармане. Promise, это посылка из магазина, точно одна, точно придёт. Async iterable, это полка, ты сам берёшь по одному предмету. Readable stream, это конвейер с тормозом, иначе тебя завалит. Event emitter, это рация: говорит, когда хочет, ты слушаешь.
2. Один результат: T, Result, Option
Самое простое и самое важное. До того как уйти в асинхронность, стоит выбрать форму единичного результата.
type Option<T> = { kind: 'some'; value: T } | { kind: 'none' };
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const head = <T>(xs: readonly T[]): Option<T> =>
xs.length === 0 ? { kind: 'none' } : { kind: 'some', value: xs[0]! };
const parseInt10 = (s: string): Result<number, string> => {
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: 'NaN' };
};
Option это “0 или 1, без причины”. Result это “1 или ошибка, с
причиной”. В TypeScript можно жить и без них, через T | undefined и
throw, но именованные варианты дают две полезные вещи: чек на
исчерпывающий switch и явный контракт на вызывающего.
В этом уроке Option и Result нам нужны как точка отсчёта. Дальше
любая асинхронная форма это та же ось, плюс время.
3. Один потом: Promise<T>
Промис, это контейнер для одного значения, которое появится позже. Состояний три: pending, fulfilled, rejected. После перехода в settled он зафиксирован навсегда.
const fetchPage = (url: string): Promise<string> =>
fetch(url).then((res) => res.text());
// Пять страниц одним залпом
const pages: string[] = await Promise.all(
urls.map((u) => fetchPage(u)),
);
Promise.all интересен тем, что превращает массив Promise<T> в один
Promise<T[]>. Это удобно, пока страниц мало. Когда их тысячи, каждый
ответ держится в памяти до возврата результата. И при первой ошибке
ничего не отдадим, даже если четыре из пяти уже пришли.
Дальше будет видно, что для большой N уместнее не массив промисов,
а ленивая последовательность.
4. 0 или 1 потом: Promise<Option<T>> плюс отмена
Любой find-запрос
имеет два штатных исхода: нашли или нет. И ещё один нештатный: пользователь
ушёл со страницы. Для двух исходов достаточно Option, для трёх нужен
AbortSignal.
const findUserByEmail = async (
email: string,
signal: AbortSignal,
): Promise<Option<User>> => {
const res = await fetch(`/api/users?email=${email}`, { signal });
if (res.status === 404) return { kind: 'none' };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { kind: 'some', value: await res.json() };
};
Договорённость такая: “не нашли”, это Option.none, значит не ошибка.
“Сервер упал” это throw, значит ошибка. “Пользователь отменил” это
AbortError, тоже ошибка, но отдельной породы, её обычно молча
проглатывают наверху.
Отмену рассмотрим подробно в уроке про события и сигналы. Здесь важно
запомнить, что AbortSignal это не штатный исход функции, а перерыв
извне.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.