Раздел 5 · Асинхронность

Оси асинхронности · гранулярность

middle~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Оси асинхронности · гранулярность

Слово “асинхронность” склеивает в один пучок несколько независимых вопросов. Сколько значений принесёт операция. Когда они появятся: сейчас, потом или по событию. Кто двигает поток: продюсер пушит или потребитель тянет. Можно ли прервать работу извне. Запускается она сразу или только когда подключился потребитель. На каждый из этих вопросов в 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 / EventEmitter0..бесконечностьпо событиюпушотписка плюс 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.