Универсальный солдат: обзор библиотеки Signals от команды Preact
Стейт-менеджеры уже давно стали своеобразным мемом среди разработчиков. Бытует мнение, что фронтедеры только тем и занимаются, что вместо решения действительно важных и актуальных проблем постоянно переписывают проект с одного стейт-менеджера на другой. Благо их количество и поток новых, выходящих в open source, позволяют.
Меня зовут Женя, я все еще фронтенд-разработчик в команде Quick Experiments inDrive. И я тоже не люблю выделяться из толпы, поэтому предлагаю обратить внимание на новое решение от команды Preact — Signals. Во вступительной статье создатели библиотеки заявляют о том, что сегодня создано огромное количество решений по управлению состоянием приложения, но они требуют сложной и долгой интеграции с фреймворком. Это усложняет проектирование, так как нужно постоянно держать в уме особенности стейт-менеджера. Усложняется и разработка, так как нужно тратить много времени и сил на интеграцию стейт-менеджера и библиотеки рендеринга.
Выход есть — Signals. Решение, по словам создателей, сочетает оптимальную производительность для разработчиков и легкое внедрение во фреймворк. Под катом — подробный разбор библиотеки.
Я не буду пересказывать все методы библиотеки из документации (коих, кстати, всего четыре), а постараюсь проиллюстрировать, какие проблемы решает новая библиотека. Для этого нужно проанализировать и понять, какие проблемы возникают с существующими в React способами хранения и передачи данных, которые авторы библиотеки условно делят на два типа:
1. Локальное состояние (useState, useReducer для сложной логики) c последующей передачей данных через пропсы.
2. Передача данных через контекст (useContext).
Локальное состояние
В любом реактовском приложении используется хук useState. В проектах с несложной логикой и малой кодовой базой можно вполне обойтись только им. Но такое бывает сравнительно редко. При возникновении ситуации, когда нескольким компонентам требуется доступ к одному и тому же фрагменту состояния — состояние поднимается к общему компоненту-предку. По мере увеличения количества компонентов прием повторяется множество раз. В итоге обычно это вызывает проблему prop-drilling, а также ненужный повторный рендеринг множества компонентов или вообще всего дерева (в случае, если большая часть состояния лежит в корне дерева). Чтобы убрать рендеринг, можно использовать мемоизацию, но нужно учесть два момента:
1. Мемоизация работает только с чистыми функциональными компонентами, то есть без сайд-эффектов.
2. Мемоизация более полезна для функций, требующих больших вычислений. В других случаях оптимизация производительности в лучшем случае будет незначительной, а в худшем — приведет к ошибкам.
Таким образом, при большой кодовой базе достаточно сложно определить, в каком месте проводить оптимизацию (нет четких правил по ее использованию). А в ряде случаев мемоизация даст обратный эффект по производительности.
Вместо мемоизации можно использовать другой метод — подъем компонента с последующей его передаей в виде пропсов, который подробно описан в этой статье. Он достаточно простой, а результаты его внедрения могут быть достаточно внушительны. Но им не получится воспользоваться, когда одни и те же данные должны быть доступны многим компонентам в дереве, причем на разных уровнях вложенности.
Вывод: локальное состояние сложно использовать в случаях, когда множеству компонентов требуется доступ к одному и тому же фрагменту состояния. Возникают prop-drilling и лишние отрисовки, а мемоизация — не панацея для решения проблем производительности.
Контекст
После того, как приходит осознание, что одним локальным состоянием не обойтись, начинают задумываться о внедрении контекстного состояния. Контекст — это не инструмент управления состоянием, а, скорее, механизм DI, сам по себе он ничем не «управляет».
Контекст полезен, когда данные должны быть доступны компонентам на разных уровнях вложенности. Это избавляет от prop-drilling, что облегчает отслеживание передачи данных между модулями. Казалось бы, это решает проблему, но не все не так просто.
Когда провайдер получает новое значение, все компоненты находящиеся под ним, обновляются и должны выполнить рендеринг. Даже если это функциональный компонент, которому важна только часть данных. Это может привести к потенциальным проблемам с производительностью.
Чтобы это обойти, контекст держат максимально близко к месту, где оно необходимо, а данные логически разделяют и хранят в разных объектах состояния. При таком подходе будет несколько провайдеров.
Проблема в использовании контекста, на которой заостряют внимание авторы Signals, состоит в том, что с увеличением объема кодовой базы будет и увеличиваться количество компонентов, которые нужны только для обмена данными. Бизнес-логика неизбежно оказывается в зависимости от нескольких контекстов, из-за чего разработчику придется реализовать компонент в определенном месте дерева.
Добавление консьюмера в середине дерева плохо для производительности, поскольку увеличивает количество компонентов, которые перерисовываются при изменении контекста. Исправить данную проблему может только мемоизация. Но, как мы выяснили в пункте с локальным состоянием — у нее есть ограничения.
Вывод: у использования контекста тоже есть много нюансов. Базовые советы заключаются в том, что контекст лучше только в случае редко изменяющихся данных, таких как тема или локализация или в случае, когда prop-drilling действительно становится проблемой.
Signals
Как видим, оба способа хранения и передачи данных требуют различных приемов по улучшению производительности. Они универсальны по своей природе, а хотелось бы решение, которое было бы быстрым по умолчанию и с легким API. Теперь можно перейти к библиотеке, которая должна их решить — Signals.
Авторы пишут, что библиотека уникальна тем, что изменения состояния автоматически обновляют компоненты и пользовательский интерфейс наиболее эффективным способом. Разработчикам не нужно писать код, чтобы проводить оптимизацию — система быстрая по умолчанию, не требует мемоизации и различных трюков по всему приложению. Signals обеспечивает преимущества точного обновления состояния независимо от того, является ли это состояние глобальным, передаваемым через пропсы или контекст, или это локальное состояние в компоненте.
Под капотом это работает так: через дерево компонентов вместо передачи значения передается объект (сигнал), содержащий свойство value с некоторым значением. Так как компоненты видят сам сигнал, а не его значение, то сигналы можно обновлять без повторного рендеринга компонентов и сразу перейти к конкретным компонентам в дереве, которые действительно обращаются к значению сигнала.
Создатели библиотеки используют тот факт, что дерево состояний приложения обычно намного меньше, чем дерево компонентов. Это ускоряет рендеринг, поскольку для обновления дерева состояний требуется гораздо меньше работы. На скриншоте ниже показана трассировка для одного и того же приложения, измеренная дважды: один раз с использованием хуков, а второй — с использованием Signals:
Библиотека очень универсальна в использовании. В отличие от хуков, ее можно использовать как внутри, так и вне компонентов. Signals отлично работает как с хуками, так и с классовыми компонентами.
Еще одно большое преимущество библиотеки, чего точно нет у стандартных хуков — она работает не только с Preact, но и с React, Svelte и многими другими решениями. Что особенно поразительно — корневой пакет preact/signals-core создан так, чтобы работать и вне Preact!
Теперь сравним preact/signals-react с другими библиотеками стейт-менеджмента по весу:
preact/signals-core — корневая библиотека: 1.4kB, нет внешних зависимостей.
preact/signals — хуки для работы с preact: 2.4kB, под капотом preact/signals-core, других зависимостей нет.
preact/signals-react — хуки для работы с React: 2.4kB, под капотом preact/signals-core и одна внешняя зависимость в виде хука use-sync-external-store (де-факто библиотечная реализация одноименного хука из 18 версии React).
Название библиотеки | Вес библиотеки |
redux + react-redux | 6.3kB |
redux-toolkit + react-redux | 8.3kB |
mobx + mobx-react-lite | 18.6kB |
effector + effector-react | 14.1kB |
nanostores + nanostores/react | 2.2kB |
preact/signals-react | 2.4kB |
Легко заметить, что preact/signals-react качественно выделяется на фоне других представленных библиотек, уступая только nanostores, но незначительно.
Любопытно, что работа preact/signals-react основана на переопределении JSX в самом React, по сути встраиваясь в него. Сами разработчики библиотеки признают, что это немного костыльный код, однако он работает и означает, что компоненты не требует модификаций или оберток для поддержки автоматических подписок.
С помощью вышеупомянутого хука useSyncExternalStore, React подписывается на стор и получает снапшот текущей «версии». Всякий раз, когда «версия» меняется, библиотека сообщает React, что пора обновить компонент.
Пока у меня достаточно небольшой опыт использования этой библиотеки. Однако тот факт, что Signals можно использовать вне Preact, в нем ленивые и оптимальные обновления, а также отсутствие зависимостей (как в хуках), рисует большие перспективы для ее использования в самых разных сценариях. Даже тех, которые не имеют ничего общего с рендерингом пользовательских интерфейсов.
Например, использование Signals вместе с postMessage позволяет синхронизировать различные вкладки браузера или фреймы между собой. Поэтому, если вам нужна реактивность в самых неочевидных и сложных кейсах, рекомендую присмотреться к Signals. Возможно, решение сэкономит кучу потраченного времени и нервов.
Источники: