Search  
Always will be ready notify the world about expectations as easy as possible: job change page
30 октября

Пишем одностраничное приложение с помощью htmx

Пишем одностраничное приложение с помощью htmx
Source:
Views:
336

JS-библиотеку htmx воспринимают как средство, которое спасает интернет от одностраничных приложений. Всё дело в том, что React поглотил разработчиков своей сложностью (так говорят), а htmx предлагает столь желанное спасение.

Создатель htmx, Карсон Гросс, иронично объясняет эту динамику библиотеки так:

Нет, здесь у нас диалектика Гегеля:

тезис: традиционные многостраничные приложения,
антитезис: одностраничные приложения,
синтез (возвышенная форма): гипермедиа-приложения с островками интерактивности.

Что ж, похоже, я пропустил эту заметку, поскольку использовал htmx как раз для создания одностраничного приложения.

Я написал простой список ToDo, подтверждающий эту концепцию. После загрузки его страницы взаимодействие с сервером прекращается — всё остальное происходит локально на клиенте.

Но как это работает, если учесть, что htmx ориентирована на управление сетевым взаимодействием посредством гипермедиа?

Вся хитрость заключается в одном простом приёме: серверный код выполняется в сервис-воркере (Service Worker, SW).

React-разработчики его ненавидят!

Если вкратце, то сервис-воркер выступает в качестве посредника между веб-страницей и интернетом. Он перехватывает сетевые запросы и позволяет ими манипулировать. Вы можете изменять запросы, кэшировать ответы для их отправки офлайн и даже создавать новые ответы с нуля, не отправляя запрос за пределы браузера.

Эта последняя возможность как раз и лежит в основе одностраничных приложений. Когда htmx совершает сетевой запрос, сервис-воркер его перехватывает. Затем он выполняет бизнес-логику этого запроса и генерирует новый HTML-код, который htmx подставляет в DOM.

Такое решение в том числе обеспечивает пару преимуществ перед традиционным одностраничным приложением, созданным при помощи фреймворка вроде React. Сервис-воркеры должны использовать для хранения данных IndexedDB, которая сохраняет состояние между загрузками страницы. Если вы закроете страницу и затем вернётесь на неё, то приложение сохранит ваши данные — причём «бесплатно», что является следствием «ямы успеха», обеспечиваемой предусмотрительным выбором этой архитектуры.

Приложение также работает офлайн. И хотя эта возможность не даётся даром, её довольно легко добавить после настройки сервис-воркера.

Естественно, механизм сервис-воркера имеет и множество недочётов. Одним из основных является абсолютно никудышная поддержка в инструментах разработчика, которые периодически проглатывают console.log и не всегда сообщают о том, что сервис-воркер установлен. Ещё одна проблема заключается в отсутствии поддержки ES-модулей в Firefox, что вынудило меня поместить весь свой код в один файл (включая вендорную версию IDB Keyval, которую я включил, поскольку IndexedDB тоже местами бесит).

И это ещё не полный список проблем. Если описывать опыт работы с сервис-воркерами в целом, то это «совсем не весело».

Но! Несмотря на всё это, одностраничное приложение, созданное с помощью htmx, работает!

Так что предлагаю его разобрать.

▍ За кадром

Начнём с HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>htmx spa</title>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./style.css" />
    <script src="./htmx.js"></script>
    <script type="module">
      async function load() {
        try {
          const registration = await navigator.serviceWorker.register("./sw.js");
          if (registration.active) return;

          const worker = registration.installing || registration.waiting;
          if (!worker) throw new Error("No worker found");

          worker.addEventListener("statechange", () => {
            if (registration.active) location.reload();
          });
        } catch (err) {
          console.error(`Registration failed with ${err}`);
        }
      }

      if ("serviceWorker" in navigator) load();
    </script>
    <meta name="htmx-config" content='{"scrollIntoViewOnBoost": false}' />
  </head>
  <body hx-boost="true" hx-push-url="false" hx-get="./ui" hx-target="body" hx-trigger="load"></body>
</html>

Если вы ранее уже создавали одностраничные приложения, то код должен показаться вам знакомым: пустая оболочка HTML-документа, ожидающая своего заполнения JS-кодом. Длинный встроенный тег <script> просто настраивает сервис-воркера и преимущественно скопирован из MDN.

