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

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (1)


  1. Hellbot
    13.10.2024 11:23

    А еще есть preact и htm, и вместе они позволяют делать все как в react, но без прекомпилятора, транспилятора и еще кого-то ***лятора. Потому что современным браузерам ничего не надо.

    <!DOCTYPE html>
    <html>
      <head>
        <script type="importmap">
          {
            "imports": {
              "preact": "https://esm.sh/preact@10.23.1",
              "htm/preact": "https://esm.sh/htm@3.1.1/preact?external=preact"
            }
          }
        </script>
      </head>
      <body>
        <div id="app"></div>
    
        <script type="module">
          import { render } from 'preact';
          import { html } from 'htm/preact';
    
          export function App() {
            return html`
              <h1>Hello, World!</h1>
            `;
          }
    
          render(html`<${App} />`, document.getElementById('app'));
        </script>
      </body>
    </html>

    https://preactjs.com/guide/v10/no-build-workflows

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