Первое знакомство с JavaScript-библиотекой Solid +30


Solid — это реактивная JavaScript-библиотека для создания пользовательских интерфейсов без использования виртуальной DOM. Она однократно компилирует шаблоны, превращая их в узлы реальной DOM, а благодаря тщательно спланированным механизмам обновления контента при изменении состояния приложения выполняется только тот код, который необходим для визуализации этого изменения.

При таком подходе компилятор может оптимизировать процесс первого рендеринга страницы, а среда выполнения кода способна оптимизировать процессы обновления страниц. Такое внимание к производительности делает 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?




К сожалению, не доступен сервер mySQL