Перейти к содержанию

Знакомство с популярными JS фреймворками - Preact.js

Сегодня мы познакомимся с более лёгкой альтернативой React — библиотекой Preact. Посмотрим на её сходства и различия с React.

В этой серии⚓︎

Вступление⚓︎

Если вы в шоке от размера проекта на React, то, во-первых, не забывайте, что речь о килобайтах, а во-вторых, посмотрите на минималистичную альтернативу — Preact. За несколькими исключениями, это тот же React, с тем же API, но с плюсами (например: меньший размер после сборки, отсутствие необходимости переименовывать атрибуты HTML в camelCase, Signals и т. д.). К примеру, чтобы переписать предыдущий проект на Preact, в компонентах вам понадобится заменить лишь верхние строчки (импорты). Небольшие изменения в main.jsx, package.json и т. д. не в счёт. Однако мы рассмотрим реализацию с нуля, как будто бы у нас нет проекта на React.

Подготовка⚓︎

Как и прежде, перейдите в папку projects, откройте консоль и запустите следующую команду:

npm create vite@latest preact-todo -- --template preact

Теперь перейдите в созданную папку preact-todo и установите UnoCSS:

npm i -D unocss @iconify-json/heroicons
pnpm create vite preact-todo --template preact

Теперь перейдите в созданную папку preact-todo и установите UnoCSS:

pnpm add -D unocss @iconify-json/heroicons
yarn create vite preact-todo --template preact

Теперь перейдите в созданную папку preact-todo и установите UnoCSS:

yarn add -D unocss @iconify-json/heroicons
bun create vite preact --template preact

Теперь перейдите в созданную папку preact-todo и установите UnoCSS:

bun add -D unocss @iconify-json/heroicons

и обновите vite.config.js:

import { defineConfig } from 'vite'
import UnoCSS from 'unocss/vite'
import preact from '@preact/preset-vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [UnoCSS(), preact()],
})

Добавьте uno.config.js:

import { defineConfig, presetIcons, presetWind4 } from 'unocss'

export default defineConfig({
  presets: [
    presetWind4(),
    presetIcons(),
  ],
})

Обновите src/main.jsx:

import { render } from 'preact'
import App from './app.jsx'
import 'virtual:uno.css'

render(<App />, document.getElementById('app'))

Не забудьте добавить класс bg-gray-200 элементу body в файле index.html, чтобы у страницы был серый фон.

Запускаем проект:

npm run dev
pnpm run dev
yarn dev
bun run dev

В Preact в ходу как классовые, так и функциональные компоненты. Для больше удобства и сравнения с React мы будем использовать последние.

Корневой компонент⚓︎

Базовый компонент app.jsx, по сути, будет мало чем отличаться от аналогичного из проекта на React:

import TodoList from './components/TodoList';

function App() {
  return <TodoList title='Список дел' />;
}

export default App;

А вот дальше появятся небольшие различия.

TodoList⚓︎

Во-первых, мы используем всё те же хуки, что и в React, но импортируем их уже из пространства preact/hooks:

import { useState, useEffect } from 'preact/hooks';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';

function TodoList(props) {
  const [todos, setTodos] = useState([]);

  // Подгружаем задачи с сервера при загрузке страницы
  useEffect(() => {
    fetch('https://dummyjson.com/todos')
      .then((response) => response.json())
      .then((data) => {
        setTodos(data.todos.slice(0, 10));
      });
  }, []);

  // Добавляем задачу в массив, если у нее есть заголовок
  const addTask = (title) => {
    if (!title) return;

    const id = Math.max(...todos.map((obj) => obj.id));

    setTodos((prevTodos) => [
      ...prevTodos,
      {
        todos.length ? id + 1 : 1,
        todo: title,
        completed: false,
      },
    ]);
  };

  // Меняем статус задачи
  const toggleTask = (id) => {
    setTodos((prevTodos) => prevTodos.map((t) =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ));
  };

  // Удаляем задачу
  const deleteTask = (id) => {
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
  };

  // Отображаем задачи и форму для добавления новой
  return (
    <div class='max-w-sm md:max-w-lg mx-auto my-10 bg-white rounded-md shadow-md overflow-hidden'>
      <h1 class='text-2xl font-bold text-center py-4 bg-gray-100'>{props.title}</h1>
      {todos.length > 0 && (
        <ul class='list-none p-4'>
          {todos.map(t => <TodoItem key={t.id} task={t} onToggle={toggleTask} onRemove={deleteTask} />)}
        </ul>
      )}
      <TodoForm onSubmit={addTask} />
    </div>
  );
}

export default TodoList;

И нам больше не нужно заменять class на className в разметке!

TodoItem⚓︎

Здесь мы вообще никаких хуков не используем, а просто принимаем из родительского компонента объект задачи, функцию переключения и функцию удаления в качестве параметров (props):

function TodoItem({ task, onToggle, onRemove }) {
  // Обработчик переключения статуса задачи
  const toggleTask = () => onToggle(task.id);
  // Обработчик удаления задачи
  const deleteTask = () => onRemove(task.id);

  return (
    <li class='flex items-center mb-2 hover:cursor-pointer' onClick={toggleTask}>
      <input type='checkbox' class='mr-2' checked={task.completed} readOnly />
      <span class={task.completed ? 'line-through' : ''}>{task.title}</span>
      <div class="ml-auto text-gray-400 hover:text-gray-600">
        <button class="i-heroicons-trash w-6 h-6" onClick={deleteTask} />
      </div>
    </li>
  );
}

export default TodoItem;

TodoForm⚓︎

import { useRef } from 'preact/hooks';