Интересен же здесь тег <body>, который использует htmx для настройки основной части приложения:

  • hx-boost="true" инструктирует htmx подставлять ответы на клики по ссылкам и отправку форм, используя Ajax, без реализации полностраничной навигации.
  • hx-push-url="false" не даёт htmx обновлять URL в ответах на клики по ссылкам и отправку форм.
  • hx-get="./ui" инструктирует htmx загрузить страницу по маршруту /ui и подставить её.
  • hx-target="body" инструктирует htmx подставлять результаты в элемент <body>.
  • hx-trigger="load" сообщает htmx, что всё это нужно проделывать при загрузке страницы.

Итак: /ui возвращает фактическую разметку приложения, после чего htmx берёт на себя обработку всех ссылок и форм, делая его интерактивным.

Что находится по адресу /ui? Вход в сервис-воркера. Он использует небольшую самописную «библиотеку» в стиле Express для обработки шаблонного кода запросов переадресации и возврата ответов. Фактический механизм работы этой библиотеки выходит за рамки текущей статьи, но используется она так:

spa.get("/ui", async (_request, { query }) => {
  const { filter = "all" } = query;
  await setFilter(filter);

  const headers = {};
  if (filter === "all") headers["hx-replace-url"] = "./";
  else headers["hx-replace-url"] = "./?filter=" + filter;

  const html = App({ filter, todos: await listTodos() });
  return new Response(html, { headers });
});

Когда к /ui поступает GET-запрос, этот код:

  1. берёт строку запроса к фильтру,
  2. сохраняет этот фильтр в IndexedDB,
  3. просит htmx соответствующим образом обновить URL,
  4. отрисовывает «компонент» App в HTML с активным фильтром и списком ToDo,
  5. возвращает отрисованный HTML в браузер.

setFilter и listTodos — это довольно простые функции, которые обёртывают IDB Keyval:

async function setFilter(filter) {
  await set("filter", filter);
}

async function getFilter() {
  return get("filter");
}

async function listTodos() {
  const todos = (await get("todos")) || [];
  const filter = await getFilter();

  switch (filter) {
    case "done":
      return todos.filter(todo => todo.done);
    case "left":
      return todos.filter(todo => !todo.done);
    default:
      return todos;
  }
}

Компонент App выглядит так:

function App({ filter = "all", todos = [] } = {}) {
  return html`
    <div class="app">
      <header class="header">
        <h1>Todos</h1>
        <form class="filters" action="./ui">
          <label class="filter">
            All
            <input
              type="radio"
              name="filter"
              value="all"
              oninput="this.form.requestSubmit()"
              ${filter === "all" && "checked"}
            />
          </label>
          <label class="filter">
            Active
            <input
              type="radio"
              name="filter"
              value="left"
              oninput="this.form.requestSubmit()"
              ${filter === "left" && "checked"}
            />
          </label>
          <label class="filter">
            Completed
            <input
              type="radio"
              name="filter"
              value="done"
              oninput="this.form.requestSubmit()"
              ${filter === "done" && "checked"}
            />
          </label>
        </form>
      </header>
      <ul class="todos">
        ${todos.map(todo => Todo(todo))}
      </ul>
      <form
        class="submit"
        action="./todos/add"
        method="get"
        hx-select=".todos"
        hx-target=".todos"
        hx-swap="outerHTML"
        hx-on::before-request="this.reset()"
      >
        <input
          type="text"
          name="text"
          placeholder="What needs to be done?"
          hx-on::after-request="this.focus()"
        />
      </form>
    </div>
  `.trim();
}

(Как и прежде, мы пропустим некоторые вспомогательные функции вроде html, которая лишь обеспечивает небольшое удобство при интерполяции значений).

App можно разделить примерно на три фрагмента:

  • Форма фильтров. Отрисовывает кнопку переключения для каждого фильтра. Изменение состояния кнопки ведёт к отправке формы по маршруту /ui, который повторно отрисовывает приложение, согласно описанным выше шагам. Атрибут hx-boost перехватывает отправку этой формы и подставляет в <body> ответ, не обновляя страницу.
  • Список ToDo. Перебирает все задачи ToDo, соответствующие текущему фильтру, отрисовывая каждую с помощью компонента Todo.
  • Форма добавления Todo. Это форма с вводом, которая отправляет значение на /todos/add. hx-target=".todos" инструктирует htmx заменить элемент на странице классом todos; hx-select=".todos" сообщает htmx, что вместо использования всего ответа, нужно использовать только элемент с классом todos.

