
Сегодня мы продолжим разбирать базовые концепции реактивности, изложенные Райаном Карниато (Ryan Carniato), автором SolidJS. Если ранее мы затрагивали производные и их планирование, то сегодня разберём более сложную тему — асинхронность в контексте реактивного программирования. Эта концепция добавляет новый уровень сложности, поскольку требует учёта динамических процессов, выходящих за рамки синхронных операций.
Реактивность может включать планирование, но большая часть того, что мы рассматривали ранее, была синхронной, где состояние можно было проверить в любой момент времени.
Асинхронность кардинально меняет подход к реактивности. В JavaScript редко встречаются примеры, которые показывают, как эффективно интегрировать асинхронные процессы в реактивное программирование. Вместо того чтобы полагаться на готовые решения экосистемы, мы сосредоточимся исключительно на принципах, которые изучили ранее, и разберем, как их можно применить для управления асинхронными операциями в реактивном контексте.
Что такое асинхронная реактивность
Async — это сложно. Гораздо проще воспринимать вещи как последовательность, которая происходит один шаг за другим. Именно поэтому у нас есть такие вещи, как async/await:
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const user = await res.json();
console.log("user", user);
return user;
}
Но создание видимости последовательности —- это ещё не конец наших проблем. Вызывающая сторона также должна знать, что что-то выполняется асинхронно:
// не ждёт выполнения
const user1 = fetchUser(1);
console.log("I will log before user 1 is fetched");
// ждёт
const user2 = await fetchUser(2);
console.log("I will log after user 2 is fetched");
Async/Await позволяет удобно работать с асинхронным кодом, но его использование требует, чтобы функции, вызывающие другие async-функции, тоже были асинхронными. Таким образом, цепочка async-функций может продолжаться до самого «верха», пока мы не достигнем точки, где ожидание результата больше не требуется.
Однако, несмотря на удобство, это может привести к непреднамеренным последствиям. Одной из таких проблем является так называемый «водопад» (waterfall), при котором операции выполняются строго последовательно. Это, в свою очередь, блокирует модель выполнения, даже в случаях, когда задачи могли бы быть запущены параллельно или независимо друг от друга.
async function ShowSomeUI() {
const user1 = await fetchUser(1);
// запуститься только после 1 фетча
const user2 = await fetchUser(2);
return <>
<User user={user1} />
<User user={user2} />
</>
}
У нас есть способы распараллелить работу, но она все равно блокируется:
async function ShowSomeUI() {
const [user1, user2] = await Promise.all([fetchUser(1), fetchUser(2)]);
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user1} />
<User user={user2} />
</SharedLayout>
}
Что, если <ShowUnrelatedUI />
зависит от других асинхронных ресурсов? Вы всё равно столкнётесь с последовательной загрузкой. А если бы можно было показать <ShowUnrelatedUI />
до завершения асинхронных операций? Или если есть состояние, которое обновляется независимо во время выполнения запросов?
Из-за этого асинхронные функции плохо подходят для интерактивных компонентов, так как они конфликтуют с принципом независимости интерфейса.
Лучше не ждать выполнения промиса с помощью await
, а передать его в компонент, где он будет использован:
function ShowSomeUI() {
const user1 = fetchUser(1);
const user2 = fetchUser(2);
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user1} />
<User user={user2} />
</SharedLayout>
}
Однако это вызывает неудобства по двум причинам.
Во-первых, ваши компоненты ожидают, что в пропсах будет передан промис. Например, props.user
— это Promise<User>
, а не просто User
. Это создаёт дополнительную сложность: все зависимые пропсы и производные значения должны быть готовы работать с возможным промисом.
function User(props: {user: Promise<User>}) {
return <>
<h3>{props.user.then(u => u.firstName)}s Profile</h3>
<Address address={props.user.then(u => u.address)} />
<>
}
Мы могли бы использовать await здесь. В какой-то момент промис всё равно нужно разрешить. Но почему мы это делаем? Чтобы выбрать подходящий момент для обработки данных? Чтобы избежать запутанных цепочек промисов? Или чтобы не писать отдельные версии компонентов для работы с промисами и не переделывать существующие, которые раньше их не обрабатывали? Эти вопросы помогают понять, как лучше интегрировать асинхронность в реактивное программирование, и в дальнейшем мы разберем, как найти баланс между удобством и эффективностью.
Вторая проблема заключается в том, что мы имеем дело не только с промисами, но и с фабриками промисов. Вы не просто запрашиваете пользователя, вы запрашиваете пользователя на основе пропса. Этот пропс может измениться, и, соответственно, промис тоже должен измениться, так как он может быть разрешён только один раз. Но при этом вы не хотите запускать новый запрос, когда изменяется независимое состояние.
function ShowSomeUI(props: {id: number}) {
const user = fetchUser(props.id); // id can update
return <User user={user} />
}
Асинхронность без лишних сложностей
Сегодня можно сказать, что использование сигналов делает асинхронность практически «бесцветной». Тем не менее сигналы с синхронными значениями отличаются от тех, что работают с асинхронными данными.
// sync
const [user1] = createSignal<User>(user1JSON);
// async
const [user2, setUser2] = createSignal<User | undefined>();
fetchUser(2).then(setUser2)
Асинхронный сигнал может оставаться undefined
, пока не завершится. Проверка на null
не так критична, а задание значений по умолчанию заранее упрощает работу. Однако, зная, что TypeScript не всегда корректно обрабатывает идемпотентные функции, стоит отметить: появление ещё одного undefined ведёт к обилию !
и лишних ?.
в коде.
Разработка асинхронного компонента требует учёта возможных undefined-значений — по крайней мере, в экосистеме сигналов. В React 19 подход иной: если значение (при использовании use) не разрешено, React выбрасывает ошибку, избавляя код от проверок на null, так как до этого этапа он не доходит.
React решает проблему с другой стороны: после разрешения асинхронности «окраска» исчезает, но выше по цепочке всё ещё нужно передавать промисы. Это подталкивает к блокировке на более высоком уровне, чтобы избежать усложнения кода. Сигналы же позволяют обрабатывать асинхронность выше, не замораживая интерфейс.
Как объединить преимущества обоих подходов? Создать библиотеку сигналов, которая выбрасывает ошибки при неразрешённых асинхронных значениях.
Работа с асинхронностью
Сначала нужно различить, что именно асинхронно, а что просто undefined
. Можно предположить, что сигнал или производный узел становится асинхронным, если он получает Promise
или Async Iterable
. Однако, как упоминалось ранее, ленивые вычисления производных узлов делают этот подход проблематичным. Для асинхронности, которая должна выбрасывать ошибки, требуется механизм планирования. Текущие базовые примитивы с этим не справляются.
Можно было бы вернуться к активным вычислениям и внедрить обработку Promise/Async
Iterable, но без ясного понимания, оправдано ли это, я предлагаю новый примитив.
const user = createAsync(() => fetchUser(props.id));
// мы тоже можем извлечь из него. Обратите внимание на отсутствие проверки нуля
const firstName = createMemo(() => user().firstName)
// используйте его в эффекте (разделите, как в предыдущей статье)
createEffect(firstName, (name) => console.log(name));
Вот как это работает при начальном выполнении:
Запускается запрос пользователя по
props.id
.Создаётся мемоизация
firstName
, но её вычисление откладывается.Эффект ставится в очередь.
Первая часть эффекта выполняется и обращается к
firstName
.Так как
firstName
ещё не вычислена, она запускается и считывает user.Обнаружив, что user ещё не готов,
firstName
выбрасывает ошибку.firstName
регистрирует узел как зависимость и сама выбрасывает ошибку.Эффект перехватывает ошибку, добавляет узел в зависимости и прерывает побочный эффект.
Когда user разрешается, эффект получает уведомление.
Первая часть эффекта снова считывает
firstName
.firstName
, помеченная как устаревшая, выполняется и обращается к user.user
возвращает готовое значение.firstName
возвращает своё значение.Эффект сохраняет новое значение.
Побочный эффект выполняется, логируя имя пользователя.
При обновлении процесс аналогичен, но начинается с изменения id, после чего повторяются шаги 4–7.
Теперь вернёмся к примеру
function ShowSomeUI(props: { id: number }) {
const user = createAsync(() => fetchUser(props.id));
return <SharedLayout>
<ShowUnrelatedUI />
<User user={user()} />
</SharedLayout>
}
function User(props: {user: User}) {
return <Suspense fallback="Loading"}>
<h3>{props.user.firstName}'s Profile</h3>
<Address address={props.user.address} />
</Suspense>
}
Реактивность и асинхронность без явных ограничений
Задача полностью решена? Есть ли готовая система для идеальной асинхронности? Любое решение сопряжено с определёнными затратами. Они не критичны, но их часто игнорируют. Ключевая идея:
Любой элемент может быть реактивным в условиях бесцветной асинхронности.
В шаблонах всё обычно воспринимается как реактивное по умолчанию. Для компонентов подходы различаются. В одной из систем реактивности (например, SolidJS) проблема решена частично: автоматическое отслеживание реактивных данных в компонентах отключено, чтобы предотвратить ошибки при их использовании на верхнем уровне. Это даёт возможность писать более компактный код.
Получение сигналов из пропсов
Есть пример, с которым, уверен, сталкивался каждый разработчик. Бывало ли так, что вы инициализировали состояние на основе пропса?
Рассмотрим разницу между:
const [count, setCount] = createSignal(props.count);
const doubleCount = createMemo(() => props.count * 2);
Сигнал (state
) инициализируется начальным значением, а производное значение обновляется на основе props.count
. Данный пример функционирует сходным образом в системах Solid и React, но по различным причинам. В React состояние сохраняется, поэтому начальное значение пропса используется только при инициализации. Это создаёт определённую непоследовательность для React, так как изменение пропса, доступного на верхнем уровне, обычно игнорируется в таких случаях. В Solid это объясняется неявным отключением отслеживания реактивности (untrack
). В обоих подходах для синхронизации состояния требуется механизм, аналогичный useEffect
.
Далее рассматриваются различия между:
const [count, setCount] = createSignal(props.count);
const doubleCount = createMemo(() => untrack(() => props.count) * 2);
Этот пример приведён исключительно для демонстрации. Производное значение, которое отключает отслеживание (untrack
) своего единственного источника, не имеет практической ценности. Оба случая используют только начальное значение, и изменение props.count не влияет на результат.
Что произойдёт, если props.count
станет асинхронным значением в будущем?
В таком случае оно превратится в реактивный источник, требующий отслеживания. Инициализация count как undefined нежелательна, если по типам входных параметров ожидается число.
При использовании createSignal
, если асинхронный источник для props.count
ещё не завершён, возникает ошибка. Эта ошибка передаётся до ближайшего узла принятия решения, например, до тернарного выражения в компоненте на несколько уровней выше. После завершения асинхронной операции вся ветка от этой точки перерисовывается. Это не оптимизированная перерисовка виртуального DOM, а полная перерисовка реального DOM. Если ниже есть другие подобные зависимости, процесс повторяется до полного разрешения всех данных.
В отличие от этого, с createMemo
действия начинаются только при обращении к значению. При вычислении createMemo
перехватывает асинхронный узел и обновляет только ту часть, где происходит рендеринг.
Такое поведение существенно отличается от кода, который ранее казался семантически близким. Выбрасывание ошибок при доступе на верхнем уровне, как в случае с createSignal
, нежелательно. Это сопоставимо с отсутствием отключения отслеживания (untrack
) для компонентов на верхнем уровне, но в асинхронном контексте нет автоматической защиты, если значения не допускают undefined
.
Проблемы отслеживания асинхронности в реактивных системах
const [multiplier, setMultiplier] = createSignal(2);
const doubleCount = createMemo(
() => untrack(() => props.count) * multiplier()
);
В этом заключается ключевая проблема. Асинхронность не только превращает всё в реактивное, но и обходит механизм отключения отслеживания (untrack
). Что происходит, если асинхронное значение считывается в контексте, где отслеживание отключено, а затем обрабатываются другие реактивные данные?
Например, если props.count является асинхронным и его чтение вызывает ошибку, потребуется повторное вычисление зависимых значений, таких как doubleCount
, после разрешения props.count
. Хотя props.count
не регистрируется как зависимость при последующих вычислениях, на первом этапе оно фактически влияет как зависимость.
Нельзя полагать, что отключение отслеживания реактивности полностью исключает обработку асинхронных значений. Такой подход может нарушить работу последующих компонентов, если синхронное значение становится асинхронным.
Как избежать такого поведения? Это сложная задача. Один из способов — считывать только последнее разрешённое значение или возвращать undefined
вместо выбрасывания ошибки, но это меняет логику и семантику кода.
const [multiplier, setMultiplier] = createSignal(2);
const doubleCount = createMemo(
() => latest(() => props.count) * multiplier()
);
Умножение undefined
на число невозможно. Даже если ввести проверки на null
в контексте, где известно, что используется последняя обёртка, это не помогает для произвольных реактивных выражений. Необходимо добавлять проверки на null для всех потенциально асинхронных значений в пределах последней обёртки, но без поддержки типизации, поскольку каждое значение воспринимается как тип T
, а не T | undefined
.
Наиболее эффективный подход — предоставить возможность отключения этого поведения на уровне источника асинхронных данных:
const count = createAsync(() => fetchCount());
<Multiplier count={count.latest || 0} />
Разработка целостной модели

