Команда JavaScript for Devs подготовила перевод статьи о SolidJS — реактивной UI-библиотеке, которая выглядит знакомо для React-разработчиков, но работает совсем иначе. Автор разбирает ключевые отличия: почему в Solid нет виртуального DOM, как устроены сигналы, эффекты и прокси-хранилища, а также какие привычки из React ломают реактивность. Если вы давно хотели понять, как SolidJS работает под капотом, эта статья — отличный старт.
SolidJS существует уже несколько лет. Как React-разработчик, я решил попробовать его. И хотя он имеет много общего с React, в нём есть и ключевые отличия.
Сначала я наивно использовал SolidJS как React (от старых привычек трудно избавиться), и это в основном работало... пока не перестало. Я быстро столкнулся с проблемами, которые я не до конца понимал, хотя они и были подробно описаны в документации SolidJS. Мне нужно было понять, почему эти проблемы возникают, чтобы усвоить их. Мне нужно было понять, как SolidJS работает под капотом. В этой статье я поделюсь своими выводами и объясню внутреннее устройство SolidJS.
Мы уже встречались?
SolidJS — это реактивная UI-библиотека, которая внешне очень похожа на React. Она использует компонентно-ориентированную архитектуру, где каждый компонент — это функция, возвращающая JSX. Она предоставляет функции, похожие на хуки, такие как createSignal
, createEffect
и createMemo
.
Она также поддерживает знакомые концепции, такие как Portals, Suspense и Error Boundaries. Даже её экосистема кажется похожей:
TanStack (Query, Form, Router) поддерживает Solid.
Testing Library также поддерживает Solid.
SUID — это порт Material UI для Solid.
SolidStart для Solid то же самое, что Next.js или Remix для React.
Если вы React-разработчик, вы будете чувствовать себя как дома, по крайней мере, поначалу.
import { createSignal } from 'solid-js';
const Counter = (props) => {
const [getCount, setCount] = createSignal(props.initialCount);
return (
<div>
<p>Count: {getCount()}</p>
<button onClick={() => setCount(getCount() + 1)}>Increment</button>
</div>
);
};
// Можете ли вы заметить все различия и понять их причины?
Однако, если вы начнёте использовать условный рендеринг, деструктуризацию пропсов или асинхронные функции внутри эффектов, как вы это делаете в React, вы быстро столкнётесь с проблемами и будете задаваться вопросом, почему ничего не работает.
SolidJS — это не React, и он работает не так, как React.
Давайте рассмотрим подробнее.
По-настоящему: без виртуального DOM
SolidJS не использует виртуальный DOM. Вместо этого он применяет принцип мелкозернистой реактивности.
Solid вызывает каждую функцию компонента один раз для инициализации реактивности. После этого Solid обновляет только те конкретные узлы DOM, которые требуют изменений.
В отличие от этого, React повторно рендерит весь компонент при изменении одного из его пропсов или переменных состояния.
Из-за этого в Solid:
Компоненты должны быть полностью инициализированы заранее.
Компоненты не должны содержать условных конструкций (
if
, тернарных операторов илиarray.map
).
Вместо этого Solid предоставляет специальные компоненты для управления потоком выполнения:
Простые условные конструкции с <Show>:
<Show when={getData()} fallback={<div>Loading...</div>}>
Display data
</Show>
Сложные условные конструкции с <Switch> и <Match>:
<Switch>
<Match when={getStatus() === 'loading'}>
<div>Loading...</div>
</Match>
<Match when={getStatus() === 'success'}>
...
</Match>
<Match when={getStatus() === 'error'}>
An error occurred
</Match>
</Switch>
Отрисовка списков с <For>:
<For each={data()}>
{(item, index) => ...}
</For>
Существуют и другие вспомогательные компоненты (Portal
, Suspense
, Dynamic
, ErrorBoundary
) — см. документацию Solid.
Примечание: Использование тернарных операторов или
array.map
внутри JSX по-прежнему будет работать, но вы потеряете преимущества тонкогранулированной реактивности в производительности.
Поощрение использования сигналов
Как упоминалось ранее, SolidJS похож на React:
// В React
const [counter, setCounter] = useState(0);
useEffect(() => {
console.log(`Count is ${counter}`);
}, [counter])
// В Solid
const [getCounter, setCounter] = createSignal(0);
createEffect(() => {
console.log(`Count is ${getCounter()}`)
})
Он использует сигналы вместо useState
из React, но синтаксис довольно схож.
Однако есть два ключевых отличия, которые важно понимать:
useState
в React возвращает само значение состояния (counter
);createSignal
возвращает функцию-геттер (getCounter
).useEffect
в React требует массив зависимостей;createEffect
в SolidJS автоматически отслеживает зависимости.
Помните, в SolidJS компоненты не перерисовываются при изменении их состояния — но Solid всё равно знает, что обновить.
Как это работает?
Это происходит потому, что сигналы SolidJS действуют как генераторы событий, следуя шаблону Наблюдатель.
Краткое повторение: Шаблон Наблюдатель
Наблюдаемый объект (Observable) отслеживает список Наблюдателей (Observers) и уведомляет их при своём изменении.
Вот простая версия:
const createObservable = () => {
const observers = [];
return {
subscribe: (callback) => {
observers.push(callback);
},
unsubscribe: (callback) => {
observers = observers.filter((observer) => observer !== callback);
},
notify: (data) => {
observers.forEach((observer) => observer(data));
},
};
};
const observable = createObservable();
const logName = (name) => console.log(`Name is ${name}`);
observable.subscribe(logName);
observable.notify('SolidJs');
// Залогируется: "Name is SolidJs"
Сигналы с нуля
createSignal
в SolidJS — это функция, которая создаёт реактивный сигнал, являющийся наблюдаемым значением, которое можно читать и обновлять.
Следующий фрагмент показывает упрощённую реализацию, которая не обрабатывает очистку эффектов и другие оптимизации, но даёт хорошее представление о том, как SolidJS работает под капотом.
// Нам нужна глобальная переменная, чтобы отслеживать текущий выполняющийся эффект во всём приложении
// Мы используем массив, чтобы иметь возможность отслеживать вложенные эффекты
const runningEffects = [];
const createSignal = (value) => {
// Это отслеживает наблюдателей (observers) сигналов
// Мы используем Set, чтобы избежать повторной регистрации одного и того же наблюдателя,
// если эффект вызывает один и тот же геттер дважды
const subscriptions = new Set();
const getter = () => {
// Переменная `runningEffect` будет хранить текущий выполняющийся эффект, если такой есть
const runningEffect = runningEffects[runningEffects.length - 1];
if (runningEffect) {
// Если есть выполняющийся эффект, регистрируем его как наблюдателя сигнала
// Так как мы знаем, что эффект вызывает этот геттер
subscriptions.add(runningEffect);
}
return value;
};
const setter = (nextValue) => {
// Мы обновляем значение сигнала
value = nextValue;
for (const subscription of [...subscriptions]) {
// Уведомляем всех наблюдателей об изменении базового значения
subscription.execute();
}
};
return [getter, setter];
}
// При создании эффекта
const createEffect = (effectFn) => {
// Создаём объект для эффекта
// Этот объект будет содержать функцию execute
const effectContext = {
execute,
};
const execute = () => {
// Добавляем текущий эффект в массив `runningEffects`
// Это позволит геттеру сигнала, вызываемому эффектом, зарегистрировать его как наблюдателя
runningEffects.push(effectContext);
try {
// Выполняем эффект, который вызовет геттеры сигналов, которые он использует
// Геттеры, в свою очередь, зарегистрируют этот эффект как наблюдателя
effectFn();
} finally {
// После выполнения эффекта удаляем его из списка выполняющихся эффектов
runningEffects.pop();
}
};
// Вызываем функцию execute, чтобы запустить эффект первый раз во время инициализации
// При последующих обновлениях сеттер сигнала будет уведомлять подписанные эффекты об изменениях
execute();
}
В этой реализации:
-
Сигналы предоставляют геттер и сеттер, а также внутренне отслеживают набор наблюдателей.
Геттер регистрирует текущий эффект как наблюдателя при его вызове.
Сеттер обновляет значение и уведомляет всех зарегистрированных наблюдателей об изменении.
-
Функция
createEffect
создает функциюexecute
, которая:добавляет текущий эффект в массив
runningEffects
вызывает функцию эффекта
удаляет текущий эффект из массива
runningEffects
Важно: Избегайте асинхронного кода внутри
createEffect
. Эффекты используют глобальную переменнуюrunningEffects
для регистрации текущего эффекта в качестве наблюдателя. При асинхронной логике эффект может вызвать геттер асинхронно, в момент, когда эффект не будет текущим выполняющимся эффектом, и геттер не зарегистрирует эффект для правильного наблюдателя.
// Не делайте так:
const [getData, setData] = createSignal(null);
const [getUser, setUser] = createSignal('admin');
const [getId, setId] = createSignal('id');
createEffect(async () => {
const authToken = await fetch(`https://api.example.com/auth/${getUser()}`);
// после первого await эффект больше не является текущим выполняющимся эффектом.
// поэтому при вызове getId() здесь он зарегистрируется в другом эффекте. И в следующий раз,
// когда сигнал id изменится, он не запустит этот эффект, а какой-нибудь другой случайный.
const data = await fetch(`https://api.example.com/data/${getId()}?token=${authToken}`);
setData(data);
});
Вместо этого вы можете добавить ещё один createEffect
внутрь эффекта (что невозможно в React).
createEffect(async () => {
const authToken = await fetch(`https://api.example.com/auth/${getUser()}`);
// после первого await эффект больше не является текущим выполняющимся эффектом.
createEffect(async () => {
// поэтому мы создаём другой эффект, который может быть запущен снова,
// когда сигнал id изменится
const data = await fetch(`https://api.example.com/data/${getId()}?token=${authToken}`);
setData(data);
});
});
Подводный камень! Геттеры свойств
Теперь мы знаем, как SolidJS использует сигналы для управления реактивностью состояния и эффектов. Но как он поддерживает реактивность для свойств (props)?
Ответ: он использует функции-геттеры.
SolidJS преобразует все свойства, передаваемые компоненту, в геттеры, чтобы они вычислялись отложено и всегда возвращали последнее значение.
Поэтому, когда вы передаёте свойство name
компоненту, он создаёт геттер для этого свойства:
<DisplayName name={name()} />
// под капотом преобразуется в:
DisplayName({
get name() {
return name();
}
});
И угадайте, что произойдёт, если вы деструктурируете ключ, который имеет геттер, из объекта свойств? Он будет преобразован в обычное значение, которое теряет отложенность и вычисляется немедленно, теряя реактивность.
Другими словами, это будет работать при первом рендере, но когда свойство изменится, деструктурированное значение останется прежним.
Поэтому избегайте деструктуризации свойств в Solid, так как это нарушит реактивность:
const DisplayName = (props) => {
const { name } = props; // Это сломает реактивщину
return <div>{name}</div>;
};
// Do this instead
const DisplayName = (props) => {
return <div>{props.name}</div>;
};
SolidJS приготовил для нас ещё кое-что
Сигналы — это здорово, но что, если вам нужно сложное или вложенное состояние? На помощь приходят хранилища (stores).
Хранилища похожи на сигналы, но вместо пары геттер/сеттер они возвращают прокси-объект и функцию setStore
. Эта функция принимает ключ и значение (или функцию):
import { createStore } from 'solid-js/store';
const [store, setStore] = createStore({
name: 'SolidJs',
stars: 33284,
});
const updateName = (name) => {
setStore('name', name);
}
const incrementStar = () => {
setStore('stars', currentAge => currentAge + 1);
}
Мы можем использовать хранилище как обычный объект, и Solid позаботится о реактивности. setStore
обновит хранилище и уведомит всех наблюдателей об изменении. Таким образом, прокси позволяет SolidJS сохранять реактивность даже при динамических именах свойств.
Однако, как и в случае с пропсами, мы должны быть осторожны при использовании хранилищ. Поскольку они являются прокси, мы не должны деструктурировать их, иначе это нарушит реактивность. Точно так же, как и с пропс-геттером.
Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Вывод
Теперь, когда мы понимаем, как SolidJS работает внутри. Давайте подытожим основные правила:
Избегайте условных выражений (if, тернарные операторы, array.map) непосредственно в компонентах.
Избегайте асинхронного кода внутри
createEffect
.Не деструктурируйте пропсы или хранилища, если хотите сохранить реактивность.
К счастью, SolidJS предоставляет плагин ESLint, который предупреждает вас, когда вы делаете что-то, что может нарушить реактивность.
Если вы будете следовать этим правилам и освоите ментальную модель, вы сможете насладиться преимуществами производительности детальной реактивности SolidJS.