Solid.js как альтернатива (P)React+MobX на практике
- суббота, 10 мая 2025 г. в 00:00:09
 
Как известно, у 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, который, возможно, сможет решить эти проблемы. Однако проблему ограниченной экосистемы ввиду низкой популярности фреймворка решить будет не так просто.
Приглашаю смотреть Реактосолид для развлечения и собственных тестов.