Первое приложение на SolidJS
- среда, 21 февраля 2024 г. в 00:00:12
В этой статье познакомимся с SolidJS − JavaScript-библиотекой для создания пользовательских интерфейсов без виртуального DOM. Мы создадим легкий список задач с использованием TypeScript и разберем некоторые особенности библиотеки.
SolidJS является JS библиотекой с открытым исходным кодом. Сами разработчики пишут на своем гитхабе: «Solid − это декларативная библиотека JavaScript для создания пользовательских интерфейсов. Вместо использования виртуального DOM он компилирует свои шаблоны в реальные узлы DOM и обновляет их с помощью детализированных реакций».
Уже в описании можно понять с какой популярной библиотекой идет сравнение про виртуальный DOM. SolidJS похож на React, предоставляя нам возможности компонентной архитектуры, хранение и обновление данных с помощью сигналов (реактовские хуки), а также пару интересных возможностей.
У SolidJS достаточно хорошая документация на русском языке и имеет ряд туториалов. Однако, в русскоязычном сегменте мало применения этой библиотеки на практике, поэтому эта статья посвящена именно этому вопросу.
Нам нужен пакетный менеджер npm и среда разработки VS Code. Также будем использовать сборщик Vite (можно использовать и webpack).
В терминале перейдем в директорию, где будет размещена папка с проектом, и запустим следующую команду: npm create vite@latest todo-list -- --template solid-ts
Эта команда создаст нам директорию todo‑list и некоторые начальные файлы.
Перейдем в папку проекта: cd todo-list
и выполним стандартную команду установки пакетов: npm install
Запустим проект и проверим, что все работает: npm run dev
Если мы все сделали правильно, то на адресе http://localhost:5173/ увидим эту страницу:
Дополнительно
Для стилизации добавим свободный набор с готовыми стилями − Bootstrap. В файле index.html вставим следующие строчки:
Перед закрывающим тегом </head>
:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
Перед закрывающим тегом </body>
:
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
Также удалим файл App.css. В index.css удалим содержимое и вставим свое:
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
width: 100%;
height: 100%;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-flow: column nowrap;
place-items: center;
}
Наше будущее приложение будет состоять из трех компонентов:
Задача − флажок «выполнена/не выполнена» и название задачи.
Текстовое поле для ввода задачи.
Кнопка для добавления/удаления задачи.
В итоге у нас должно получиться вот такое готовое приложение.
В папке src создадим новый файл components/task/index.tsx и в нем создадим компонент для отображения одной задачи:
export default function Task() {
return (
<div class="row form-check">
<input
class="form-check-input"
type="checkbox"
value=""
id="flexCheckDefault"
/>
<label class="form-check-label" for="flexCheckDefault">
Задача №1
</label>
</div>
);
}
В input мы будем передавать значение выполнена или не выполнена задача и рисовать на основе этого флажок, а в тег label мы передадим название задачи. Эти значения будем получать из параметров (далее − пропсов) компонента, представлены в объекте props (код ниже).
Теперь добавим адаптивности. В пропсы будем передавать название и статус (выполнена/не выполнена). Здесь наступает первый интересный момент в SolidJS.
Все динамические изменения в SolidJS − это реактивность. Здесь она нам нужна,чтобы мы могли динамически менять статус задачи без перерисовки страницы. Чтобы реактивность работала правильно, нельзя деструктуризировать объект props, как в React. Пропсы в SolidJS доступны только для чтения и уже имеют реактивные свойства, при деструктуризации эти свойства теряются, поэтому очень важно сохранить объект props.
Но если мы хотим задать некоторые параметры по умолчанию, то как это сделать? В таком случае нам необходим метод − mergeProps
. В mergeProps
необходимо передать объект со значениями пропсов по умолчанию и объект входящих пропсов, в итоге у нас получится новая переменная:
import { mergeProps } from "solid-js";
type Props = {
name?: string;
isDone?: boolean;
};
export default function Task(_props: Props) {
const props = mergeProps(
{
name: "Задача №1",
isDone: false,
},
_props
);
return (
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value=""
id="flexCheckDefault"
checked={props.isDone}
/>
<label class="form-check-label" for="flexCheckDefault">
{props.name}
</label>
</div>
);
}
Теперь перейдем в App.tsx, удалим весь код, что там есть, и вставим свой:
import Task from "./components/task";
function App() {
return (
<div>
<Task />
</div>
);
}
export default App;
Опять перейдем на http://localhost:5173/ и увидим наш результат (проект должен быть запущен):
Отлично! Полдела сделано, осталось только создать еще парочку компонентов.
Создадим файл components/input-field/index.tsx и запишем следующее:
type Props = {
onChanged: () => void;
};
export default function InputField(props: Props) {
return (
<div class="mb-3">
<label for="exampleFormControlInput1" class="form-label">
Введите название задачи
</label>
<input
type="email"
class="form-control form-control"
id="exampleFormControlInput1"
placeholder="Прочитать статью..."
onChange={props.onChanged}
/>
</div>
);
}
В input будем записывать наши задачи, поэтому необходим метод для отслеживания изменений − onChanged
. В этом компоненте уже не будем определять пропсы по умолчанию, сделаем функцию onChanged
обязательной для вызова этого компонента.
Создадим один компонент для двух действий и в пропсах будем передавать тип кнопки. В файле components/button/index.tsx запишем:
type Props = {
name: string;
type: "add" | "delete";
onClick: () => void;
};
export default function Button(props: Props) {
return (
<button
type="button"
class="btn btn-success"
classList={{
"btn-succes": props.type === "add",
"btn-danger": props.type === "delete",
}}
onClick={props.onClick}
>
{props.name}
</button>
);
}
Здесь также в просы прокидываем название для кнопки, обработчик события-клик и тип кнопки. У нас только два типа кнопок: удаление и добавление, поэтому можем записать их просто строчками ( на будущее лучше вынести в отдельную константу). Тип кнопки нужен для добавление стилей.
Как вы наверное уже заметили, SolidJS предоставляет нам целых два свойства для указания класса: class
и classList
. Первый принимает на вход строку с названием класса, последний − объект, у которого слева находится класс, а справа − значение типа boolean, которое в случае true добавит класс к элементу.
Теперь наша папка с проектом должна выглядеть примерно так:
Добавляем новые компоненты в App:
import Button from "./components/button";
import InputField from "./components/input-field";
import Task from "./components/task";
function App() {
return (
<div>
<InputField onChanged={() => console.log("input changed")} />
<Button
name="Добавить"
onClick={() => console.log("btn click")}
type="add"
/>
<Task />
<Button
name="Удалить"
onClick={() => console.log("btn click")}
type="delete"
/>
</div>
);
}
export default App;
Пока в компоненты передаем заглушки с выводом в консоль, позже это изменим.
Теперь наше приложение выглядит так:
Мда, пока не очень красиво, сейчас это исправим.
Добавим файл components/task-row/index.tsx с компонентом для отрисовки задачи и кнопки «Удалить» на одной строке:
import Button from "../button";
import Task from "../task";
export default function TaskRow() {
return (
<div class="d-flex flex-row justify-content-between align-items-center gap-3">
<div class="flex-grow-1">
<Task />
</div>
<Button
name="Удалить"
onClick={() => console.log("btn click")}
type="delete"
/>
</div>
);
}
И добавим в App несколько тегов:
import Button from "./components/button";
import InputField from "./components/input-field";
import TaskRow from "./components/task-row";
function App() {
return (
<div class="container">
<div class="d-flex flex-row justify-content-between align-items-center gap-3">
<div class="flex-grow-1">
<InputField onChanged={() => console.log("input changed")} />
</div>
<div class="mt-3">
<Button
name="+ Добавить"
onClick={() => console.log("btn click")}
type="add"
/>
</div>
</div>
<div class="mb-3 d-flex flex-column gap-3">
<TaskRow />
</div>
</div>
);
}
export default App;
Теперь наше приложение будет выглядеть более опрятно:
Теперь перейдем к самому интересному − логика нашего приложения. Добавим обработку добавления новой задачи.
В компоненте App создадим сигнал для хранения всех наших задач и определим тип ITask:
import { createSignal } from "solid-js";
...
export interface ITask {
id: number;
name: string;
isDone: boolean;
}
function App() {
const [tasks, setTasks] = createSignal<ITask[]>([]);
const [taskId, setTaskId] = createSignal(0);
const [newTask, setNewTask] = createSignal("");
...
Вызов функции createSignal возвращает пару: значение + функция для изменения значения (тот же самый useState). Однако сигналы не привязаны к компонентам и эти строчки можно спокойно разместить перед компонентом App.
Мы создали несколько сигналов:
tasks − для хранения всех задач,
taskId − индекс для новой задачи (этот сигнал для учебных целей, можно обойтись и без него),
newTask − название для новой задачи.
Для создания новой задачи добавим в App.tsx следующую функциональность:
function App() {
...
const handleAddClick = () => {
if (newTask()) {
setTasks([...tasks(), { id: taskId(), name: newTask(), isDone: false }]);
setTaskId((prev) => prev + 1);
setNewTask("");
}
};
...
return (
<div class="container">
<div class="d-flex flex-row justify-content-between align-items-center gap-3">
<div class="flex-grow-1">
<InputField
value={newTask()}
onChanged={(value: string) => setNewTask(value)}
/>
</div>
<div class="mt-3">
<Button name="+ Добавить" onClick={handleAddClick} type="add" />
</div>
...
)}
В функции добавления новой задачи, проверяем значение newTask, вызванного с помощью геттер-метода (все значения сигналов в SolidJS получаются с помощью круглых скобок => newTask()). Если у нас непустая строка, то в setTasks добавляем новую задачу в конец списка tasks, увеличиваем индекс для следующей задачи и очищаем поле newTasks.
Для изменения значения сигнала используем сеттеры(методы установки), как setTaskId
. Внутрь можно передать новое значение (как в setTasks
) или преобразовать прошлое значение: setTaskId((prev) => prev + 1)
.
Обновим компонент InputField:
import type { JSX } from "solid-js";
type Props = {
value: string;
onChanged: (value: string) => void;
};
export default function InputField(props: Props) {
const handleOnChanged: JSX.EventHandler<HTMLInputElement, Event> = (
e: Event
) => {
const target = e.target as HTMLInputElement;
if (target) props.onChanged(target.value);
};
return (
<div class="mb-3">
<label for="exampleFormControlInput1" class="form-label">
Введите название задачи
</label>
<input
type="email"
class="form-control form-control"
id="exampleFormControlInput1"
placeholder="Прочитать статью..."
value={props.value}
onChange={handleOnChanged}
/>
</div>
);
}
Здесь обновили пропсы и добавили функцию-обработчик, которая из объекта события получает ссылку на DOM элемент input (e.target) и его значение передает в родительскую функцию, переданную через props.
Удаление. В компоненте App добавим функцию:
const handleDeleteClick = (id: number) => {
setTasks(tasks().filter((item) => item.id !== id));
};
Здесь мы просто фильтруем значения по индексу задачи, сохраняем в итоговый сигнал те задачи, которые не равны переданному индексу.
А в компоненте TaskRow добавим обработчик нажатия на кнопку и передачу id задачи в функцию:
import { ITask } from "../../App";
import Button from "../button";
import Task from "../task";
type Props = {
task: ITask;
onDeleteClick: (id: number) => void;
};
export default function TaskRow(props: Props) {
const handleDeleteClick = () => {
props.onDeleteClick(props.task.id);
};
return (
<div class="d-flex flex-row justify-content-between align-items-center gap-3">
<div class="flex-grow-1">
<Task name={props.task.name} isDone={props.task.isDone} />
</div>
<Button name="Удалить" onClick={handleDeleteClick} type="delete" />
</div>
);
}
Ну и самое главное − вывести все наши задачи на экран. Для этого воспользуемся специальным тегом For и добавим его в App вместо строчки <TaskRow />:
<For each={tasks()}>
{(item, _index) => <TaskRow task={item} onDeleteClick={handleDeleteClick} />}
</For>
В атрибут each
необходимо передать массив, потом, как в функции map, определить элемент и индекс. Только в нашем случае index будет являться сигналом для отслеживания перемещение строки независимо от изменений внутри элемента.
В SolidJS рекомендуется также использовать именно встроенный инструмент For, нежели метод map, по причине реактивности. Готовый инструмент позволяет не перерисовывать несколько раз одни и те же данные и сохраняет реактивность.
Перейдем на наш сайт, теперь список задач пустой. Попробуем добавить новую задачу:
Вроде все работает!
Ну и конечно же, добавим переключение статуса задачи.
В компоненте Task обновим пропсы, добавив функцию обработчик:
type Props = {
name: string;
isDone: boolean;
onIsDoneChanged: () => void;
};
Добавим в input атрибут onChange
и нашу функцию из пропсов:
<input
...
onChange={props.onIsDoneChanged}
/>
А теперь выведем зачеркнутый текст, если задача выполнена:
<label class="form-check-label" for="flexCheckDefault">
<Show when={props.isDone} fallback={props.name}>
<del>{props.name}</del>
</Show>
</label>
Продолжая об особенностях SolidJS − компонент Show. Этот компонент оптимально обрабатывает условие в шаблонах (хотя SolidJS знает что такое &&, но в документации советуют использовать именно встроенный компонент).
Пропс fallback
выполняет функцию else
и будет показан в том случае если условие, которое мы передали в when
вернет false значение.
В компоненте TaskRow также добавляем обработчик и обновляем пропсы:
type Props = {
task: ITask;
onDeleteClick: (id: number) => void;
onIsDoneChanged: (id: number) => void;
};
export default function TaskRow(props: Props) {
...
const handleIsDoneChanged = () => {
props.onIsDoneChanged(props.task.id);
};
return (
...
<Task
name={props.task.name}
isDone={props.task.isDone}
onIsDoneChanged={handleIsDoneChanged}
/>
...
Ну и в компоненте App добавляем всю оставшуюся логику:
function App() {
...
const handleIsDoneChanged = (id: number) => {
setTasks(
tasks().map((task) =>
task.id !== id ? task : { ...task, isDone: !task.isDone }
)
);
};
return (
...
<TaskRow
task={item}
onDeleteClick={handleDeleteClick}
onIsDoneChanged={handleIsDoneChanged}
/>
...
Проверяем:
Отлично, все готово!
Для хранения всех задач мы используем сигнал с массивом, хотя каждая вложенная задача может менять значение. Вложенная реактивность реализуется в SolidJS с помощью специального метода createStore
. Используем его для наших задач, вместо createSignal для tasks напишем следующее:
const [store, setStore] = createStore([] as ITask[]);
Обновим все функции в App:
const handleAddClick = () => {
if (newTask()) {
setStore((t) => [...t, { id: taskId(), name: newTask(), isDone: false }]);
setTaskId((prev) => prev + 1);
setNewTask("");
}
};
const handleDeleteClick = (id: number) => {
setStore((t) => t.filter((item) => item.id !== id));
};
const handleIsDoneChanged = (id: number) => {
setStore(
(todo) => todo.id === id,
"isDone",
(isDone) => !isDone
);
};
Функции handleAddClick
и handleDeleteClick
оставим примерно как было, а в методе handleIsDoneChanged
реализуем нашу вложенность. В первый параметр setStore
передаем функцию для выбора нужного объекта, вторым аргументов − поле, которое необходимо изменить и последний аргумент − изменение самого поля. Насколько лаконично это выглядит!
И в тег For передадим наш стор:
<For each={store}>
{(item, _index) => (
<TaskRow
task={item}
onDeleteClick={handleDeleteClick}
onIsDoneChanged={handleIsDoneChanged}
/>
)}
</For>
Теперь мы реализовали все согласно рекомендациям SolidJS и добились более простой реактивности в наших вложенных задачах.
Полный код проекта есть в git-репозитории, а итоговый проект доступен по ссылке.
В этой статье мы рассмотрели лишь часть возможностей библиотеки, но, надеюсь, после прочтения библиотека SolidJS стала для вас более понятной :) Пишите в комментариях, если у вас остались какие-либо вопросы.