Solid.js как альтернатива (P)React+MobX на практике
Как известно, у Solid довольно скудная экосистема, поэтому для сложных проектов я беру React+MobX. Однако недавно подвернулся небольшой mobile-only проект, в котором разве что маскированные инпуты и кастомные селекты, которых для Solid предостаточно. При этом требования к размеру выходных файлов и перфомансу были высокие.
Очевидным решением посчитал взять Solid, заодно и сравнить его по всем параметрам (размер, перфоманс, возможности реактивности, удобство настройки) в реальном проекте. Никаких синтетических тестов с рендерингом больших таблиц и хранением в сторе нескольких мегабайт данных не будет, зато приведу замеры из реального приложения. Бонусом - репозиторий с универсальной архитектурой для Solid+Preact+React, где замена фреймворка (набора стейт-менеджер + рендеринг UI) производится одной строчкой кода.
Создание кросс-фреймворкового проекта
Первым делом при изучении Solid мне было интересно, какие возможности из привычного для меня стека он поддерживает, а также сравнить side-to-side с ним без модификации кода проекта, кроме файла с адаптерами. Основные моменты, которые потребовались для создания универсального проекта:
при настройке tsconfig для Solid нужно указывать
{ "jsx": "preserve", "jsxImportSource": "solid-js" }
транспиляция Solid JSX сейчас поддерживается только с помощью babel с плагином
babel-preset-solid
, поэтому пришлось его подключить к esbuild, который я сейчас использую для сборки большинства проектовSolid предоставляет ряд вспомогательных компонентов (
<For>
для списков,<Show>
для показа по условию,<Dynamic>
для динамических компонентов и html-тегов), поэтому в адаптерах React добавил их базовые реализациидля создания сторов я выбрал аналог MobX
makeAutoObservable
, который в Solid называетсяcreateMutable
. Однако он не поддерживает автобиндинг методов класса, что решилось библиотекойauto-bind
, и не поддерживает присвоение observable-объекту нового значения. В целом адаптация выглядела так:
// mobx
constructor() { makeAutoObservable(this, undefined, { autoBind: true }) }
// solid
constructor() { return createMutable(this) }
autoBind(store)
// mobx
store.object = newObject
// solid
modifyMutable(store.object, produce(state => {
for (const key in state) delete state[key];
Object.assign(state, newObject);
}))
// mobx
method() { this.param = 1 }
// в Solid нет action, поэтому везде ручной батчинг
method() { batch(() => { this.param = 1 }) }
// mobx
const disposer = autorun(fn)
// в Solid нет disposer, эффекты автоматически удаляются при размонтировании
createRenderEffect(fn)
Solid JSX немного отличается от React JSX.
innerHTML
вместоdangerouslySetInnerHTML
,onInput
вместоonChange
, названия свойств в style такое же, как в css (z-index
вместоzIndex
,border-top
вместоborderTop
), нельзя деструктурироватьprops
В целом, отличий оказалось достаточно мало, и написание адаптеров не заняло много времени. Основное время заняла конвертация архитектурных библиотек с React+Mobx на Solid (роутинг, подключение ViewModel, работа с асинхронными функциями и т.п.). Хотя сам перевод библиотек выполнялся буквально парой строк, с полным сохранением публичного api пакетов, настройка тестов оказалась довольно неудобной.
Для тестирования компонентов Solid в настоящее время поддерживает только Jest и uvu, в связи с чем вместо стандартных зависимостей
"mocha": "10.4.0",
"sinon": "17.0.1",
"chai": "4.4.1",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "15.0.1",
"global-jsdom": "24.0.0",
"jsdom": "24.0.0",
пришлось настраивать Jest и babel
"@babel/core": "7.26.10",
"@babel/preset-env": "7.26.9",
"@babel/preset-typescript": "7.27.0",
"@solidjs/testing-library": "0.8.10",
"@testing-library/jest-dom": "6.6.3",
"babel-jest": "29.7.0",
"babel-preset-jest": "29.6.3",
"babel-preset-solid": "1.9.5",
"chai": "5.2.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jsdom": "26.1.0",
"regenerator-runtime": "0.14.1",
"sinon": "20.0.0",
"solid-jest": "0.2.0",
"ts-jest-resolver": "2.0.1",
Благо, что тесты практически не пришлось менять - разве что у Solid нет функционала rerender для компонента, чтобы протестировать сценарий изменения props. Для этого нужно создавать внешний сигнал (в MobX это был бы внешний observable
).
Архитектурные ограничения
Для простоты я буду далее называть получившийся "кросс-фреймворковый проект" Реактосолидом.
В прошлом разделе я перечислил ряд синтаксических различий при написании JSX и реактивных сторов, однако есть и архитектурные ограничения, если требуется писать универсальный код, работающий в нескольких фреймворках.
1. Solid createMutable
, безусловно, не полноценная замена MobX. Он не поддерживает Set и Map и в целом довольно нестабильный. Да, можно использовать Solid+MobX для получения бескомпромиссной реактивности и стабильности, однако ряд преимуществ в плане размера файлов и перфоманса пропадет.
2. В Solid нет controllable inputs. Это создает массу неудобств при работе с инпутами - особенно при обработке вводимых данных. Issue открыт давно, но предлагаемые решения с контролированием положения каретки и ручным ререндером довольно проблемные. Поэтому в Реактосолиде создать полноценный адаптер для инпутов не получилось.
3. Нельзя использовать специфичный для фреймворка функционал внутри компонента (onMount/createSignal/createMemo
у Solid и хуки useState/useMemo/useEffect
и т.п. у Реакта). Они не являются прямой заменой друг друга и даже с помощью адаптеров не удастся добиться полной совместимости. Для меня это не стало ограничением, т.к. я использую ViewModel подход и не пользуюсь подобным функционалом
class VM implements ViewModel {
constructor() { return createMutable(this); }
// React class-component equivalent: componentWillMount
beforeMount() {}
// React class-component equivalent: componentDidMount
afterMount() {}
// React class-component equivalent: componentWillUnmount
beforeUnmount() {}
reactiveData = 1;
// computed getters
get param() { return this.reactiveData + 1 }
// user action handlers
handleClick() {}
}
function App() {
const { vm } = useStore(VM);
return <div />;
});
4. Нельзя использовать динамическую логику внутри компонентов
// было
function Component(props) {
if (props.isLoading) return <Loader />
return <div />
}
// стало
function Component(props) {
return (
<Show when={!props.isLoading} fallback={<Loader />}>
<div />
</Show>
)
}
Я считаю, что это более правильный подход, чем в React, в котором внутри функции компонента можно писать лапшу из хуков и вычисляемых параметров. Логика должна группироваться во ViewModel и там же оптимизироваться с помощью computed геттеров, а тело функции оставаться чистым - только подключение ViewModel и jsx разметка. Это же способствует эффективному и чистому тестированию.
Разумеется, если писать на 1 фреймворке (например, только на Solid), то ограничений будет меньше, но мне для side-to-side сравнения потребовалось привести все к общему знаменателю и отсечь лишнее, заодно и наработал практику миграции проекта с одного фреймворка на другой.
Сравнение размера и производительности
В "чистом" виде (1 компонент + 1 стор) разница в размерах колоссальная. React и Preact (compat) в таблице везде считаются вместе с MobX и mobx-react-lite
/ mobx-preact
.
Solid | Preact | React | |
dev | 38.31 kb | 237.03 kb | 1269.26 kb |
dev min+br | 6.52 kb | 33.90 kb | 128.13 kb |
prod | 35.26 kb | 227.41 kb | 808.35 kb |
prod min+br | 5.78 kb | 29.84 kb | 78.99 kb |
В реальном проекте с десятком страниц и полноценной архитектурой разница тоже довольно ощутима.
Solid | Preact | React | |
prod | 551.60 kb | 756.51 kb | 1346.44 kb |
prod min+br | 65.00 kb | 88.53 kb | 134.68 kb |
По производительности кода в реальном проекте ситуация следующая (измерения проводились в Chrome Profiler). Первая загрузка:
Solid | Preact | React | |
First full render | 42 ms | 59 ms | 71 ms |
Выполнение определенного пользовательского сценария, с переходом на несколько страниц, вызовом модальных окон, показом загрузчиков и т.п., не считая первой отрисовки страницы:
Solid | Preact | React | |
Scripting | 113 ms | 185 ms | 374 ms |
Rendering | 71 ms | 97 ms | 113 ms |
Painting | 30 ms | 46 ms | 49 ms |
System | 220 ms | 289 ms | 307 ms |
Так как весь код, кроме адаптеров - универсальный, сравнение должно быть достаточно точным. Однако есть и ряд неточностей - например, если на React писать в привычном многим формате с вычислениями в функции рендера и хуками, то результаты у него будут хуже. Если же оптимизировать компоненты для React+MobX, то есть выносить условно Table + TableRow + TableCell
и в props
передавать целые observable объекты для достижения точечной реактивности, то результаты будут чуть лучше. Но архитектура Реактосолида "усредняет" результаты - не используются все преимущества и не скрываются недостатки конкретного фреймворка, так что порядок цифр должен сохраниться.
Выводы
Здесь все предсказуемо. Для проектов, где экосистема не сильно важна, и при этом важен перфоманс (лендинги, mobile-only приложения, бизнес-сайты "визитки", личные проекты и т.п.) однозначно можно брать Solid. А при умении его правильно "готовить" можно буквально за пару часов перевести на React/Preact и получить огромную экосистему. Можно также попробовать связку Solid+MobX для получения бескомпромиссной реактивности и еще более простого перехода.
У Solid есть неоспоримые архитектурные преимущества - например, точечная реактивность (можно хоть всю разметку держать в 1 файле - перерендерится только конкретный элемент, читающий реактивные данные). Это позволяет выделять компоненты только по семантическому признаку, а не ради перфоманса. Подход "функция компонента вызывается только 1 раз" тоже очень удобен и убирает из проекта бойлерплейт и правила, навязываемые React-хуками. Также отсутствует необходимость оборачивать компонент в MobX observer
и вручную синхронизировать props с ViewModel, что упрощает сборку, линтинг и улучшает перфоманс.
Но есть и недостатки - отсутствие контролируемых инпутов, нестабильность и ограниченный функционал createMutable
. Тем не менее, Solid активно развивается, и ведется работа над Solid 2.0, который, возможно, сможет решить эти проблемы. Однако проблему ограниченной экосистемы ввиду низкой популярности фреймворка решить будет не так просто.
Приглашаю смотреть Реактосолид для развлечения и собственных тестов.