function TodoForm(props) {
  // Создаем ссылку для привязки к элементу `input` с помощью атрибута `ref` (см. разметку)
  const inputRef = useRef(null);

  // Обработчик добавления новой задачи
  const addTask = () => {
    props.onSubmit(inputRef.current.value);
    inputRef.current.value = '';
    inputRef.current.focus();
  };

  // Обработчик клавиши `Enter`
  const handleKeyDown = (e) => {
    if (e.keyCode !== 13) return;

    addTask();
  };

  return (
    <div class='p-4 bg-gray-100'>
      <div class='flex items-center'>
        <input
          ref={inputRef}
          type='text'
          class='flex-1 mr-2 py-2 px-4 rounded-md border border-gray-300'
          placeholder='Новая задача'
          autofocus
          onKeyDown={handleKeyDown}
        />
        <button
          class='bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-md'
          onClick={addTask}
        >
          Добавить
        </button>
      </div>
    </div>
  );
}

export default TodoForm;

Сигналы⚓︎

Итак, у нас всё работает и работает согласно задуманному. Но мы можем пойти дальше и улучшить наш проект, внедрив сигналы, которые, в соответствии с их предназначением, помогут улучшить быстродействие и эргономику нашего небольшого приложения.

Выполните в консоли следующую команду:

npm install @preact/signals

Согласно документации, сигналы — объекты со свойством value, содержащим некоторое значение. Это значение может меняться, но сами сигналы — нет. В глобальном пространстве принято использовать объект signal, импортируемый из пространства @preact/signals, но поскольку мы работаем с функциональными компонентами, нам хватит и локального доступа к нашим задачам. Для этого мы воспользуемся хуком useSignal:

import { useSignal } from '@preact/signals';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';

function TodoList(props) {
  // Устанавливаем начальное значение — пустой массив
  const todos = useSignal([]);

  // Обращаемся к нашей переменной теперь так:
  console.log(todos.value);

  // ...
}

Для изменения todos нам больше не потребуется некая функция типа setTodos, теперь достаточно присвоить значение свойству value:

todos.value = 'новое значение';

Для безопасной замены хука useEffect в сигналах есть аналог — useSignalEffect:

useSignalEffect(() => {
  fetch('https://dummyjson.com/todos')
    .then((response) => response.json())
    .then((data) => (todos.value = data.todos.slice(0, 10)));
});

Теперь компонент TodoList.jsx будет выглядеть так:

import { useSignal, useSignalEffect } from '@preact/signals';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';

function TodoList(props) {
  const todos = useSignal([]);

  useSignalEffect(() => {
    fetch('https://dummyjson.com/todos')
      .then((response) => response.json())
      .then((data) => (todos.value = data.todos.slice(0, 10)));
  });

  const addTask = (title) => {
    if (!title) return;

    const id = Math.max(...todos.value.map((obj) => obj.id));

    todos.value = [
      ...todos.value,
      {
        id: id + 1,
        todo: title,
        completed: false,
      },
    ];
  };

  const toggleTask = (id) => {
    const index = todos.value.findIndex((todo) => todo.id === id);

    if (index === -1) return;

    todos.value = [
      ...todos.value.slice(0, index),
      { ...todos.value[index], completed: !todos.value[index].completed },
      ...todos.value.slice(index + 1),
    ];
  };

  const deleteTask = (id) => {
    todos.value = todos.value.filter((todo) => todo.id !== id);
  };

  return (
    <div class='max-w-sm md:max-w-lg mx-auto my-10 bg-white rounded-md shadow-md overflow-hidden'>
      <h1 class='text-2xl font-bold text-center py-4 bg-gray-100'>{props.title}</h1>
      {todos.value.length > 0 && (
        <ul class='list-none p-4'>
          {todos.value.map(t => <TodoItem key={t.id} task={t} onToggle={toggleTask} onRemove={deleteTask} />)}
        </ul>
      )}
      <TodoForm onSubmit={addTask} />
    </div>
  );
}

export default TodoList;

Идём дальше. Компонент TodoItem остаётся без изменений, поскольку не использует хуков, а лишь принимает входные параметры в компоненте TodoList. А компонент TodoForm? И в нём тоже нечего менять, поскольку там лишь используется ссылка на элемент input, для добавления новых задач и установки фокуса ввода после добавления. Если бы нам не требовалось фокусироваться на элементе, можно было бы реализовать изменение значения input через сигнал, с очищением после добавления.

Для справки⚓︎

Помимо рассмотренных useSignal и useSignalEffect есть в сигналах ещё один хук — useComputed. С помощью него можно создавать вычисляемые сигналы, зависящие от исходного значения другого сигнала. Например:

import { useSignal, useComputed } from '@preact/signals';

function ExampleComponent() {
  // Первый счётчик, с начальным значением 0
  const counter = useSignal(0);

  // Второй счётчик, зависящий от первого и имеющий значение, которое всегда в 2 раза больше значения первого счётчика
  const doubleCounter = useComputed(() => counter.value * 2);

  return (
    <div>
      <p>
        {counter} x 2 = {doubleCounter}
      </p>
      <button onClick={() => counter.value++}>Нажми меня</button>
    </div>
  );
}

Примечание

Здесь прослеживается аналогия с ref и computed из Vue.js

Примечание

Вы можете использовать сигналы и в приложениях на React. Для этого есть аналогичные хуки useSignal, useSignalEffect и useComputed, доступные в React-компонентах после импорта @preact/signals-react.

Документация⚓︎

Если вы заинтересовались Preact, загляните на этот сайт.

Заключение⚓︎

Итак, мы перенесли наше простое приложение TODO на Preact.js:

  • узнали некоторые отличия от React
  • создали функциональные компоненты на Preact
  • познакомились с сигналами и успешно их внедрили

В следующей части этой серии мы познакомимся с маленьким и шустрым Svelte.


Комментарии