Вы могли заметить, что в форме используется метод GET, а не POST. Дело в том, что сервис-воркеры в Firefox не поддерживают тела запросов, в связи с чем все актуальные данные нужно включить в URL.

Взглянем на маршрут /todos/add:

async function addTodo(text) {
  const id = crypto.randomUUID();
  await update("todos", (todos = []) => [...todos, { id, text, done: false }]);
}

spa.get("/todos/add", async (_request, { query }) => {
  if (query.text) await addTodo(query.text);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html, {});
});

Всё просто! Он лишь сохраняет задачу todo и возвращает ответ с повторно отрисованным UI, который htmx подставляет в DOM.

Теперь рассмотрим компонент Todo:

function Icon({ name }) {
  return html`
    <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
      <use href="./icons.svg#${name}" />
    </svg>
  `;
}

function Todo({ id, text, done, editable }) {
  return html`
    <li class="todo">
      <input
        type="checkbox"
        name="done"
        value="true"
        hx-get="./todos/${id}/update"
        hx-vals="js:{done: event.target.checked}"
        ${done && "checked"}
      />
      ${editable
        ? html`<input
            type="text"
            name="text"
            value="${text}"
            hx-get="./todos/${id}/update"
            hx-trigger="change,blur"
            autofocus
          />`
        : html`<span
            class="preview"
            hx-get="./ui/todos/${id}?editable=true"
            hx-trigger="dblclick"
            hx-target="closest .todo"
            hx-swap="outerHTML"
          >
            ${text}
          </span>`}
      <button class="delete" hx-delete="./todos/${id}">${Icon({ name: "ex" })}</button>
    </li>
  `;
}

Здесь у нас три основных части: чекбокс, кнопка удаления и текст todo.

Сначала разберём чекбокс. Этот элемент при каждой постановке/снятии в нём галочки активирует GET-запрос по маршруту /todos/${id}/update. При этом строка запроса done соответствует его текущему состоянию. Весь полученный ответ htmx подставляет в <body>.

Вот код для этого маршрута запроса:

async function updateTodo(id, { text, done }) {
  await update("todos", (todos = []) =>
    todos.map(todo => {
      if (todo.id !== id) return todo;
      return { ...todo, text: text || todo.text, done: done ?? todo.done };
    })
  );
}

spa.get("/todos/:id/update", async (_request, { params, query }) => {
  const updates = {};
  if (query.text) updates.text = query.text;
  if (query.done) updates.done = query.done === "true";

  await updateTodo(params.id, updates);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html);
});

(Обратите внимание, что этот маршрут также поддерживает изменение текста todo. Вскоре мы этот момент разберём).

Кнопка удаления устроена ещё проще: она отправляет запрос DELETE на /todos/${id}. Как и в случае чекбокса, здесь htmx подставляет весь ответ в <body>.

Вот этот маршрут:

async function deleteTodo(id) {
  await update("todos", (todos = []) => todos.filter(todo => todo.id !== id));
}

spa.delete("/todos/:id", async (_request, { params }) => {
  await deleteTodo(params.id);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html);
});

Последним идёт текст todo, обработка которого усложняется поддержкой возможности редактирования. Здесь у нас два возможных состояния: «normal», которое отображает простой <span> с текстом todo (извиняюсь, что оно недоступно) и «editing», которое выводит <input>, позволяя пользователю его редактировать. Состояние, которое нужно отобразить, компонент Todo определяет по свойству editing.

Тем не менее, в отличие от клиентского фреймворка вроде React, здесь мы не можем просто переключить состояние в произвольном месте и ожидать внесения необходимых изменений в DOM. htmx отправляет сетевой запрос для обновления UI, и нам нужно вернуть гипермедиа-ответ, который она сможет подставить в DOM.

Вот этот маршрут:

async function getTodo(id) {
  const todos = await listTodos();
  return todos.find(todo => todo.id === id);
}

spa.get("/ui/todos/:id", async (_request, { params, query }) => {
  const todo = await getTodo(params.id);
  if (!todo) return new Response("", { status: 404 });

  const editable = query.editable === "true";

  const html = Todo({ ...todo, editable });
  return new Response(html);
});

На верхнем уровне координация между веб-страницей и сервис-воркером происходит так:

  1. htmx прослушивает события двойного клика в <span>-ах текста todo,
  2. htmx отправляет запрос на /ui/todos/${id}?editable=true,
  3. сервис-воркер возвращает для компонента Todo HTML-содержимое, которое включает не <span>, а <input>,
  4. htmx замещает текущий элемент списка ToDo HTML-содержимым из ответа.