Можно ли считать бесцветную асинхронность иллюзией?
Если всё имеет одинаковый подход, остаётся ли оно «окрашенным»? Когда всё по умолчанию считается потенциально реактивным и эта реактивность неизбежна, выбор исключается. Независимо от преимуществ или недостатков, принимается единая модель, подобно тому, как изначально принимаются принципы реактивности в выбранной библиотеке.
Отличается ли эта модель от привычных? API одной из систем (например, Solid) спроектирован так, чтобы все данные рассматривались как потенциально реактивные. Поэтому отсутствуют методы вроде isSignal
, а входные параметры оборачиваются. Аналогичный подход используется в Runes системы Svelte, где доступ к базовому сигналу ограничен. В React компилятор представлен как инструмент для естественного взаимодействия с реактивностью на уровне компонентов. Общим является то, что, несмотря на явное определение состояния, реактивность свободно пронизывает эти системы.
Такой подход требует строгого соответствия. Как компилятор React функционирует только при соблюдении его правил, так и эта модель предполагает неукоснительное следование принципам реактивности — концепции, что все данные могут быть реактивными и что всё, что поддаётся вычислению, должно быть вычислено.
// don't do this
const [count, setCount] = createSignal(props.count);
createEffect(() => setCount(props.count));
// do this (assuming this expresses a derived Signal)
const [count, setCount] = createSignal(() => props.count);
Этот подход подтверждает идеи, которые ранее лишь подразумевались. В этом заключается его ценность. Почему обновляемое состояние не рассматривалось как производное? Сколько проблем с использованием useEffect
можно было бы предотвратить, если бы синхронизация входных параметров не требовалась? Насколько позже начинающим разработчикам пришлось бы сталкиваться с эффектами, если бы данные можно было выводить таким образом? Удивительно, что даже после многих лет изучения реактивности продолжают открываться новые перспективы.
В дальнейшем будет рассмотрена ещё одна малоизученная область реактивности — изменяемое состояние и производные данные. Мы проанализируем природу процессов сравнения (diffing) и возможности сосуществования неизменяемой и изменяемой реактивности.
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.