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
.1hx-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 ?
ссылка на оригинал статьи https://habr.com/ru/articles/849854/
Добавить комментарий