javascript

Первое приложение на SolidJS

  • среда, 21 февраля 2024 г. в 00:00:12
https://habr.com/ru/articles/794903/

В этой статье познакомимся с SolidJS − JavaScript-библиотекой для создания пользовательских интерфейсов без виртуального DOM. Мы создадим легкий список задач с использованием TypeScript и разберем некоторые особенности библиотеки.

SolidJS
SolidJS

Что такое SolidJS?

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;
}

Структура

Наше будущее приложение будет состоять из трех компонентов:

  1. Задача − флажок «выполнена/не выполнена» и название задачи.

  2. Текстовое поле для ввода задачи.

  3. Кнопка для добавления/удаления задачи.

В итоге у нас должно получиться вот такое готовое приложение.

Первые компоненты

Задача

В папке 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 будет являться сигналом для отслеживания перемещение строки независимо от изменений внутри элемента.

Почему рекомендуется использовать именно For для массивов?

В 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 стала для вас более понятной :) Пишите в комментариях, если у вас остались какие-либо вопросы.