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
-запрос, этот код:
- берёт строку запроса к фильтру,
- сохраняет этот фильтр в IndexedDB,
- просит htmx соответствующим образом обновить URL,
- отрисовывает «компонент»
App
в HTML с активным фильтром и списком ToDo,
- возвращает отрисованный 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);
});
На верхнем уровне координация между веб-страницей и сервис-воркером происходит так:
- htmx прослушивает события двойного клика в
<span>
-ах текста todo
,
- htmx отправляет запрос на
/ui/todos/${id}?editable=true
,
- сервис-воркер возвращает для компонента
Todo
HTML-содержимое, которое включает не <span>
, а <input>
,
- 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 💻