Когда пользователь изменяет ввод, происходит аналогичный процесс, а именно вызов конечной точки /todos/${id}/update вместо подстановки всего <body>. Если вы уже использовали htmx, то эта схема должна быть вам достаточно знакома.

Вот и всё! Теперь у нас есть одностраничное приложение, созданное с помощью htmx (и Service Worker), которое не опирается на удалённый веб-сервер. Опущенный мной в целях сокращения код доступен на GitHub.

▍ Выводы

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

htmx работает путём добавления в UI косвенной адресации, загружая новое HTML-содержимое из-за пределов сетевых границ. Это может иметь смысл в случае клиент-серверного приложения, поскольку сокращает объём косвенной адресации к базе данных за счёт её колокации в памяти рядом с рендерингом. С другой стороны, реализация клиент-серверного взаимодействия в React может вызывать боль, требуя тщательной координации между клиентами и серверами через неудобный канал обмена данными.

Хотя, когда все взаимодействия происходят локально, отрисовка данных уже колоцирована (в памяти), и их обновление при использовании фреймворка вроде React происходит легко и синхронно. В этом случае требуемая htmx косвенная адресация начинает доставлять больше хлопот, нежели приносить облегчения.* Если говорить о полностью локальных приложениях, то я не думаю, что оно того стоит.

*htmx не является необходимым компонентом этой архитектуры. Теоретически вы можете создать полностью клиентское одностраничное приложение вообще без JS (вне сервис-воркера), просто обернув каждую кнопку в тег <form> и заменяя всю страницу при каждом действии. Поскольку все ответы поступают от сервис-воркера, приложение по-прежнему будет очень быстрым. Вы наверняка даже сможете добавить несколько приятных анимаций, используя переходы между представлениями документов.

Естественно, большинство приложений не являются полностью локальными — обычно это смесь локальных взаимодействий и сетевых запросов. Я считаю, что даже в этом случае использование островков интерактивности будет более удачным паттерном, чем разделение «серверного» кода между сервис-воркером и самим сервером.

В любом случае всё это я проделал преимущественно в качестве упражнения, чтобы понять, как может выглядеть процесс создания полностью локального одностраничного приложения с помощью средств гипермедиа, а не императивного или функционального программирования.

Обратите внимание, что гипермедиа — это техника, а не конкретный инструмент. Я выбрал htmx, потому что это современная библиотека фреймворк для гипермедиа, и мне хотелось задействовать его по максимуму. Существуют и другие инструменты вроде Mavo, которые ориентированы конкретно на этот случай. И вы действительно можете обнаружить, что реализация TodoMVC посредством Mavo получается намного проще, чем моя. Но ещё лучше подошло бы какое-нибудь приложение в стиле HyperCad, в котором можно было бы реализовать всё визуально.

В целом мне понравилось создавать своё небольшое приложение ToDo с помощью htmx. Вы же можете рассмотреть этот эксперимент, как минимум, в качестве напоминания, что время от времени следует пытаться использовать привычные инструменты странным и неожиданным образом.

Автор оригинала: Jake Lazaroff

Similar
24 марта
Автор: Рафаил Агазода
Сегодня мы попытаемся построить и запустить магазин мороженого, при этом одновременно изучить асинхронный JavaScript. Также вы узнаете, как использовать: Callbacks. Promises. Async / Await. Вот что мы рассмотрим в этой статье: Что такое асинхронный JavaScript. Синхронный и асинхронный JavaScript. Как...
10 апреля
Vite.js - это один из инструментов для настройки сред разработки. Чаще всего используется в связке с фреймворками React и Vue. Однако функционал не ограничен только их поддержкой - инструмент способен работать даже с ванильным JavaScript, следовательно, и различными библиотеками, написанным...
27 мая
Автор: Марина Александровна
Уверены, что вопросы на собеседовании frontend — настоящая боль? Мы взяли на себя поиск наиболее популярных и дали на них развёрнутые ответы. Казалось бы, вопросы на собеседовании frontend разработчика Junior не должны отличаться от стандартных задачек с IT-сайтов. Даже мы...
Feb 3, 2022
Author: Satish Chandra Gupta
What if programming languages were stocks? And you had to make a portfolio to fetch good returns in 2022? You probably have seen various surveys and analyses listing the most popular programming languages. Those cover the universe of programming languages,...
Send message
Type
Email
Your name
*Message