При таком подходе компилятор может оптимизировать процесс первого рендеринга страницы, а среда выполнения кода способна оптимизировать процессы обновления страниц. Такое внимание к производительности делает Solid одним из JavaScript-инструментов, показывающих самые высокие результаты в тестированиях производительности.
Меня эта библиотека заинтересовала, и я решила её испытать. В результате я уделила некоторое время созданию небольшого ToDo-приложения, изучая то, как эта библиотека рендерит компоненты, как она обновляет состояние приложение, как организует работу с хранилищем и исследуя многие другие её особенности.
Тех, кому не терпится взглянуть на код готового проекта, приглашаю заглянуть сюда.
Начало работы
Для начала, как и в случае с большинством подобных инструментов, библиотеку, в виде npm-пакета, надо установить. Для того чтобы пользоваться ей можно было бы с применением JSX — нужно выполнить такую команду:
npm install solid-js babel-preset-solid
После этого нужно добавить babel-preset-solid
в конфигурационный файл Babel, webpack или Rollup:
"presets": ["solid"]
Или, если нужно быстро сделать маленькое приложение, можно воспользоваться одним из шаблонов Solid:
# Создать маленькое приложение на основе шаблона Solid
npx degit solidjs/templates/js my-app
# Перейти в директорию проекта
cd my-app
# Установить зависимости
npm i # or yarn or pnpm
# Запустить сервер разработчика
npm run dev
Solid предлагает и шаблоны, рассчитанные на создание TypeScript-приложений. Поэтому — если вы хотите создать именно такой проект — первую из вышеприведённых команд надо заменить на такую:
npx degit solidjs/templates/ts my-app
Создание и рендеринг компонентов
Для описания компонентов, которые нужно вывести на страницу, используются синтаксические конструкции, напоминающие аналогичные конструкции React. Поэтому тому, кто знаком с React, следующий фрагмент кода может показаться знакомым:
import { render } from "solid-js/web";
const HelloMessage = props => <div>Hello {props.name}</div>;
render(
() => <HelloMessage name="Taylor" />,
document.getElementById("hello-example")
);
Всё начинается с импорта функции render
, потом создаётся элемент <div>
с каким-то текстом и со свойством, а после этого вызывается функция render
, которой передаётся компонент и элемент-контейнер.
Библиотека компилирует этот код в структуры реальной DOM. Например, то, что показано выше, компилируется в нечто, выглядящее примерно так:
import { render, template, insert, createComponent } from "solid-js/web";
const _tmpl$ = template(`<div>Hello </div>`);
const HelloMessage = props => {
const _el$ = _tmpl$.cloneNode(true);
insert(_el$, () => props.name);
return _el$;
};
render(
() => createComponent(HelloMessage, { name: "Taylor" }),
document.getElementById("hello-example")
);
На сайте библиотеки есть раздел для экспериментов с ней — Solid Playground. В ходе этих экспериментов можно выяснить, что библиотека поддерживает разные режимы рендеринга страниц. Это — клиентский и серверный рендеринг, а так же — клиентский рендеринг с последующим приведением страницы в рабочее состояние (hydration).
Режимы рендеринга, поддерживаемые Solid
Наблюдение за изменяющимися значениями с помощью сигналов
В Solid имеется хук, называемый createSignal
, который возвращает две функции: геттер и сеттер. Если вы привыкли к использованию фреймворков или библиотек наподобие React — это может показаться несколько странным. Обычно ожидается, что первым элементом является само значение. Но в Solid нужно явным образом вызвать геттер для выявления момента чтения значения, что позволяет отслеживать изменения значений.
Например, напишем следующий код:
const [todos, addTodos] = createSignal([]);
Если вывести в консоль todos
, то окажется, что это не значение, а функция. Если нам нужно значение — нужно вызвать эту функцию, воспользовавшись конструкцией todos()
.
Если речь идёт об использовании этого механизма при организации работы с небольшим списком дел, то получится следующее:
import { createSignal } from "solid-js";
const TodoList = () => {
let input;
const [todos, addTodos] = createSignal([]);
const addTodo = value => {
return addTodos([...todos(), value]);
};
return (
<section>
<h1>To do list:</h1>
<label for="todo-item">Todo item</label>
<input type="text" ref={input} name="todo-item" id="todo-item" />
<button onClick={() => addTodo(input.value)}>Add item</button>
<ul>
{todos().map(item => (
<li>{item}</li>
))}
</ul>
</section>
);
};
Этот код выводит на страницу текстовое поле, кнопку для добавления элементов и список дел. Введя описание задачи в поле и нажав на кнопку можно добавить эту задачу в список, а обновлённый список дел будет выведен на странице.
Вышеописанная схема работы может показаться очень похожей на использование useState
. В чём же заключаются особенности применения геттера? Рассмотрим следующий пример:
console.log("Create Signals");
const [firstName, setFirstName] = createSignal("Whitney");
const [lastName, setLastName] = createSignal("Houston");
const [displayFullName, setDisplayFullName] = createSignal(true);
const displayName = createMemo(() => {
if (!displayFullName()) return firstName();
return `${firstName()} ${lastName()}`;
});
createEffect(() => console.log("My name is", displayName()));
console.log("Set showFullName: false ");
setDisplayFullName(false);
console.log("Change lastName ");
setLastName("Boop");
console.log("Set showFullName: true ");
setDisplayFullName(true);
Запуск этого кода приведёт к следующим результатам:
Create Signals
My name is Whitney Houston
Set showFullName: false
My name is Whitney
Change lastName
Set showFullName: true
My name is Whitney Boop
Самое главное, на что тут стоит обратить внимание, заключается в том, что строка вида My name is ...
не выводится в консоль после указания новой фамилии с использованием сеттера setLastName
. Дело тут в том, что в момент изменения фамилии нет ничего, что прослушивало бы эти изменения в lastName()
. Новое значение в displayName()
устанавливается лишь тогда, когда меняется значение, возвращаемое displayFullName()
. Именно поэтому мы видим, что новая фамилия выводится тогда, когда в setShowFullName
снова записывается значение true
.
Это даёт нам более безопасный, чем при использовании других подходов, способ наблюдения за изменениями значений.
Реактивные примитивы
В предыдущем разделе я рассказала о createSignal
, но там была показана и пара других примитивов: createEffect
и createMemo
.
▍CreateEffect
CreateEffect
отслеживает состояние зависимостей и запускается после каждого рендеринга когда меняется значение зависимости.
// Не забудьте сначала импортировать createEffect: 'import { createEffect } from "solid-js";'
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("Count is at", count());
});
Count is at...
выводится в консоль каждый раз, когда меняется значение, возвращаемое count()
.
▍CreateMemo
CreateMemo
позволяет создавать сигналы, предназначенные только для чтения, значения, возвращаемые которыми, изменяются тогда, когда обновляются значения-зависимости выполняемого кода. Этот механизм используют, когда нужно кешировать какие-то значения и обращаться к ним без необходимости выполнения вычислений до тех пор, пока не изменится значение-зависимость.
Например, если нужно вывести то, что возвращает counter()
, 100 раз, и обновить это значение при нажатии на кнопку, использование createMemo
позволит выполнять вычисление нового значения лишь один раз на один щелчок по кнопке:
function Counter() {
const [count, setCount] = createSignal(0);
// Вызов `counter` без использования при создании этой функции `createMemo` приведёт к 100 вызовам `counter`.
// const counter = () => {
// return count();
// }
// Вызов функции `counter`, созданной с использованием `createMemo`, приводит к её однократному вызову на каждое обновление.
// Не забудьте сначала импортировать createMemo: 'import { createMemo } from "solid-js";'
const counter = createMemo(() => {
return count()
})
return (
<>
<button onClick={() => setCount(count() + 1)}>Count: {count()}</button>
<div>1. {counter()}</div>
<div>2. {counter()}</div>
<div>3. {counter()}</div>
<div>4. {counter()}</div>
<!-- ещё 96 раз -->
</>
);
}
Методы жизненного цикла
Библиотека Solid даёт в наше распоряжение несколько методов жизненного цикла компонентов, таких, как onMount
, onCleanup
и onError
. Если, например, надо, чтобы какой-то код выполнился после первого рендеринга, нужно воспользоваться onMount
:
// Не забудьте сначала импортировать onMount: 'import { onMount } from "solid-js";'
onMount(() => {
console.log("I mounted!");
});
Метод onCleanup
похож на componentDidUnmount
из React: он запускается при пересчёте текущей реактивной области видимости или при её очистке.
Метод onError
выполняется при возникновении ошибки в ближайшей дочерней области видимости. Например, его можно использовать для обработки ошибок, возникающих при загрузке данных из внешних источников.
Хранилища
Solid позволяет создавать хранилища данных с помощью createStore
. Значение, возвращаемое этим методом, представляет собой прокси-объект, предназначенный только для чтения, и функцию-сеттер.
Например, если мы переработаем наше ToDo-приложение так, чтобы вместо размещения данных в состоянии приложения, размещать их в хранилище, у нас получится следующее:
const [todos, addTodos] = createStore({ list: [] });
createEffect(() => {
console.log(todos.list);
});
onMount(() => {
addTodos("list", [
...todos.list,
{ item: "a new todo item", completed: false }
]);
});
Тут мы сначала выводим в консоль прокси-объект с пустым массивом. А потом — этот же объект, в массиве которого содержится объект, представляющий собой элемент списка дел: {item: «a new todo item», completed: false}
.
Тут стоит обратить внимание на то, что для наблюдения за состоянием данных, которые находятся в хранилище, нужно обращаться не к объекту верхнего уровня, а к его свойству. Именно поэтому мы выводим в консоль не todo
, а todo.list
.
Если бы в createEffect
мы выводили бы в консоль лишь todo
, то мы увидели бы массив в его исходном состоянии, а не в новом, которое он принял после внесения в него новых данных в onMount
.
Для того чтобы изменять значения, находящиеся в хранилище, можно пользоваться функцией, возвращаемой при создании хранилища (в нашем случае — setTodos
) с помощью createStore
. Например, если нужно перевести дела, находящиеся в списке, в состояние «завершено», изменить данные, размещённые в хранилище, можно так:
const [todos, setTodos] = createStore({
list: [{ item: "new item", completed: false }]
});
const markAsComplete = text => {
setTodos(
"list",
i => i.item === text,
"completed",
c => !c
);
};
return (
<button onClick={() => markAsComplete("new item")}>Mark as complete</button>
);
Управляющая логика
Для того чтобы избежать ненужных операций по повторному созданию всех узлов DOM при применении методов наподобие .map()
, Solid позволяет использовать вспомогательные механизмы при создании шаблонов.
Среди них можно отметить, например, For
для обхода коллекций элементов, Show
для показа и скрытия элементов по условию, Switch
и Match
— для показа элементов, соответствующих неким условиям.
Вот примеры использования этих механизмов:
<For each={todos.list} fallback={<div>Loading...</div>}>
{(item) => <div>{item}</div>}
</For>
<Show when={todos.list[0].completed} fallback={<div>Loading...</div>}>
<div>1st item completed</div>
</Show>
<Switch fallback={<div>No items</div>}>
<Match when={todos.list[0].completed}>
<CompletedList />
</Match>
<Match when={!todos.list[0].completed}>
<TodosList />
</Match>
</Switch>
Итоги
В этом материале я, в общих чертах, рассказала о том, как приступить к работе с библиотекой Solid. Если вы хотите продолжить эксперименты с ней — взгляните на мой базовый Solid-проект, который вы можете автоматически развернуть на Netlify и клонировать в свой GitHub-аккаунт.
В состав этого проекта входят стандартные структуры, необходимые для работы Solid-приложений, а так же приложение-пример, о котором я, демонстрируя основы Solid, рассказывала в этом материале.
Библиотека Solid обладает ещё многими интересными возможностями, которых я тут не касалась. Узнать о них можно, обратившись к документации этой библиотеки.
Пользуетесь ли вы библиотекой Solid?
Автор:
ru_vds