Понимая реактивные системы: искусство планирования зависимостей
- пятница, 21 марта 2025 г. в 00:00:11
В этой статье мы продолжим разбирать базовые концепции реактивности на основе идей и примеров, изложенных Райан Карниато (Ryan Carniato), автором SolidJS. Сегодня рассмотрим, как в реактивных системах планируется выполнение изменений производных значений.
Большинство разработчиков воспринимают реактивность как систему событий. У вас есть некоторое состояние, которое обновляется, а все зависящие от него данные пересчитываются. В итоге это изменение проявляется через побочные эффекты.
let name = state("John");
const upperName = memo(() => name.toUpperCase());
effect(() => console.log(upperName));
Мы будем использовать псевдокод, не ориентируясь на синтаксис конкретной библиотеки или фреймворка.
Но это чрезмерное упрощение. Как мы узнали из предыдущей статьи, существует множество способов распространения изменений в системе: "Push", "Pull" или даже "Push-Pull".
Хотя, рассуждая о реактивности, мы чаще всего представляем простую модель «Push», практически ни один современный фреймворк не использует её в чистом виде. Такая система не может обеспечить гарантии, к которым мы привыкли.
Как только мы выходим за пределы чистой модели «Push», система планирования становится неотъемлемой частью решения. Если выполнение задачи откладывается, её всё равно нужно будет выполнить позже. То, что именно запланировано и когда будет выполнено, напрямую влияет на результат.
При создании реактивной сущности у нас есть три варианта её вычисления.
Во-первых, выполнить её сразу. Если эффект создаёт другие эффекты, может понадобиться выполнение в глубину (depth-first) вместо обхода в ширину (breadth-first). Иногда нам нужно обработать всё дерево за один проход — такое поведение довольно часто встречается при рендеринге.
Во-вторых, можно отложить вычисления до момента, когда значение действительно потребуется. Такой метод называется ленивым вычислением (lazy evaluation). Это полезно, если производное значение нигде не используется или требуется только при изменении конкретного состояния UI, а его расчёт при этом требует много ресурсов. В таких случаях вычислять его заранее нет никакого смысла.
И наконец, запланировать выполнение узла на более поздний момент. Это важно, чтобы иметь возможность сначала обработать все промежуточные состояния. Например, если эффект не используется напрямую, его нельзя вычислить лениво. Вместо этого мы добавляем его в очередь, чтобы выполнить после подготовки всех зависимостей.
При обновлении у нас есть похожие варианты действий. Вне модели «Push» мы не выполняем вычисления мгновенно, но можем либо запланировать выполнение узла, либо оставить его расчёт на момент, когда значение действительно будет прочитано.
Вычисления стоит выполнять лениво, где это возможно, и планировать только те, которые действительно необходимы, чтобы не тратить ресурсы на лишнюю работу.
Производное состояние — отличный кандидат для ленивых вычислений, поскольку оно должно быть прочитано, чтобы использоваться.
Но есть ли другие факторы, которые стоит учитывать при принятии решения о планировании выполнения?
Кроме того, что ленивая оценка снижает риск ненужных вычислений, она обладает ещё одним важным преимуществом. Поскольку значения вычисляются только при необходимости, а не заранее, они могут автоматически освобождаться сборщиком мусора (garbage collector), если больше не используются.
В реактивных системах, таких как Signals, использующих паттерн наблюдателя, издавна существуют опасения по поводу утечек памяти. Это связано с тем, что подписчики обычно привязаны к зависимостям в обоих направлениях.
Но это также означает, что потеря ссылки на один из этих узлов недостаточна для сборщика мусора. Если сигнал и эффект связаны, то даже если эффект больше не используется, сигнал всё равно будет на него ссылаться, а он — на сигнал. Если на них обоих больше нет ссылок, их можно будет утилизировать, но нередко бывает, что состояние переживает свои побочные эффекты.
Когда выполняется реактивное выражение, оно подписывается на исходные сигналы и добавляет их в свои зависимости. Двусторонняя связь возникает из-за того, что при обновлении сигналам нужно уведомить зависимые узлы, а тем, в свою очередь, необходимо сбросить свои зависимости для всех узлов, с которыми они взаимодействуют. Таким образом, зависимости определяются динамически при каждом выполнении.
Однако это также означает, что обычного удаления ссылки на узел недостаточно, чтобы он был собран сборщиком мусора. Например, если у нас есть Signal и связанный с ним Effect, то даже если эффект больше не используется, сигнал всё равно продолжает на него ссылаться. Даже при удалении всех ссылок состояние часто продолжает существовать дольше своих побочных эффектов.
Обычно эффекты требуют явного удаления. Однако производные состояния могли бы самоочищаться, если на них никто не ссылается. Если его прочитают в будущем, оно пересчитается и восстановит зависимости, как и при первом вычислении.
Если же мы планируем выполнение производного состояния заранее, то узлы и их зависимости создаются немедленно, независимо от того, будет ли оно вообще использовано. В такой системе во время планирования неизвестно, понадобится ли вычисленный результат, поэтому он всё равно будет создан, а его зависимости установлены. В итоге это усложняет автоматическое освобождение памяти.
При создании UI работа с системами, требующими ручного удаления зависимостей, может быть неудобной и громоздкой. Большинство библиотек управления состоянием сосредоточены только на хранении данных и вычислении производных значений. При этом обработка эффектов остаётся на стороне фреймворка рендеринга. Благодаря этому такие системы обычно не требуют явного удаления состояния или производных значений, поэтому их удобно использовать.
В отсутствие библиотеки рендеринга именно S.js стал пионером модели реактивного владения (Reactive Ownership Model), которая теперь является неотъемлемой частью гранулированных рендеров (Fine-Grained Renderers) вроде SolidJS.
Если узлы, требующие ручного удаления, создаются внутри родительского реактивного контекста, то при следующем выполнении родительского узла (вместе с его зависимостями) дочерние узлы автоматически удаляются.
Это создаёт второстепенный граф, независимый от основного графа зависимостей, но он связывает эффекты и другие запланированные узлы, позволяя автоматически освобождать их при необходимости.
Кроме того, этот механизм лежит в основе таких возможностей, как Context API, а также группировка границ для обработки ошибок (Error Boundaries) и Suspense. По своей структуре этот граф напоминает дерево виртуального DOM, но содержит меньше узлов. Вместо того чтобы зависеть от количества элементов и компонентов, он формируется динамически — например, через условные конструкции.
Тем не менее, независимо от структуры, именно планирование выполнения определяет, какие узлы могут существовать внутри или вне этого дерева — особенно с точки зрения их управляемого удаления.
Должен ли код выполняться в предсказуемый момент времени?
С реактивностью у нас есть возможность моделировать самые разные системы, и мы не ограничены привычными представлениями о последовательности выполнения кода. Одна строка не обязана выполняться строго после другой.
Но разработчики — всего лишь люди, и момент выполнения кода имеет значение.
Побочные эффекты нельзя отменить.
Как только вы решили отобразить что-то на экране, это действие уже зафиксировано, и показать результат только частично — значит, создать несогласованное состояние.
Ошибки требуют строгой изоляции.
Если произошла ошибка, необходимо блокировать всё, что от неё зависит — именно поэтому существуют механизмы вроде Error Boundaries и Suspense.
Именно из-за этих факторов мы целенаправленно планируем выполнение кода, чтобы обеспечить осмысленный порядок обновлений.
React популяризовал модель, состоящую из трёх фаз выполнения:
Чистая фаза (Pure Phase) – выполняется пользовательский код: компоненты, вычисления.
Фаза рендеринга (Render Phase) – происходит сравнение виртуального DOM и обновление реального DOM.
Пост-рендеринг (Post-Render Phase) – выполняются пользовательские эффекты (Effects).
Тут используется это название, так как React использует термин «рендер» не в соответствии с тем, как работают другие фреймворки. Я использую термин «рендеринг» для обновления DOM, а не для запуска кода компонента.
Со стороны разработчика, весь ваш код выполняется на этапе Pure, за исключением эффектов, которые выполняются после рендеринга.
Это также включает массивы зависимостей (Dependency Arrays). В модели React все зависимости обновлений известны ещё до выполнения внутренних и внешних эффектов.
Эта возможность прекратить (bail out) цикл обновления до момента финального применения изменений является основой для конкурентного выполнения.
Например, код может выбросить Promise
в любой момент, что не прервет текущее отображение интерфейса.
При каждом ререндере можно гарантированно ожидать одинакового поведения, поскольку весь код выполняется заново от начала до конца.
В подходе "Push-Pull" можно использовать подобную систему, но при этом нельзя в полной мере воспользоваться возможностью "Push" обновлений на более точечном уровне. Однако существуют и другие способы организовать выполнение кода по фазам.
Прежде всего, важно понимать, что лениво вычисляемые производные значения будут выполняться в тот момент, когда первый эффект, использующий их, начнёт свою работу.
Если, например, ввести renderEffect
, который выполняется до пользовательских effect
, то именно в этот момент производные значения начнут вычисляться.
Изменение места, где читается реактивное выражение или производное значение, может повлиять на момент их вычисления — до или после рендеринга. Неожиданное добавление зависимости в новую фазу может изменить поведение несвязанного кода.
Изначально при разработке SolidJS ленивое поведение не вызывало особых опасений. Все вычисляемые узлы, как производные значения, так и эффекты, планировались к выполнению. Хотя это могло приводить к дополнительным вычислениям, большая часть состояния в компонентах имеет иерархическую структуру, поэтому, если данные не используются, они, как правило, удаляются. Однако планирование выполнения позволяло добиться следующего поведения:
Хотя разница незначительная, такое поведение означало, что все "чистые" вычисления выполнялись до эффектов.
Однако есть одно отличие: getFirstLetter выполняется в пост-рендер фазе. Любая зависимость, впервые появляющаяся внутри эффекта, который не был запланирован заранее, становится недоступной для обнаружения до выполнения всех эффектов. Поскольку асинхронные примитивы в этой системе также являются запланированными узлами, это редко приводит к проблемам, но остаётся небольшой, хоть и логичной, особенностью.
В Solid, как и в React, процесс выполнения разбит на три чётко определённые фазы. Именно это, вероятно, делает Solid единственным фреймворком на основе Signals, поддерживающим конкурентный рендеринг.
В отличие от Solid, почти все современные библиотеки с моделью Signals используют ленивые вычисления производных состояний.
Однако полный отказ от преимуществ поэтапного выполнения (Phased Execution) — неприемлемый компромисс. Поэтому стоит рассмотреть альтернативное решение.
Что ж, то, что работает для «Pull», работает и для «Push-Pull».
Наверное, последнее, что хотелось бы видеть — это возвращение «массивов зависимостей».
Но если разделить эффекты на чистый этап отслеживания (без сайд-эффектов) и сам эффект, то весь пользовательский код (кроме самого эффекта) мог бы выполняться в чистой фазе, до рендеринга.
Такой процесс мог бы выглядеть следующим образом:
Чистая фаза (Pure Phase) – выполняются все контексты отслеживания: первая часть renderEffects и эффектов. Здесь происходит чтение (и, возможно, вычисление) всех производных значений.
Фаза рендеринга (Render Phase) – выполняется заключительная часть renderEffects
.
Пост-рендер фаза (Post-Render Phase) – выполняется оставшаяся часть эффектов.
Этот подход отличается от массивов зависимостей тем, что компоненты не пересоздаются при обновлениях и могут динамически изменять зависимости на каждом запуске. Нет строгих правил хуков, как в React.
Если стоит задача использовать лениво вычисляемые производные значения и при этом соблюдать распределение работы по фазам, чтобы обеспечить предсказуемое планирование, то такой метод может быть подходящим решением.
Другой важный аспект планирования выполнения — это асинхронность.
Большинство реактивных систем работают синхронно. Асинхронные операции выполняются помимо основной реактивной системы. Например, создаётся эффект, который обновляет состояние, когда данные становятся доступными.
let userId = state(1);
let user = state();
effect(() => {
fetchUser(userId).then(value => user = value);
});
Но, как и в случае синхронного выполнения, здесь теряется информация о том, что user
зависит от userId
.
Если бы асинхронные обновления можно было представить как производное состояние, то можно было бы точно отслеживать, какие данные от них зависят.
let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));
И это относится не только к прямым зависимостям, но и ко всему, что находится ниже по цепочке:
let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));
const upperName = memo(() => user.firstName.toUpperCase());
upperName
зависит от user
, который, в свою очередь, зависит от userId
и может быть асинхронным.
Эта информация особенно полезна при реализации механизмов Suspense
. Чтобы корректно запускать Suspense
, когда обновляется userId
, необходимо точно знать, что это — зависимость асинхронной операции.
Кроме того, лучше приостанавливать выполнение ближе к месту, где данные реально используются, а не сразу, как только возникает первая зависимость.
В идеале Suspense должен срабатывать при чтении upperName
, а не там, где upperName
определён. Это даёт возможность запрашивать данные выше по дереву, чтобы они могли использоваться в нескольких местах ниже, без блокировки рендера всей вложенной структуры.
Давайте рассмотрим лёгкий пример с асинхронной реактивностью.
let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));
const upperName = memo(() => user.firstName.toUpperCase());
Что произойдёт, если fetchUser ещё не завершился к моменту вычисления upperName?
Изначально user будет равен undefined. Можно ожидать ошибку вида:
Cannot read property 'firstName' of undefined
Есть несколько способов решить эту проблему:
Задать значения по умолчанию.
Это помогает избежать ошибок, но не всегда уместно, так как некоторые данные не предполагают значений по умолчанию. Особенно в случае с глубоко вложенными объектами, где для предотвращения ошибок приходится создавать фиктивные структуры.
Проверять null и undefined в коде.
Это надёжный вариант, но приводит к разбросанным по приложению проверкам наличия значений. Нередко приходится делать такие проверки выше в дереве компонентов, чтобы не повторять их в каждом отдельном месте.
Выбрасывать специальную ошибку для повторного выполнения после загрузки данных.
React предложил решение — выбрасывать Promise
, когда данные ещё не готовы. Это позволяет избежать множества проверок на null, не требуя значений по умолчанию, гарантируя, что данные будут доступны, когда приложение действительно их получит.
Но при таком подходе снова возникает старая проблема:
const A = asyncState(() => fetchA(depA));
const B = asyncState(() => fetchB(depB));
const C = memo(() => A + B)
Если использовать метод выброса ошибок или другой способ условного прерывания вычислений, а производные значения вычисляются лениво, то возникает проблема «водопада» (waterfall).
При чтении C
выполнение начинается с A
. Данные могут начать загружаться, но поскольку A
ещё не разрешён, выполнение прерывается.
B
не будет вычисляться до тех пор, пока A
не завершится и не произойдёт повторный запуск. Только после этого начнётся загрузка B
. Это приводит к последовательному выполнению, где каждая операция блокирует последующие.
Если же A
и B
планируются заранее, их загрузка начинается независимо от того, когда C
будет прочитан. Это значит, что даже если A
выбросит исключение, B
уже может быть загружен к моменту завершения A
, поскольку оба запроса выполняются параллельно.
В целом, асинхронные значения лучше планировать заранее.
Хотя в некоторых случаях может быть полезно вычислять лениво асинхронные данные, определяя, что загружать, на основе пути выполнения кода, всё это может серьёзно сказываться на производительности.
В системах, использующих выброс исключений для управления асинхронными операциями, водопады возникают очень легко. Использование планирования выполнения (scheduling) и знания о графе зависимостей — один из способов избежать этой проблемы.
Надеюсь, этот разбор показал, насколько важную роль играет планирование выполнения (scheduling) в реактивных системах.
Модель "Push-Pull" на самом деле является Pull-механизмом, встроенным в Push-систему.
Использование ленивых вычислений производного состояния влечёт за собой множество последствий, которые не возникают в системах, где всё предварительно планируется или где используется чистая "Pull"-модель. Даже при оптимизации под ленивое выполнение остаётся множество случаев, когда планирование всё же необходимо. Однако при аккуратном проектировании "Push-Pull" становится очень мощным инструментом, так как добавляет ещё одно измерение к традиционной "Pull"-реактивности. Это позволяет получать преимущества в плане предсказуемости и согласованности, но с возможностью применять их более точно и гибко.
Эта тема ещё активно исследуется.
Помимо работы над Solid 2.0, этот вопрос становится всё более актуальным из-за прогресса в разработке предложения TC-39 по Signals и растущего запроса от сообщества на интеграцию планирования выполнения в браузерные и DOM API.
Поскольку здесь ещё много неопределённостей и разногласий, преждевременный подход к внедрению может привести к неэффективным решениям, проблемам совместимости, а также создать ограничения, которые позже будет сложно пересмотреть.
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.