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

от автора


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 ?


ссылка на оригинал статьи https://habr.com/ru/articles/849854/