Hypermedia Systems

Карсон Гросс, Адам Степински, Дениз Акшимшек

2022

перевод В.Айсин

Часть I
Концепции гипермедиа

Введение

Это книга о создании приложений с использованием гипермедиа-систем. Hypermedia systems может показаться странной фразой: почему гипермедиа является системой? Разве гипермедиа не является просто способом связать документы вместе?

Как в случае с HTML во Всемирной паутине?

Что вы подразумеваете под гипермедийными системами?

Ну да, HTML — это гипермедиа. Но в работе Интернета есть нечто большее, чем просто HTML: HTTP, протокол передачи гипертекста, передает HTML с серверов клиентам, и с ним связано множество деталей и функций: кеширование, различные заголовки, коды ответов, и так далее.

И, конечно же, есть гипермедиа-серверы, которые предоставляют гипермедийные API (да, API) клиентам по сети.

И, наконец, есть важнейший гипермедийный клиент: программный клиент, который понимает, как понятным образом передать человеку ответ гипермедиа, чтобы человек мог взаимодействовать с удаленной системой. Наиболее широко известными и используемыми гипермедийными клиентами являются, конечно же, веб-браузеры.

Веб-браузеры, пожалуй, самые сложные программы, которые мы используем. Они не только понимают HTML, CSS и многие другие форматы файлов, но также предоставляют среду выполнения JavaScript и среду программирования, которая настолько мощна, что веб-разработчики могут создавать в ней целые приложения, почти такие же сложные, как толстые клиенты, то есть собственные приложения.

Эта среда выполнения JavaScript настолько мощна, что сегодня многие разработчики игнорируют гипермедийные функции браузера, предпочитая создавать свои веб-приложения полностью на JavaScript. Приложения, созданные таким образом, стали называть одностраничными приложениями (SPA). Вместо навигации между страницами эти веб-приложения используют JavaScript для непосредственного обновления пользовательского интерфейса. Когда они взаимодействуют с сервером, эти приложения обычно используют вызовы API JSON через AJAX. И они часто обновляют пользовательский интерфейс, используя библиотеку JavaScript для внешнего интерфейса в «реактивном» стиле.

В этих приложениях HTML становится (несколько неуклюжим) языком описания графического интерфейса, который используется потому, что по историческим причинам именно он присутствует в браузере.

Приложения, созданные в этом стиле, не управляются гипермедиа: они не используют преимущества базовой гипермедиа-системы Интернета.

Чтобы объяснить, как выглядит Hypermedia-Driven Application, и сравнить его с популярным сегодня подходом SPA, нам нужно сначала изучить всю гипермедийную систему Интернета, а не просто обсуждать HTML. Нам необходимо рассмотреть сетевую архитектуру Интернета, в том числе то, как веб-сервер предоставляет API гипермедиа и как эффективно использовать функции гипермедиа, доступные в клиенте гипермедиа (например, в браузере).

Каждый из них является важным аспектом создания эффективного Hypermedia-Driven Application, и именно вся система гипермедиа объединяется, чтобы сделать гипермедиа такой мощной архитектурой.

Что такое гипермедийная система?

Чтобы понять, что такое система гипермедиа, мы сначала подробно рассмотрим каноническую систему гипермедиа: Всемирную паутину. Рой Филдинг, инженер, который помогал создавать спецификации и реализовывать многие ранние части Интернета, дал нам термин REpresentational State Transfer, или REST. В своей докторской диссертации он описал REST как сетевую архитектуру и противопоставил ее более ранним подходам к созданию распределенного программного обеспечения.

Мы определяем гипермедиа-систему как систему, которая соответствует сетевой архитектуре RESTful в первоначальном понимании этого термина Филдингом.

К сожалению, сегодня вы, вероятно, ассоциируете термин «REST» с API-интерфейсами JSON, поскольку именно здесь этот термин обычно используется в промышленности. Это неправильное использование термина REST, поскольку JSON не является естественным гипермедиа из-за отсутствия элементов управления гипермедиа. Обмен гипермедиа является явным требованием для того, чтобы система считалась «RESTful». Это длинная история того, как мы дошли до этого, так неправильно используя термин REST, и мы углубимся в подробности позже в этой книге. Но на данный момент, если вы считаете, что REST подразумевает JSON, попробуйте отложить это понимание во время чтения этой книги и подойти к этой концепции свежим взглядом.

Важно понимать, что в своей диссертации Филдинг описывал Всемирную паутину в том виде, в котором она существовала в конце 1990-х годов. На тот момент Интернет представлял собой просто веб-браузеры, обменивающиеся гипермедиа. Эту систему с ее простыми ссылками и формами Филдинг называл RESTful.

API-интерфейсам JSON оставалось еще десять лет до того, чтобы стать распространенным инструментом веб-разработки: REST касалось гипермедиа и веб-версии 1.0.

Hypermedia-Driven Application

В этой книге мы рассмотрим гипермедиа как системную архитектуру, а затем рассмотрим некоторые практические современные подходы к созданию веб-приложений с ее использованием. Мы будем называть приложения, созданные в этом стиле, Hypermedia-Driven Application, или HDA, и противопоставим их популярному сегодня стилю — одностраничному приложению (SPA).

Hypermedia-Driven Application, — это приложение, созданное на основе системы гипермедиа, которое учитывает и использует функциональные возможности гипермедиа этой базовой системы.

Цели

Цель этой книги — дать вам четкое представление о том, чем RESTful архитектура системы гипермедиа отличается от других систем клиент-сервер и каковы сильные (и слабые) стороны подхода гипермедиа. Кроме того, мы надеемся убедить вас в том, что архитектура гипермедиа актуальна для разработчиков, создающих современные веб-приложения.

Мы стремимся предоставить вам инструменты для оценки требований к приложению и ответа на вопрос: «Могу ли я создать это как Hypermedia-Driven Application?»

Мы надеемся, что для многих приложений ответом на этот вопрос будет «Да!»

Макет книги

Книга разбита на три части:

Обратите внимание, что каждый раздел в некоторой степени независим от других. Если вы уже хорошо разбираетесь в гипермедиа и в том, как функционируют базовые приложения Web 1.0, возможно, вам стоит перейти ко второму разделу, посвященному htmx и тому, как создавать современные веб-приложения с использованием гипермедиа. Аналогичным образом, если вы хорошо разбираетесь в HTML и хотите погрузиться в новую мобильную гипермедиа, вы можете перейти к разделу Hyperview.

При этом книга предназначена для чтения по порядку, и разделы htmx и Hyperview основаны на приложении Web 1.0, описанном в конце первого раздела. Более того, даже если вы хорошо разбираетесь во всех концепциях гипермедиа и деталях HTML и HTTP, вероятно, стоит хотя бы просмотреть первые несколько глав, чтобы освежить знания.

Гипермедиа: новое поколение

Гипермедиа в наши дни не является частой темой для обсуждения. Даже многие программисты старшего возраста, выросшие в Интернете в конце 1990-х и начале 2000-х годов, годами не задумывались об этих идеях. Многие молодые веб-разработчики выросли, не зная ничего, кроме одностраничных приложений и фреймворков, которые используются для их создания.

В частности, многие молодые веб-разработчики начали свою карьеру с создания приложений React.js, которые взаимодействуют с сервером Node с помощью JSON API; они, возможно, вообще никогда не узнали о гипермедиа как о системе.

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

Гипермедиа была отличной идеей! Это все еще так!

К концу этой книги у вас будут инструменты и язык, позволяющие реализовать эту замечательную идею в ваших собственных приложениях. Кроме того, вы сможете донести идеи и концепции гипермедиа-систем до более широкого сообщества веб-разработчиков.

Гипермедиа может конкурировать, гипермедиа может победить, гипермедиа как архитектурный выбор победила подход одностраничного приложения, но только если умные люди (такие как вы) узнают об этом, построят с его помощью и затем расскажут об этом миру.

Помните сообщение? «Будущее не предопределено. Нет другой судьбы, кроме той, которую мы создаём сами».
~ Кайл Риз Терминатор 2: Судный день

HTML-заметки: гипермедиа на практике

Очевидно, что HTML играет центральную роль в истории, которую мы здесь рассказываем. В конце каждой главы мы поделимся тем, что узнали о написании HTML для веб-приложений, управляемых гипермедиа (Hypermedia-Driven Application).

Для начала помните, что наши веб-приложения — не острова. Мы пишем HTML не только для конкретного приложения, но и для того, чтобы играть вместе с другими участниками сети. Когда мы пишем, помня о системе гипермедиа, мы можем лучше использовать весь спектр возможностей, доступных в Интернете.

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

Нет, мы не можем решить каждую проблему с помощью хорошего HTML. Мантра о том, что HTML «доступен по умолчанию», вводит в заблуждение. Мы бы упустили важные возможности, если бы избегали других технологий, таких как JavaScript. И нам все еще нужно много и повсюду тестировать, чтобы убедиться, что все работает так, как ожидалось.

Но хороший HTML позволяет браузерам выполнять за нас большую работу.

Глава 1
Гипермедиа: реинтродукция

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

Миллиарды людей используют системы на основе гипермедиа каждый день, в основном взаимодействуя с языком разметки гипертекста (HTML), обмен которым осуществляется через протокол передачи гипертекста (HTTP), с помощью веб-браузера, подключенного к Всемирной паутине.

Люди используют эти системы, чтобы узнавать новости, общаться с друзьями, покупать вещи в Интернете, играть в игры, отправлять электронные письма и т. д. Разнообразие и огромное количество онлайн-услуг, предоставляемых гипермедиа, поистине поразительны.

И все же, несмотря на эту повсеместность, сама по себе тема гипермедиа сегодня является на удивление малоисследованной концепцией, оставленной в основном специалистам. Да, вы можете найти множество руководств о том, как создавать HTML, создавать ссылки и формы и т. д. Но редко можно увидеть обсуждение HTML как гипермедиа и, в более широком смысле, о том, как вся система гипермедиа сочетается друг с другом.

Это контрастирует с ранней эпохой веб-разработки, когда такие концепции, как передача репрезентативного состояния (REST) и гипермедиа как двигатель состояния приложения (HATEOAS), часто обсуждались, уточнялись и обсуждались среди веб-разработчиков.

При печальном повороте событий сегодня к самому популярному в мире гипермедиа, HTML, часто относятся с негодованием: это неуклюжий, устаревший язык разметки, который приходится неохотно использовать для создания пользовательских интерфейсов в веб-приложениях, которые все чаще полностью основаны на JavaScript.

HTML есть в браузере, и поэтому нам приходится его использовать.

Это позор, и мы надеемся убедить вас, что гипермедиа — это не просто часть устаревшей технологии, которую нам приходится принимать и с которой приходится иметь дело. Вместо этого мы стремимся показать вам, что гипермедиа — это чрезвычайно инновационный, простой и гибкий способ создания надежных приложений: Hypermedia-Driven Applications (приложений, управляемых гипермедиа).

Мы надеемся, что к концу этой книги вы, как и мы, почувствуете, что подход гипермедиа заслуживает места за столом, когда вы, веб-разработчик, обдумываете архитектуру своего следующего приложения. Создание Hypermedia-Driven Application, поверх такой гипермедийной системы, как Интернет, является жизнеспособным и зачастую отличным выбором для современных веб-приложений.

(И, как будет показано в разделе, посвященном Hyperview, не только для веб-приложений.)

Что такое гипермедиа?

Гипертексты: новые формы письма, появляющиеся на экранах компьютеров, которые разветвляются или действуют по команде читателя. Гипертекст — это непоследовательный фрагмент текста; только дисплей компьютера делает его практичным.
~ Тед Нельсон https://archive.org/details/SelectedPapers1977/page/n7/mode/2up

Начнем с самого начала: что такое гипермедиа?

Гипермедиа — это носитель, например текст, который включает в себя нелинейное ветвление от одного места носителя к другому, например, посредством гиперссылок, встроенных в носитель. Префикс «гипер-» происходит от греческого префикса «ὑπερ-», который означает «за пределами» или «сверх», указывая на то, что гипермедиа выходит за рамки обычных, пассивно потребляемых средств массовой информации, таких как журналы и газеты.

Гиперссылки являются каноническим примером того, что называется управлением гипермедиа:

Hypermedia Control
Элемент управления гипермедиа — это элемент гипермедиа, который описывает (или контролирует) некоторый вид взаимодействия, часто с удаленным сервером, путем кодирования информации об этом взаимодействии непосредственно и полностью внутри себя.

Элементы управления гипермедиа — это то, что отличает гипермедиа от других видов медиа.

Возможно, вам более знаком термин «гипертекст», со страницы Википедии взята приведенная выше цитата. Гипертекст — это подкатегория гипермедиа, и большая часть этой книги посвящена обсуждению того, как создавать современные приложения с использованием гипертекстов, таких как HTML, язык разметки гипертекста или HXML, гипертекст, используемый системой мобильной гипермедиа Hyperview.

Гипертексты, такие как HTML, функционируют наряду с другими технологиями, имеющими решающее значение для работы всей системы гипермедиа: сетевые протоколы, такие как HTTP, другие типы мультимедиа, такие как изображения и видео, серверы гипермедиа (т. е. серверы, предоставляющие API-интерфейсы гипермедиа), сложные клиенты гипермедиа (например, веб-браузеры), и так далее.

По этой причине мы предпочитаем более широкий термин гипермедиа системы при описании базовой архитектуры приложений, созданных с использованием гипертекста, чтобы подчеркнуть архитектуру системы, а не конкретную используемую гипермедиа.

Многие современные веб-разработчики недооценивают и игнорируют всю архитектуру системы гипермедиа.

Краткая история гипермедиа

Откуда взялась идея гипермедиа?

Хотя существовало множество предшественников современной идеи гипертекста и более общей гипермедиа, многие люди указывают на статью 1945 года Как мы можем думать, написанную Ванневаром Бушем в The Atlantic, как отправную точку для рассмотрения того, что стало современной гипермедиа.

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

Термины «гипертекст» и «гипермедиа» были придуманы в 1963 году Тедом Нельсоном, который продолжил работу над системой редактирования гипертекста в Университете Брауна, а позже создал систему поиска и редактирования файлов (FRESS), потрясающе продвинутую систему гипермедиа для своего времени. (Возможно, это была первая цифровая система, в которой было понятие «отмена» (undo).)

Пока Нельсон работал над своими идеями, Дуглас Энгельбарт был занят работой в Стэнфордском исследовательском институте, явно пытаясь воплотить в жизнь Мемекс Ванневара Буша. В 1968 году Энглбарт выступил с «Матерью всех демонстраций» в Сан-Франциско, Калифорния.

Энглбарт продемонстрировал невероятное количество технологий:

Несмотря на овации потрясенной аудитории после его выступления, прошли десятилетия, прежде чем технологии, продемонстрированные Энглбартом, стали мейнстримом.

Современная реализация

В 1990 году Тим Бернерс-Ли, работавший в ЦЕРНе, опубликовал первый веб-сайт. Он работал над идеей гипертекста в течение десяти лет и, наконец, в отчаянии от того, что исследователям было так трудно делиться своими исследованиями, нашел подходящий момент и институциональную поддержку для создания Всемирной паутины:

Создание сети было действительно актом отчаяния, потому что ситуация без нее была очень сложной, когда я позже работал в ЦЕРНе. Большинство технологий, используемых в сети, например, гипертекст, Интернет, многошрифтовые текстовые объекты, уже были разработаны. Мне оставалось только собрать их вместе. Это был шаг к обобщению, переходу на более высокий уровень абстракции, размышлению обо всех существующих системах документации как о возможной части более крупной воображаемой системы документации.
~ Тим Бернерс-Ли https://britishheritage.org/tim-berners-lee-the-world-wide-web

К 1994 году его творение стало настолько популярным, что Бернерс-Ли основал W3C, рабочую группу компаний и исследователей, задачей которой было улучшение Интернета. Все стандарты, созданные W3C, были бесплатными и могли быть приняты и реализованы кем угодно, что закрепляло открытую и совместную природу Интернета.

В 2000 году Рой Филдинг, тогда работавший в Калифорнийском университете в Ирвайне, опубликовал в Интернете основополагающую докторскую диссертацию: «Архитектурные стили и проектирование сетевых архитектур программного обеспечения». Филдинг работал над HTTP-сервером Apache с открытым исходным кодом, и его диссертация представляла собой описание того, что, по его мнению, было новой и особой сетевой архитектурой, возникшей на заре Интернета. Филдинг работал над первоначальными спецификациями HTTP и в своей статье определил модель сети гипермедиа в Интернете, используя термин REpresentational State Transfer (REST).

Работа Филдинга стала основным пробным камнем для первых веб-разработчиков, предоставив им язык для обсуждения новой технической среды, в которой они создавали приложения.

Мы подробно обсудим ключевые идеи Филдинга в главе 2 и попытаемся исправить ситуацию в отношении REST, HATEOAS и гипермедиа.

Самый успешный гипертекст в мире: HTML

Вначале была гиперссылка, и гиперссылка была в веб, и гиперссылка была веб. И это было хорошо.
~ Спасение REST от API-Зимы https://intercoolerjs.org/2016/01/18/rescuing-rest.html

Система, которую создали Бернерс-Ли, Филдинг и многие другие, вращалась вокруг гипермедиа: HTML. HTML начинался как гипермедиа, доступная только для чтения и использовавшаяся (сначала) для публикации научных документов. Эти документы были связаны между собой с помощью тегов якоря, которые создавали между ними гиперссылки, позволяя пользователям быстро перемещаться между документами.

Когда был выпущен HTML 2.0, в нем появилось понятие тега form, присоединившегося к тегу якоря (т. е. гиперссылке) в качестве второго элемента управления гипермедиа. Введение тега формы сделало создание веб-приложений жизнеспособным, предоставив механизм обновления ресурсов, а не просто их чтения.

Именно в этот момент Интернет превратился из интересной документально-ориентированной системы в привлекательную прикладную архитектуру.

Сегодня HTML является наиболее широко используемым из существующих гипермедиа, и в этой книге, естественно, предполагается, что читатель имеет достаточное представление о нем. Вам не нужно быть экспертом по HTML (или CSS), чтобы понять код в этой книге, но чем лучше вы понимаете основные теги и концепции HTML, тем больше вы получите от нее.

Сущность HTML как гипермедиа

Давайте рассмотрим эти два определяющих элемента гипермедиа (то есть два определяющих элемента управления гипермедиа) HTML, тег якоря и тег формы, немного подробнее.

Теги якоря

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

Рассмотрим простой тег якоря, встроенный в более крупный HTML-документ:

Листинг 1. Простая гиперссылка

<a href="https://hypermedia.systems/">
  Hypermedia Systems
</a>

Тег якоря состоит из самого тега <a></a>, а также атрибутов и содержимого внутри тега. Особый интерес представляет атрибут href, задающий гипертекстовую ссылку на другой документ или фрагмент документа. Именно этот атрибут делает тег якоря элементом управления гипермедиа.

В типичном веб-браузере этот тег якоря будет интерпретироваться как:

Якоря представляют собой основной механизм, который мы используем сегодня для навигации по сети, выбирая ссылки для перехода от документа к документу или от ресурса к ресурсу.

Вот как визуально выглядит взаимодействие пользователя с тегом якоря/гиперссылкой:

Рисунок 1. HTTP GET в действии

При щелчке по ссылке браузер (или, как мы его иногда называем, гипермедиа клиент) инициирует HTTP-запрос GET к URL-адресу, закодированному в атрибуте href ссылки.

Обратите внимание, что HTTP-запрос включает дополнительные данные (т. е. метаданные) о том, что именно браузер хочет от сервера, в виде заголовков. Мы обсудим эти заголовки и HTTP более подробно в главе 2.

Затем сервер гипермедиа отвечает на этот запрос ответом гипермедиа — HTML — для новой страницы. Это может показаться небольшим и очевидным моментом, но это абсолютно важный аспект правильной RESTful гипермедиа-системы: клиент и сервер должны взаимодействовать через гипермедиа!

Теги формы

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

Вот простой пример формы в HTML:

Листинг 2. Простая форма

<form action="/signup" method="post">
  <input type="text" name="email" placeholder="Enter Email
To Sign Up..."/>
  <button>Sign Up</button>
</form>

Как и тег якоря, тег формы состоит из самого тега <form></form> в сочетании с атрибутами и содержимым внутри тега. Обратите внимание, что тег формы не имеет атрибута href, а имеет атрибут action, который указывает, куда отправлять HTTP-запрос.

Кроме того, у него также есть атрибут method, который точно определяет, какой именно «метод» HTTP использовать. В этом примере форма запрашивает браузер выполнить запрос POST.

В отличие от тегов якоря, содержимое и теги внутри формы могут влиять на взаимодействие гипермедиа, которое форма осуществляет с сервером. Значения тегов input и других тегов, таких как теги select, будут включены в HTTP-запрос при отправке формы, как параметры URL-адреса в случае GET и как часть тела запроса в случае POST. Это позволяет форме включать в запрос произвольный объем информации, полученной от пользователя, в отличие от тега якоря.

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

Этот механизм позволяет пользователю отправлять запросы на обновление состояния ресурсов на сервере. Обратите внимание, что, несмотря на этот новый тип запроса, связь между клиентом и сервером по-прежнему полностью осуществляется с помощью гипермедиа.

Именно тег формы делает возможными Hypermedia-Driven Application.

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

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

Вот схема взаимодействия:

Рисунок 2. HTTP POST в действии

Приложения Веб 1.0

Как человеку, интересующемуся веб-разработкой, приведенные выше диаграммы и обсуждение, вероятно, вам очень знакомы. Возможно, вам даже покажется этот контент скучным. Но сделайте шаг назад и учтите тот факт, что эти два элемента управления гипермедиа, якоря и формы, являются единственными собственными способами взаимодействия пользователя с сервером в простом HTML.

Всего два тега!

И все же, вооружившись только этими двумя тегами, ранняя сеть могла расти в геометрической прогрессии и предлагать ошеломляюще большое количество онлайн-динамических функций миллиардам людей.

Это убедительное свидетельство силы гипермедиа. Даже сегодня, в мире веб-разработки, в котором все больше доминируют крупные интерфейсные платформы, ориентированные на JavaScript, многие люди предпочитают использовать простой HTML-код для достижения целей своих приложений и часто полностью довольны результатами.

Эти два тега придают HTML огромную выразительную силу.

Так чем же не гипермедиа?

Таким образом, ссылки и формы — это два основных механизма взаимодействия с сервером, основанных на гипермедиа, доступных в HTML.

Теперь давайте рассмотрим другой подход: давайте взаимодействовать с сервером, отправляя HTTP-запрос через JavaScript. Для этого мы будем использовать API fetch(), популярный API для выдачи «Асинхронного JavaScript и XML» или запроса AJAX, доступного во всех современных веб-браузерах:

Листинг 3. JavaScript

<button onclick="fetch('/api/v1/contacts/1')        # 1
                            .then(response => response.json()) # 2
                            .then(data => updateUI(data))"> # 3
    Fetch Contact
</button>
  1. Выполнить запрос.
  2. Преобразовать ответ в объект JavaScript.
  3. Вызвать функцию updateUI() с объектом.

Эта кнопка имеет атрибут onclick, который определяет некоторый JavaScript, который будет запускаться при нажатии кнопки.

JavaScript отправит запрос AJAX HTTP GET к /api/v1/contacts/1 с помощью fetch(). Запрос AJAX похож на «обычный» HTTP-запрос, но он выдается браузером «за кулисами». Пользователь не видит индикатор запроса из браузера, как при использовании обычных ссылок и форм. Кроме того, в отличие от запросов, выдаваемых этими элементами управления гипермедиа, код JavaScript должен обрабатывать ответ от сервера.

Несмотря на то, что AJAX имеет XML как часть своей аббревиатуры, сегодня ответ HTTP на этот запрос почти наверняка будет в формате нотации объектов JavaScript (JSON), а не XML.

HTTP-ответ на этот запрос может выглядеть примерно так:

Листинг 4. JSON

{ # 1
  "id": 42,  # 2
  "email" : "json-example@example.org" # 3
}
  1. Начало объекта JSON.

  2. Свойство, в данном случае с именем id и значением 42.

  3. Еще одно свойство — адрес электронной почты контакта с этим идентификатором.

Приведенный выше код JavaScript преобразует текст JSON, полученный с сервера, в объект JavaScript, вызывая для него метод json(). Этот новый объект JavaScript затем передается методу updateUI().

Метод updateUI() отвечает за обновление пользовательского интерфейса на основе данных, закодированных в объекте JavaScript, возможно, путем отображения контакта в фрагменте HTML, сгенерированном с помощью шаблона на стороне клиента в приложении JavaScript.

Детали того, что именно делает функция updateUI(), не важны для нашего обсуждения.

Что важно, решающим аспектом этого взаимодействия с сервером на основе JSON является то, что оно не использует гипермедиа. Используемый здесь JSON API не возвращает ответ гипермедиа. В нем нет гиперссылок или других элементов управления в стиле гипермедиа.

Этот JSON API — это, скорее, Data API.

Поскольку ответ находится в формате JSON и не является гипермедиа, метод JavaScript updateUI() должен понимать, как преобразовать эти контактные данные в HTML.

В частности, код updateUI() должен знать внутреннюю структуру и значение данных.

Ему необходимо знать:

Короче говоря, логика в updateUI() должна иметь глубокие знания о конечной точке API в /api/v1/contact/1, знания, предоставляемые через некоторый побочный канал, помимо самого ответа. В результате код updateUI() и API имеют прочную связь, известную как жесткая связь: если формат ответа JSON изменится, то почти наверняка также потребуется изменить код updateUI().

Одностраничные приложения

Этот фрагмент JavaScript, хотя и очень скромный, является естественным началом гораздо более масштабного концептуального подхода к созданию веб-приложений. Это начало одностраничного приложения (SPA). Веб-приложение больше не перемещается между страницами с помощью элементов управления гипермедиа, как это было в случае со ссылками и формами.

Вместо этого приложение обменивается простыми данными с сервером, а затем обновляет содержимое на одной странице.

Когда эта стратегия или архитектура применяется для всего приложения, все происходит на «одной странице», и, таким образом, приложение становится «одностраничным приложением».

Архитектура одностраничных приложений сегодня чрезвычайно популярна и является доминирующим подходом к созданию веб-приложений за последнее десятилетие. Об этом можно судить по тому высокому уровню внимания и дискуссий, которые он получил в отрасли.

Сегодня подавляющее большинство одностраничных приложений используют гораздо более сложные структуры для управления пользовательским интерфейсом, чем показано в этом простом примере. Популярные библиотеки, такие как React, Angular, Vue.js и т. д., теперь являются распространенным и даже стандартным способом создания веб-приложений.

В этих более сложных средах разработчики обычно работают со сложной моделью на стороне клиента, то есть с объектами JavaScript, хранящимися локально в памяти браузера и представляющими «модель» или «домен» вашего приложения. Эти объекты JavaScript обновляются с помощью кода JavaScript, а затем платформа «реагирует» на эти изменения, обновляя пользовательский интерфейс.

Когда пользователь обновляет пользовательский интерфейс, эти изменения также передаются в объекты модели, создавая «двусторонний» механизм привязки: модель может обновлять пользовательский интерфейс, а пользовательский интерфейс может обновлять модель.

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

HTML по-прежнему используется для создания пользовательских интерфейсов, но гипермедийный аспект двух основных элементов управления гипермедиа — якорей и форм — не используется. Ни один из тегов не взаимодействует с сервером через собственный механизм гипермедиа. Скорее, они становятся элементами пользовательского интерфейса, которые управляют локальным взаимодействием с моделью предметной области в памяти через JavaScript, который затем синхронизируется с сервером с помощью API-интерфейсов JSON с простыми данными.

Итак, как и в случае с нашей простой кнопкой выше, подход «Одностраничное приложение» исключает архитектуру гипермедиа. Он оставляет в стороне преимущества существующей веб-архитектуры RESTful и встроенных функций, имеющихся в собственных элементах управления гипермедиа HTML, в пользу поведения, управляемого JavaScript.

SPA больше похожи на толстые клиентские приложения, то есть на клиент-серверные приложения 1980-х годов — архитектуру, популярную до появления Интернета и на которую Интернет во многом был реакцией.

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

Зачем использовать гипермедиа?

Новой нормой веб-разработки является создание одностраничного приложения React с серверным рендерингом. Два ключевых элемента этой архитектуры выглядят примерно так:

Эта идея действительно захлестнула Интернет. Все началось с нескольких крупных популярных веб-сайтов и распространилось на такие уголки, как маркетинговые сайты и блоги.
~ Том Макрайт https://macwright.com/2020/05/10/spa-fatigue.html

Подход одностраничного приложения на основе JavaScript покорил мир веб-разработки, и если и была одна-единственная причина его бешеного успеха, то она заключалась в следующем: одностраничное приложение предлагает гораздо более интерактивный и захватывающий опыт, чем старые, неуклюжие приложения на основе гипермедиа Web 1.0 могли бы это сделать. SPA имеют возможность плавно обновлять элементы внутри страницы без резкой перезагрузки всего документа, у них есть возможность использовать CSS-переходы для создания приятных визуальных эффектов и возможность подключаться к произвольным событиям, таким как движения мыши.

Все эти возможности дают приложениям на основе JavaScript огромное преимущество в создании сложного пользовательского опыта.

Учитывая популярность, мощь и успех этого современного подхода к созданию веб-приложений, с какой стати вам рассматривать более старый, более громоздкий и менее популярный подход, такой как гипермедиа?

Усталость от JavaScript

Мы рады, что вы спросили!

Оказывается, архитектура гипермедиа, даже в ее исходной форме Web 1.0, имеет ряд преимуществ по сравнению с подходом Single Page Application + JSON Data API. Три из самых крупных:

Первые два преимущества, в частности, устраняют основные болевые точки современной веб-разработки:

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

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

Но если гипермедиа так хороша и если она решает так много проблем, с которыми сталкивается индустрия веб-разработки, почему ее вообще оставили в стороне? В конце концов, гипермедиа была первой. Почему веб-разработчики просто не придерживались этого?

Есть две основные причины, по которым гипермедиа не вернулась в веб-разработку.

Во-первых, выразительность HTML как гипермедиа не сильно изменилась, если вообще изменилась, со времен HTML 2.0, выпущенного в середине 1990-х. Конечно, в HTML было добавлено много новых функций, но за последние три десятилетия не было никаких серьезных новых способов взаимодействия с сервером в HTML.

Разработчики HTML по-прежнему имеют только теги якоря и формы, доступные в качестве элементов управления гипермедиа, и эти элементы управления гипермедиа по-прежнему могут выдавать только запросы GET и POST.

Это озадачивающее отсутствие прогресса в HTML немедленно приводит ко второй и, возможно, более практической причине того, что HTML как гипермедиа переживает трудные времена: поскольку интерактивность и выразительность HTML остаются замороженными, требования веб-пользователей продолжают расти, призывая к созданию все большего количества интерактивных веб-приложений.

Приложения на основе JavaScript в сочетании с API-интерфейсами JSON, ориентированными на данные, стали способом предоставления этих более сложных пользовательских интерфейсов. Именно тот пользовательский опыт, которого можно было достичь с помощью JavaScript и которого нельзя было достичь с помощью простого HTML, побудил сообщество веб-разработчиков перейти к подходу к одностраничным приложениям на основе JavaScript. Этот сдвиг не был вызван каким-либо внутренним превосходством одностраничного приложения как системной архитектуры.

Так не должно было быть. В идее гипермедиа нет ничего такого, что мешало бы иметь более богатую и выразительную модель интерактивности, чем стандартный HTML. Вместо того, чтобы отходить от подхода, основанного на гипермедиа, отрасль могла бы потребовать большей интерактивности от HTML.

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

Конечно, не все игнорируют гипермедиа. Были предприняты героические усилия по дальнейшему развитию гипермедиа за пределами HTML, такие как HyTime, VoiceXML и HAL.

Но HTML, наиболее широко используемый гипермедиа в мире, перестал развиваться как гипермедиа. Мир веб-разработки пошел дальше, решая проблемы интерактивности с HTML путем принятия SPA на основе JavaScript и, в основном непреднамеренно, совершенно другой системной архитектуры.

Возрождение гипермедиа?

Интересно подумать о том, как мог бы развиваться HTML. Как HTML мог бы продолжать развиваться вместо того, чтобы застопориться в качестве гипермедиа? Могло ли развитие продолжать добавлять новые элементы управления гипермедиа и увеличивать выразительность существующих? Можно ли было создавать современные веб-приложения в рамках этой оригинальной, ориентированной на гипермедиа и RESTful модели, которая сделала раннюю сеть Интернет такой мощной, такой гибкой и такой увлекательной?

Это может показаться пустым предположением, но у нас есть хорошие новости на этот счет: за последнее десятилетие появилось несколько своеобразных альтернативных библиотек внешнего интерфейса, которые пытаются снова продвинуть HTML. По иронии судьбы, эти библиотеки написаны на JavaScript — технологии, которая вытеснила HTML в качестве центра веб-разработки.

Однако эти библиотеки используют JavaScript не в качестве замены фундаментальной гипермедийной системы Интернета.

Вместо этого они используют JavaScript для расширения самого HTML как гипермедиа.

Эти библиотеки, ориентированные на гипермедиа, превращают гипермедиа в основную технологию веб-приложений.

Библиотеки JavaScript, ориентированные на гипермедиа

В мире веб-разработки продолжаются споры между подходом одностраничного приложения (SPA) и подходом, который сейчас называется подходом «многостраничного приложения» (MPA). MPA — это современное название старого способа создания веб-приложений Web 1.0, использующего ссылки и формы, расположенные на нескольких веб-страницах, отправки HTTP-запросов и получения ответов HTML.

Приложения MPA по своей природе являются Hypermedia-Driven Application: в конце концов, это именно то, что Рой Филдинг описал в своей диссертации.

Эти приложения, как правило, неуклюжи, но работают достаточно хорошо. Многие веб-разработчики и команды предпочитают принять ограничения простого HTML в интересах простоты и надежности.

Рич Харрис, создатель Svelte.js, популярной библиотеки SPA, и идейный лидер в дебатах по поводу SPA, предложил сочетание этого старого стиля MPA и нового стиля SPA. Харрис называет этот подход к созданию веб-приложений «переходным», поскольку он пытается объединить подход MPA и новый подход SPA в единое целое. (Это чем-то похоже на «переходное» направление в архитектуре, сочетающее традиционные и современные архитектурные стили.)

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

Но этот компромисс по-прежнему кажется неудовлетворительным.

Должны ли мы по умолчанию использовать эти две очень разные архитектурные модели в наших приложениях?

Напомним, что суть компромисса между SPA и MPA заключается в удобстве взаимодействия с пользователем или интерактивности приложения. Обычно это определяет решение о выборе одного подхода по сравнению с другим для приложения или — в случае «переходного» приложения — для конкретной функции.

Оказывается, что благодаря использованию библиотеки, ориентированной на гипермедиа, разрыв в интерактивности между подходами MPA и SPA резко сокращается. Вы можете использовать подход MPA, то есть подход гипермедиа, для большей части вашего приложения без ущерба для пользовательского интерфейса. Возможно, вы даже сможете использовать подход гипермедиа для всех нужд вашего приложения.

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

Это может значительно упростить ваше веб-приложение и создать гораздо более связную и понятную часть программного обеспечения. Хотя еще есть время и место для более сложного подхода SPA, который мы обсудим позже в книге, приняв подход, ориентированный на гипермедиа, и используя библиотеку, ориентированную на гипермедиа, чтобы максимально расширить HTML, ваше веб-приложение может быть мощным, интерактивным и простым.

Одной из таких библиотек, ориентированных на гипермедиа, является htmx. Htmx будет в центре внимания второй части этой книги. Мы показываем, что на самом деле вы можете создать множество распространенных «современных» функций пользовательского интерфейса, которые можно найти в сложных одностраничных приложениях, используя вместо этого модель гипермедиа.

И делать это очень весело и просто.

Hypermedia-Driven Application

При создании веб-приложения с помощью htmx термин «многостраничное приложение» применяется неточно, но он не полностью характеризует ядро архитектуры приложения. Как вы увидите, htmx не требует замены целых страниц, и фактически приложение на основе htmx может полностью размещаться на одной странице. Мы не рекомендуем эту практику, но это возможно!

Поэтому неправильно называть веб-приложения, созданные с помощью htmx, «многостраничными приложениями». Что общего между старым подходом Web 1.0 MPA и новыми гипермедиа-ориентированными библиотечными приложениями, так это использование гипермедиа в качестве основной технологии и архитектуры.

Поэтому для описания обоих мы используем термин приложения, управляемые гипермедиа (Hypermedia-Driven Application (HDA)).

Это поясняет, что основное различие между этими двумя подходами и подходом SPA заключается не в количестве страниц в приложении, а в базовой архитектуре системы.

Приложение, управляемое гипермедиа (HDA)
Веб-приложение, использующее гипермедиа и обмен гипермедиа в качестве основного механизма взаимодействия с сервером.

Итак, как выглядит HDA вблизи?

Давайте посмотрим на реализацию простой кнопки на основе JavaScript, представленной выше, на базе HTML:

Листинг 5. Реализация HTML-кода

<button hx-get="/contacts/1" hx-target="#contact-ui"> # 1
    Fetch Contact
</button>
  1. выдает запрос GET к /contacts/1, заменяя contact-ui.

Как и в случае с кнопкой с поддержкой JavaScript, эта кнопка снабжена некоторыми атрибутами. Однако в данном случае у нас нет каких-либо (явных) сценариев JavaScript.

Вместо этого у нас есть декларативные атрибуты, подобные атрибуту href в тегах якоря и атрибуту action в тегах формы. Атрибут hx-get сообщает htmx: «Когда пользователь нажимает эту кнопку, выдайте запрос GET к /contacts/1». Атрибут hx-target сообщает htmx: «Когда ответ вернется, возьмите полученный HTML-код и поместите его в элемент с идентификатором contact-ui».

Здесь мы подходим к сути htmx и тому, как он позволяет создавать Hypermedia-Driven Application:

Ожидается, что HTTP-ответ от сервера будет в формате HTML, а не JSON.

Ответ HTTP на этот запрос, управляемый htmx, может выглядеть примерно так:

Листинг 6. JSON

<details>
  <div>
    Contact: HTML Example
  </div>
  <div>
    <a href="mailto:html-example@example.com">Email</a>
  </div>
</details>

Этот небольшой фрагмент HTML будет помещен в элемент DOM с идентификатором contact-ui.

Таким образом, эта кнопка на базе htmx обменивается гипермедиа с сервером, точно так же, как это может делать тег привязки или форма, и, таким образом, взаимодействие по-прежнему использует базовую гипермедийную модель Интернета. Htmx добавляет функциональность этой кнопке (через JavaScript), но эта функциональность дополняет HTML как гипермедиа. Htmx расширяет гипермедийную систему Интернета, а не заменяет эту гипермедийную систему совершенно другой архитектурой.

Несмотря на внешнее сходство друг с другом, оказывается, что эта кнопка на базе HTML и кнопка на основе JavaScript используют совершенно разные системные архитектуры и, следовательно, подходы к веб-разработке.

По мере того как в этой книге мы будем рассматривать создание Hypermedia-Driven Application, различия между этими двумя подходами будут становиться все более и более очевидными.

Когда следует использовать гипермедиа?

Гипермедиа часто, хотя и не всегда, является отличным выбором для веб-приложения.

Возможно, вы создаете веб-сайт или приложение, которое просто не требует большого взаимодействия с пользователем. Таких полезных веб-приложений много, и в этом нет ничего постыдного! Такие приложения, как Amazon, eBay, любое количество новостных сайтов, торговых сайтов, досок объявлений и т. д., чтобы быть эффективными, не нуждаются в большом количестве интерактивности: они в основном состоят из текста и изображений, а именно для этого и был разработан Интернет.

Возможно, ваше приложение добавляет большую часть своей ценности на стороне сервера за счет координации пользователей или применения сложного анализа данных и последующего представления их пользователю. Возможно, ваше приложение повышает ценность, просто работая с хорошо спроектированной базой данных и выполняя простые операции Create-Read-Update-Delete (CRUD). Опять же, в этом нет ничего постыдного!

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

Все эти приложения поддерживают то, что Рой Филдинг назвал «крупномасштабной передачей гипермедийных данных»: вы можете просто использовать теги привязки и формы с ответами, которые возвращают целые HTML-документы из запросов, и все будет работать нормально. Это именно то, для чего был создан Интернет!

Приняв для этих приложений подход гипермедиа, вы избавите себя от огромного количества сложностей на стороне клиента, которые возникают при использовании подхода одностраничного приложения: нет необходимости в маршрутизации на стороне клиента, для управления моделью на стороне клиента, для ручного подключения логики JavaScript и т. д. Кнопка «Назад» будет «просто работать». Глубокие ссылки «просто сработают». Вы сможете сосредоточить свои усилия на своем сервере, где ваше приложение действительно приносит пользу.

И, наложив на этот подход htmx или другую библиотеку, ориентированную на гипермедиа, вы можете решить многие проблемы с удобством использования, которые возникают в обычном HTML, и воспользоваться преимуществами более детальной передачи гипермедиа. Это открывает целый ряд новых пользовательских интерфейсов и возможностей, значительно расширяя набор приложений, которые можно создавать с использованием гипермедиа.

Но об этом позже.

Когда не следует использовать гипермедиа?

А что насчет того, что не всегда? Когда гипермедиа не подойдет для приложения?

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

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

Это просто не та ситуация, в которой можно использовать подход «крупномасштабной передачи гипермедийных данных» в Интернете. Для такого приложения мы, безусловно, рекомендуем рассмотреть возможность использования сложного подхода на клиентском JavaScript.

Однако даже в случае с онлайн-таблицей есть области, где подход гипермедиа может помочь.

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

И, приняв гипермедиа для этой части вашего приложения, вы сможете значительно упростить эту часть приложения. Тогда вы сможете сэкономить большую часть бюджета сложности вашего приложения на основной, сложной логике электронных таблиц, сохраняя простые вещи простыми.

Зачем тратить всю сложность, связанную с тяжелым фреймворком JavaScript, на такую простую вещь, как страница настроек?

Бюджет сложности

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

Что особенно неприятно в сложности, так это то, что она имеет тенденцию расти в геометрической прогрессии: однажды вы можете держать всю систему в голове и понимать последствия конкретного изменения, а неделю спустя вся система кажется неразрешимой. Хуже того, попытки контролировать сложность, такие как введение абстракций или инфраструктуры для управления сложностью, часто в конечном итоге усложняют ситуацию. Действительно, задача хорошего инженера-программиста — держать сложность под контролем.

Надежный способ снизить сложность также и самый трудный: сказать «нет». Отказываться от запросов на новые функции — это искусство, и если вы научитесь делать это хорошо, заставляя людей чувствовать, что они сказали «нет», вы далеко пойдете.

К сожалению, это не всегда возможно: некоторые функции придется создавать. В этот момент возникает вопрос: «Какая самая простая вещь может сработать?» Понимание возможностей, доступных в подходе гипермедиа, даст вам еще один инструмент в вашем наборе «самых простых вещей».

Гипермедиа: сложная современная системная архитектура

В кругах веб-разработчиков гипермедиа часто рассматривается как старая и устаревшая технология, возможно, полезная для статических веб-сайтов, но определенно не являющаяся реалистичным выбором для современных, сложных веб-приложений.

Серьезно? Утверждаем ли мы, что с его помощью можно создавать современные веб-приложения?

Да серьезно.

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

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

HTML-примечания: <div>-суп

Самый известный вид беспорядочного HTML — это <div>-суп.

Когда разработчики прибегают к общим элементам <div> и <span> вместо более значимых тегов, мы либо ухудшаем качество наших веб-сайтов, либо создаем себе больше работы — возможно, и то, и другое.

Например, вместо добавления кнопки с помощью специального элемента <button> к элементу <div> может быть добавлен обработчик событий click.

<div class="bg-accent padding-4 rounded-2"
onclick="doStuff()">Do stuff</div>

С этой кнопкой есть две основные проблемы:

Да, мы можем это исправить, добавив role="button" и tabindex="0":

<div class="bg-accent padding-4 rounded-2"
  role="button"
  tabindex="0"
  onclick="doStuff()">Do stuff</div>

Это простые исправления, но о них следует помнить. Из исходного кода HTML также неочевидно, что это кнопка, что затрудняет чтение источника и затрудняет обнаружение отсутствия этих атрибутов. Исходный код страниц с div-супом сложно редактировать и отлаживать.

Чтобы избежать супа с элементами div, ознакомьтесь со спецификацией HTML доступных тегов и рассматривайте каждый тег как еще один инструмент в своем наборе инструментов. Там могут быть вещи, которые вы раньше не помнили! (С учетом того, что в спецификации на данный момент определены 113 элементов, это скорее набор инструментов).

Конечно, не каждый шаблон пользовательского интерфейса имеет назначенный элемент HTML. Нам часто приходится составлять элементы и дополнять их атрибутами. Однако прежде чем вы это сделаете, покопайтесь в ящике с инструментами HTML. Иногда вы можете быть удивлены тем, сколько всего доступно.

Глава 2
Компоненты гипермедиа-системы

Гипермедийная система состоит из ряда компонентов, в том числе:

В этой главе мы рассмотрим эти компоненты и их реализацию в контексте Интернета.

После того, как мы рассмотрели основные компоненты Интернета как гипермедийной системы, мы рассмотрим некоторые ключевые идеи, лежащие в основе этой системы, особенно те, которые были разработаны Роем Филдингом в его диссертации «Архитектурные стили и проектирование сетевых программных архитектур». Мы увидим, откуда взялись термины REpresentational State Transfer (REST), RESTful и Hypermedia As The Engine Of Application State (HATEOAS), и проанализируем эти термины в контексте Интернета.

Это должно дать вам более глубокое понимание теоретической основы Интернета как гипермедийной системы, того, как они должны сочетаться друг с другом и почему Hypermedia-Driven Application, являются RESTful, тогда как API-интерфейсы JSON — несмотря на то, как термин REST в настоящее время используется в промышленность — нет.

Компоненты гипермедиа-системы

Гипермедиа

Фундаментальная технология системы гипермедиа — это гипермедиа, которая позволяет клиенту и серверу взаимодействовать друг с другом динамическим, нелинейным образом. Опять же, что делает гипермедиа гипермедиа, так это наличие элементов управления гипермедиа: элементов, которые позволяют пользователям выбирать нелинейные действия внутри гипермедиа. Пользователи могут взаимодействовать со средствами массовой информации не только путем простого чтения от начала до конца.

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

В случае HTML эти ссылки и формы обычно указывают цель своих операций с помощью унифицированных указателей ресурсов (URL):

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

URL-адрес — это строка, состоящая из различных подкомпонентов:

Листинг 7. Компоненты URL

[scheme]://[userinfo]@[host]:[port][path]?[query]#[fragment]

Многие из этих подкомпонентов не являются обязательными и часто опускаются.

Типичный URL-адрес может выглядеть так:

Листинг 8. Простой URL-адрес

https://hypermedia.systems/book/contents/

Этот конкретный URL-адрес состоит из следующих компонентов:

Этот URL-адрес уникальным образом идентифицирует извлекаемый ресурс в Интернете, к которому может быть отправлен HTTP-запрос гипермедийным клиентом, «говорящим» по HTTPS, например веб-браузером. Если этот URL-адрес обнаружен как ссылка на элемент управления гипермедиа в документе HTML, это означает, что на другой стороне сети есть сервер гипермедиа, который также понимает HTTPS и который может ответить на этот запрос представлением данного ресурса (или перенаправить вас в другое место и т. д.)

Обратите внимание, что URL-адреса часто не полностью записываются в HTML. Очень часто можно увидеть теги привязки, которые выглядят следующим образом, например:

Листинг 9. Простая ссылка

<a href="/book/contents/">Table Of Contents</a>

Здесь у нас есть относительная ссылка на гипермедиа, где протокол, хост и порт подразумеваются как протокол, хост и порт «текущего документа», то есть такие же, какие бы протокол и сервер ни использовались для получения текущей HTML-страницы. Итак, если эта ссылка была найдена в HTML-документе, полученном с https://hypermedia.systems/, то подразумеваемый URL-адрес этого якоря будет https://hypermedia.systems/book/contents/.

Протоколы гипермедиа

Элемент управления гипермедиа (ссылка) выше сообщает браузеру: «Когда пользователь нажимает на этот текст, отправьте запрос на https://hypermedia.systems/book/contents/, используя протокол передачи гипертекста» или HTTP.

HTTP — это протокол, используемый для передачи HTML (гипермедиа) между браузерами (клиентами гипермедиа) и серверами (серверами гипермедиа) и, как таковой, является ключевой сетевой технологией, которая объединяет распределенную систему гипермедиа в Интернете.

HTTP версии 1.1 — относительно простой сетевой протокол, поэтому давайте посмотрим, как будет выглядеть запрос GET, инициируемый тегом якоря. Это запрос, который будет отправлен на сервер, расположенный по адресу hypermedia.systems, по умолчанию через порт 80:

GET /book/contents/ HTTP/1.1
Accept: text/html,*/*
Host: hypermedia.systems

Первая строка указывает, что это HTTP-запрос GET. Затем она указывает путь к запрашиваемому ресурсу. Наконец, она содержит версию HTTP для этого запроса.

После этого следует ряд заголовков HTTP-запроса: отдельные строки пар имя/значение, разделенные двоеточием. Заголовки запроса предоставляют метаданные, которые могут использоваться сервером, чтобы точно определить, как ответить на запрос клиента. В этом случае с помощью заголовка Accept браузер сообщает, что предпочитает HTML в качестве формата ответа, но примет любой ответ сервера.

Далее у него есть заголовок Host, который указывает, на какой сервер был отправлен запрос. Это полезно, когда на одном хосте размещено несколько доменов.

HTTP-ответ сервера на этот запрос может выглядеть примерно так:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 870
Server: Werkzeug/2.0.2 Python/3.8.10
Date: Sat, 23 Apr 2022 18:27:55 GMT

<html lang="en">
<body>
  <header>
    <h1>HYPERMEDIA SYSTEMS</h1>
  </header>
  ...
</body>
</html>

В первой строке ответа HTTP указывается используемая версия HTTP, за которой следует код ответа 200, указывающий, что данный ресурс был найден и что запрос выполнен успешно. За ним следует строка OK, соответствующая коду ответа. (Фактическая строка не имеет значения, это код ответа, который сообщает клиенту результат запроса, о чем мы более подробно поговорим ниже.)

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

Наконец, мы видим новый HTML-контент. Это содержимое представляет собой HTML-представление запрошенного ресурса, в данном случае оглавление книги. Браузер будет использовать этот HTML-код для замены всего содержимого в своем окне отображения, показывая пользователю эту новую страницу и обновляя адресную строку, чтобы отразить новый URL-адрес.

HTTP-методы

Приведенный выше тег привязки выдал HTTP GET, где GET — это метод запроса. Конкретный метод, используемый в HTTP-запросе, возможно, является наиболее важной частью информации о нем после фактического ресурса, на который направлен запрос.

В HTTP доступно множество методов; Наиболее практическое значение для разработчиков имеют следующие:

GET
Запрос GET извлекает представление указанного ресурса. Запросы GET не должны изменять данные.
POST
Запрос POST отправляет данные в указанный ресурс. Это часто приводит к изменению состояния на сервере.
PUT
Запрос PUT заменяет данные указанного ресурса. Это приводит к изменению состояния на сервере.
PATCH
Запрос PATCH заменяет данные указанного ресурса. Это приводит к изменению состояния на сервере.
DELETE
Запрос DELETE удаляет указанный ресурс. Это приводит к изменению состояния на сервере.

Эти методы примерно соответствуют шаблону «Create/Read/Update/Delete» или CRUD, встречающемуся во многих приложениях:

Ставка против публикации

PUT vs. POST

Хотя действия HTTP примерно соответствуют CRUD, они не совпадают. В технических спецификациях этих методов такой связи нет, и их часто трудно читать. Вот, например, документация о различии между POST и PUT из RFC-2616.

Целевой ресурс в запросе POST предназначен для обработки вложенного представления в соответствии с собственной семантикой ресурса, тогда как вложенное представление в запросе PUT определяется как замена состояния целевого ресурса. Следовательно, цель PUT идемпотентна и видна посредникам, хотя точный эффект известен только исходному серверу.
~ RFC-2616 https://www.rfc-editor.org/rfc/rfc2616#section-9.6

Проще говоря, POST может обрабатываться сервером практически так, как ему нравится, тогда как PUT должен обрабатываться как «замена» ресурса, хотя язык снова позволяет серверу делать практически все, что он хочет. в пределах идемпотентности.

В правильно структурированной системе гипермедиа на основе HTML вы должны использовать соответствующий метод HTTP для операции, которую выполняет конкретный элемент управления гипермедиа. Например, если элемент управления гипермедиа, такой как кнопка, удаляет ресурс, в идеале для этого он должен выдать HTTP-запрос DELETE.

Однако странная особенность HTML заключается в том, что встроенные элементы управления гипермедиа могут выдавать только HTTP-запросы GET и POST.

Теги якоря всегда выдают запрос GET.

Формы могут выдавать GET или POST, используя атрибут method.

Несмотря на то, что HTML — самый популярный в мире гипермедиа — был разработан вместе с HTTP (который, в конце концов, является протоколом передачи гипертекста!): если вы хотите выдавать запросы PUT, PATCH или DELETE, вам в настоящее время приходится прибегать к JavaScript, чтобы сделать это. Поскольку POST может делать практически все, в конечном итоге он используется для любой мутации на сервере, а PUT, PATCH и DELETE остаются в стороне в простых приложениях на основе HTML.

Это очевидный недостаток HTML как гипермедиа; было бы замечательно увидеть это исправленным в спецификации HTML. А пока, в главе 4, мы обсудим способы обойти эту проблему.

Коды ответа HTTP

Методы HTTP-запроса позволяют клиенту сообщить серверу, что делать с данным ресурсом. Ответы HTTP содержат коды ответов, которые сообщают клиенту, каков был результат запроса. Коды ответов HTTP — это числовые значения, которые встроены в ответ HTTP, как мы видели выше.

Самый знакомый код ответа для веб-разработчиков — это, вероятно, 404, что означает «Not Found». Это код ответа, который возвращается веб-серверами, когда у них запрашивается несуществующий ресурс.

HTTP разбивает коды ответов на различные категории:

100-199
Информационные ответы, предоставляющие информацию о том, как сервер обрабатывает ответ.
200-299
Успешные ответы, указывающие на то, что запрос выполнен успешно.
300-399
Ответы на перенаправление, указывающие, что запрос следует отправить на другой URL-адрес.
400-499
Ответы клиента об ошибках, указывающие на то, что клиент сделал какой-то неправильный запрос (например, запрашивал что-то, чего не было в случае ошибки 404).
500-599
Ответы сервера об ошибках, указывающие на то, что сервер обнаружил внутреннюю ошибку при попытке ответить на запрос.

В каждой из этих категорий имеется несколько кодов ответов для конкретных ситуаций.

Вот некоторые из наиболее распространенных и интересных из них:

200 OK
HTTP-запрос выполнен успешно.
301 Moved Permanently
URL-адрес запрошенного ресурса навсегда перемещен в новое местоположение, и новый URL-адрес будет указан в заголовке ответа Location.
302 Found
URL-адрес запрошенного ресурса временно перемещен в новое местоположение, и новый URL-адрес будет указан в заголовке ответа Location.
303 See Other
URL-адрес запрошенного ресурса перемещен в новое расположение, и новый URL-адрес будет указан в заголовке ответа Location. Кроме того, этот новый URL-адрес следует получить с помощью запроса GET.
401 Unauthorized
Клиент еще не аутентифицирован (да, не аутентифицирован, несмотря на имя) и должен пройти аутентификацию для получения данного ресурса.
403 Forbidden
У клиента нет доступа к этому ресурсу.
404 Not Found
Сервер не может найти запрошенный ресурс.
500 Internal Server Error
Сервер обнаружил ошибку при попытке обработать ответ.

Между кодами ответов HTTP есть некоторые довольно тонкие различия (и, честно говоря, между ними есть некоторая двусмысленность). Например, разница между редиректом 302 и редиректом 303 заключается в том, что первый выдает запрос на новый URL-адрес, используя тот же метод HTTP, что и первоначальный запрос, тогда как второй всегда будет использовать GET. Это небольшое, но часто решающее различие, как мы увидим далее в книге.

Хорошо созданное Hypermedia-Driven Application, будет использовать преимущества как методов HTTP, так и кодов ответов HTTP для создания разумного API гипермедиа. Например, вы не хотите создавать Hypermedia-Driven Application (HDA), которое использует метод POST для всех запросов и отвечает, например, 200 OK на каждый ответ. (Некоторые API данных JSON, созданные на основе HTTP, делают именно это!)

Вместо этого при создании Hypermedia-Driven Application, вы хотите следовать «зерну» Интернета и использовать методы HTTP и коды ответов так, как они были разработаны для использования.

Кэширование HTTP-ответов

Ограничением REST (и, следовательно, особенностью HTTP) является идея кэширования ответов: сервер может указать клиенту (а также промежуточным HTTP-серверам), что данный ответ может быть кэширован для будущих запросов к тому же URL-адресу.

Поведение кэша HTTP-ответа от сервера можно указать с помощью заголовка ответа Cache-Control. Этот заголовок может иметь несколько различных значений, указывающих на возможность кэширования данного ответа. Если, например, заголовок содержит значение max-age=60, это означает, что клиент может кэшировать этот ответ в течение 60 секунд и ему не нужно выдавать еще один HTTP-запрос для этого ресурса, пока этот лимит времени не истечет.

Еще один важный заголовок ответа, связанный с кэшированием, — Vary. Этот заголовок ответа можно использовать, чтобы указать, какие именно заголовки в HTTP-запросе образуют уникальный идентификатор для кэшированного результата. Это становится важным, чтобы позволить браузеру правильно кэшировать контент в ситуациях, когда конкретный заголовок влияет на форму ответа сервера.

Например, в приложениях на основе htmx обычно используется специальный заголовок HX-Request, установленный htmx, чтобы различать «обычные» веб-запросы и запросы, отправленные htmx. Чтобы правильно кэшировать ответ на эти запросы, заголовок запроса HX-Request должен быть указан заголовком ответа Vary.

Полное обсуждение кэширования HTTP-ответов выходит за рамки этой главы; см. статью MDN о HTTP-кешировании, если вы хотите узнать больше по этой теме.

Гипермедиа-серверы

Серверы гипермедиа — это любой сервер, который может ответить на HTTP-запрос HTTP-ответом. Поскольку HTTP настолько прост, это означает, что для создания гипермедиа-сервера можно использовать практически любой язык программирования. Существует огромное количество библиотек для создания гипермедиа-серверов на основе HTTP практически на всех мыслимых языках программирования.

Это оказывается одним из лучших аспектов использования гипермедиа в качестве основной технологии создания веб-приложения: это устраняет необходимость использования JavaScript в качестве серверной технологии. Если вы используете интерфейс на основе одностраничного приложения с большим количеством JavaScript и API данных JSON, вы также почувствуете значительное давление при развертывании JavaScript на внутренней стороне.

В последней ситуации у вас уже есть тонна кода, написанного на JavaScript. Зачем поддерживать две отдельные базы кода на двух разных языках? Почему бы не создать многократно используемую логику домена как на стороне клиента, так и на стороне сервера? Теперь, когда в JavaScript доступны отличные серверные технологии, такие как Node и Deno, почему бы просто не использовать для всего один язык?

Напротив, создание Hypermedia-Driven Application, дает вам гораздо больше свободы в выборе серверной технологии, которую вы хотите использовать. Ваше решение может основываться на предметной области вашего приложения, с какими языками и серверным программным обеспечением вы знакомы или которым увлечены, или просто на том, что вам хочется попробовать.

Вы определенно не пишете свою серверную логику в HTML! И каждый основной язык программирования имеет по крайней мере один хороший веб-фреймворк и библиотеку шаблонов, которые можно использовать для чистой обработки HTTP-запросов.

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

Если вы занимаетесь работой с искусственным интеллектом, возможно, вы захотите использовать Lisp, опираясь на язык с долгой историей в этой области исследований.

Возможно, вы энтузиаст функционального программирования и хотите использовать OCaml или Haskell. Возможно, вам просто очень нравится Julia или Nim.

Это вполне веские причины для выбора конкретной серверной технологии!

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

Гипермедиа о чем угодно (HOWL)

В htmx-сообществе мы называем это (иронично) стеком HOWL: Hypermedia On Whatever you’d Like (гипермедиа на все, что захотите). Сообщество htmx мультиязычное и мультифреймворчное, в нем есть как рубисты, так и питонисты, лисперы и хаскеллеры. Есть даже энтузиасты JavaScript! Все эти языки и платформы способны использовать гипермедиа и по-прежнему могут совместно использовать методы и предлагать поддержку друг другу, поскольку они имеют общую базовую архитектуру: все они используют Интернет как систему гипермедиа.

В этом смысле гипермедиа обеспечивает «универсальный язык» Интернета, который мы все можем использовать.

Гипермедийные клиенты

Теперь мы подошли к последнему основному компоненту гипермедийной системы: гипермедийному клиенту. Клиенты гипермедиа — это программное обеспечение, которое понимает, как правильно интерпретировать конкретную гипермедиа и элементы управления гипермедиа внутри нее. Каноническим примером, конечно же, является веб-браузер, который понимает HTML и может предоставить его пользователю для взаимодействия. Веб-браузеры — это невероятно сложные программы. (На самом деле они настолько сложны, что их часто перепрофилируют из гипермедийных клиентов в своего рода кроссплатформенную виртуальную машину для запуска одностраничных приложений.)

Однако браузеры — не единственные клиенты гипермедиа. В последнем разделе этой книги мы рассмотрим Hyperview, гипермедиа, ориентированную на мобильные устройства. Одной из выдающихся особенностей Hyperview является то, что он не просто предоставляет гипермедиа HXML, но также предоставляет работающий гипермедиа-клиент для этого гипермедиа. Это делает создание правильного Hypermedia-Driven Application, с помощью Hyperview чрезвычайно простым.

Важнейшей особенностью системы гипермедиа является так называемый единый интерфейс. Мы подробно обсудим эту концепцию в следующем разделе, посвященном REST. В дискуссиях о гипермедиа часто игнорируется то, насколько важен клиент гипермедиа в использовании преимуществ этого единообразного интерфейса. Клиент гипермедиа должен знать, как правильно интерпретировать и представлять элементы управления гипермедиа, найденные в ответе гипермедиа от сервера гипермедиа, чтобы вся система гипермедиа работала вместе. Без сложного клиента, который может это сделать, элементы управления гипермедиа и API на основе гипермедиа будут гораздо менее полезны.

Это одна из причин, почему API-интерфейсы JSON редко успешно применяют элементы управления гипермедиа: API-интерфейсы JSON обычно используются кодом, который ожидает фиксированного формата и не предназначен для использования в качестве клиента гипермедиа. Это вполне понятно: создать хороший гипермедиа-клиент сложно! Для таких клиентов JSON API возможности элементов управления гипермедиа, встроенных в ответ API, не имеют значения и часто просто раздражают:

Короткий ответ на этот вопрос: HATEOAS не подходит для большинства современных вариантов использования API. Именно поэтому спустя почти 20 лет HATEOAS до сих пор не получил широкого распространения среди разработчиков. GraphQL, с другой стороны, распространяется со скоростью лесного пожара, поскольку решает реальные проблемы.
~ Фредди Карлбом https://techblog.commercetools.com/graphql-and-rest-level-3-hateoas-70904ff1f9cf

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

REST

Теперь, когда мы рассмотрели основные компоненты гипермедиа-системы, пришло время более глубоко изучить концепцию REST. Термин «REST» взят из докторской диссертации Роя Филдинга по архитектуре Интернета. Филдинг написал свою диссертацию в Калифорнийском университете в Ирвине после того, как помог создать большую часть инфраструктуры ранней сети, включая веб-сервер Apache. Рой пытался формализовать и описать новую распределенную вычислительную систему, которую он помог построить.

Мы собираемся сосредоточиться на том, что, по нашему мнению, является наиболее важным в работе Филдинга с точки зрения веб-разработки: раздел 5.1. В этом разделе содержатся основные концепции (Филдинг называет их ограничениями) передачи репрезентативного состояния, или REST.

Однако прежде чем мы углубимся в суть, важно понять, что Филдинг рассматривает REST как сетевую архитектуру, то есть совершенно другой способ проектирования распределенной системы. И, кроме того, как новую сетевую архитектуру, которую следует противопоставлять более ранним подходам к распределенным системам.

Также важно подчеркнуть, что на момент написания Филдингом своей диссертации API JSON и AJAX еще не существовало. Он описывал раннюю сеть, в которой HTML передавался через HTTP ранними браузерами, как система гипермедиа.

Сегодня, по странному стечению обстоятельств, термин «REST» в основном ассоциируется с API-интерфейсами данных JSON, а не с HTML и гипермедиа. Это становится чрезвычайно забавно, если осознать, что подавляющее большинство API-интерфейсов данных JSON не являются RESTful в исходном смысле и фактически не могут быть RESTful, поскольку они не используют естественный формат гипермедиа.

Еще раз подчеркну: REST, как его придумал Филдинг, описывает сеть до веб-API, и для правильного понимания идеи необходимо отказаться от текущего, общепринятого использования термина REST, который просто означает «JSON API».

«Ограничения» REST

В своей диссертации Филдинг определяет различные «ограничения», описывающие, как должна вести себя система RESTful. Многим людям этот подход может показаться немного запутанным и трудным для понимания, но для академического документа он вполне подходит. Потратив немного времени на размышления об ограничениях, которые он обрисовывает, и на некоторые конкретные примеры этих ограничений, можно легко оценить, действительно ли данная система удовлетворяет архитектурным требованиям REST или нет.

Вот описание ограничений REST Филдинга:

Давайте последовательно рассмотрим каждое из этих ограничений и обсудим их подробно, глядя на то, как (и в какой степени) Интернет удовлетворяет каждому из них.

Ограничение клиент-сервер

См. раздел 5.1.2 об ограничении клиент-сервер.

Модель REST, которую описывал Филдинг, включала в себя как клиенты (браузеры, в случае Интернета), так и сервера (такие как веб-сервер Apache, над которым он работал), взаимодействующие через сетевое соединение. Таков был контекст его работы: он описывал сетевую архитектуру Всемирной паутины и противопоставлял ее более ранним архитектурам, в частности сетевым моделям с толстым клиентом, таким как архитектура брокера общих объектных запросов (CORBA).

Должно быть очевидно, что любое веб-приложение, независимо от того, как оно спроектировано, будет удовлетворять этому требованию.

Отсутствие состояния (stateless)

См. раздел 5.1.3 об ограничении Stateless.

По описанию Филдинга, система RESTful не имеет состояния: каждый запрос должен инкапсулировать всю информацию, необходимую для ответа на этот запрос, без сохранения какого-либо побочного состояния или контекста ни на клиенте, ни на сервере.

На практике сегодня для многих веб-приложений мы фактически нарушаем это ограничение: обычно устанавливается файл cookie сеанса, который действует как уникальный идентификатор для данного пользователя и отправляется вместе с каждым запросом. Хотя этот файл cookie сеанса сам по себе не сохраняет состояние (он отправляется с каждым запросом), он обычно используется в качестве ключа для поиска информации, хранящейся на сервере, в том, что обычно называют «сеансом».

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

Это нарушение архитектурного ограничения REST без гражданства оказалось полезным для создания веб-приложений и, похоже, не оказало серьезного влияния на общую гибкость Интернета. Но стоит иметь в виду, что даже приложения Web 1.0 часто нарушают чистоту REST ради прагматических компромиссов.

И надо сказать, что сеансы действительно вызывают дополнительные трудности при развертывании гипермедиа-серверов; им может потребоваться общий доступ к информации о состоянии сеанса, хранящейся во всем кластере. Таким образом, Филдинг был прав, отметив, что идеальная система RESTful, не нарушающая это ограничение, будет проще и, следовательно, более надежной.

Ограничение кэширования

См. раздел 5.1.4 об ограничении кэширования.

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

Как мы обсуждали ранее, HTTP имеет сложный механизм кэширования через заголовки ответов, который часто упускается из виду или недостаточно используется при создании гипермедийных приложений. Однако, учитывая существование этой функциональности, легко увидеть, как Интернет удовлетворяет этому ограничению.

Ограничение единого интерфейса (Uniform Interface)

Теперь мы подходим к самому интересному и, на наш взгляд, самому инновационному ограничению REST: ограничению единого интерфейса.

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

См. раздел 5.1.5 об ограничении Uniform Interface.

В этом разделе Филдинг говорит:

Центральной особенностью, которая отличает архитектурный стиль REST от других сетевых стилей, является акцент на единообразном интерфейсе между компонентами. Чтобы получить единообразный интерфейс, необходимо множество архитектурных ограничений, определяющих поведение компонентов. REST определяется четырьмя ограничениями интерфейса: идентификация ресурсов; манипулирование ресурсами через представления; самоописательные сообщения; и гипермедиа как двигатель состояния приложения.
~ Рой Филдинг Архитектурные стили и проектирование сетевых архитектур программного обеспечения

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

Идентификация ресурсов

В системе RESTful ресурсы должны иметь уникальный идентификатор. Сегодня концепция универсальных указателей ресурсов (URL) широко распространена, но на момент написания Филдинга она была еще относительно новой и неизведанной.

Что сегодня может быть более интересным, так это понятие ресурса, которое идентифицируется таким образом: в системе RESTful любой тип данных, на которые можно ссылаться, то есть цель ссылки на гипермедиа, считается ресурсом. URL-адреса, хотя и достаточно распространены сегодня, в конечном итоге решают очень сложную проблему уникальной идентификации любого ресурса в Интернете.

Манипулирование ресурсами через представления

В системе RESTful представления ресурса передаются между клиентами и серверами. Эти представления могут содержать как данные, так и метаданные о запросе (например, «управляющие данные», такие как метод HTTP или код ответа). Конкретный формат данных или тип носителя может использоваться для представления данного ресурса клиенту, и этот тип носителя может быть согласован между клиентом и сервером.

Мы видели этот последний аспект единого интерфейса в заголовке Accept в приведенных выше запросах.

Самоописательные сообщения

Ограничение самоописательных сообщений в сочетании со следующим, HATEOAS, образует то, что мы считаем ядром единого интерфейса REST и почему гипермедиа обеспечивает такую мощную системную архитектуру.

Ограничение на самоописательные сообщения требует, чтобы в системе RESTful сообщения были самоописывающимися.

Это означает, что в ответе должна присутствовать вся информация, необходимая как для отображения, так и для работы с представляемыми данными. В правильно RESTful-системе не может быть никакой дополнительной «побочной» информации, необходимой клиенту для преобразования ответа сервера в полезный пользовательский интерфейс. Все должно «находиться» в самом сообщении, в виде элементов управления гипермедиа.

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

Рассмотрим два разных потенциальных ответа HTTP-сервера на URL-адрес https://example.com/contacts/42.

Оба ответа вернут информацию о контакте, но каждый ответ будет принимать совершенно разные формы.

Первая реализация возвращает HTML-представление:

<html lang="en">
<body>
<h1>Joe Smith</h1>
<div>
  <div>Email: joe@example.bar</div>
  <div>Status: Active</div>
</div>
<p>
  <a href="/contacts/42/archive">Archive</a>
</p>
</body>
</html>

Вторая реализация возвращает представление JSON:

{
  "name": "Joe Smith",
  "email": "joe@example.org",
  "status": "Active"
}

Что мы можем сказать о различиях между этими двумя ответами?

Одно различие, которое может сразу бросаться в глаза, это то, что представление JSON меньше, чем представление HTML. Филдинг отмечает именно этот компромисс при использовании архитектуры RESTful:

Однако компромисс заключается в том, что унифицированный интерфейс снижает эффективность, поскольку информация передается в стандартизированной форме, а не в форме, специфичной для нужд приложения.
~ Рой Филдинг Архитектурные стили и проектирование сетевых архитектур программного обеспечения

Таким образом, REST жертвует эффективностью представления ради других целей.

Чтобы понять эти другие цели, сначала обратите внимание, что в HTML-представлении есть гиперссылка для перехода на страницу для архивирования контакта. Представление JSON, напротив, не имеет этой ссылки.

Каковы последствия этого факта для клиента JSON API?

Это означает, что клиент JSON API должен заранее точно знать, какие другие URL-адреса (и методы запроса) доступны для работы с контактной информацией. Если клиент JSON может каким-либо образом обновить этот контакт, он должен знать, как это сделать, из какого-либо источника информации, внешнего по отношению к сообщению JSON. Если у контакта другой статус, скажем «В архиве», меняет ли это допустимые действия? Если да, то каковы новые допустимые действия?

Источником всей этой информации может быть документация API, устное сообщение или, если разработчик контролирует и сервер, и клиент, внутренние знания. Но эта информация неявная и находится за пределами ответа.

Сравните это с ответом гипермедиа (HTML). В этом случае гипермедийному клиенту (то есть браузеру) нужно только знать, как визуализировать данный HTML. Ему не нужно понимать, какие действия доступны для этого контакта: они просто закодированы в самом ответе HTML как элементы управления гипермедиа. Ему не нужно понимать, что означает поле статуса. На самом деле клиент даже не знает, что такое контакт!

Браузер, наш гипермедийный клиент, просто отображает HTML и позволяет пользователю, который предположительно понимает концепцию контакта, принять решение о том, какое действие следует выполнить, исходя из действий, доступных в представлении.

Эта разница между двумя ответами демонстрирует суть REST и гипермедиа, что делает их такими мощными и гибкими: клиентам (опять же, веб-браузерам) не нужно ничего понимать о представляемых базовых ресурсах.

Браузерам всего лишь (всего лишь! Как будто это легко!) необходимо понимать, как интерпретировать и отображать гипермедиа, в данном случае HTML. Это дает системам на основе гипермедиа беспрецедентную гибкость в работе с изменениями как в поддерживающих представлениях, так и в самой системе.

Гипермедиа как двигатель состояния приложения (HATEOAS)

Последнее дополнительное ограничение универсального интерфейса заключается в том, что в системе RESTful гипермедиа должна быть «двигателем состояния приложения». Иногда это сокращают как «HATEOAS», хотя Филдинг при обсуждении этого вопроса предпочитает использовать терминологию «ограничение гипермедиа».

Это ограничение тесно связано с предыдущим ограничением сообщения с самоописанием. Давайте еще раз рассмотрим две разные реализации эндпоинта /contacts/42: одна возвращает HTML, а другая — JSON. Давайте обновим ситуацию так, чтобы контакт, указанный по этому URL-адресу, был заархивирован.

Как выглядят наши ответы?

Первая реализация возвращает следующий HTML:

<html lang="en">
<body>
<h1>Joe Smith</h1>
<div>
  <div>Email: joe@example.bar</div>
  <div>Status: Archived</div>
</div>
<p>
  <a href="/contacts/42/unarchive">Unarchive</a>
</p>
</body>
</html>

Вторая реализация возвращает следующее представление JSON:

{
  "name": "Joe Smith",
  "email": "joe@example.org",
  "status": "Archived"
}

Здесь важно отметить, что, поскольку HTML-ответ представляет собой самоописывающееся сообщение, теперь показывает, что операция «Архивировать» больше недоступна, и стала доступна новая операция «Разархивировать». HTML-представление контакта кодирует состояние приложения; оно точно кодирует то, что можно и что нельзя сделать с этим конкретным представлением, чего не делает представление JSON.

Клиент, интерпретирующий ответ JSON, опять же должен понимать не только общую концепцию Контакта, но и конкретно, что означает поле «статус» со значением «Archived». Он должен точно знать, какие операции доступны для «заархивированного» контакта, чтобы правильно отображать их конечному пользователю. Состояние приложения не кодируется в ответе, а скорее передается через смесь необработанных данных и информации по побочным каналам, например документации API.

Более того, сегодня в большинстве интерфейсных SPA-инфраструктур эта контактная информация будет храниться в памяти в объекте JavaScript, представляющем модель контакта, а данные страницы хранятся в объектной модели документа (DOM) браузера. DOM будет обновляться на основе изменений в этой модели, то есть DOM будет «реагировать» на изменения в этой поддерживающей модели JavaScript.

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

При подходе HTML гипермедиа действительно является механизмом состояния приложения: на стороне клиента нет дополнительной модели, и все состояние выражается непосредственно в гипермедиа, в данном случае в HTML. По мере изменения состояния на сервере это отражается в представлении (то есть HTML), отправляемом обратно клиенту. Гипермедийный клиент (браузер) ничего не знает о контактах, что такое понятие «Архивирование» или что-либо еще о конкретной модели домена для этого ответа: он просто знает, как отображать HTML.

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

HATEOAS и отток API

Этот последний пункт имеет решающее значение для понимания гибкости гипермедиа, поэтому давайте посмотрим на практический пример в действии. Рассмотрим ситуацию, когда в веб-приложение была добавлена новая функция с этими двумя конечными точками (end points). Эта функция позволяет отправить сообщение определенному контакту.

Как это изменит каждый из двух ответов — HTML и JSON — от сервера?

HTML-представление теперь может выглядеть так:

<html lang="en">
<body>
<h1>Joe Smith</h1>
<div>
  <div>Email: joe@example.bar</div>
  <div>Status: Active</div>
</div>
<p>
  <a href="/contacts/42/archive">Archive</a>
  <a href="/contacts/42/message">Message</a>
</p>
</body>
</html>

С другой стороны, представление JSON может выглядеть так:

{
  "name": "Joe Smith",
  "email": "joe@example.org",
  "status": "Active"
}

Обратите внимание, что представление JSON не изменилось. Никаких указаний на эту новую функциональность нет. Вместо этого клиент должен знать об этом изменении, предположительно, через некоторую общую документацию между клиентом и сервером.

Сравните это с ответом HTML. Из-за единообразного интерфейса модели RESTful и, в частности, из-за того, что мы используем Hypermedia в качестве механизма состояния приложения, такой обмен документацией не требуется! Вместо этого клиент (браузер) просто отображает новый HTML-код с этой операцией, делая эту операцию доступной для конечного пользователя без каких-либо дополнительных изменений кода.

Довольно хитрый трюк!

Теперь, в этом случае, если клиент JSON не обновлен должным образом, состояние ошибки относительно безопасно: новая часть функциональности просто недоступна для пользователей. Но рассмотрим более серьезное изменение API: что, если функциональность архивирования будет удалена? Или что, если URL-адреса или методы HTTP для этих операций каким-либо образом изменились?

В этом случае клиент JSON может быть поврежден гораздо более серьезно.

Однако ответ HTML будет просто обновлен, чтобы исключить удаленные параметры или обновить используемые для них URL-адреса. Клиенты увидят новый HTML-код, отобразят его правильно и позволят пользователям выбирать любой новый набор операций. В очередной раз единый интерфейс REST оказался чрезвычайно гибким: несмотря на потенциально радикально новую структуру нашего гипермедийного API, клиенты продолжают работать.

Из этого вытекает важный факт: благодаря такой гибкости API-интерфейсы гипермедиа не испытывают головной боли с управлением версиями, как API-интерфейсы данных JSON.

После того, как Hypermedia-Driven Application, «введено» (то есть загружено через некоторый URL-адрес точки входа), все функциональные возможности и ресурсы отображаются через самоописывающиеся сообщения. Поэтому нет необходимости обмениваться документацией с клиентом: клиент просто рендерит гипермедиа (в данном случае HTML) и все работает. Когда происходит изменение, нет необходимости создавать новую версию API: клиенты просто извлекают обновленную гипермедиа, которая кодирует в ней новые операции и ресурсы, и отображают ее пользователям для работы.

Многоуровневая система

Последнее «обязательное» ограничение для системы RESTful, которое мы рассмотрим, — это ограничение многоуровневой системы. Это ограничение можно найти в разделе 5.1.6 диссертации Филдинга.

Честно говоря, после ажиотажа, связанного с ограничением единого интерфейса, ограничение «многоуровневой системы» немного разочаровывает. Но это все же стоит понять, и на самом деле оно эффективно используется в Интернете. Ограничение требует, чтобы архитектура RESTful была «многоуровневой», позволяющей нескольким серверам выступать в качестве посредников между клиентом и возможным сервером «источника истины».

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

Распространенным современным примером этой многоуровневой функции REST является использование сетей доставки контента (CDN) для более быстрой доставки неизменных статических ресурсов клиентам за счет хранения ответа от исходного сервера на промежуточных серверах, более близко расположенных к клиенту, делающему запрос.

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

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

Необязательное ограничение: код по требованию (Code On Demand)

Мы назвали ограничение «Многоуровневая система» последним «обязательным» ограничением, поскольку Филдинг упоминает одно дополнительное ограничение для системы RESTful. Это ограничение Code On Demand несколько неловко описано как «необязательное» (раздел 5.1.7).

В этом разделе Филдинг говорит:

REST позволяет расширять функциональность клиента путем загрузки и выполнения кода в форме апплетов или сценариев. Это упрощает работу клиентов за счет уменьшения количества функций, которые необходимо реализовать заранее. Разрешение загрузки функций после развертывания улучшает расширяемость системы. Однако это также снижает видимость и, таким образом, является лишь необязательным ограничением в REST.
~ Рой Филдинг Архитектурные стили и проектирование сетевых архитектур программного обеспечения

Таким образом, сценарии были и остаются встроенным аспектом исходной RESTful-модели Интернета, и поэтому их, конечно, следует разрешить в Hypermedia-Driven Application.

Однако в Hypermedia-Driven Application наличие сценариев не должно менять фундаментальную сетевую модель: гипермедиа должна продолжать оставаться двигателем состояния приложения, взаимодействие с сервером по-прежнему должно состоять из обмена гипермедиа, а не, например, обмена данными JSON, и так далее. (API данных JSON, безусловно, имеют свое место; в главе 10 мы обсудим, когда и как их использовать).

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

Заключение

После этого глубокого погружения в компоненты и концепции, лежащие в основе гипермедиа-систем, включая идеи Роя Филдинга об их работе, мы надеемся, что вы гораздо лучше поймете REST и, в частности, единый интерфейс и HATEOAS. Мы надеемся, что вы понимаете, почему эти характеристики делают гипермедийные системы такими гибкими.

Если вы до сих пор не осознавали всю значимость REST и HATEOAS, не расстраивайтесь: некоторым из нас потребовалось более десяти лет работы в области веб-разработки и создания библиотеки, ориентированной на гипермедиа, чтобы понять особенности природа HTML, гипермедиа и Интернета!

HTML-примечания: HTML5-суп

Начало мудрости – называть вещи своими именами.
~ Конфуций

Такие элементы, как <section>, <article>, <nav>, <header>, <footer>, <figure> стали своего рода сокращением HTML.

Используя эти элементы, страница может давать ложные обещания, например, что элементы <article> являются самодостаточными, многократно используемыми объектами, таким клиентам, как браузеры, поисковые системы и парсеры, которые не знают ничего лучше. Чтобы избежать этого:

Наиболее авторитетным ресурсом для изучения HTML является спецификация HTML. Текущая спецификация находится на https://html.spec.whatwg.org/multipage[1]. Нет необходимости полагаться на слухи, чтобы идти в ногу с развитием HTML.

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

  1. Одностраничная версия слишком медленно загружается и отображается на большинстве компьютеров. Существует также версия для разработчиков в /dev, но стандартная версия имеет более приятный стиль.

Глава 3
Приложение Web 1.0

Чтобы начать наше путешествие в Hypermedia-Driven Application, мы собираемся создать простое веб-приложение для управления контактами под названием Contact.app. Мы начнем с базового многостраничного приложения (MPA) в стиле «Web 1.0» в великой традиции CRUD (Создание, Чтение, Обновление, Удаление). Это будет не лучшее в мире приложение для управления контактами, но оно будет простым и будет выполнять свою работу.

Это приложение также будет легко постепенно улучшать в следующих главах, используя гипермедиа-ориентированную библиотеку htmx.

К тому времени, как мы закончим создание и улучшение приложения, в следующих нескольких главах оно будет иметь несколько очень интересных функций, которые, по мнению большинства сегодняшних разработчиков, требуют использования инфраструктуры SPA JavaScript.

Выбор «Веб-стека»

Чтобы продемонстрировать, как работают приложения Web 1.0, нам нужно выбрать серверный язык и библиотеку для обработки HTTP-запросов. В просторечии это называется нашим «Серверным стеком» или «Веб-стеком», и на выбор есть буквально сотни вариантов, многие из которых имеют страстных последователей. Вероятно, у вас есть веб-фреймворк, который вы предпочитаете, и хотя нам хотелось бы написать эту книгу для всех возможных стеков, в интересах простоты (и здравомыслия) мы можем выбрать только один.

Для этой книги мы будем использовать следующий стек:

Почему именно этот стек?

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

Мы выбрали веб-фреймворк Flask, потому что он прост и не требует много структурирования помимо основ обработки HTTP-запросов.

Этот простой подход хорошо подходит для наших нужд: в других случаях вы можете рассмотреть возможность использования более полнофункциональной среды Python, такой как Django, которая предоставляет гораздо больше функциональных возможностей «из коробки», чем Flask.

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

Мы выбрали шаблонизатор Jinja2, потому что он является языком шаблонов по умолчанию для Flask. Он достаточно прост и достаточно похож на большинство других серверных языков шаблонов, поэтому большинство людей, знакомых с любой серверной (или клиентской) библиотекой шаблонов, смогут быстро и легко его понять.

Даже если эта комбинация технологий не является вашим предпочтительным стеком, продолжайте читать: вы многому научитесь из шаблонов, которые мы представим в следующих главах, и вам не составит труда отобразить их в предпочитаемом вами языке и средах.

С помощью этого стека мы будем отображать HTML на стороне сервера для возврата клиентам, а не создавать JSON. Это традиционный подход к созданию веб-приложений. Однако с появлением SPA этот подход перестал так широко использоваться, как раньше. Сегодня, когда люди заново открывают для себя этот стиль веб-приложений, термин «серверный рендеринг» или SSR (Server-Side Rendering) становится тем, как люди говорят об этом. Это контрастирует с «клиентским рендерингом» (Client-Side Rendering), то есть рендерингом шаблонов в браузере с данными, полученными в форме JSON с сервера, как это принято в библиотеках SPA.

В Contact.app мы намеренно сделаем все максимально простым, чтобы максимизировать обучающую ценность нашего кода: это не будет идеально факторизованный код, но читателям будет легко следовать ему, даже если у них мало опыта работы с Python, и Приложение и продемонстрированные методы должны быть легко переведены в предпочитаемую вами среду программирования.

Python

Поскольку эта книга предназначена для изучения того, как эффективно использовать гипермедиа, мы лишь кратко представим различные технологии, которые мы используем в этой гипермедиа. У этого подхода есть некоторые очевидные недостатки: например, если вы не знакомы с Python, некоторые примеры кода Python в книге могут поначалу показаться немного запутанными или загадочными.

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

Мы считаем, что большинство веб-разработчиков, даже тех, кто не знаком с Python, смогут следовать нашим примерам. Большинство авторов этой книги до ее написания почти не писали на Python, и мы довольно быстро освоились.

Представляем Flask: наш первый маршрут

Flask — это простой, но гибкий веб-фреймворк для Python. Мы углубимся в него, коснувшись его основных элементов.

Приложение Flask состоит из серии маршрутов, привязанных к функциям, которые выполняются при выполнении HTTP-запроса по заданному пути. Он использует функцию Python, называемую «decorators», для объявления маршрута, который будет обрабатываться, за которым затем следует функция для обработки запросов к этому маршруту. Мы будем использовать термин «handler» (обработчик) для обозначения функций, связанных с маршрутом.

Давайте создадим наше первое определение маршрута, простой маршрут «Hello Flask». В следующем коде Python вы увидите символ @app. Это декоратор Flask, который позволяет нам настраивать маршруты. Не беспокойтесь слишком сильно о том, как работают декораторы в Python, просто знайте, что эта функция позволяет нам сопоставить заданный путь с определенной функцией (например, обработчиком). Приложение Flask при запуске будет принимать HTTP-запросы, искать соответствующий обработчик и вызывать его.

Листинг 10. Простой маршрут «Hello World»

@app.route("/") # 1
def index(): # 2
    return "Hello World!" # 3
  1. Устанавливает, что мы отображаем путь / как маршрут.

  2. Следующий метод — это обработчик этого маршрута.

  3. Возвращает строку «Hello World!» клиенту.

Метод route() в декораторе Flask принимает аргумент: путь, который вы хотите обработать маршрутом. Здесь мы передаем корневой путь или / в виде строки для обработки запросов к корневому пути.

За этим объявлением маршрута следует простое определение функции index(). В Python декораторы, вызываемые таким образом, применяются к функции, следующей сразу за ними. Таким образом, эта функция становится «обработчиком» этого маршрута и будет выполняться при выполнении HTTP-запроса по данному пути.

Обратите внимание, что имя функции не имеет значения, мы можем называть ее как угодно, главное, чтобы она была уникальной. В данном случае мы выбрали index(), поскольку он соответствует маршруту, который мы обрабатываем: корневому «индексу» веб-приложения.

Итак, у нас есть функция index(), следующая за определением корневого маршрута, и она станет обработчиком корневого URL-адреса в нашем веб-приложении.

Обработчик в этом случае предельно прост: он просто возвращает клиенту строку «Hello Flask!». Это еще не гипермедиа, но браузер прекрасно отобразит ее:

Рисунок 3. Привет, Flask!

Отлично, это наш первый шаг во Flask, демонстрирующий основную технику, которую мы собираемся использовать для ответа на HTTP-запросы: маршруты, сопоставленные с обработчиками.

Для Contact.app вместо отображения «Hello Flask!» на корневом пути мы собираемся сделать что-то необычное: мы собираемся перенаправить на другой путь, путь /contacts. Перенаправления — это функция HTTP, которая позволяет перенаправить клиента в другое место с помощью ответа HTTP.

Мы собираемся отображать список контактов в качестве нашей корневой страницы, и, возможно, перенаправление на путь /contacts для отображения этой информации немного больше соответствует понятию ресурсов с REST. Это суждение с нашей стороны, и мы не считаем его чем-то слишком важным, но это имеет смысл с точки зрения маршрутов, которые мы настроим позже в приложении.

Чтобы изменить наш маршрут «Hello World» на перенаправление, нам нужно изменить всего одну строку кода:

Листинг 11. Изменение «Hello World» на редирект

@app.route("/")
def index():
    return redirect("/contacts") # 1
  1. Обновление до вызова redirect()

Теперь функция index() возвращает результат функции redirect(), предоставленной Flask, с указанным нами путем. В данном случае путь — /contacts, передаваемый как строковый аргумент. Теперь, если вы перейдете по корневому пути /, наше приложение Flask перенаправит вас по пути /contacts.

Функциональность Contact.app

Теперь, когда у нас есть некоторое представление о том, как определять маршруты, давайте приступим к определению и последующей реализации нашего веб-приложения.

Что будет делать Contact.app?

Первоначально это позволит пользователям:

Итак, как вы можете видеть, Contact.app — это CRUD-приложение, которое идеально подходит для подхода старой школы Web 1.0.

Обратите внимание, что исходный код Contact.app доступен на GitHub.

Отображение списка контактов с возможностью поиска

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

Эту функцию можно найти по пути /contacts, который является путем, на который перенаправляется наш предыдущий маршрут.

Мы будем использовать Flask для маршрутизации пути /contacts к функции-обработчику contacts(). Эта функция сделает одно из двух:

Это распространенный подход в приложениях в стиле Web 1.0: один и тот же URL-адрес, который отображает все экземпляры некоторого ресурса, также служит страницей результатов поиска для этих ресурсов. Такой подход позволяет легко повторно использовать отображение списка, общее для обоих типов запросов.

Вот как выглядит код этого обработчика:

Листинг 12. Обработчик поиска на стороне сервера

@app.route("/contacts")
def contacts():
    search = request.args.get("q") # 1
    if search is not None:
        contacts_set = Contact.search(search) # 2
    else:
        contacts_set = Contact.all() # 3
    return render_template("index.html",
contacts=contacts_set) # 4
  1. Найти параметр запроса с именем q, что означает «query» (запрос).

  2. Если параметр существует, вызвать с ним функцию Contact.search().

  3. Если нет, вызвать функцию Contact.all().

  4. Передать результат в шаблон index.html для обработки клиенту.

Мы видим тот же код маршрутизации, что и в первом примере, но у нас более сложная функция-обработчик. Сначала мы проверяем, является ли параметр поискового запроса с именем q частью запроса.

Строки запроса
«Строка запроса» (query string) является частью спецификации URL. Вот пример URL-адреса со строкой запроса: https://example.com/contacts?q=joe. Строка запроса — это все, что следует после ?, и имеет формат пары имя-значение. В этом URL-адресе параметру запроса q присвоено строковое значение joe. В простом HTML строка запроса может быть включена в запрос либо путем жесткого кодирования в теге привязки, либо, что более динамично, с использованием тега формы с запросом GET.

Чтобы вернуться к нашему маршруту Flask, если найден параметр запроса с именем q, мы вызываем метод search() объекта модели Contact, чтобы выполнить фактический поиск контактов и вернуть все соответствующие контакты.

Если параметр запроса не найден, мы просто получаем все контакты, вызывая метод all() объекта Contact.

Наконец, мы визуализируем шаблон index.html, который отображает заданные контакты, передавая результаты любой из этих двух функций, которые мы в конечном итоге вызываем.

Примечание о классе контактов

Класс Contact Python, который мы используем, является «моделью предметной области» или просто классом «модели» для нашего приложения, обеспечивающим «бизнес-логику» управления контактами.

Это может быть работа с базой данных (в нашем случае это не так) или с простым плоским файлом (это наш случай), но мы пропустим внутренние детали модели. Думайте об этом как об «обычном» классе предметной модели с методами, которые действуют «нормальным» образом.

Мы будем относиться к Contact как к ресурсу и сосредоточимся на том, как эффективно предоставлять гипермедийные представления этого ресурса клиентам.

Шаблоны списка и поиска

Теперь, когда у нас написана логика обработчика, мы создадим шаблон для отображения HTML в нашем ответе клиенту. На высоком уровне наш HTML-ответ должен содержать следующие элементы:

Мы используем язык шаблонов Jinja2, который имеет следующие возможности:

Помимо этого базового синтаксиса, Jinja2 очень похож на другие шаблонизаторы, используемые для создания контента, и большинству веб-разработчиков должно быть легко ему следовать.

Давайте посмотрим на первые несколько строк кода в шаблоне index.html:

Листинг 13. Начало index.html

{% extends 'layout.html' %} # 1

{% block content %} # 2
    <form action="/contacts" method="get" class="tool-bar"> # 3
        <label for="search">Search Term</label>
        <input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"/> # 4
        <input type="submit" value="Search"/>
    </form>
  1. Установить шаблон макета для этого шаблона.

  2. Ограничить содержимое, которое будет вставлено в макет.

  3. Создать форму поиска, которая будет отправлять HTTP GET для /contacts.

  4. Создать поле ввода, позволяющее пользователю вводить поисковые запросы.

Первая строка кода ссылается на базовый шаблон layout.html с директивой extends. Этот шаблон макета обеспечивает макет страницы (опять же, иногда называемый «the chrome»): он оборачивает содержимое шаблона в тег <html>, импортирует все необходимые CSS и JavaScript в элемент <head>, оборачивает основной контент тегом <body> и т. д. В этом файле находится весь общий контент, окружающий «обычный» контент всего приложения.

Следующая строка кода объявляет раздел content этого шаблона. Этот блок содержимого используется шаблоном layout.html для внедрения содержимого index.html в его HTML.

Далее у нас есть первый кусочек настоящего HTML, а не просто директивы Jinja. У нас есть простая HTML-форма, которая позволяет вам искать контакты, отправляя запрос GET по пути /contacts. Сама форма содержит метку и поле ввода с именем «q». Значение этого ввода будет отправлено вместе с запросом GET по пути /contacts в виде строки запроса (поскольку это запрос GET).

Обратите внимание, что значение этого ввода установлено в выражение Jinja {{ request.args.get('q') or '' }}. Это выражение оценивается Jinja и вставляет значение запроса «q» в качестве входного значения, если оно существует. Оно «сохранит» значение поиска, когда пользователь выполняет поиск, так что при отображении результатов поиска текстовый ввод будет содержать искомый термин. Это улучшает взаимодействие с пользователем, поскольку пользователь может точно видеть, чему соответствуют текущие результаты, а не иметь пустое текстовое поле в верхней части экрана.

Наконец, у нас есть ввод типа submit. Он будет отображаться как кнопка, и при нажатии на него форма будет отправлять HTTP-запрос.

Этот интерфейс поиска находится в верхней части нашей страницы контактов. Далее следует таблица контактов: либо все контакты, либо контакты, соответствующие запросу, если поиск был выполнен.

Вот как выглядит код шаблона для таблицы контактов:

Листинг 14. Таблица контактов

    <table>
        <thead>
        <tr>
            <th>First</th> <th>Last</th> <th>Phone</th> <th>Email</th> <th></th> # 1
        </tr>
        </thead>
        <tbody>
        {% for contact in contacts %} # 2
            <tr>
                <td>{{ contact.first }}</td>
                <td>{{ contact.last }}</td>
                <td>{{ contact.phone }}</td>
                <td>{{ contact.email }}</td> # 3
                <td><a href="/contacts/{{ contact.id }}/edit">Edit</a> <a
    href="/contacts/{{ contact.id }}">View</a></td> # 4
            </tr>
        {% endfor %}
        </tbody>
    </table>
  1. Вывести несколько заголовков для нашей таблицы.

  2. Перебрать контакты, которые были переданы в шаблон.

  3. Вывести значения текущего контакта, имени, фамилии и т. д.

  4. Столбец «операции» со ссылками для редактирования или просмотра контактной информации.

Это ядро страницы: мы создаем таблицу с соответствующими заголовками, соответствующими данным, которые мы собираемся отображать для каждого контакта. Мы перебираем контакты, которые были переданы в шаблон методом-обработчиком, используя директиву цикла for в Jinja2. Затем мы создаем серию строк, по одной для каждого контакта, где мы отображаем имя и фамилию, телефон и адрес электронной почты контакта в виде ячеек таблицы в строке.

Кроме того, у нас есть ячейка таблицы, содержащая две ссылки:

Наконец, у нас есть кое-что финальное: ссылка для добавления нового контакта и директива Jinja2 для завершения блока content:

Листинг 15. Ссылка «добавить контакт»

    <p>
        <a href="/contacts/new">Add Contact</a> # 1
    </p>
{% endblock %} # 2
  1. Ссылка на страницу, позволяющую создать новый контакт.

  2. Закрывающий элемент блока content.

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

Вот как выглядит шаблон с небольшим количеством контактной информации:

Рисунок 4. Приложение Contact.app

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

И все это происходит без того, чтобы клиент (то есть браузер) ничего не знал о том, что такое контакты и как с ними работать. Все закодировано в гипермедиа. Веб-браузер, обращающийся к этому приложению, просто знает, как выдавать HTTP-запросы, а затем отображать HTML, не говоря уже о специфике конечных точек наших приложений или базовой модели домена.

Каким бы простым на данный момент ни было наше приложение, оно полностью RESTful.

Добавление нового контакта

Следующая функциональность, которую мы добавим в наше приложение, — это возможность добавлять новые контакты. Для этого нам нужно будет обработать URL-адрес /contacts/new, указанный в ссылке «Add Contact» выше. Обратите внимание: когда пользователь нажимает на эту ссылку, браузер отправляет запрос GET на URL-адрес /contacts/new.

Все остальные маршруты, которые у нас есть до сих пор, также используют GET, но на самом деле мы собираемся использовать два разных HTTP-метода для этой части функциональности: HTTP GET для отображения формы для добавления нового контакта, а затем HTTP POST для того же пути для фактического создания контакта, поэтому мы собираемся четко указать метод HTTP, который мы хотим обработать, когда объявляем этот маршрут.

Вот код:

Листинг 16. GET-маршрут «нового контакта»

@app.route("/contacts/new", methods=['GET']) # 1
def contacts_new_get():
    return render_template("new.html", contact=Contact()) # 2
  1. Объявить маршрут, явно обрабатывая запросы GET к этому пути.

  2. Отобразить шаблон new.html, передав ему новый объект контакта.

Достаточно просто. Мы просто отображаем шаблон new.html с новым контактом. (Contact() — это способ создания нового экземпляра класса Contact в Python, если вы с ним не знакомы.)

Хотя код обработчика этого маршрута очень прост, шаблон new.html более сложен.

В остальных шаблонах мы собираемся опустить директиву макета и объявление блока контента, но вы можете предположить, что они одинаковы, если мы не оговорим иное. Это позволит нам сосредоточиться на «мясе» шаблона.

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

Вот как выглядит наш HTML:

Листинг 17. Форма «новый контакт»

<form action="/contacts/new" method="post"> # 1
    <fieldset>
        <legend>Contact Values</legend>
        <p>
            <label for="email">Email</label> # 2
            <input name="email" id="email" type="email" placeholder="Email" value="{{ contact.email or '' }}"> # 3
            <span class="error">{{ contact.errors['email'] }}</span> # 4
        </p>
  1. Форма, которая отправляется по пути /contacts/new с использованием HTTP POST.

  2. Метка для ввода первой формы.

  3. Первый ввод формы типа электронная почта.

  4. Любые сообщения об ошибках, связанные с этим полем.

В первой строке кода мы создаем форму, которая будет отправляться обратно по тому же пути, который мы обрабатываем: /contacts/new. Однако вместо того, чтобы отправлять HTTP GET по этому пути, мы выполним для него HTTP POST. Использование POST таким образом будет сигнализировать серверу о том, что мы хотим создать новый контакт, а не получить форму для его создания.

Затем у нас есть метка (всегда хорошая практика!) и входные данные, которые фиксируют адрес электронной почты создаваемого контакта. Имя ввода — email, и при отправке этой формы значение этого ввода будет отправлено в запросе POST, связанном с ключом email.

Далее у нас есть входные данные для других полей для контактов:

Листинг 18. Входные данные и метки для формы «новый контакт»

        <p>
            <label for="first_name">First Name</label>
            <input name="first_name" id="first_name" type="text" placeholder="First Name" value="{{ contact.first or '' }}">
            <span class="error">{{ contact.errors['first'] }}</span>
        </p>
        <p>
            <label for="last_name">Last Name</label>
            <input name="last_name" id="last_name" type="text" placeholder="Last Name" value="{{ contact.last or '' }}">
            <span class="error">{{ contact.errors['last'] }}</span>
        </p>
        <p>
            <label for="phone">Phone</label>
            <input name="phone" id="phone" type="text" placeholder="Phone" value="{{ contact.phone or '' }}">
            <span class="error">{{ contact.errors['phone'] }}</span>
        </p>

Наконец, у нас есть кнопка, которая отправит форму, конец тега формы и обратная ссылка на основную таблицу контактов:

Листинг 19. Кнопка отправки для формы «новый контакт»

        <button>Save</button>
    </fieldset>
</form>

<p>
    <a href="/contacts">Back</a>
</p>

В этом прямом примере легко упустить из виду: мы видим гибкость гипермедиа в действии.

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

Обработка сообщения в /contacts/new

Следующим шагом в нашем приложении является обработка POST, который эта форма отправляет в /contacts/new.

Для этого нам нужно добавить в наше приложение еще один маршрут, который обрабатывает путь /contacts/new. Новый маршрут будет обрабатывать метод HTTP POST вместо HTTP GET. Мы будем использовать отправленные значения формы, чтобы попытаться создать новый контакт.

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

Вот наш новый обработчик запросов:

Листинг 20. Код контроллера «нового контакта»

@app.route("/contacts/new", methods=['POST'])
def contacts_new():
    c = Contact(None, request.form['first_name'],
                request.form['last_name'],
                request.form['phone'],
                request.form['email']) # 1
    if c.save(): # 2
        flash("Created New Contact!")
        return redirect("/contacts") # 3
    else:
        return render_template("new.html", contact=c) # 4
  1. Мы создаем новый объект контакта со значениями из формы.

  2. Мы стараемся его сохранить.

  3. В случае успеха «высветить» сообщение об успехе и перенаправиться на страницу /contacts.

  4. В случае неудачи повторно отобразить форму, показывая пользователю все ошибки.

Логика этого обработчика немного сложнее, чем у других методов, которые мы видели. Первое, что мы делаем, это создаем новый контакт, снова используя синтаксис Contact() в Python для создания объекта. Мы передаем значения, отправленные пользователем в форму, с помощью объекта request.form — функции, предоставляемой Flask.

Эта форма request.form позволяет нам легко и удобно получать доступ к отправленным значениям формы, просто передавая одно и то же имя, связанное с различными входными данными.

Мы также передаем None в качестве первого значения конструктору Contact. Это параметр «id», и, передавая None, мы сигнализируем, что это новый контакт и для него необходимо сгенерировать ID. (Опять же, мы не вдаемся в подробности реализации этого объекта модели, наша единственная задача — использовать его для генерации ответов гипермедиа.)

Затем мы вызываем метод save() объекта Contact. Этот метод возвращает true, если сохранение прошло успешно, и false, если сохранение не удалось (например, пользователь отправил неверное электронное письмо).

Если нам удалось сохранить контакт (то есть ошибок проверки не было), мы создаем флэш-сообщение, указывающее на успех, и перенаправляем браузер обратно на страницу списка. «Flash» — это обычная функция в веб-фреймворках, которая позволяет хранить сообщение, которое будет доступно при следующем запросе, обычно в файле cookie или в хранилище сеансов.

Наконец, если мы не можем сохранить контакт, мы повторно отображаем шаблон new.html с контактом. Будет показан тот же шаблон, что и выше, но входные данные будут заполнены отправленными значениями, а любые ошибки, связанные с полями, будут отображаться для обратной связи с пользователем о том, какая проверка не удалась.

Шаблон POST/REDIRECT/GET

Этот обработчик реализует общую стратегию разработки в стиле Web 1.0, называемую шаблоном Post/Redirect/Get или PRG. Выполняя перенаправление HTTP после создания контакта и перенаправляя браузер в другое место, мы гарантируем, что POST не попадет в кеш запросов браузера.

Это означает, что если пользователь случайно (или намеренно) обновит страницу, браузер не отправит еще один POST, потенциально создавая еще один контакт. Вместо этого он выдаст GET, на который мы перенаправляем, который не должен иметь побочных эффектов.

Мы будем использовать шаблон PRG в нескольких местах этой книги.

Итак, у нас есть серверная логика, настроенная на сохранение контактов. И, хотите верьте, хотите нет, это примерно настолько сложно, насколько сложна наша логика обработчика, даже если мы посмотрим на добавление более сложных поведений, управляемых htmx.

Просмотр сведений о контакте

Следующая часть функциональности, которую мы реализуем, — это страница сведений о контакте. Пользователь перейдет на эту страницу, нажав ссылку «View» в одной из строк списка контактов. Это приведет их к пути /contact/<contact id> (например, /contacts/42).

Это обычная закономерность в веб-разработке: контакты рассматриваются как ресурсы, а URL-адреса вокруг этих ресурсов организованы последовательным образом.

Вечный велосипедный сарай URL-дизайна

Легко поспорить о деталях схемы пути, которую вы используете для своего приложения:

«Должны ли мы отправлять POST в /contacts/new или в /contacts

Мы видели много аргументов в Интернете и лично в защиту одного подхода по сравнению с другим. Мы считаем, что важнее понять общую идею ресурсов и гипермедийных представлений, а не беспокоиться о мелких деталях дизайна вашего URL-адреса.

Мы рекомендуем вам просто выбрать разумный, ориентированный на ресурсы макет URL-адреса, который вам нравится, и затем придерживаться единообразия. Помните, что в системе гипермедиа вы всегда можете изменить свои конечные точки позже, поскольку вы используете гипермедиа в качестве механизма состояния приложения!

Логика нашего обработчика подробного маршрута будет очень простой: мы просто просматриваем контакт по идентификатору, который встроен в путь URL-адреса маршрута. Чтобы извлечь этот идентификатор, нам нужно будет представить последнюю часть функциональности Flask: возможность вызывать части пути, автоматически извлекать их и передавать в функцию-обработчик.

Вот как выглядит код, всего несколько строк простого Python:

@app.route("/contacts/<contact_id>") # 1
def contacts_view(contact_id=0): # 2
    contact = Contact.find(contact_id) # 3
    return render_template("show.html", contact=contact) # 4
  1. Сопоставить путь с переменной пути с именем contact_id.

  2. Обработчик принимает значение этого параметра пути.

  3. Найти соответствующий контакт.

  4. Отобразить шаблон show.html.

Вы можете увидеть синтаксис извлечения значений из пути в первой строке кода: вы заключаете часть пути, которую хотите извлечь, в <> и даете ей имя. Этот компонент пути будет извлечен и затем передан в функцию-обработчик через параметр с тем же именем.

Итак, если вы перейдете по пути /contacts/42, значение 42 будет передано в функцию contact_view() для значения contact_id.

Получив идентификатор контакта, который мы хотим найти, мы загружаем его, используя метод find объекта Contact. Затем мы передаем этот контакт в шаблон show.html и отображаем ответ.

Шаблон контактной информации

Наш шаблон show.html относительно прост: он отображает ту же информацию, что и таблица, но в немного другом формате (возможно, для печати). Если мы позже добавим в приложение такие функции, как «заметки», это даст нам хорошее место для этого.

Опять же опустим «хром» шаблона и сосредоточимся на мясе:

Листинг 21. Шаблон «контактные данные»

<h1>{{contact.first}} {{contact.last}}</h1>

<div>
    <div>Phone: {{contact.phone}}</div>
    <div>Email: {{contact.email}}</div>
</div>

<p>
    <a href="/contacts/{{contact.id}}/edit">Edit</a>
    <a href="/contacts">Back</a>
</p>

Мы просто отображаем заголовок имени и фамилии с дополнительной контактной информацией под ним и парой ссылок: ссылку для редактирования контакта и ссылку для возврата к полному списку контактов.

Редактирование и удаление контакта

Далее мы рассмотрим функциональность на другом конце ссылки «Edit». Редактирование контакта будет очень похоже на создание нового контакта. Как и при добавлении нового контакта, нам понадобятся два маршрута, которые обрабатывают один и тот же путь, но используют разные методы HTTP: GET для /contacts/<contact_id>/edit вернет форму, позволяющую редактировать контакт, и POST на этот путь обновит его.

Мы также собираемся объединить возможность удаления контакта с функцией редактирования. Для этого нам нужно будет обработать POST для /contacts/<contact_id>/delete.

Давайте посмотрим на код для обработки GET, который, опять же, вернет HTML-представление интерфейса редактирования для данного ресурса:

Листинг 22. Код контроллера «редактировать контакт»

@app.route("/contacts/<contact_id>/edit", methods=["GET"])
def contacts_edit_get(contact_id=0):
    contact = Contact.find(contact_id)
    return render_template("edit.html", contact=contact)

Как видите, код очень похож на нашу функцию «Показать контакт». Фактически, он почти идентичен, за исключением шаблона: здесь мы отображаем edit.html, а не show.html.

Хотя наш код обработчика похож на функцию «Показать контакт», шаблон edit.html будет очень похож на шаблон для функции «Новый контакт»: у нас будет форма, которая отправляет обновленные значения контакта в тот же «edit» URL-адрес, который представляет все поля контакта в качестве входных данных для редактирования, а также любые сообщения об ошибках.

Вот первая часть формы:

Листинг 23. Начало формы «Редактировать контакт»

    <form action="/contacts/{{ contact.id }}/edit" method="post"> # 1
        <fieldset>
            <legend>Contact Values</legend>
                <p>
                    <label for="email">Email</label>
                    <input name="email" id="email" type="text" placeholder="Email" value="{{ contact.email }}"> # 2
                    <span class="error">{{ contact.errors['email'] }}</span>
                </p>
  1. Отправить POST по пути /contacts/{{ contact.id }}/edit.

  2. Как и в случае со страницей new.html, входные данные привязаны к адресу электронной почты контакта.

Этот HTML-код почти идентичен нашей форме new.html, за исключением того, что эта форма будет отправлять POST по другому пути, в зависимости от идентификатора контакта, который мы хотим обновить. (Здесь стоит отметить, что вместо POST мы бы предпочли использовать PUT или PATCH, но они недоступны в простом HTML.)

После этого у нас есть оставшаяся часть нашей формы, опять же очень похожая на шаблон new.html, и кнопка для отправки формы.

Листинг 24. Тело формы «Редактировать контакт»

            <p>
                <label for="first_name">First Name</label>
                <input name="first_name" id="first_name" type="text" placeholder="First Name" value="{{ contact.first }}">
                <span class="error">{{ contact.errors['first'] }}</span>
            </p>
            <p>
                <label for="last_name">Last Name</label>
                <input name="last_name" id="last_name" type="text" placeholder="Last Name" value="{{ contact.last }}">
                <span class="error">{{ contact.errors['last'] }}</span>
            </p>
            <p>
                <label for="phone">Phone</label>
                <input name="phone" id="phone" type="text" placeholder="Phone" value="{{ contact.phone }}">
                <span class="error">{{ contact.errors['phone'] }}</span>
            </p>
            <button>Save</button>
        </fieldset>
    </form>

В заключительной части нашего шаблона есть небольшая разница между new.html и edit.html. Под основной формой редактирования мы разместили вторую форму, позволяющую удалить контакт. Это делается путем отправки POST по пути /contacts/<contact id>/delete. Точно так же, как мы предпочли бы использовать PUT для обновления контакта, мы предпочли бы использовать HTTP-запрос DELETE для его удаления. К сожалению, это также невозможно в простом HTML.

Чтобы завершить страницу, есть простая гиперссылка на список контактов.

Листинг 25. Нижний колонтитул формы «Редактировать контакт»

    <form action="/contacts/{{ contact.id }}/delete" method="post">
        <button>Delete Contact</button>
    </form>
    <p>
        <a href="/contacts/">Back</a>
    </p>

Учитывая все сходство между шаблонами new.html и edit.html, вы можете задаться вопросом, почему мы не проводим рефакторинг этих двух шаблонов, чтобы разделить между ними логику. Это хорошее наблюдение, и в производственной системе мы, вероятно, поступили бы именно так.

Однако для наших целей, поскольку наше приложение маленькое и простое, мы оставим шаблоны отдельно.

Факторинг ваших приложений

Одна вещь, которая часто сбивает с толку людей, которые приходят к гипермедийным приложениям на основе JavaScript, — это понятие «компонентов». В приложениях, ориентированных на JavaScript, принято разбивать приложение на небольшие клиентские компоненты, которые затем объединяются вместе. Эти компоненты часто разрабатываются и тестируются изолированно и предоставляют разработчикам хорошую абстракцию для создания тестируемого кода.

В отличие от этого, в приложениях, управляемых гипермедиа, вы размещаете свое приложение на стороне сервера. Как мы уже говорили, приведенную выше форму можно преобразовать в общий шаблон между шаблонами редактирования и создания, что позволит вам реализовать многоразовую реализацию DRY (принцип Don’t Repeat Yourself (не повторяй себя)).

Обратите внимание, что факторинг на стороне сервера имеет тенденцию быть более детальным, чем на стороне клиента: вы склонны разделять общие разделы, а не создавать множество отдельных компонентов. У этого есть свои преимущества (он обычно прост), а также недостатки (он не так изолирован, как клиентские компоненты).

В целом, правильно спроектированное серверное гипермедийное приложение может быть чрезвычайно DRY.

Обработка сообщения в /contacts/<contact_id>

Далее нам нужно обработать запрос HTTP POST, который отправляет форма в нашем шаблоне edit.html. Мы объявим другой маршрут, который будет обрабатывать тот же путь, что и GET выше.

Вот новый код обработчика:

@app.route("/contacts/<contact_id>/edit", methods=["POST"]) # 1
def contacts_edit_post(contact_id=0):
    c = Contact.find(contact_id) # 2
    c.update(request.form['first_name'],
             request.form['last_name'],
             request.form['phone'],
             request.form['email']) # 3
    if c.save(): # 4
        flash("Updated Contact!")
        return redirect("/contacts/" + str(contact_id)) # 5
    else:
        return render_template("edit.html", contact=c) # 6
  1. Обработка POST в /contacts/<contact_id>/edit.

  2. Найти контакт по идентификатору.

  3. Обновить контакт новой информацией из формы.

  4. Попытаться сохранить его.

  5. В случае успеха отобразить сообщение об успехе и перенаправить на страницу сведений.

  6. В случае неудачи повторно отобразить шаблон редактирования, отобразив все ошибки.

Логика этого обработчика очень похожа на логику обработчика добавления нового контакта. Единственное реальное отличие состоит в том, что вместо создания нового контакта мы просматриваем контакт по его идентификатору, а затем вызываем для него метод update() со значениями, которые были введены в форму.

Еще раз, эта согласованность между нашими операциями CRUD является одним из приятных и упрощающих аспектов традиционных веб-приложений CRUD.

Удаление контакта

Мы объединили функцию удаления контактов с тем же шаблоном, который используется для редактирования контакта. Эта вторая форма выдаст HTTP POST для /contacts/<contact_id>/delete, и нам также нужно будет создать обработчик для этого пути.

Вот как выглядит контроллер:

Листинг 26. Код контроллера «удалить контакт»

@app.route("/contacts/<contact_id>/delete",
methods=["POST"]) # 1
def contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete() # 2
    flash("Deleted Contact!")
    return redirect("/contacts") # 3
  1. Обработать POST путь /contacts/<contact_id>/delete.

  2. Найдти и затем вызоввать метод delete() для контакта.

  3. Показать сообщение об успехе и перенаправить к основному списку контактов.

Код обработчика очень прост, поскольку нам не нужно выполнять какую-либо проверку или условную логику: мы просто ищем контакт так же, как мы это делали в других наших обработчиках, и вызываем для него метод delete(), а затем перенаправляем обратно в список контактов с флэш-сообщением об успехе.

Шаблон в этом случае не нужен, контакт пропал.

Contact.app… Реализовано!

И, ну… хотите верьте, хотите нет, но это все наше приложение для контактов!

Если до сих пор у вас были проблемы с частями кода, не волнуйтесь: мы не ожидаем, что вы станете экспертом по Python или Flask (мы таковыми не являемся!). Вам просто нужно базовое понимание того, как они работают, чтобы извлечь пользу из оставшейся части книги.

Это небольшое и простое приложение, но оно демонстрирует многие аспекты традиционных приложений Web 1.0: CRUD, шаблон Post /Redirect /Get, работу с логикой домена в контроллере, организацию наших URL-адресов согласованным, ресурсоориентированным образом.

Более того, это веб-приложение, глубоко ориентированное на гипермедиа. Не особо задумываясь об этом, мы использовали REST, HATEOAS и все другие концепции гипермедиа, которые мы обсуждали ранее. Мы готовы поспорить, что это наше простое маленькое контактное приложение более RESTful, чем 99% всех когда-либо созданных API JSON!

Просто благодаря использованию гипермедиа HTML мы естественным образом попадаем в сетевую архитектуру RESTful.

Ну и замечательно. Но что случилось с этим маленьким веб-приложением? Почему бы не закончить на этом и заняться разработкой приложений в стиле Web 1.0?

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

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

Contact.app на данный момент просто не выглядит «современным» веб-приложением.

Не пора ли использовать фреймворк JavaScript и API-интерфейсы JSON, чтобы сделать наше контактное приложение более интерактивным?

Нет. Нет, это не так.

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

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

HTML-заметки: Framework Soup

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

Прежде чем обращаться к компонентам для повторного использования, рассмотрите варианты. Механизмы более низкого уровня часто позволяют создавать более качественный HTML. В некоторых случаях компоненты действительно могут улучшить ясность вашего HTML.

Тот факт, что HTML-документ — это то, к чему вы почти не прикасаетесь, потому что все, что вам нужно, будет добавлено через JavaScript, выводит документ и структуру страницы из фокуса.
~ Мануэль Матузович Почему я не самый большой поклонник одностраничных приложений

Чтобы избежать супа <div> (или супа Markdown, или супа компонентов), вам необходимо знать разметку, которую вы создаете, и иметь возможность ее изменить.

Некоторые инфраструктуры SPA и некоторые веб-компоненты усложняют эту задачу, помещая уровни абстракции между кодом, который пишет разработчик, и сгенерированной разметкой.

Хотя эти абстракции могут позволить разработчикам создавать более богатый пользовательский интерфейс или работать быстрее, их повсеместное распространение означает, что разработчики могут упустить из виду фактический HTML (и JavaScript), отправляемый клиентам. Без тщательного тестирования это приводит к недоступности, плохому SEO и раздуванию сайта.

Часть II
Веб-приложения, управляемые гипермедиа, с использованием htmx

Глава 4
Расширение HTML как гипермедиа

В предыдущей главе мы представили простое гипермедийное приложение в стиле Web 1.0 для управления контактами. Наше приложение поддерживало обычные CRUD-операции для контактов, а также простой механизм поиска контактов. Наше приложение было создано с использованием только форм и тегов якоря — традиционных элементов управления гипермедиа, используемых для взаимодействия с серверами. Приложение обменивается гипермедиа (HTML) с сервером через HTTP, отправляя HTTP-запросы GET и POST и получая в ответ полные HTML-документы.

Это базовое веб-приложение, но оно также определенно является приложением, управляемым гипермедиа. Оно надежно, использует собственные технологии Интернета и просто для понимания.

Так что же не нравится в приложении?

К сожалению, у нашего приложения есть несколько проблем, характерных для приложений в стиле Web 1.0:

Первый момент, в частности, заметен в приложениях в стиле Web 1.0, подобных нашему, и именно он ответственен за то, что они получили репутацию «неуклюжих» по сравнению с их более сложными собратьями одностраничных приложений на основе JavaScript.

Мы могли бы решить эту проблему, внедрив структуру одностраничного приложения и обновив нашу серверную часть для предоставления ответов на основе JSON. Одностраничные приложения устраняют неуклюжесть приложений Web 1.0, обновляя веб-страницу без ее обновления: они могут изменять части объектной модели документа (DOM) существующей страницы без необходимости замены (и повторной визуализации) всей страницы.

DOM
DOM — это внутренняя модель, которую браузер создает при обработке HTML, формируя дерево «узлов» для тегов и другого контента в HTML. DOM предоставляет программный API JavaScript, который позволяет обновлять узлы на странице напрямую, без использования гипермедиа. Используя этот API, код JavaScript может вставлять новый контент, а также удалять или обновлять существующий контент, полностью выходя за рамки обычного механизма запросов браузера.

Существует несколько различных стилей SPA, но, как мы обсуждали в главе 1, наиболее распространенным сегодня подходом является привязка DOM к модели JavaScript, а затем позволить инфраструктуре SPA, такой как React или Vue, реактивно обновлять DOM, когда модель JavaScript обновляется: вы вносите изменения в объект JavaScript, который хранится локально в памяти браузера, и веб-страница «волшебным образом» обновляет свое состояние, чтобы отразить изменение в модели.

В этом стиле приложения связь с сервером обычно осуществляется через API данных JSON, при этом приложение жертвует преимуществами гипермедиа, чтобы обеспечить лучший и более плавный пользовательский интерфейс.

Многие веб-разработчики сегодня даже не рассматривают подход с использованием гипермедиа из-за ощущения «устаревшего» приложения в стиле Web 1.0.

Теперь вторая, более техническая проблема, которую мы упомянули, может показаться вам немного педантичной, и мы первые, кто признает, что разговоры о REST и о том, какое действие HTTP подходит для конкретной операции, могут стать очень утомительными. Но все же странно, что при использовании простого HTML невозможно использовать всю функциональность HTTP!

Это кажется неправильным, не так ли?

Пристальный взгляд на гиперссылку

Оказывается, мы можем повысить интерактивность нашего приложения и решить обе эти проблемы, не прибегая к подходу SPA. Мы можем сделать это, используя библиотеку JavaScript, ориентированную на гипермедиа, htmx. Авторы этой книги создали htmx специально для расширения HTML как гипермедиа и решения проблем устаревших HTML-приложений, о которых мы упоминали выше (а также некоторых других).

Прежде чем мы перейдем к тому, как htmx позволяет нам улучшить UX нашего приложения в стиле Web 1.0, давайте вернемся к тегу гиперссылки/якоря из главы 1. Напомним, гиперссылка — это так называемый элемент управления гипермедиа, механизм, который описывает своего рода взаимодействие с сервером путем кодирования информации об этом взаимодействии непосредственно и полностью внутри самого элемента управления.

Рассмотрим еще раз этот простой тег привязки, который при интерпретации браузером создает гиперссылку на веб-сайт этой книги:

Листинг 27. Простая гиперссылка, еще раз

<a href="https://hypermedia.systems/">
  Hypermedia Systems
</a>

Давайте подробно разберем, что происходит с этой ссылкой:

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

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

Почему только якоря и формы?

Подумайте: что делают теги якоря (и формы) такими особенными?

Почему другие элементы также не могут отправлять HTTP-запросы?

Например, почему элементы button не могут отправлять HTTP-запросы? Кажется произвольным обертывать тег формы вокруг кнопки, например, только для того, чтобы удаление контактов работало в нашем приложении.

Возможно: другие элементы также должны иметь возможность отправлять HTTP-запросы. Возможно, другие элементы смогут действовать как элементы управления гипермедиа самостоятельно.

Это наша первая возможность обобщить HTML как гипермедиа.

Почему только события Click и Submit?

Далее давайте рассмотрим событие, которое запускает запрос к серверу по нашей ссылке: событие клика.

Что же такого особенного в щелчках (в случае якорей) или отправке (в случае форм) вещей? В конце концов, это всего лишь два из многих, многих событий, которые инициируются DOM. Такие события, как клик мыши, нажатие клавиши или потеря фокуса, — это все события, которые вы можете использовать для выдачи HTTP-запроса.

Почему другие события не могут также инициировать запросы?

Это дает нам вторую возможность расширить выразительность HTML:

Почему только GET & POST?

Немного более технический подход приводит нас к проблеме, которую мы отметили ранее: простой HTML дает нам доступ только к действиям GET и POST HTTP.

HTTP означает протокол передачи гипертекста, однако формат HTML, для которого он был явно разработан, поддерживает только два из пяти типов запросов, ориентированных на разработчиков. Вам нужно использовать JavaScript и выполнить запрос AJAX, чтобы получить остальные три: DELETE, PUT и PATCH.

Давайте вспомним, что представляют собой эти различные типы HTTP-запросов:

Эти операции во многом соответствуют операциям CRUD, которые мы обсуждали в главе 2. Предоставляя нам доступ только к двум из пяти, HTML ограничивает нашу способность в полной мере использовать преимущества HTTP.

Это дает нам третью возможность расширить выразительность HTML:

Зачем заменять только весь экран?

В качестве последнего замечания рассмотрим последний аспект гиперссылки: она заменяет весь экран, когда пользователь нажимает на нее.

Оказывается, эта техническая деталь является основной причиной плохого взаимодействия с пользователем в приложениях Web 1.0. Полное обновление страницы может вызвать вспышку нестилизованного контента, когда контент «перепрыгивает» на экране при переходе от исходной формы к окончательной стилизованной форме. Он также разрушает состояние прокрутки пользователя, прокручивая страницу до верха, удаляет фокус с элемента, находящегося в фокусе, и т. д.

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

Это дает нам четвертую, последнюю и, возможно, самую важную возможность обобщить HTML:

На самом деле это очень старая концепция гипермедиа. Тед Нельсон в своей книге «Literary Machines» 1980 года ввел термин трансклюзия, чтобы отразить эту идею: включение контента в существующий документ посредством ссылки на гипермедиа. Если бы HTML поддерживал этот стиль «динамического включения», то HDA, могли бы функционировать скорее как одностраничные приложения, где только часть DOM обновляется в результате определенного взаимодействия с пользователем или сетевого запроса.

Расширение HTML как гипермедиа с помощью Htmx

Эти четыре возможности дают нам возможность расширить возможности HTML далеко за пределы его текущих возможностей, но таким образом, чтобы это полностью соответствовало гипермедийной модели Интернета. Основы HTML, HTTP, браузера и т. д. кардинально не изменятся. Скорее, эти обобщения существующих функций, уже имеющихся в HTML, просто позволят нам добиться большего с помощью HTML.

Htmx — это библиотека JavaScript, которая расширяет HTML именно таким образом, и ей будут посвящены следующие несколько глав этой книги. Опять же, htmx — не единственная библиотека JavaScript, использующая этот подход, ориентированный на гипермедиа (другими прекрасными примерами являются Unpoly и Hotwire), но htmx является самой чистой в своем стремлении расширить HTML как гипермедиа.

Установка и использование Htmx

С практической точки зрения «начало работы» htmx — это простая, независимая и автономная библиотека JavaScript, которую можно добавить в веб-приложение, просто включив ее через тег script в элементе head.

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

Ниже приведен пример использования популярной сети доставки контента (CDN) unpkg для установки библиотеки версии 1.9.2. Мы используем хеш целостности, чтобы гарантировать, что доставленный контент JavaScript соответствует тому, что мы ожидаем. Этот SHA можно найти на веб-сайте htmx.

Мы также помечаем скрипт как crossorigin="anonymous", чтобы в CDN не отправлялись учетные данные.

Листинг 28. Установка htmx

<head>
<script src="https://unpkg.com/htmx.org@1.9.2" integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h" crossorigin="anonymous"></script>
</head>

Если вы привыкли к современной разработке на JavaScript со сложными системами сборки и большим количеством зависимостей, для вас может стать приятным сюрпризом обнаружить, что это все, что нужно для установки htmx.

Это в духе раннего Интернета, когда вы могли просто включить тег сценария, и все «просто работало».

Если вы не хотите использовать CDN, вы можете загрузить htmx в свою локальную систему и настроить тег сценария так, чтобы он указывал на то, где вы храните свои статические ресурсы. Или у вас может быть система сборки, которая автоматически устанавливает зависимости. В этом случае вы можете использовать имя Node Package Manager (npm) для библиотеки: htmx.org и установить ее обычным способом, который поддерживает ваша система сборки.

После установки htmx вы можете сразу же начать его использовать.

JavaScript не требуется…

И здесь мы подходим к интересной части htmx: htmx не требует от вас, пользователя htmx, написания какого-либо JavaScript.

Вместо этого вы будете использовать атрибуты, размещенные непосредственно в элементах вашего HTML, чтобы обеспечить более динамичное поведение. Htmx расширяет возможности HTML как гипермедиа и создан для того, чтобы сделать это расширение максимально естественным и совместимым с существующими концепциями HTML. Точно так же, как тег якоря использует атрибут href для указания URL-адреса для получения, а формы используют атрибут action для указания URL-адреса для отправки формы, htmx использует атрибуты HTML для указания URL-адреса, на который должен быть отправлен HTTP-запрос.

Запуск HTTP-запросов

Давайте посмотрим на первую особенность htmx: возможность любого элемента веб-страницы отправлять HTTP-запросы. Это основная функциональность, предоставляемая htmx, и она состоит из пяти атрибутов, которые можно использовать для выдачи пяти различных типов HTTP-запросов, ориентированных на разработчика:

Каждый из этих атрибутов, помещенный в элемент, сообщает библиотеке htmx: «Когда пользователь щелкает (или что-то еще) на этом элементе, выдает HTTP-запрос указанного типа».

Значения этих атрибутов аналогичны значениям href для якорей и action в формах: вы указываете URL-адрес, на который хотите отправить данный тип HTTP-запроса. Обычно это делается через путь, относящийся к серверу.

Например, если бы мы хотели, чтобы кнопка отправляла запрос GET к /contacts, мы бы написали следующий HTML-код:

Листинг 29. Простая кнопка с поддержкой HTML

<button hx-get="/contacts"> <!-- 1 -->
  Get The Contacts
</button>
  1. Простая кнопка, которая отправляет HTTP GET в /contacts.

Библиотека htmx увидит атрибут hx-get на этой кнопке и подключит некоторую логику JavaScript для выдачи HTTP-запроса GET AJAX по пути /contacts, когда пользователь нажимает на нее.

Очень прост для понимания и очень совместим с остальной частью HTML.

Это все просто HTML

С запросом, отправленным кнопкой выше, мы подходим, пожалуй, к самой важной вещи, которую нужно понять о htmx: он ожидает, что ответом на этот запрос AJAX будет HTML. Htmx — это расширение HTML. Собственный элемент управления гипермедиа, такой как тег якоря, обычно получает ответ HTML на созданный им HTTP-запрос. Аналогично, htmx ожидает, что сервер ответит на запросы, которые он делает с помощью HTML.

Это может удивить веб-разработчиков, которые привыкли отвечать на запрос AJAX с помощью JSON, который, безусловно, является наиболее распространенным форматом ответа для таких запросов. Но запросы AJAX — это всего лишь HTTP-запросы, и не существует правила, согласно которому они должны использовать JSON. Напомним еще раз, что AJAX означает асинхронный JavaScript и XML, поэтому JSON — это уже шаг в сторону от формата, изначально предусмотренного для этого API: XML.

Htmx просто идет в другом направлении и ожидает HTML.

Htmx против «простых» HTML-ответов

Существует важное различие между ответами HTTP на «обычные» HTTP-запросы, управляемые якорем или формой, и на запросы на базе htmx: в случае запросов, запускаемых htmx, ответы могут представлять собой частичные фрагментами HTML.

Как вы увидите, при взаимодействии на основе HTML мы часто не заменяем весь документ. Скорее мы используем «включение» для вставки контента в существующий документ. По этой причине зачастую нет необходимости или нежелательно переносить весь HTML-документ с сервера в браузер.

Этот факт можно использовать для экономии пропускной способности, а также времени загрузки ресурсов. С сервера клиенту передается меньше содержимого, и нет необходимости повторно обрабатывать тег head с помощью таблиц стилей, тегов сценариев и т. д.

При нажатии кнопки «Получить контакты» частичный HTML-ответ может выглядеть примерно так:

Листинг 30. Частичный ответ HTML на запрос htmx

<ul>
  <li><a href="mailto:joe@example.com">Joe</a></li>
  <li><a href="mailto:sarah@example.com">Sarah</a></li>
  <li><a href="mailto:fred@example.com">Fred</a></li>
</ul>

Это просто неупорядоченный список контактов с некоторыми кликабельными элементами. Обратите внимание, что здесь нет открывающего тега html, тега head и т. д.: это необработанный HTML-список без каких-либо украшений вокруг него. Ответ в реальном приложении может содержать более сложный HTML-код, чем этот простой список, но даже если бы он был более сложным, это не обязательно должна быть целая страница HTML: это может быть просто «внутреннее» содержимое HTML-представления для этого ресурса.

Теперь этот простой ответ в виде списка идеально подходит для htmx. Htmx просто возьмет возвращенный контент и затем заменит его в DOM вместо какого-либо элемента на странице. (Подробнее о том, где именно он будет помещен в DOM, чуть позже.) Такая замена HTML-контента является быстрой и эффективной, поскольку она использует существующий встроенный анализатор HTML в браузере, а не требует значительного объема операций на стороне клиента. JavaScript для выполнения.

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

Эта кнопка просто дает нам немного более сложный механизм создания веб-приложения с использованием гипермедиа.

Нацеливание на другие элементы

Теперь, учитывая, что htmx выдал запрос и получил в ответ некоторый HTML-код, и что мы собираемся заменить этот контент на существующую страницу (вместо того, чтобы заменять всю страницу), возникает вопрос: где должен находиться этот новый контент?

Оказывается, поведение htmx по умолчанию заключается в том, чтобы просто поместить возвращаемый контент внутрь элемента, который инициировал запрос. В случае с нашей кнопкой это не очень хорошо: в итоге мы получим список контактов, неуклюже встроенный в элемент кнопки. Это будет выглядеть довольно глупо и явно не то, чего мы хотим.

К счастью, у htmx есть еще один атрибут, hx-target, который можно использовать для точного указания места в DOM, где следует разместить новый контент. Значение атрибута hx-target — это селектор каскадной таблицы стилей (CSS), который позволяет указать элемент, в который будет помещен новый гипермедийный контент.

Давайте добавим тег div, который окружает кнопку с идентификатором main. Затем мы нацелимся на этот div с ответом:

Листинг 31. Простая кнопка с поддержкой HTML

<div id="main"> <!-- 1 -->

  <button hx-get="/contacts" hx-target="#main"> <!-- 2 -->
    Get The Contacts
  </button>

</div>
  1. Элемент div, который оборачивает кнопку.

  2. Атрибут hx-target, указывающий цель ответа.

Мы добавили hx-target="#main" к нашей кнопке, где #main — это CSS-селектор, который говорит: «Нечто с идентификатором "main"».

Используя селекторы CSS, htmx основывается на знакомых и стандартных концепциях HTML. Это сводит к минимуму дополнительную концептуальную нагрузку при работе с htmx.

Учитывая эту новую конфигурацию, как будет выглядеть HTML-код на клиенте после того, как пользователь нажмет эту кнопку и ответ будет получен и обработан?

Это будет выглядеть примерно так:

Листинг 32. Наш HTML-код после завершения htmx-запроса

<div id="main">
  <ul>
    <li><a href="mailto:joe@example.com">Joe</a></li>
    <li><a href="mailto:sarah@example.com">Sarah</a></li>
    <li><a href="mailto:fred@example.com">Fred</a></li>
  </ul>
</div>

HTML-код ответа был заменен на элемент div, заменив кнопку, которая инициировала запрос. Включение! И это произошло «в фоновом режиме» через AJAX, без неуклюжего обновления страницы.

Поменять стили

Теперь, возможно, мы не хотим загружать содержимое ответа сервера в div как дочерние элементы. Возможно, по какой-то причине мы хотим заменить весь div ответом. Чтобы справиться с этим, htmx предоставляет другой атрибут hx-swap, который позволяет вам точно указать, как контент должен быть заменен в DOM.

Атрибут hx-swap поддерживает следующие значения:

Первые два значения, innerHTML и outerHTML, берутся из стандартных свойств DOM, которые позволяют заменять содержимое внутри элемента или вместо всего элемента соответственно.

Следующие четыре значения взяты из API DOM Element.insertAdjacentHTML(), который позволяет вам размещать элемент или элементы вокруг данного элемента различными способами.

Последние два значения, delete и none, относятся только к htmx. Первый вариант удалит целевой элемент из DOM, а второй вариант ничего не сделает (возможно, вы захотите работать только с заголовками ответов — продвинутый метод, который мы рассмотрим позже в книге).

Опять же, вы можете видеть, что htmx максимально приближен к существующим веб-стандартам, чтобы минимизировать концептуальную нагрузку, необходимую для его использования.

Итак, давайте рассмотрим тот случай, когда вместо замены содержимого innerHTML основного элемента div, указанного выше, мы хотим заменить весь элемент div ответом HTML.

Для этого потребуется лишь небольшое изменение нашей кнопки, добавив новый атрибут hx-swap:

Листинг 33. Замена всего элемента div

<div id="main">

  <button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML"> <span><!-- 1 --></span>
    Get The Contacts
  </button>

</div>
  1. Атрибут hx-swap указывает, как заменять новый контент.

Теперь при получении ответа весь div будет заменен гипермедийным контентом:

Листинг 34. Наш HTML-код после завершения htmx-запроса

<ul>
  <li><a href="mailto:joe@example.com">Joe</a></li>
  <li><a href="mailto:sarah@example.com">Sarah</a></li>
  <li><a href="mailto:fred@example.com">Fred</a></li>
</ul>

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

Далее в книге мы увидим дополнительные варианты использования hx-swap, например, когда мы реализуем бесконечную прокрутку в нашем приложении для управления контактами.

Обратите внимание, что с помощью атрибутов hx-get, hx-post, hx-put, hx-patch и hx-delete мы рассмотрели две из четырех возможностей улучшения, которые мы перечислили относительно простого HTML:

А с помощью hx-target и hx-swap мы устранили третий недостаток: требование замены всей страницы.

Итак, используя всего семь относительно простых дополнительных атрибутов, мы устранили большинство недостатков HTML как гипермедиа, которые мы выявили ранее.

Что дальше? Вспомните еще одну возможность, которую мы отметили: тот факт, что только событие click (на якоре) или событие submit (в форме) может инициировать HTTP-запрос. Давайте посмотрим, как мы можем устранить это ограничение.

Использование событий

До сих пор мы использовали кнопку для отправки запроса с помощью htmx. Вы, вероятно, интуитивно поняли, что кнопка выдает свой запрос, когда вы нажимаете на кнопку, поскольку, ну, это то, что вы делаете с кнопками: вы нажимаете на них.

И да, по умолчанию, когда на кнопку помещается hx-get или другая аннотация, управляющая запросами, из htmx, запрос будет выдан при нажатии кнопки.

Однако htmx обобщает понятие события, запускающего запрос, используя, как вы уже догадались, другой атрибут: hx-trigger. Атрибут hx-trigger позволяет указать одно или несколько событий, которые заставят элемент инициировать HTTP-запрос.

Часто вам не нужно использовать hx-trigger, поскольку событие запуска по умолчанию будет тем, что вы хотите. Событие, вызывающее срабатывание по умолчанию, зависит от типа элемента и должно быть достаточно интуитивно понятным:

Чтобы продемонстрировать, как работает hx-trigger, рассмотрим следующую ситуацию: мы хотим инициировать запрос на нашей кнопке, когда указатель появляется над ней. Конечно, это не очень хороший UX-шаблон, но будьте терпеливы: мы просто используем этот пример.

Чтобы реагировать на появление мыши над кнопкой, мы добавим к нашей кнопке следующий атрибут:

Листинг 35. (Плохая?) кнопка, срабатывающая при появлении мыши

<div id="main">

<button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML" hx-trigger="mouseenter"> <!-- 1 -->
  Get The Contacts
</button>

</div>
  1. Выполните запрос на событие… mouseenter.

Теперь, когда этот атрибут hx-trigger установлен, всякий раз, когда мышь появляется над этой кнопкой, будет запускаться запрос. Глупо, но это работает.

Давайте попробуем что-то более реалистичное и потенциально полезное: добавим поддержку сочетания клавиш для загрузки контактов Ctrl-L (для «Load»). Для этого нам нужно будет воспользоваться дополнительным синтаксисом, который поддерживает атрибут hx-trigger: фильтрами событий и дополнительными аргументами.

Фильтры событий — это механизм определения того, должно ли данное событие инициировать запрос или нет. Они применяются к событию путем добавления после него квадратных скобок: someEvent[someFilter]. Сам фильтр представляет собой выражение JavaScript, которое будет оцениваться при возникновении данного события. Если результат верный в смысле JavaScript, запрос инициируется. В противном случае запрос не будет запущен.

В случае сочетаний клавиш мы хотим перехватить событие нажатия клавиши keyup в дополнение к событию щелчка:

Листинг 36. Старт, триггер по нажатию клавиши

<div id="main">

  <button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML" hx-trigger="click, keyup"> <!-- 1 -->
    Get The Contacts
  </button>

</div>
  1. Триггер с двумя событиями.

Обратите внимание, что у нас есть список событий, разделенных запятыми, которые могут активировать этот элемент, что позволяет нам реагировать более чем на одно потенциальное событие-триггер. Мы по-прежнему хотим реагировать на событие click и загружать контакты в дополнение к обработке сочетания клавиш Ctrl-L.

К сожалению, есть две проблемы с нашим добавлением keyup: в его нынешнем виде оно будет инициировать запросы при любом происходящем событии нажатия клавиши. И, что еще хуже, он сработает только тогда, когда внутри этой кнопки произойдет нажатие клавиши. Пользователю нужно будет нажать на кнопку, чтобы сделать ее активной, а затем начать печатать.

Давайте исправим эти две проблемы. Чтобы исправить первый, мы будем использовать триггерный фильтр, чтобы проверить, что клавиши Control и клавиши «L» нажаты вместе:

Листинг 37. Улучшаем ситуацию с помощью фильтра по клавишам

<div id="main">

  <button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML" hx-trigger="click, keyup[ctrlKey && key == 'l']"> <!-- 1 -->
    Get The Contacts
  </button>

</div>
  1. keyup теперь имеет фильтр, поэтому необходимо нажать клавишу управления и L.

Триггерным фильтром в этом случае является ctrlKey && key == 'l'. Это можно прочитать как «Событие нажатия клавиши, где свойство ctrlKey имеет значение true, а свойство key равно l». Обратите внимание, что свойства ctrlKey и key разрешаются по событию, а не по глобальному пространству имен, поэтому вы можете легко фильтровать свойства данного события. Однако для фильтра вы можете использовать любое выражение: вызов глобальной функции JavaScript, например, вполне приемлем.

Хорошо, поэтому этот фильтр ограничивает события нажатия клавиш, которые запускают запрос, только нажатиями Ctrl-L. Однако у нас все еще есть проблема: в нынешнем виде только события keyup внутри кнопки запускают запрос.

Если вы не знакомы с моделью всплытия событий JavaScript: события обычно «всплывают» до родительских элементов. Таким образом, такое событие, как keyup, будет запущено сначала на элементе, находящемся в фокусе, а затем на его родительском (включающем) элементе и так далее, пока оно не достигнет объекта документа верхнего уровня, который является корнем всех других элементов.

Чтобы поддерживать глобальное сочетание клавиш, которое работает независимо от того, какой элемент находится в фокусе, мы воспользуемся всплыванием событий и функцией, которую поддерживает атрибут hx-trigger: возможность прослушивать события в других элементах. Синтаксисом для этого является модификатор from:, который добавляется после имени события и позволяет вам указать конкретный элемент для прослушивания данного события при использовании селектора CSS.

В данном случае мы хотим прослушивать элемент body, который является родительским элементом всех видимых элементов на странице.

Вот как выглядит наш обновленный атрибут hx-trigger:

Листинг 38. Еще лучше: слушайте keyup на body

<div id="main">

  <button hx-get="/contacts" hx-target="#main" hx-swap="outerHTML" hx-trigger="click, keyup[ctrlKey && key == 'L'] from:body"> <!-- 1 -->
    Get The Contacts
  </button>

</div>
  1. Прослушивание события «keyup» в теге body.

Теперь, помимо кликов, кнопка будет прослушивать события keyup в теле страницы. Таким образом, она выдает запрос при нажатии на нее, а также всякий раз, когда кто-то нажимает Ctrl-L в теле страницы.

И теперь у нас есть хорошее сочетание клавиш для нашего приложения Hypermedia-Driven Application.

Атрибут hx-trigger поддерживает гораздо больше модификаторов и более сложен, чем другие атрибуты htmx. Это потому, что события, как правило, сложны и требуют большого количества деталей, чтобы все получилось правильно. Однако триггера по умолчанию часто бывает достаточно, и вам обычно не нужно использовать сложные функции hx-trigger при использовании htmx.

Даже с более сложными спецификациями триггеров, такими как только что добавленное сочетание клавиш, общее ощущение htmx является скорее декларативным, чем императивным. Это позволяет приложениям на базе htmx «ощущаться» как стандартные приложения Web 1.0, в отличие от добавления значительного количества JavaScript.

Htmx: расширенный HTML

И эй, проверьте это! С помощью hx-trigger мы реализовали последнюю возможность улучшения HTML, которую мы обрисовали в начале этой главы:

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

Вот таблица, в которой обобщаются эти возможности и атрибуты htmx, которые их реализуют:

Возможности улучшения HTML

Любой элемент должен иметь возможность отправлять HTTP-запросы.
hx-get, hx-post, hx-put, hx-patch, hx-delete
Любое событие должно иметь возможность инициировать HTTP-запрос.
hx-trigger
Любое действие HTTP должно быть доступно.
hx-put, hx-patch, hx-delete
Любое место на странице должно быть заменяемым (трансклюзией)
hx-target, hx-swap

Передача параметров запроса

До сих пор мы рассматривали ситуацию, когда кнопка отправляет простой запрос GET. Концептуально это очень близко к тому, что может делать тег якоря. Но в приложениях на основе HTML есть еще один встроенный элемент управления гипермедиа: формы. Формы используются для передачи дополнительной информации, помимо URL-адреса, на сервер в запросе.

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

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

Вложенные формы

Самый простой способ передать входные значения с помощью запроса в htmx — заключить элемент, отправляющий запрос, в тег формы.

Давайте возьмем нашу оригинальную кнопку для получения контактов и перепрофилируем ее для поиска контактов:

Листинг 39. Кнопка поиска в формате HTML

<div id="main">

  <form> <!-- 1 -->
    <label for="search">Search Contacts:</label>
    <input id="search" name="q" type="search" placeholder="Search Contacts"> <!-- 2 -->
    <button hx-post="/contacts" hx-target="#main"> <!-- 3 -->
      Search The Contacts
    </button>
  </form>

</div>
  1. С помощью прилагаемого тега формы будут отправлены все входные значения.

  2. Новый ввод для ввода текста поиска пользователя.

  3. Наша кнопка была преобразована в hx-post.

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

Теперь, когда пользователь нажимает на кнопку, значение ввода с идентификатором search будет включено в запрос. Это связано с тем, что существует тег формы, охватывающий как кнопку, так и ввод: когда запускается запрос, управляемый htmx, htmx будет искать в иерархии DOM включающую форму, и, если она найдена, она будет включать все значения из этой формы. (Иногда это называют «сериализацией» формы.)

Возможно, вы заметили, что кнопка переключилась с запроса GET на запрос POST. Это связано с тем, что по умолчанию htmx не включает ближайшую закрывающую форму для запросов GET, но включает форму для всех других типов запросов.

Это может показаться немного странным, но это позволяет избежать мусора URL-адресов, которые используются в формах при работе с записями истории, о чем мы поговорим чуть позже. И вы всегда можете включить значения включающей формы в элемент, который использует GET, используя атрибут hx-include, который обсуждается ниже.

Включение входных данных

Хотя включение всех входных данных, которые вы хотите включить в запрос, является наиболее распространенным подходом к входным данным в htmx-запросах, это не всегда возможно или желательно: теги формы могут иметь последствия для макета и просто не могут быть размещены в некоторых местах HTML-документов. Хорошим примером последней ситуации являются элементы строки таблицы (tr): тег form не является допустимым дочерним или родительским элементом строк таблицы, поэтому вы не можете разместить форму внутри или вокруг строки данных в таблице.

Чтобы решить эту проблему, htmx предоставляет механизм включения входных значений в запросы: атрибут hx-include. Атрибут hx-include позволяет вам выбирать входные значения, которые вы хотите включить в запрос, с помощью селекторов CSS.

Вот приведенный выше пример, переработанный для включения ввода, с удалением формы:

Листинг 40. Кнопка поиска на основе htmx с hx-include

<div id="main">

  <label for="search">Search Contacts:</label>
  <input id="search" name="q" type="search" placeholder="Search Contacts">
  <button hx-post="/contacts" hx-target="#main" hx-include="#search"> <!-- 1 -->
    Search The Contacts
  </button>

</div>
  1. hx-include можно использовать для включения значений непосредственно в запрос.

Атрибут hx-include принимает значение селектора CSS и позволяет вам точно указать, какие значения отправлять вместе с запросом. Это может быть полезно, если сложно совместить элемент, выдающий запрос, со всеми желаемыми входными данными.

Это также полезно, когда вы действительно хотите отправить значения с помощью запроса GET и преодолеть поведение htmx по умолчанию.

Относительные селекторы CSS

Атрибут hx-include и, по сути, большинство атрибутов, принимающих селектор CSS, также поддерживают относительные селекторы CSS. Они позволяют вам указать селектор CSS относительно элемента, для которого он объявлен. Вот некоторые примеры:

closest:: Найдти ближайший родительский элемент, соответствующий данному селектору, например, ближайшую form.

next:: Найти следующий элемент (сканируя вперед), соответствующий данному селектору, например, следующий input.

previous:: Найти предыдущий элемент (сканируя назад), соответствующий данному селектору, например, предыдущий input.

find:: Найти следующий элемент внутри этого элемента, соответствующий данному селектору, например, найти input.

this:: Текущий элемент.

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

Встроенные значения

Последний способ включения значений в запросы, управляемые htmx, — это использование атрибута hx-vals, который позволяет включать в запрос «статические» значения. Это может быть полезно, если у вас есть дополнительная информация, которую вы хотите включить в запросы, но вы не хотите, чтобы эта информация была встроена, например, в скрытые входные данные (что было бы стандартным механизмом включения дополнительной скрытой информации в HTML).

Вот пример hx-vals:

Листинг 41. Кнопка с поддержкой htmx и hx-vals

<button hx-get="/contacts" hx-vals='{"state":"MT"}'> <!-- 1 -->
  Get The Contacts In Montana
</button>
  1. hx-vals — значение JSON, которое нужно включить в запрос.

Параметр state со значением MT будет включен в запрос GET, в результате чего путь и параметры будут выглядеть следующим образом: /contacts?state=MT. Обратите внимание, что мы изменили атрибут hx-vals, чтобы его значение заключалось в одинарные кавычки. Это связано с тем, что JSON строго требует двойных кавычек и, следовательно, чтобы избежать экранирования, нам нужно было использовать форму одинарных кавычек для значения атрибута.

Вы также можете префиксировать hx-vals с помощью js: и передавать значения, вычисляемые во время запроса, что может быть полезно для включения таких вещей, как динамически поддерживаемая переменная или значение из сторонней библиотеки JavaScript.

Например, если переменная state поддерживалась динамически, с помощью некоторого JavaScript, и существовала функция JavaScript getCurrentState(), которая возвращала текущее выбранное состояние, ее можно было бы динамически включать в запросы htmx следующим образом:

Листинг 42. Динамическое значение

<button hx-get="/contacts" hx-vals='js:{"state":getCurrentState()}'> <!-- 1 -->
  Get The Contacts In The Selected State
</button>
  1. С префиксом js: это выражение будет выполняться во время отправки.

Эти три механизма, использующие теги form, атрибут hx-include и атрибут hx-vals, позволяют вам включать значения в ваши запросы гипермедиа с помощью htmx таким образом, который должен быть очень знакомым и соответствовать духу HTML. а также дает вам гибкость для достижения того, чего вы хотите.

Поддержка истории

У нас есть последняя часть функциональности, чтобы завершить наш обзор htmx: поддержка истории браузера. Когда вы используете обычные HTML-ссылки и формы, ваш браузер будет отслеживать все страницы, которые вы посетили. Затем вы можете использовать кнопку «Назад», чтобы вернуться на предыдущую страницу, и как только вы это сделаете, вы можете использовать кнопку «Вперед», чтобы перейти к исходной странице, на которой вы были.

Это понятие истории было одной из убийственных особенностей раннего Интернета. К сожалению, оказывается, что история становится сложнее, когда вы переходите к парадигме одностраничного приложения. Запрос AJAX сам по себе не регистрирует веб-страницу в истории вашего браузера, и это хорошо: запрос AJAX может не иметь ничего общего с состоянием веб-страницы (возможно, он просто записывает некоторую активность в браузере), поэтому было бы нецелесообразно создавать новую запись истории для взаимодействия.

Однако в одностраничном приложении, вероятно, будет много взаимодействий, управляемых AJAX, где уместно создать запись истории. Существует API JavaScript для работы с историей браузера, но этот API глубоко раздражает и с ним сложно работать, поэтому разработчики JavaScript его часто игнорируют.

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

В htmx, как и в средах одностраничных приложений, вам часто придется явно работать с API истории. К счастью, поскольку htmx очень близок к собственной модели Интернета и является декларативным, правильную историю веб-поиска обычно гораздо проще сделать в приложении на основе htmx.

Рассмотрим кнопку, которую мы рассматривали для загрузки контактов:

Листинг 43. Наша верная кнопка

<button hx-get="/contacts" hx-target="#main">
  Get The Contacts
</button>

В нынешнем виде, если вы нажмете эту кнопку, он извлечет содержимое из /contacts и загрузит его в элемент с идентификатором main, но не создаст новую запись в истории.

Если бы мы хотели, чтобы при возникновении этого запроса создавалась запись в истории, мы бы добавили к кнопке новый атрибут — hx-push-url:

Листинг 44. Наша верная кнопка, теперь с историей!

<button hx-get="/contacts" hx-target="#main" hx-push-url="true"> <!-- 1 -->
  Get The Contacts
</button>
  1. hx-push-url создаст запись в истории при нажатии кнопки.

Теперь при нажатии кнопки путь /contacts будет помещен в панель навигации браузера, и для него будет создана запись в истории. Более того, если пользователь нажмет кнопку «Назад», исходное содержимое страницы будет восстановлено вместе с исходным URL-адресом.

Имя hx-push-url для этого атрибута может показаться немного непонятным, но оно основано на API JavaScript, history.pushState(). Это понятие «проталкивания» происходит от того факта, что записи истории моделируются как стек, и поэтому вы «проталкиваете» новые записи на вершину стека записей истории.

Благодаря этому относительно простому декларативному механизму htmx позволяет интегрироваться с кнопкой «Назад» таким образом, чтобы имитировать «нормальное» поведение HTML.

Теперь есть еще одна вещь, которую нам нужно обработать, чтобы получить историю «правильно»: мы успешно «вставили» путь /contacts в адресную строку браузера, и кнопка «Назад» работает. Но что, если кто-то обновит свой браузер, находясь на странице /contacts?

В этом случае вам нужно будет обрабатывать «частичный» ответ на основе htmx, а также «полностраничный» ответ, отличный от HTML. Вы можете сделать это с помощью HTTP-заголовков — эту тему мы подробно рассмотрим далее в книге.

Заключение

Вот и закончилось наше стремительное знакомство с htmx. Мы видели только около десяти атрибутов из библиотеки, но вы можете увидеть намек на то, насколько мощными могут быть эти атрибуты. Htmx позволяет создавать гораздо более сложные веб-приложения, чем это возможно в обычном HTML, с минимальной дополнительной концептуальной нагрузкой по сравнению с большинством подходов на основе JavaScript.

Htmx стремится постепенно улучшать HTML как гипермедиа таким образом, чтобы он был концептуально согласован с базовым языком разметки. Как и любой технический выбор, здесь есть компромиссы: оставаясь настолько близким к HTML, htmx не дает разработчикам большого количества инфраструктуры, которая, по мнению многих, должна быть там «по умолчанию».

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

Примечания к HTML: бюджетирование для HTML

Тесная взаимосвязь между контентом и разметкой означает, что хороший HTML требует больших затрат труда. На большинстве сайтов существует разделение между авторами, которые слабо знакомы с HTML, и разработчиками, которым необходимо разработать универсальную систему, способную обрабатывать любой загружаемый на нее контент — это разделение обычно принимает форму CMS. В результате создание разметки, адаптированной к контенту, что часто необходимо для продвинутого HTML, редко осуществимо.

Более того, для интернационализированных сайтов содержание на разных языках, вставленное в одни и те же элементы, может ухудшить качество разметки, поскольку стилистические соглашения различаются в зависимости от языка. Это расходы, которые немногие организации могут себе позволить.

Таким образом, мы не ожидаем, что каждый сайт будет содержать полностью совместимый HTML. Самое главное — избегать неправильного HTML — возможно, лучше вернуться к более общему элементу, чем быть совершенно неверным.

Однако если у вас есть ресурсы, если вы будете уделять больше внимания HTML-коду, сайт станет более совершенным.

Глава 5
Htmx-шаблоны

Теперь, когда мы увидели, как HTML расширяет возможности HTML как гипермедиа, пришло время применить это на практике. Поскольку мы используем htmx, мы по-прежнему будем использовать гипермедиа: мы будем отправлять HTTP-запросы и получать обратно HTML. Но благодаря дополнительным функциям, предоставляемым htmx, у нас будет более мощная гипермедиа для работы, что позволит нам создавать гораздо более сложные интерфейсы.

Это позволит нам решать проблемы взаимодействия с пользователем, такие как длительные циклы обратной связи или тягостное обновление страниц, без необходимости писать много кода JavaScript (если таковой вообще имеется) и без создания JSON API. Все будет реализовано в гипермедиа с использованием основных концепций гипермедиа раннего Интернета.

Установка Htmx

Первое, что нам нужно сделать, это установить htmx в наше веб-приложение. Мы собираемся сделать это, загрузив исходный код и сохранив его локально в нашем приложении, чтобы не зависеть от каких-либо внешних систем. Это известно как «vendoring» библиотеки. Мы можем получить последнюю версию htmx, перейдя в браузере по адресу https://unpkg.com/htmx.org, который перенаправит нас к источнику последней версии библиотеки.

Мы можем сохранить содержимое этого URL-адреса в файле static/js/htmx.js в нашем проекте.

Конечно, вы можете использовать более сложный менеджер пакетов JavaScript, такой как Node Package Manager (NPM) или Yarn, для установки htmx. Вы делаете это, ссылаясь на имя его пакета, htmx.org, способом, подходящим для вашего инструмента. Однако htmx очень мал (около 12 КБ в сжатом и заархивированном виде) и свободен от зависимостей, поэтому для его использования не требуется сложный механизм или инструмент сборки.

Теперь, когда htmx загружен локально в каталог наших приложений /static/js, мы можем загрузить его в наше приложение. Мы делаем это, добавляя следующий тег script к тегу head в нашем файле layout.html, что сделает htmx доступным и активным на каждой странице нашего приложения:

Листинг 45. Установка htmx

<head>
  <script src="/js/htmx.js"></script>
  ...
</head>

Напомним, что файл layout.html — это файл макета, включенный в большинство шаблонов, который оборачивает содержимое этих шаблонов в общий HTML, включая элемент head, который мы используем здесь для установки htmx.

Хотите верьте, хотите нет, но это так! Этот простой тег скрипта сделает функциональность htmx доступной для всего нашего приложения.

AJAX-модификация нашего приложения

Чтобы освоить htmx, первая функция, которой мы собираемся воспользоваться, известна как «ускорение». Это своего рода «магическая» функция, поскольку нам не нужно ничего делать, кроме добавления в приложение единственного атрибута hx-boost.

Когда вы добавляете hx-boost к данному элементу со значением true, он «усиливает» все элементы привязки и формы внутри этого элемента. «Boost» здесь означает, что htmx преобразует все эти привязки и формы из «обычных» элементов управления гипермедиа в элементы управления гипермедиа на базе AJAX. Вместо выдачи «обычных» HTTP-запросов, заменяющих всю страницу, ссылки и формы будут выдавать запросы AJAX. Затем Htmx заменяет внутреннее содержимое тега <body> в ответе на эти запросы на тег <body> существующей страницы.

Это ускоряет навигацию, поскольку браузер не будет заново интерпретировать большинство тегов в ответе <head> и т. д.

Усиленные ссылки

Давайте рассмотрим пример усиленной ссылки. Ниже приведена ссылка на гипотетическую страницу настроек веб-приложения. Поскольку в нем есть hx-boost="true", htmx остановит нормальное поведение ссылки, выдавая запрос по пути /settings и заменяя всю страницу ответом. Вместо этого htmx отправит AJAX-запрос в /settings, примет результат и заменит элемент body новым содержимым.

Листинг 46. Усиленная ссылка

<a href="/settings" hx-boost="true">Settings</a> <!-- 1 -->
  1. Атрибут hx-boost делает эту ссылку управляемой AJAX.

Вы можете резонно спросить: в чем здесь преимущество? Выдаем AJAX-запрос и просто заменяем все тело.

Значительно ли это отличается от простого запроса ссылки?

Да, на самом деле все по-другому: с усиленной ссылкой браузер может избежать любой обработки, связанной с тегом head. Тег head часто содержит множество скриптов и ссылок на файлы CSS. В усиленном сценарии нет необходимости повторно обрабатывать эти ресурсы: сценарии и стили уже обработаны и будут продолжать применяться к новому содержимому. Часто это может быть очень простым способом ускорить работу вашего гипермедийного приложения.

Второй вопрос, который у вас может возникнуть: нужно ли форматировать ответ специально для работы с hx-boost? В конце концов, страница настроек обычно отображает тег html с тегом head и т. д. Вам нужно специально обрабатывать «форсированные» запросы?

Ответ — нет: htmx достаточно умен, чтобы извлекать только содержимое тега body для замены на новую страницу. Тег head в основном игнорируется: будет обрабатываться только тег title, если он присутствует. Это означает, что вам не нужно делать ничего особенного на стороне сервера для отображения шаблонов, которые может обрабатывать hx-boost: просто верните обычный HTML-код для вашей страницы, и он должен работать нормально.

Обратите внимание, что усиленные ссылки (и формы) также будут продолжать обновлять панель навигации и историю, как и обычные ссылки, поэтому пользователи смогут использовать кнопку «Назад» браузера, смогут копировать и вставлять URL-адреса (или «глубинные ссылки»). ) и так далее. Ссылки будут вести себя почти как «обычные», только они будут быстрее.

Расширенные формы

Теги усиленной формы работают аналогично усиленным тегам якоря: усиленная форма будет использовать запрос AJAX, а не обычный запрос, отправленный браузером, и заменит все тело ответом.

Ниже приведен пример формы, которая отправляет сообщения в конечную точку /messages с помощью запроса HTTP POST. Если добавить к нему hx-boost, эти запросы будут выполняться в AJAX, а не в обычном режиме браузера.

Листинг 47. Расширенная форма

<form action="/messages" method="post" hx-boost="true"> <!-- 1 -->
  <input type="text" name="message" placeholder="Enter A Message...">
  <button>Post Your Message</button>
</form>
  1. Как и в случае со ссылкой, hx-boost делает эту форму управляемой AJAX.

Большим преимуществом запроса на основе AJAX, который использует hx-boost (и отсутствия обработки head), является то, что он позволяет избежать того, что известно как вспышка нестилизованного контента:

Вспышка нестилизованного контента (Flash Of Unstyled Content (FOUC))
Ситуация, когда браузер отображает веб-страницу до того, как для нее станет доступна вся информация о стилях. FOUC вызывает сбивающую с толку кратковременную «вспышку» нестилизованного контента, который затем подвергается рестилированию, когда вся информация о стиле становится доступной. Вы заметите это по мерцанию при перемещении по Интернету: текст, изображения и другой контент могут «перепрыгивать» на странице, когда к ней применяются стили.

При использовании hx-boost стиль сайта уже загружается до получения нового контента, поэтому не возникает такой вспышки нестилизованного контента. Это может сделать «ускоренное» приложение одновременно более плавным и быстрым в целом.

Наследование атрибутов

Давайте расширим наш предыдущий пример усиленной ссылки и добавим рядом с ним еще несколько усиленных ссылок. Мы добавим ссылки, чтобы они были на страницах /contacts, /settings и /help. Все эти ссылки усилены и будут вести себя так, как мы описали выше.

Это кажется немного излишним, не так ли? Кажется глупым аннотировать все три ссылки атрибутом hx-boost="true" рядом друг с другом.

Листинг 48. Набор усиленных ссылок

<a href="/contacts" hx-boost="true">Contacts</a>
<a href="/settings" hx-boost="true">Settings</a>
<a href="/help" hx-boost="true">Help</a>

Htmx предлагает функцию, помогающую уменьшить эту избыточность: наследование атрибутов. Для большинства атрибутов в htmx, если вы поместите их в родительский элемент, атрибут также будет применяться к дочерним элементам. Именно так работают каскадные таблицы стилей, и эта идея вдохновила htmx на внедрение аналогичной функции «каскадных атрибутов htmx».

Чтобы избежать избыточности в этом примере, давайте введем элемент div, который включает в себя все ссылки, а затем «поднимем» атрибут hx-boost до этого родительского элемента div. Это позволит нам удалить избыточные атрибуты hx-boost, но гарантировать, что все ссылки по-прежнему будут усилены, наследуя эту функциональность от родительского div.

Обратите внимание, что здесь можно использовать любой допустимый HTML-элемент, мы просто используем div по привычке.

Листинг 49. Повышение эффективности ссылок через родительский элемент

<div hx-boost="true"> <!-- 1 -->
  <a href="/contacts">Contacts</a>
  <a href="/settings">Settings</a>
  <a href="/help">Help</a>
</div>
  1. hx-boost был перенесен в родительский элемент div.

Теперь нам не нужно ставить hx-boost="true" для каждой ссылки и, по сути, мы можем добавлять больше ссылок рядом с существующими, и они тоже будут усилены, без необходимости явно их аннотировать.

Это нормально, но что, если у вас есть ссылка, которую вы не хотите повышать внутри элемента, на котором есть hx-boost="true"? Хорошим примером такой ситуации является ссылка на ресурс для загрузки, например PDF-файл. Загрузка файла не может быть обработана запросом AJAX, поэтому вы, вероятно, захотите, чтобы эта ссылка вела себя «нормально», выдавая полный запрос страницы для PDF-файла, который затем браузер предложит сохранить в виде файла в локальной системе пользователя.

Чтобы справиться с этой ситуацией, вы просто переопределяете родительское значение hx-boost с помощью hx-boost="false" в теге привязки, который вы не хотите повышать:

Листинг 50. Отключение повышения

<div hx-boost="true"> <!-- 1 -->
  <a href="/contacts">Contacts</a>
  <a href="/settings">Settings</a>
  <a href="/help">Help</a>
  <a href="/help/documentation.pdf" hx-boost="false">Download Docs</a> <!-- 2 -->
</div>
  1. hx-boost все еще находится в родительском div.

  2. Для этой ссылки поведение повышения переопределяется.

Здесь у нас есть новая ссылка на PDF-файл документации, который мы хотим использовать как обычную ссылку. Мы добавили hx-boost="false" к ссылке, и это объявление переопределяет hx-boost="true" в родительском элементе div, возвращая его к обычному поведению ссылки и, таким образом, позволяя вести загрузку файла так, как мы хотим.

Прогрессивное улучшение

Приятным аспектом hx-boost является то, что он является примером прогрессивного улучшения:

Прогрессивное улучшение
Философия разработки программного обеспечения, целью которой является предоставление как можно большего количества необходимого контента и функций как можно большему количеству пользователей, одновременно обеспечивая лучший опыт для пользователей с более продвинутыми веб-браузерами.

Рассмотрим ссылки в примере выше. Что произойдет, если у кого-то не будет включен JavaScript?

Без проблем. Приложение продолжит работать, но будет выдавать обычные HTTP-запросы, а не HTTP-запросы на основе AJAX. Это означает, что ваше веб-приложение будет работать для максимального количества пользователей; Те, у кого есть современные браузеры (или пользователи, у которых не отключен JavaScript), могут воспользоваться преимуществами навигации в стиле AJAX, которую предлагает htmx, а другие по-прежнему могут нормально использовать приложение.

Сравните поведение атрибута hx-boost в htmx с одностраничным приложением, насыщенным JavaScript: такое приложение часто вообще не будет работать без включенного JavaScript. При использовании инфраструктуры SPA часто бывает очень сложно принять подход постепенного улучшения.

Это не означает, что каждая функция htmx предлагает постепенное улучшение. Конечно, можно создавать функции, которые не предлагают запасной вариант «No JS» в htmx, и фактически многие из функций, которые мы создадим позже в книге, попадут в эту категорию. Мы будем отмечать, когда функция поддерживает прогрессивное улучшение, а когда нет.

В конечном счете, вам, разработчику, решать, стоят ли компромиссы прогрессивного улучшения (более простой UX, ограниченные улучшения по сравнению с простым HTML) преимуществами для пользователей вашего приложения.

Добавление «hx-boost» в Contact.app

Для приложения контактов, которое мы создаем, нам нужно такое «ускоряющее» поведение htmx… ну, везде.

Верно? Почему нет?

Как мы могли бы этого добиться?

Что ж, это просто (и довольно часто встречается в веб-приложениях на основе htmx): мы можем просто добавить hx-boost в тег body нашего шаблона layout.html, и все готово.

Листинг 51. Повышение эффективности всего приложения contact.app

<html>
...
<body hx-boost="true"> <!-- 1 -->
...
</body>
</html>
  1. Все ссылки и формы теперь будут прокачаны!

Теперь каждая ссылка и форма в нашем приложении по умолчанию будут использовать AJAX, что сделает его более быстрым. Рассмотрим ссылку «New Contact», которую мы создали на главной странице:

Листинг 52. Недавно усиленная ссылка «добавить контакт»

<a href="/contacts/new">Add Contact</a>

Несмотря на то, что мы ничего не трогали в этой ссылке или в обработке целевого URL-адреса на стороне сервера, теперь она будет «просто работать» как усиленная ссылка, используя AJAX для более быстрого взаимодействия с пользователем, включая обновление истории, поддержку кнопки «Назад» и так далее. И если JavaScript не включен, она вернется к нормальному поведению ссылки.

Все это с одним атрибутом htmx.

Атрибут hx-boost удобен, но отличается от других атрибутов htmx тем, что он довольно «волшебный»: внося одно небольшое изменение, вы изменяете поведение большого количества элементов на странице, превращая их в элементы на базе AJAX. Большинство других атрибутов htmx обычно относятся к более низкому уровню и требуют более явных аннотаций, чтобы точно указать, что вы хотите, чтобы htmx делал. В общем, это философия дизайна htmx: предпочитать явное неявному и очевидное «магическому».

Однако атрибут hx-boost был слишком полезен, чтобы позволить догмам преобладать над практичностью, поэтому он включен в библиотеку как функция.

Второй шаг: удаление контактов с помощью HTTP DELETE

Для нашего следующего шага с htmx вспомните, что Contact.app имеет небольшую форму на странице редактирования контакта, которая используется для удаления контакта:

Листинг 53. Простая HTML-форма для удаления контакта

<form action="/contacts/{{ contact.id }}/delete" method="post"&bt;
  <button&bt;Delete Contact</button&bt;
</form&bt;

Эта форма отправляла HTTP POST, например, /contacts/42/delete, чтобы удалить контакт с идентификатором 42.

Ранее мы упоминали, что одна из неприятных особенностей HTML заключается в том, что вы не можете напрямую выполнить HTTP-запрос DELETE (или PUT или PATCH), хотя все они являются частью HTTP, а HTTP очевидно разработан для передачи HTML.

К счастью, теперь, благодаря htmx, у нас есть шанс исправить эту ситуацию.

«Правильным» с точки зрения RESTful и ресурсоориентированной точки зрения будет вместо отправки HTTP POST для /contacts/42/delete выполнить HTTP DELETE для /contacts/42. Мы хотим удалить контакт. Контакт — это ресурс. URL-адрес этого ресурса — /contacts/42. Таким образом, идеальным вариантом является запрос DELETE к /contacts/42/.

Давайте обновим наше приложение, добавив атрибут htmx hx-delete к кнопке «Delete Contact»:

Листинг 54. Кнопка с поддержкой HTML для удаления контакта

<button hx-delete="/contacts/{{ contact.id }}">Delete Contact</button>

Теперь, когда пользователь нажимает эту кнопку, htmx отправляет HTTP-запрос DELETE через AJAX на URL-адрес рассматриваемого контакта.

Несколько вещей, на которые стоит обратить внимание:

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

Нам больше не нужно использовать несколько неуклюжий маршрут "/contacts/{{ contact.id }}/delete", мы можем просто использовать маршрут "/contacts/{{ contact.id }}", поскольку мы выдаем команду DELETE. Используя DELETE, мы устраняем неоднозначность между запросом, предназначенным для обновления контакта, и запросом, предназначенным для его удаления, используя собственные инструменты HTTP, доступные именно для этой цели.

Обратите внимание, что мы сделали здесь нечто волшебное: превратили эту кнопку в элемент управления гипермедиа. Больше нет необходимости размещать эту кнопку внутри более крупного тега формы для запуска HTTP-запроса: это отдельный и полнофункциональный элемент управления гипермедиа. Это лежит в основе htmx, позволяя любому элементу стать элементом управления гипермедиа и полностью участвовать в приложении, управляемом гипермедиа.

Следует также отметить, что, в отличие от приведенных выше примеров hx-boost, это решение не будет изящной деградацией (degrade gracefully). Чтобы это решение корректно ухудшалось, нам нужно будет обернуть кнопку в элемент формы и также обрабатывать POST на стороне сервера.

В интересах простоты нашего приложения мы собираемся опустить это более сложное решение.

Обновление серверного кода

Мы обновили код на стороне клиента (если HTML можно считать кодом), и теперь он отправляет запрос DELETE на соответствующий URL-адрес, но нам еще есть над чем поработать. Поскольку мы обновили как маршрут, так и метод HTTP, который мы используем, нам также потребуется обновить реализацию на стороне сервера для обработки этого нового HTTP-запроса.

Листинг 55. Исходный серверный код для удаления контакта

@app.route("/contacts/<contact_id>/delete", methods=["POST"])
def contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete()
    flash("Deleted Contact!")
    return redirect("/contacts")

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

Листинг 56. Обновленный обработчик с новым маршрутом и методом

@app.route("/contacts/<contact_id>", methods=["DELETE"]) # 1
def contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete()
    flash("Deleted Contact!")
    return redirect("/contacts")
  1. Обновленный путь и метод для обработчика.

Довольно просто и намного чище.

Код ответа получен

К сожалению, есть проблема с нашим обновленным обработчиком: по умолчанию во Flask метод redirect() отвечает кодом HTTP 302 Found.

Согласно веб-документам Mozilla Developer Network (MDN) по ответу 302 Found, это означает, что метод HTTP-запроса не изменится при выдаче перенаправленного HTTP-запроса.

Теперь мы выдаем запрос DELETE с помощью htmx, а затем перенаправляем его на путь /contacts с помощью flask. Согласно этой логике, это будет означать, что перенаправленный HTTP-запрос по-прежнему будет методом DELETE. Это означает, что в нынешнем виде браузер отправит запрос DELETE к /contacts.

Это определенно не то, что нам нужно: мы хотели бы, чтобы перенаправление HTTP выдавало запрос GET, слегка изменяя поведение Post/Redirect/Get, которое мы обсуждали ранее, на Delete/Redirect/Get.

К счастью, есть другой код ответа, 303 See Other, который делает то, что мы хотим: когда браузер получает ответ перенаправления 303 See Other, он выдает GET для нового местоположения.

Итак, мы хотим обновить наш код, чтобы использовать код ответа 303 в контроллере.

К счастью, это очень просто: у redirect() есть второй параметр, который принимает числовой код ответа, который вы хотите отправить.

Листинг 57. Обновленный обработчик с ответом на перенаправление 303

@app.route("/contacts/<contact_id>", methods=["DELETE"])
def contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete()
    flash("Deleted Contact!")
    return redirect("/contacts", 303) # 1
  1. Код ответа теперь 303.

Теперь, когда вы хотите удалить определенный контакт, вы можете просто выполнить команду DELETE на тот же URL-адрес, который вы использовали для доступа к контакту в первую очередь.

Это естественный подход к удалению ресурса на основе HTTP.

Выбор правильного элемента

Мы еще не закончили работу над обновленной кнопкой удаления. Напомним, что по умолчанию htmx «нацеливается» на элемент, который запускает запрос, и помещает HTML, возвращаемый сервером, внутрь этого элемента. Прямо сейчас кнопка «Delete Contact» нацелена на себя.

Это означает, что, поскольку при перенаправлении на URL-адрес /contacts будет перерисован весь список контактов, в конечном итоге мы получим этот список контактов, помещенный внутри кнопки «Delete Contact».

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

Исправить это легко: добавьте явную цель к кнопке и укажите элемент body с ответом:

Листинг 58. Фиксированная кнопка с поддержкой htmx для удаления контакта

<button hx-delete="/contacts/{{ contact.id }}" hx-target="body"> <!-- 1 -->
  Delete Contact
</button>
  1. К кнопке добавлена явная цель.

Теперь наша кнопка ведет себя так, как и ожидалось: нажатие на кнопку отправит серверу HTTP DELETE на URL-адрес текущего контакта, удалит контакт и перенаправит обратно на страницу списка контактов с красивым флэш-сообщением.

Сейчас все работает гладко?

Правильное обновление URL-адреса строки адреса

Ну, почти.

Если вы нажмете кнопку, вы заметите, что, несмотря на перенаправление, URL-адрес в строке адреса неверен. Он по-прежнему указывает на /contacts/{{ contact.id }}. Это потому, что мы не указали htmx обновить URL-адрес: он просто выдает запрос DELETE, а затем обновляет DOM с помощью ответа.

Как мы уже упоминали, повышение эффективности с помощью hx-boost естественным образом обновит для вас строку адреса, имитируя обычные привязки и формы, но в этом случае мы создаем настраиваемый элемент управления гипермедиа «Кнопка» для выполнения DELETE. Нам нужно сообщить htmx, что мы хотим, чтобы URL-адрес, полученный в результате этого запроса, «поместился» в адресную строку.

Мы можем добиться этого, добавив к нашей кнопке атрибут hx-push-url со значением true:

Листинг 59. Удаление контакта, теперь с правильной информацией о местоположении

<button hx-delete="/contacts/{{ contact.id }}"
        hx-push-url="true" <!-- 1 -->
        hx-target="body">
  Delete Contact
</button>
  1. Мы приказываем htmx переместить перенаправленный URL-адрес в адресную строку.

Теперь мы закончили.

У нас есть кнопка, которая сама по себе способна отправить правильно отформатированный HTTP-запрос DELETE на правильный URL-адрес, а пользовательский интерфейс и строка адреса обновляются правильно. Это было достигнуто с помощью трех декларативных атрибутов, размещенных непосредственно на кнопке: hx-delete, hx-target и hx-push-url.

Это потребовало больше работы, чем изменение hx-boost, но явный код позволяет легко увидеть, что делает кнопка, в качестве пользовательского элемента управления гипермедиа. Полученное решение кажется чистым; оно использует встроенные функции Интернета как системы гипермедиа без каких-либо хаков URL-адресов.

Еще кое-что…

Есть еще одна «бонусная» функция, которую мы можем добавить к нашей кнопке «Delete Contact»: диалоговое окно подтверждения. Удаление контакта является деструктивной операцией, и на данный момент, если пользователь случайно нажмет кнопку «Delete Contact», приложение просто удалит этот контакт. Жаль, так грустно для пользователя.

К счастью, в htmx есть простой механизм добавления сообщения о подтверждении подобных деструктивных операций: атрибут hx-confirm. Вы можете разместить этот атрибут в элементе с сообщением в качестве его значения, и перед выдачей запроса будет вызываться метод JavaScript confirm(), который покажет пользователю простое диалоговое окно подтверждения с просьбой подтвердить действие. Очень простой и отличный способ предотвратить несчастные случаи.

Вот как бы мы добавили подтверждение операции удаления контакта:

Листинг 60. Подтверждение удаления

<button hx-delete="/contacts/{{ contact.id }}"
        hx-push-url="true"
        hx-confirm="Are you sure you want to delete this contact?" <!-- 1 -->
        hx-target="body">
  Delete Contact
</button>
  1. Это сообщение будет показано пользователю с просьбой подтвердить удаление.

Теперь, когда кто-то нажимает кнопку «Delete Contact», ему будет задан вопрос: «Вы уверены, что хотите удалить этот контакт?», и у него будет возможность отменить действие, если кнопка была нажата по ошибке. Очень хорошо.

Благодаря этому последнему изменению у нас теперь есть довольно надежный механизм «удаления контакта»: мы используем правильные маршруты RESTful и методы HTTP, мы подтверждаем удаление и удалили много мусора, который навязывает нам обычный HTML, используя при этом декларативные атрибуты в нашем HTML и оставаясь в рамках обычной гипермедийной модели Интернета.

Прогрессивное улучшение?

Как мы уже отмечали ранее об этом решении: оно не является прогрессивным улучшением нашего веб-приложения. Если кто-то отключил JavaScript, кнопка «Delete Contact» больше не будет работать. Нам потребуется проделать дополнительную работу, чтобы старый механизм на основе форм работал в среде с отключенным JavaScript.

Прогрессивное улучшение может быть горячей темой в веб-разработке, вызывающей множество восторженных мнений и точек зрения. Как и почти все библиотеки JavaScript, htmx позволяет создавать приложения, которые не работают без JavaScript. Сохранение поддержки клиентов, не использующих JavaScript, требует дополнительной работы и сложности вашего приложения. Прежде чем вы начнете использовать htmx или любую другую среду JavaScript для улучшения своих веб-приложений, важно точно определить, насколько важна поддержка клиентов, не поддерживающих JavaScript.

Следующие шаги: проверка контактных адресов электронной почты

Перейдем к еще одному усовершенствованию нашего приложения. Большая часть любого веб-приложения — это проверка данных, отправляемых на сервер: проверка правильности формата и уникальности электронных писем, допустимости числовых значений, допустимости дат и т. д.

В настоящее время наше приложение имеет небольшой объем проверки, которая выполняется полностью на стороне сервера и отображает сообщение об ошибке при обнаружении ошибки.

Мы не будем вдаваться в подробности того, как работает валидация в объектах модели, а вспомним, как выглядит код обновления контакта из главы 3:

Листинг 61. Проверка на стороне сервера при обновлении контактов

def contacts_edit_post(contact_id=0):
    c = Contact.find(contact_id)
    c.update(request.form['first_name'],
request.form['last_name'], request.form['phone'],
request.form['email'])
    if c.save(): # 1
        flash("Updated Contact!")
        return redirect("/contacts/" + str(contact_id))
    else:
        return render_template("edit.html", contact=c) # 2
  1. Пытаемся сохранить контакт.

  2. Если сохранение не удалось, мы повторно отображаем форму для отображения сообщений об ошибках.

Поэтому мы пытаемся сохранить контакт и, если метод save() возвращает true, мы перенаправляем на страницу сведений о контакте. Если метод save() не возвращает true, это указывает на ошибку проверки; вместо перенаправления мы повторно отображаем HTML для редактирования контакта. Это дает пользователю возможность исправить ошибки, которые отображаются рядом с входными данными.

Давайте посмотрим на HTML для ввода электронной почты:

Листинг 62. Сообщения об ошибках проверки

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="text" placeholder="Email" value="{{ contact.email }}">
  <span class="error">{{ contact.errors['email'] }}</span> <!-- 1 -->
</p>
  1. Отображать любые ошибки, связанные с полем электронной почты.

У нас есть метка для ввода, ввод типа text, а затем немного HTML для отображения любых сообщений об ошибках, связанных с электронным письмом. Когда шаблон отображается на сервере, если есть ошибки, связанные с электронной почтой контакта, они будут отображаться в этом диапазоне, который будет выделен красным.

Логика проверки на стороне сервера

Прямо сейчас в классе контактов есть небольшая логика, которая проверяет, есть ли какие-либо другие контакты с тем же адресом электронной почты, и если да, добавляет ошибку в модель контакта, поскольку мы не хотим иметь повторяющиеся электронные письма в базе данных. Это очень распространенный пример проверки: электронные письма обычно уникальны, и добавление двух контактов с одним и тем же адресом электронной почты почти наверняка является ошибкой пользователя.

Опять же, мы не будем вдаваться в подробности того, как работает проверка в наших моделях, но почти все серверные платформы предоставляют способы проверки данных и сбора ошибок для отображения пользователю. Такая инфраструктура очень распространена в серверных средах Web 1.0.

Когда пользователь пытается сохранить контакт с дубликатом адреса электронной почты, отображается сообщение об ошибке «Email Must Be Unique»:

Рисунок 5. Проверка ошибок электронной почты.

Все это делается с использованием простого HTML и технологий Web 1.0, и работает хорошо.

Однако в нынешнем виде приложения есть две неприятности.

Обновление нашего типа ввода

Что касается первой проблемы, у нас есть чистый HTML-механизм для улучшения нашего приложения: HTML 5 поддерживает входные данные типа email. Все, что нам нужно сделать, это переключить ввод с типа text на тип email, и браузер будет следить за тем, чтобы введенное значение правильно соответствовало формату электронной почты:

Листинг 63. Изменение поля для ввода email

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email" placeholder="Email" value="{{ contact.email }}"> <!-- 1 -->
  <span class="error">{{ contact.errors['email'] }}</span>
</p>
  1. Изменение атрибута type на email гарантирует, что введенные значения являются действительными адресами электронной почты.

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

Таким образом, простое изменение одного атрибута, выполненное в чистом HTML, улучшает нашу проверку и решает первую отмеченную нами проблему.

Проверка на стороне сервера и на стороне клиента

Опытные веб-разработчики, возможно, стиснут зубы над приведенным выше кодом: эта проверка выполняется на стороне клиента. То есть мы полагаемся на то, что браузер обнаружит неверный адрес электронной почты и исправит пользователя. К сожалению, клиентская часть не заслуживает доверия: в браузере может быть ошибка, позволяющая пользователю обойти этот код проверки. Или, что еще хуже, пользователь может действовать злонамеренно и полностью выяснить механизм нашей проверки, например использовать консоль разработчика для редактирования HTML.

Это постоянная опасность в веб-разработке: всем проверкам, выполняемым на стороне клиента, нельзя доверять, и, если проверка важна, ее необходимо выполнять также и на стороне сервера. Это меньшая проблема в приложениях, управляемых гипермедиа, чем в одностраничных приложениях, поскольку HDA фокусируется на стороне сервера, но об этом стоит помнить при создании приложения.

Встроенная проверка

Хотя мы немного улучшили процесс проверки, пользователю по-прежнему необходимо отправить форму, чтобы получить отзыв о повторяющихся электронных письмах. Затем мы можем использовать htmx, чтобы улучшить взаимодействие с пользователем.

Было бы лучше, если бы пользователь мог видеть повторяющуюся ошибку электронной почты сразу после ввода значения электронной почты. Оказывается, инпуты вызывают событие change, и фактически это событие является триггером по умолчанию для инпутов в htmx. Итак, запустив эту функцию, мы можем реализовать следующее поведение: когда пользователь вводит адрес электронной почты, немедленно отправить запрос на сервер и проверить это письмо, а также при необходимости отобразить сообщение об ошибке.

Напомним текущий HTML-код для ввода электронной почты:

Листинг 64. Первоначальная конфигурация электронной почты

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email" placeholder="Email" value="{{ contact.email }}"> <!-- 1 -->
  <span class="error">{{ contact.errors['email'] }}</span> <!-- 2 -->
</p>
  1. Это входные данные, которые мы хотим использовать для HTTP-запроса для проверки электронной почты.

  2. Это диапазон, в который мы хотим поместить сообщение об ошибке, если таковое имеется.

Итак, мы хотим добавить к этому входу атрибут hx-get. Это приведет к тому, что входные данные отправят запрос HTTP GET на заданный URL-адрес для проверки электронной почты. Затем мы хотим указать диапазон ошибок, следующий за вводом, с любым сообщением об ошибке, возвращаемым с сервера.

Давайте внесем эти изменения в наш HTML:

Листинг 65. Наш обновленный HTML-код

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email"
         hx-get="/contacts/{{ contact.id }}/email" <!-- 1 -->
         hx-target="next .error" <!-- 2 -->
         placeholder="Email"
         value="{{ contact.email }}"> <!-- 1 -->
  <span class="error">{{ contact.errors['email'] }}</span>
</p>
  1. Выполнить HTTP GET для конечной точки email контакта.

  2. Нацелиться на следующий элемент с классом error.

Обратите внимание, что в атрибуте hx-target мы используем относительный позиционный селектор, next. Это особенность htmx и расширение обычного CSS. Htmx поддерживает префиксы, которые будут находить цели относительно текущего элемента.

Относительные позиционные выражения в Htmx
next
Сканирует DOM вперед в поисках следующего соответствующего элемента, например, next .error.
previous
Сканирует DOM назад в поисках ближайшего предыдущего совпадающего элемента, например previous .alert.
closest
Сканирует родителей этого элемента на предмет соответствующего элемента, например, closest table.
find
Сканирует дочерние элементы этого элемента на предмет соответствующего элемента, например, find span.
this
Текущий элемент является целью (по умолчанию)

Используя выражения относительного положения, мы можем избежать добавления явных идентификаторов к элементам и воспользоваться преимуществами локальной структуры HTML.

Итак, в нашем примере с добавленными атрибутами hx-get и hx-target всякий раз, когда кто-то меняет значение входных данных (помните, change — это триггер по умолчанию для входных данных в htmx), на данный URL-адрес будет отправлен HTTP-запрос GET. Если есть какие-либо ошибки, они будут загружены в диапазон ошибок.

Проверка электронной почты на стороне сервера

Далее давайте посмотрим на реализацию на стороне сервера. Мы собираемся добавить еще одну конечную точку, в некотором смысле похожую на нашу конечную точку редактирования: она будет искать контакт на основе идентификатора, закодированного в URL-адресе. Однако в этом случае мы хотим только обновить адрес электронной почты контакта и, очевидно, не хотим его сохранять! Вместо этого мы вызовем для него метод validate().

Этот метод подтвердит уникальность email и так далее. На этом этапе мы можем вернуть любые ошибки, связанные непосредственно с email, или пустую строку, если таковая не существует.

Листинг 66. Код для нашей конечной точки проверки email

@app.route("/contacts/<contact_id>/email", methods=["GET"])
def contacts_email_get(contact_id=0):
    c = Contact.find(contact_id) # 1
    c.email = request.args.get('email') # 2
    c.validate() # 3
      return c.errors.get('email') or "" # 4
  1. Найдти контакт по идентификатору.

  2. Обновить его адрес электронной почты (обратите внимание, что, поскольку это GET, мы используем свойство args, а не свойство form).

  3. Подтвердить контакт.

  4. Вернуть строку: либо ошибки, связанные с полем электронной почты, либо, если их нет, пустую строку.

Благодаря этому небольшому фрагменту серверного кода у нас теперь есть следующий пользовательский опыт: когда пользователь вводит адрес электронной почты и переходит к следующему полю ввода, он немедленно уведомляется, если электронное письмо уже занято.

Обратите внимание, что проверка электронной почты по-прежнему выполняется, когда весь контакт отправляется на обновление, поэтому нет опасности пропустить дубликаты контактов электронной почты: мы просто предоставили пользователям возможность обнаружить эту ситуацию раньше, используя htmx.

Также стоит отметить, что эта конкретная проверка электронной почты должна выполняться на стороне сервера: вы не можете определить, что электронная почта уникальна для всех контактов, если у вас нет доступа к хранилищу данных записей. Это еще один упрощенный аспект приложений, управляемых гипермедиа: поскольку проверки выполняются на стороне сервера, у вас есть доступ ко всем данным, которые могут вам понадобиться для выполнения любого вида проверки, которую вы захотите.

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

Дальнейшее развитие пользовательского опыта

Несмотря на то, что мы не добавили здесь много кода, у нас довольно сложный пользовательский интерфейс, по крайней мере, по сравнению с простыми приложениями на основе HTML. Однако, если вы использовали более продвинутые одностраничные приложения, вы, вероятно, видели шаблон, в котором поле электронной почты (или аналогичный ввод) проверяется по мере ввода.

Это похоже на тот вид интерактивности, который возможен только с помощью сложного фреймворка JavaScript, не так ли?

Но нет.

Оказывается, вы можете реализовать эту функциональность в htmx, используя чистые атрибуты HTML.

Фактически, все, что нам нужно сделать, это изменить наш триггер. В настоящее время мы используем триггер по умолчанию для входных данных — событие change. Чтобы проверить ввод данных пользователем, нам также нужно зафиксировать событие keyup:

Листинг 67. Запуск с помощью событий keyup

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email"
         hx-get="/contacts/{{ contact.id }}/email"
         hx-target="next .error"
         hx-trigger="change, keyup" <!-- 1 -->
         placeholder="Email" value="{{ contact.email }}">
  <span class="error">{{ contact.errors['email'] }}</span>
</p>
  1. Вместе с change был добавлен явный триггер keyup.

Благодаря этому небольшому изменению каждый раз, когда пользователь вводит символ, мы выдаем запрос и проверяем электронное письмо. Просто.

Отказ от наших запросов на проверку

Да, просто, но, вероятно, это не то, что нам нужно: выдача нового запроса при каждом событии нажатия клавиши была бы очень расточительной и потенциально могла бы перегрузить ваш сервер. Вместо этого мы хотим выдавать запрос только в том случае, если пользователь сделал паузу на небольшой промежуток времени. Это называется «устранением дребезга» ввода, когда запросы задерживаются до тех пор, пока все не «стабилизируется».

Htmx поддерживает модификатор delay для триггеров, который позволяет предотвратить отказ запроса, добавив задержку перед отправкой запроса. Если в течение этого интервала появится другое событие того же типа, htmx не выдаст запрос и сбросит таймер.

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

Давайте добавим к триггеру keyup задержку в 200 миллисекунд, которой достаточно, чтобы обнаружить, что пользователь перестал печатать:

Листинг 68. Устранение дребезга события keyup

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email"
         hx-get="/contacts/{{ contact.id }}/email"
         hx-target="next .error"
         hx-trigger="change, keyup delay:200ms" <!-- 1 -->
         placeholder="Email" value="{{ contact.email }}">
  <span class="error">{{ contact.errors['email'] }}</span>
</p>
  1. Мы устраняем дребезг события keyup, добавляя модификатор delay.

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

Игнорирование неизменяемых ключей

Есть еще одна проблема, которую мы должны решить с событием keyup: в его нынешнем виде мы выдадим запрос независимо от того, какие клавиши нажаты, даже если это клавиши, которые не влияют на значение ввода, например клавиши со стрелками. Было бы лучше, если бы существовал способ выдавать запрос только в том случае, если входное значение изменилось.

И оказывается, что htmx поддерживает именно этот шаблон, используя модификатор changed для событий. (Не путать с событием change, инициируемым DOM для входных элементов.)

При добавлении changed в наш триггер keyup ввод не будет выдавать запросы проверки, если событие keyup фактически не обновит значение входных данных:

Листинг 69. Отправка запросов только при изменении входного значения

<p>
  <label for="email">Email</label>
  <input name="email" id="email" type="email"
         hx-get="/contacts/{{ contact.id }}/email"
         hx-target="next .error"
         hx-trigger="change, keyup delay:200ms changed" <!-- 1 -->
         placeholder="Email" value="{{ contact.email }}">
  <span class="error">{{ contact.errors['email'] }}</span>
</p>
  1. Мы избавляемся от бессмысленных запросов, выдавая их только тогда, когда входное значение действительно изменилось.

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

Имея в общей сложности три атрибута и новую простую конечную точку на стороне сервера, мы добавили в наше веб-приложение довольно сложный пользовательский интерфейс. Более того, любые правила проверки электронной почты, которые мы добавляем на стороне сервера, будут автоматически работать с использованием этой модели: поскольку мы используем гипермедиа в качестве нашего механизма связи, нет необходимости синхронизировать модели на стороне клиента и на стороне сервера друг с другом.

Великолепная демонстрация мощи гипермедийной архитектуры!

Еще одно улучшение приложения: пейджинг

Давайте немного отойдём от страницы редактирования контактов и улучшим корневую страницу приложения, находящуюся по пути /contacts и отображающую шаблон index.html.

На данный момент Contact.app не поддерживает пейджинг: если в базе данных 10 000 контактов, мы отобразим все 10 000 контактов на корневой странице. Отображение такого большого количества данных может привести к зависанию браузера (и сервера), поэтому большинство веб-приложений используют концепцию «постраничного просмотра» для работы с такими большими наборами данных, при которых отображается только одна «страница» из меньшего числа элементов, с возможностью навигации по страницам набора данных.

Давайте исправим наше приложение так, чтобы мы показывали одновременно только десять контактов со ссылками «Next» и «Previous», если в базе данных контактов более 10 контактов.

Первое изменение, которое мы сделаем, — это добавим простой виджет подкачки в наш шаблон index.html.

Условно включим две ссылки:

Это не идеальный виджет подкачки: в идеале мы должны показывать количество страниц и предлагать возможность более конкретной навигации по страницам, и существует вероятность того, что на следующей странице может быть 0 результатов, поскольку мы не проверяем общее количество результатов, но на данный момент этого достаточно для нашего простого приложения.

Давайте посмотрим на код шаблона jinja в index.html.

Листинг 70. Добавление виджетов подкачки в наш список контактов

<div>
  <span style="float: right"> <!-- 1 -->
    {% if page > 1 %}
      <a href="/contacts?page={{ page - 1 }}">Previous</a> <!-- 2 -->
    {% endif %}
    {% if contacts|length == 10 %}
      <a href="/contacts?page={{ page + 1 }}">Next</a> <!-- 3 -->
    {% endif %}
  </span>
  </div>
  1. Добавить новый элемент div под таблицей для хранения наших навигационных ссылок.

  2. Если мы вышли за пределы страницы 1, добавить тег привязки со страницей, уменьшенной на единицу.

  3. Если на текущей странице 10 контактов, добавить тег привязки, ссылающийся на следующую страницу, увеличив его на единицу.

Обратите внимание, что здесь мы используем специальный синтаксис фильтра jinja contact|length для вычисления длины списка контактов. Детали синтаксиса этого фильтра выходят за рамки этой книги, но в данном случае вы можете думать о нем как о вызове свойства contacts.length и последующем сравнении его с 10.

Теперь, когда у нас есть эти ссылки, давайте рассмотрим реализацию пейджинга на стороне сервера.

Мы используем параметр запроса page для кодирования состояния подкачки пользовательского интерфейса. Итак, в нашем обработчике нам нужно найти этот параметр page и передать его в нашу модель как целое число, чтобы модель знала, какую страницу контактов возвращать:

Листинг 71. Добавление пейджинга в наш обработчик запросов

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    page = int(request.args.get("page", 1)) # 1
    if search is not None:
        contacts_set = Contact.search(search)
    else:
        contacts_set = Contact.all(page) # 2
    return render_template("index.html",
contacts=contacts_set, page=page)
  1. Разрешить параметр page, по умолчанию установив значение page в 1, если страница не передана.

  2. Передать страницу модели при загрузке всех контактов, чтобы она знала, какую страницу из 10 контактов вернуть.

Это довольно просто: нам просто нужно получить еще один параметр, например параметр q, который мы передали ранее для поиска контактов, преобразовать его в целое число и затем передать в модель Contact, чтобы она знала, какую страницу возвращать.

И с этим небольшим изменением мы закончили: теперь у нас есть очень простой механизм подкачки для нашего веб-приложения.

И, хотите верьте, хотите нет, он уже использует AJAX благодаря использованию в приложении hx-boost. Легко!

Нажмите, чтобы загрузить

Этот механизм подкачки подходит для базового веб-приложения и широко используется в Интернете. Но у него есть некоторые недостатки: каждый раз, когда вы нажимаете кнопки «Далее» или «Предыдущий», вы получаете совершенно новую страницу контактов и теряете весь контекст, который у вас был на предыдущей странице.

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

Это распространенный шаблон UX «нажмите для загрузки», встречающийся в более продвинутых веб-приложениях.

Рис. 6. Пользовательский интерфейс «нажмите для загрузки»

Здесь у вас есть кнопка, которую вы можете нажать, и она загрузит следующий набор контактов непосредственно на страницу, а не «перелистывая» на следующую страницу. Это позволяет вам визуально сохранять текущие контакты «в контексте» на странице, но при этом продвигаться по ним так же, как в обычном страничном пользовательском интерфейсе.

Давайте посмотрим, как мы можем реализовать этот шаблон UX в htmx.

На самом деле это удивительно просто: мы можем просто взять существующую ссылку «Next» и немного изменить ее назначение, используя только несколько атрибутов htmx!

Мы хотим иметь кнопку, которая при нажатии добавляет строки со следующей страницы контактов в текущую выходную таблицу, а не перерисовывает всю таблицу. Этого можно добиться, добавив в нашу таблицу новую строку, в которой есть именно такая кнопка:

Листинг 72. Изменение на «нажмите для загрузки»

    <tbody>
    {% for contact in contacts %}
      <tr>
        <td>{{ contact.first }}</td>
        <td>{{ contact.last }}</td>
        <td>{{ contact.phone }}</td>
        <td>{{ contact.email }}</td>
        <td><a href="/contacts/{{ contact.id }}/edit">Edit</a> <a href="/contacts/{{ contact.id }}">View</a></td>
      </tr>
    {% endfor %}
    {% if contacts|length == 10 %} <!-- 1 -->
      <tr>
        <td colspan="5" style="text-align: center">
          <button hx-target="closest tr" <!-- 2 -->
                  hx-swap="outerHTML" <!-- 3 -->
                  hx-select="tbody > tr" <!-- 4 -->
                  hx-get="/contacts?page={{ page + 1 }}">
            Load More
          </button>
        </td>
      </tr>
    {% endif %}
    </tbody>
  1. Показать «Load More», только если на текущей странице есть 10 результатов контактов.

  2. Выбрать ближайшую закрывающую строку.

  3. Заменить всю строку ответом сервера.

  4. Выбрать строки таблицы из ответа.

Давайте подробно рассмотрим каждый атрибут здесь.

Во-первых, мы используем hx-target для нацеливания на «ближайший» элемент tr, то есть ближайшую родительскую строку таблицы.

Во-вторых, мы хотим заменить всю эту строку всем содержимым, возвращаемым с сервера.

В-третьих, мы хотим извлечь из ответа только элементы tr. Мы заменяем этот элемент tr новым набором элементов tr, в которых будет дополнительная контактная информация, а также, при необходимости, новая кнопка «Load More», указывающая на следующую страницу. Для этого мы используем селектор CSS tbody > tr, чтобы гарантировать, что мы извлекаем только строки в теле таблицы в ответе. Это позволяет избежать, например, включения строк в заголовок таблицы.

Наконец, мы выдаем HTTP GET на URL-адрес, который будет обслуживать следующую страницу контактов, который выглядит так же, как ссылка «Next» сверху.

Несколько удивительно, что для этой новой функциональности не требуется никаких изменений на стороне сервера. Это связано с гибкостью, которую дает htmx в отношении обработки ответов сервера.

Итак, четыре атрибута, и теперь у нас есть сложный пользовательский интерфейс «Click To Load» через htmx.

Бесконечная прокрутка

Другой распространенный шаблон работы с большими наборами объектов известен как шаблон «Бесконечная прокрутка». В этом шаблоне, когда последний элемент списка или таблица элементов прокручивается в поле зрения, дополнительные элементы загружаются и добавляются в список или таблицу.

Теперь такое поведение имеет больше смысла в ситуациях, когда пользователь просматривает категорию или серию публикаций в социальных сетях, а не в контексте приложения для контактов. Однако для полноты картины и просто для того, чтобы показать, что можно делать с помощью htmx, мы также реализуем этот шаблон.

Оказывается, мы можем довольно легко переназначить код «Нажмите для загрузки», чтобы реализовать этот новый шаблон: если вы задумаетесь об этом на мгновение, бесконечная прокрутка на самом деле является просто логикой «Нажмите для загрузки», а не загрузкой, когда происходит событие click, мы хотим загружаться, когда элемент «появляется» на портале просмотра браузера.

По счастливой случайности, htmx предлагает синтетическое (нестандартное) событие DOM, которое можно использовать в тандеме с атрибутом hx-trigger, чтобы инициировать запрос, когда элемент обнаружен.

Итак, давайте преобразуем нашу кнопку в диапазон и воспользуемся этим событием:

Листинг 73. Переход к «бесконечной прокрутке»

{% if contacts|length == 10 %} <!-- 1 -->
  <tr>
    <td colspan="5" style="text-align: center">
      <span hx-target="closest tr" <!-- 1 -->
            hx-trigger="revealed" <!-- 2 -->
            hx-swap="outerHTML"
            hx-select="tbody > tr"
            hx-get="/contacts?page={{ page + 1 }}">Loading More...</span>
    </td>
  </tr>
{% endif %}
  1. Мы преобразовали наш элемент из кнопки в диапазон, так как пользователь не будет на него нажимать.

  2. Мы запускаем запрос, когда элемент раскрывается, то есть когда он появляется на портале.

Все, что нам нужно было сделать, чтобы преобразовать «Нажмите для загрузки» в «Бесконечную прокрутку», — это обновить наш элемент, чтобы он был span, а затем добавить триггер события revealed.

Тот факт, что переключиться на бесконечную прокрутку было так легко, показывает, насколько хорошо htmx обобщает HTML: всего несколько атрибутов позволяют нам значительно расширить возможности гипермедиа.

И, опять же, мы делаем все это, используя преимущества модели Интернета RESTful. Несмотря на все это новое поведение, мы по-прежнему обмениваемся гипермедиа с сервером без какого-либо ответа JSON API.

Как и был спроектирован веб.

Примечания к HTML: осторожность с модальными окнами и «display: none»

Подумайте дважды о модальных окнах. Модальные окна сегодня стали популярными, почти стандартными во многих веб-приложениях.

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

Модальные окна можно безопасно использовать для представлений, которые не представляют собой ресурс или не соответствуют объекту домена:

В противном случае рассмотрите возможность использования альтернатив, таких как встроенное редактирование или отдельная страница, а не модальное окно.

Используйте display: none; с осторожностью. Проблема в том, что это не чисто косметический эффект — он также удаляет элементы из дерева доступности и фокуса клавиатуры. Иногда это делается для представления одного и того же контента в визуальных и звуковых интерфейсах. Если вы хотите скрыть элемент визуально, не скрывая его от вспомогательных технологий (например, элемент содержит информацию, передаваемую посредством стиля), вы можете использовать этот служебный класс:

.vh {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  block-size: 1px;
  inline-size: 1px;
  overflow: hidden;
  white-space: nowrap;
}

vh — сокращение от «visually hidden». Этот класс использует несколько методов и обходных путей, чтобы гарантировать, что ни один браузер не удалит функцию элемента.

Глава 6
Больше шаблонов Htmx

Активный поиск

С Contact.app пока все хорошо: у нас есть симпатичное маленькое веб-приложение с некоторыми значительными улучшениями по сравнению с простым приложением на основе HTML. Мы добавили правильную кнопку «Delete Contact», выполнили динамическую проверку ввода и рассмотрели различные подходы к добавлению пейджинга в приложение. Как мы уже говорили, многие веб-разработчики ожидали, что для реализации этих функций потребуется множество сценариев на основе JavaScript, но мы сделали все это в относительно чистом HTML, используя только атрибуты htmx.

Со временем мы добавим в наше приложение некоторые сценарии на стороне клиента: гипермедиа — это мощный инструмент, но он не является полностью мощным, и иногда сценарии могут быть лучшим (или единственным) способом достижения определенной цели. А пока давайте посмотрим, чего мы можем достичь с помощью гипермедиа.

Первая расширенная функция HTML, которую мы создадим, известна как шаблон «Активный поиск». Активный поиск — это когда, когда пользователь вводит текст в поле поиска, результаты этого поиска динамически отображаются. Этот шаблон стал популярным, когда Google применил его для результатов поиска, и теперь его реализуют многие приложения.

Для реализации активного поиска мы собираемся использовать методы, тесно связанные с тем, как мы выполняли проверку электронной почты в предыдущей главе. Если подумать, эти две функции во многом схожи: в обоих случаях мы хотим выдать запрос, когда пользователь вводит данные, а затем обновить какой-то другой элемент ответом. Реализации на стороне сервера, конечно, будут сильно отличаться, но код внешнего интерфейса будет выглядеть довольно похожим из-за общего подхода htmx: «выдать запрос на событие и заменить что-то на экране».

Наш текущий интерфейс поиска

Давайте вспомним, как сейчас выглядит поле поиска в нашем приложении:

Листинг 74. Наша форма поиска

<form action="/contacts" method="get" class="tool-bar">
  <label for="search">Search Term</label>
  <input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"/> <!-- 1 -->
  <input type="submit" value="Search"/>
</form>
  1. Параметр q или «query», который наш клиентский код использует для поиска.

Напомним, что у нас есть серверный код, который ищет параметр q и, если он присутствует, ищет контакты по этому термину.

В нынешнем виде пользователь должен нажать Enter, когда поле поиска находится в фокусе, или нажать кнопку «Search». Оба этих события вызовут событие submit в форме, заставив ее выдать HTTP GET и повторно отобразить всю страницу.

В настоящее время, благодаря hx-boost, форма будет использовать запрос AJAX для этого GET, но мы пока не получаем того приятного поведения поиска по мере ввода, которое нам нужно.

Добавление активного поиска

Чтобы добавить активное поведение поиска, мы добавим несколько атрибутов htmx к входным данным поиска. Мы оставим текущую форму как есть, с action и method, чтобы нормальное поведение поиска работало, даже если у пользователя не включен JavaScript. Это сделает наше улучшение «Активный поиск» приятным «прогрессивным улучшением».

Итак, в дополнение к обычному поведению формы мы также хотим выдавать HTTP-запрос GET при нажатии клавиши. Мы хотим отправить этот запрос на тот же URL-адрес, что и при отправке обычной формы. Наконец, мы хотим сделать это только после небольшой паузы при наборе текста.

Как мы уже говорили, эта функциональность очень похожа на то, что нам нужно для проверки электронной почты. Фактически мы можем скопировать атрибут hx-trigger непосредственно из нашего примера проверки электронной почты с его небольшой задержкой в 200 миллисекунд, чтобы позволить пользователю прекратить ввод до того, как будет запущен запрос.

Это еще один пример того, как общие шаблоны возникают снова и снова при использовании htmx.

Листинг 75. Добавление поведения активного поиска

<form action="/contacts" method="get" class="tool-bar">
  <label for="search">Search Term</label>
  <input id="search" type="search" name="q"
         value="{{ request.args.get('q') or '' }}" <!-- 1 -->
         hx-get="/contacts" <!-- 2 -->
         hx-trigger="search, keyup delay:200ms changed"/> <!-- 3 -->
  <input type="submit" value="Search"/>
</form>
  1. Сохранить исходные атрибуты, чтобы поиск работал, даже если JavaScript недоступен.

  2. Выполнить GET по тому же URL-адресу, что и форма.

  3. Почти та же спецификация hx-trigger, что и для проверки ввода электронной почты.

Мы внесли небольшое изменение в атрибут hx-trigger: исключили событие change для события search. Событие search запускается, когда кто-то очищает поиск или нажимает клавишу ввода. Событие нестандартное, но включить сюда не помешает. Основная функциональность функции обеспечивается вторым триггерным событием — keyup. Как и в примере с электронной почтой, этот триггер задерживается с помощью модификатора delay:200ms, чтобы «устранить дребезг» входных запросов и избежать перегрузки нашего сервера запросами при каждом нажатии клавиши.

Выбор правильного элемента

То, что мы имеем, близко к тому, что мы хотим, но нам нужно установить правильную цель. Напомним, что целью по умолчанию для элемента является он сам. В настоящее время по пути /contacts будет отправлен HTTP-запрос GET, который на данный момент вернет весь HTML-документ с результатами поиска, а затем весь этот документ будет вставлен во внутренний HTML-код результатов поиска.

На самом деле это чепуха: элементы input не могут содержать внутри себя какой-либо HTML. Браузер, разумно, просто проигнорирует запрос htmx, чтобы поместить ответный HTML-код во входные данные. Итак, на этом этапе, когда пользователь вводит что-либо в наш ввод, будет выдан запрос (вы можете увидеть его в консоли разработки вашего браузера, если попробуете), но, к сожалению, пользователю это покажется так, как будто вообще ничего не произошло.

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

Атрибут hx-target позволяет нам сделать именно это. Давайте используем его для нацеливания на тело результатов, элемент tbody в таблице контактов:

Листинг 76. Добавление поведения активного поиска

<form action="/contacts" method="get" class="tool-bar">
  <label for="search">Search Term</label>
  <input id="search" type="search" name="q"
         value="{{ request.args.get('q') or '' }}"
         hx-get="/contacts"hx-trigger="search, keyup delay:200ms changed"
         hx-target="tbody"/> <!-- 1 -->
  <input type="submit" value="Search"/>
</form>
<table>
  ...
  <tbody>
    ...
  </tbody>
</table>
  1. Нацелиться на тег tbody на странице.

Поскольку на странице есть только одно tbody, мы можем использовать общий селектор CSS tbody, и htmx будет нацелен на тело таблицы на странице.

Теперь, если вы попытаетесь ввести что-нибудь в поле поиска, мы увидим некоторые результаты: делается запрос, и результаты вставляются в документ внутри тела. К сожалению, возвращаемый контент по-прежнему представляет собой целый HTML-документ.

Здесь мы получаем ситуацию «двойного рендеринга», когда весь документ был вставлен внутрь другого элемента, при этом вся навигация, верхние и нижние колонтитулы и т. д. повторно визуализировались внутри этого элемента. Это пример одной из тех проблем неправильного таргетинга, о которых мы упоминали ранее.

К счастью, это довольно легко исправить.

Уменьшение содержания нашего контента

Теперь мы могли бы использовать тот же прием, который мы использовали в функциях «Нажмите для загрузки» и «Бесконечная прокрутка»: атрибут hx-select. Напомним, что атрибут hx-select позволяет нам выбрать интересующую нас часть ответа с помощью селектора CSS.

Итак, мы могли бы добавить это к нашему инпуту:

Листинг 77. Использование «hx-select» для активного поиска

<input id="search" type="search" name="q"
value="{{ request.args.get('q') or '' }}"
    hx-get="/contacts"
    hx-trigger="change, keyup delay:200ms changed"
    hx-target="tbody"
    hx-select="tbody tr"/> # 1
  1. Добавление hx-select, который выбирает строки таблицы в tbody ответа.

Однако это не единственное решение этой проблемы, и в данном случае оно не самое эффективное. Вместо этого давайте изменим серверную часть нашего Hypermedia-Driven Application, чтобы он обслуживал только необходимый HTML-контент.

Заголовки HTTP-запросов в Htmx

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

Однако в нашем приложении, поскольку мы занимаемся разработкой «Full Stack» (то есть: мы контролируем как интерфейсный, так и внутренний код и можем легко изменять любой из них), у нас есть другой вариант: мы можем изменить ответы нашего сервера, чтобы возвращать только необходимый контент и избавить нас от необходимости выполнять фильтрацию на стороне клиента.

Это оказывается более эффективным, поскольку мы возвращаем не весь контент, окружающий интересующий нас фрагмент, экономя пропускную способность, а также процессор и память на стороне сервера. Итак, давайте рассмотрим возврат различного HTML-содержимого на основе контекстной информации, которую htmx предоставляет с помощью HTTP-запросов.

Давайте еще раз взглянем на текущий серверный код нашей логики поиска:

Листинг 78. Поиск на стороне сервера

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    if search is not None:
        contacts_set = Contact.search(search) # 1
    else:
        contacts_set = Contact.all()
    return render_template("index.html",
contacts=contacts_set) # 2
  1. Здесь происходит логика поиска.

  2. Мы просто каждый раз перерисовываем шаблон index.html, несмотря ни на что.

Как мы хотим это изменить? Мы хотим условно визуализировать два разных фрагмента HTML-контента:

Поэтому нам нужен какой-то способ точно определить, какой из этих двух разных типов запросов к URL-адресу /contact выполняется, чтобы точно знать, какой контент мы хотим отобразить.

Оказывается, htmx помогает нам различать эти два случая, включая несколько HTTP Request Headers при выполнении запросов. Заголовки запросов — это функция HTTP, позволяющая клиентам (например, веб-браузерам) включать пары имя/значение метаданных, связанных с запросами, чтобы помочь серверу понять, что запрашивает клиент.

Вот пример (некоторых) заголовков, которые браузер FireFox выдает при запросе https://hypermedia.systems:

Листинг 79. HTTP-заголовки

GET / HTTP/2
Host: hypermedia.systems
User-
Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15;
rv:103.0) Gecko/20100101 Firefox/103.0
Accept:
text/html,application/xhtml
+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.5
Cache-Control: no-cache
Connection: keep-alive
DNT: 1
Pragma: no-cache

Htmx использует эту особенность HTTP и добавляет дополнительные заголовки и, следовательно, дополнительный контекст к отправляемым HTTP-запросам. Это позволяет вам проверять эти заголовки и выбирать, какую логику выполнять на сервере и какой тип HTML-ответа вы хотите отправить клиенту.

Вот таблица HTTP-заголовков, которые htmx включает в HTTP-запросы:

HX-Boosted
Это будет строка «true», если запрос выполняется через элемент с использованием hx-boost.
HX-Current-URL
Это будет текущий URL-адрес браузера.
HX-History-Restore-Request
Это будет строка «true», если запрос направлен на восстановление истории после ошибки в локальном кэше истории
HX-Prompt
Будет содержать ответ пользователя на hx-prompt.
HX-Request
Это значение всегда «true» для запросов на основе htmx.
HX-Target
Это значение будет идентификатором целевого элемента, если он существует.
HX-Trigger-Name
Это значение будет именем триггерного элемента, если он существует.
HX-Trigger
Это значение будет идентификатором сработавшего элемента, если он существует.

Просматривая этот список заголовков, выделяется последний: у нас есть идентификатор, search на нашем поисковом инпуте. Таким образом, значение заголовка HX-Trigger должно быть установлено в search, когда запрос поступает с инпута поиска, который имеет идентификатор search.

Давайте добавим в наш контроллер некоторую условную логику для поиска этого заголовка, и, если значением является search, мы отображаем только строки, а не весь шаблон index.html:

Листинг 80. Обновление поиска на стороне сервера

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    if search is not None:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search': # 1
            # TODO: render only the rows here # 2
    else:
        contacts_set = Contact.all()
    return render_template("index.html", contacts=contacts_set) # 2
  1. Если заголовок запроса HX-Trigger равен «search», мы хотим сделать что-то другое.

  2. Нам нужно научиться отображать только строки таблицы.

Хорошо, а как нам визуализировать только строки результатов?

Факторинг ваших шаблонов

Теперь мы подошли к общему шаблону в htmx: мы хотим факторизовать наши серверные шаблоны. Это означает, что мы хотим немного разбить наши шаблоны, чтобы их можно было вызывать из нескольких контекстов. В этом случае мы хотим разбить строки таблицы результатов на отдельный шаблон, который мы назовем rows.html. Мы включим его из исходного шаблона index.html, а также будем использовать его в нашем контроллере для его рендеринга, когда мы хотим отвечать только строками на запросы активного поиска.

Вот как сейчас выглядит таблица в нашем файле index.html:

Листинг 81. Таблица контактов

  <table>
    <thead>
    <tr>
      <th>First</th> <th>Last</th> <th>Phone</th> <th>Email</th> <th></th>
    </tr>
    </thead>
    <tbody>
    {% for contact in contacts %}
      <tr>
        <td>{{ contact.first }}</td>
        <td>{{ contact.last }}</td>
        <td>{{ contact.phone }}</td>
        <td>{{ contact.email }}</td>
        <td><a href="/contacts/{{ contact.id }}/edit">Edit</a> <a href="/contacts/{{ contact.id }}">View</a></td>
      </tr>
    {% endfor %}
    </tbody>
  </table>

Цикл for в этом шаблоне создает все строки в конечном содержимом, сгенерированном index.html. Мы хотим переместить цикл for и, следовательно, строки, которые он создает, в отдельный файл шаблона, чтобы только этот небольшой фрагмент HTML можно было отображать независимо от index.html.

И снова назовем этот новый шаблон rows.html:

Листинг 82. Наш новый файл rows.html

{% for contact in contacts %} <!-- 2 -->
  <tr>
    <td>{{ contact.first }}</td>
    <td>{{ contact.last }}</td>
    <td>{{ contact.phone }}</td>
    <td>{{ contact.email }}</td>
    <td><a href="/contacts/{{ contact.id }}/edit">Edit</a> <a
href="/contacts/{{ contact.id }}">View</a></td>
  </tr>
{% endfor %}

Используя этот шаблон, мы можем визуализировать только элементы tr для данной коллекции контактов.

Конечно, мы по-прежнему хотим включить это содержимое в шаблон index.html: иногда мы будем отображать всю страницу, а иногда — только строки. Чтобы обеспечить правильную отрисовку шаблона index.html, мы можем включить шаблон rows.html, используя директиву jinja include в ту позицию, в которую мы хотим вставить содержимое из rows.html:

Листинг 83. Включение нового файла

  <table>
    <thead>
    <tr>
      <th>First</th>
      <th>Last</th>
      <th>Phone</th>
      <th>Email</th>
      <th></th>
    </tr>
    </thead>
    <tbody>
    {% include 'rows.html' %} <!-- 1 -->
    </tbody>
  </table>
  1. Эта директива «включает» файл rows.html, вставляя его содержимое в текущий шаблон.

Пока все хорошо: наша страница /contacts по-прежнему отображается правильно, как и до того, как мы разделили строки из шаблона index.html.

Использование нашего нового шаблона

Последним шагом в факторизации наших шаблонов является изменение нашего веб-контроллера, чтобы он мог использовать преимущества нового файла шаблона rows.html, когда он отвечает на активный поисковый запрос.

Поскольку rows.html — это просто еще один шаблон, как и index.html, все, что нам нужно сделать, — это вызвать функцию render_template с помощью rows.html, а не index.html. Это отобразит только содержимое строки, а не всю страницу:

Листинг 84. Обновление поиска на стороне сервера

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    if search is not None:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search':
            return render_template("rows.html", contacts=contacts_set) # 1
    else:
        contacts_set = Contact.all()
    return render_template("index.html", contacts=contacts_set)
  1. Отображение нового шаблона в случае активного поиска.

Теперь, когда делается запрос активного поиска, вместо того, чтобы возвращать весь HTML-документ, мы получаем только частичный фрагмент HTML — строки таблицы для контактов, соответствующих запросу. Эти строки затем вставляются в tbody индексной страницы без необходимости использования hx-select или другой обработки на стороне клиента.

И, в качестве бонуса, старый поиск на основе форм все еще работает. Мы условно отображаем строки только тогда, когда данные поля search отправляют HTTP-запрос через htmx. Опять же, это прогрессивное улучшение нашего приложения.

HTTP-заголовки и кеширование

Одним из тонких аспектов подхода, который мы здесь применяем, используя заголовки для определения содержимого того, что мы возвращаем, является функция, встроенная в HTTP: кэширование. В нашем обработчике запросов мы теперь возвращаем разное содержимое в зависимости от значения заголовка HX-Trigger. Если бы мы использовали HTTP-кэширование, мы могли бы попасть в ситуацию, когда кто-то делает запрос, отличный от htmx (например, обновление страницы), и все же содержимое htmx возвращается из HTTP-кэша, в результате чего получается частичная страница контента для пользователя.

Решение этой проблемы — использовать заголовок HTTP Response Vary и вызывать заголовки htmx, которые вы используете, чтобы определить, какой контент вы возвращаете. Полное объяснение HTTP-кэширования выходит за рамки этой книги, но статья MDN по этой теме довольно хороша, и в документации htmx также обсуждается этот вопрос.

Обновление панели навигации с помощью «hx-push-url»

Одним из недостатков нашей текущей реализации активного поиска по сравнению с обычной отправкой формы является то, что когда вы отправляете версию формы, она обновляет панель навигации браузера, включив в нее поисковый запрос. Так, например, если вы введете в поле поиска слово «joe», в навигационной панели вашего браузера вы получите URL-адрес, который выглядит следующим образом:

Листинг 85. Обновленное местоположение после поиска в форме

https://example.com/contacts?q=joe

Это приятная функция браузеров: она позволяет вам добавить этот поиск в закладки или скопировать URL-адрес и отправить его кому-то другому. Все, что им нужно сделать, это нажать на ссылку, и они повторят тот же поиск. Это также связано с понятием истории браузера: если вы нажмете кнопку «Назад», вы перейдете на предыдущий URL-адрес, с которого вы пришли. Если вы отправите два поисковых запроса и захотите вернуться к первому, вы можете просто нажать «Назад», и браузер «вернется» к этому поиску.

В настоящее время во время активного поиска мы не обновляем панель навигации браузера. Таким образом, пользователи не получают ссылки, которые можно скопировать и вставить, и вы также не получаете записи истории, что означает отсутствие поддержки кнопки «Назад». К счастью, мы уже видели, как это исправить: с помощью атрибута hx-push-url.

Атрибут hx-push-url позволяет вам сообщить htmx: «Пожалуйста, вставьте URL-адрес этого запроса в панель навигации браузера». Push (заталкивать) может показаться странным глаголом для использования здесь, но именно этот термин использует базовый API истории браузера, который обусловлен тем фактом, что он моделирует историю браузера как «стек» местоположений: когда вы переходите в новое место, Местоположение «заталкивается» в стек элементов истории, и когда вы нажимаете «Назад», это местоположение «выталкивается» из стека истории.

Итак, чтобы получить правильную поддержку истории для нашего активного поиска, все, что нам нужно сделать, это установить атрибут hx-push-url в значение true.

Листинг 86. Обновление URL-адреса во время активного поиска

<input id="search" type="search" name="q"
value="{{ request.args.get('q') or '' }}"
       hx-get="/contacts"
       hx-trigger="change, keyup delay:200ms changed"
       hx-target="tbody"
       hx-push-url="true"/> <!-- 1 -->
  1. Добавив атрибут hx-push-url со значением true, htmx обновит URL-адрес при выполнении запроса.

Теперь, когда отправляются запросы Active Search, URL-адрес в панели навигации браузера обновляется, чтобы в нем содержался правильный запрос, точно так же, как при отправке формы.

Возможно, вам не понравится такое поведение. Вы можете почувствовать, что пользователей будет сбивать с толку, например, обновление панели навигации и наличие записей истории для каждого выполненного активного поиска. И это нормально: вы можете просто опустить атрибут hx-push-url, и он вернется к желаемому поведению. Цель htmx — быть достаточно гибким для достижения желаемого UX, оставаясь при этом в рамках декларативной модели HTML.

Добавление индикатора запроса

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

Htmx обеспечивает поддержку индикаторов запроса через атрибут hx-indicator. Как вы уже догадались, этот атрибут принимает селектор CSS, указывающий на индикатор для данного элемента. Индикатором может быть что угодно, но обычно это какое-то анимированное изображение, например файл gif или svg, которое вращается или иным образом визуально сообщает, что «что-то происходит».

Давайте добавим счетчик после ввода поиска:

Листинг 87. Добавление индикатора запроса для поиска

<input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
       hx-get="/contacts"
       hx-trigger="change, keyup delay:200ms changed"
       hx-target="tbody"
       hx-push-url="true"
       hx-indicator="#spinner"/> <!-- 1 -->
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg" alt="Request In Flight..."/> <!-- 2 -->
  1. Атрибут hx-indicator указывает на изображение индикатора после ввода.

  2. Индикатор представляет собой SVG-файл с вращающимся кругом и содержит класс htmx-indicator.

Мы добавили счетчик сразу после ввода. Это визуально объединяет индикатор запроса с элементом, отправляющим запрос, и позволяет пользователю легко увидеть, что что-то действительно происходит.

Это просто работает, но как htmx заставляет счетчик появляться и исчезать? Обратите внимание, что тег img индикатора содержит класс htmx-indicator. htmx-indicator — это класс CSS, который автоматически внедряется на страницу с помощью htmx. Этот класс устанавливает opacity элемента по умолчанию равным 0, что скрывает элемент от просмотра, но в то же время не нарушает макет страницы.

Когда запускается запрос htmx, указывающий на этот индикатор, к индикатору добавляется другой класс, htmx-request, который меняет его непрозрачность на 1. Таким образом, вы можете использовать в качестве индикатора практически что угодно, и по умолчанию он будет скрыт. Затем, когда запрос находится в работе, он будет показан. Все это делается с помощью стандартных классов CSS, что позволяет вам управлять переходами и даже механизмом отображения индикатора (например, вы можете использовать display, а не opacity).

Используйте индикаторы запросов!

Индикаторы запросов — важный аспект UX любого распределенного приложения. К сожалению, браузеры со временем перестали уделять внимание своим собственным индикаторам запросов, и вдвойне прискорбно, что индикаторы запросов не являются частью API-интерфейсов JavaScript ajax.

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

Благодаря этому индикатору запроса у нас теперь есть довольно сложный пользовательский интерфейс по сравнению с простым HTML, но мы создали все это как функцию, управляемую гипермедиа. Никакого JSON или JavaScript не видно. Преимущество нашей реализации состоит в том, что она представляет собой прогрессивное улучшение; приложение продолжит работать для клиентов, у которых не включен JavaScript.

Ленивая загрузка

Теперь, когда активный поиск позади, давайте перейдем к совершенно другому усовершенствованию: ленивой загрузке. Ленивая загрузка — это когда загрузка определенного фрагмента контента откладывается на более позднее время, когда это необходимо. Обычно это используется для повышения производительности: вы избегаете ресурсов обработки, необходимых для создания некоторых данных, до тех пор, пока эти данные действительно не потребуются.

Давайте добавим подсчет общего количества контактов в Contact.app чуть ниже нижней части нашей таблицы контактов. Это даст нам потенциально дорогостоящую операцию, которую мы можем использовать, чтобы продемонстрировать, как добавить отложенную загрузку с помощью htmx.

Сначала давайте обновим код нашего сервера в обработчике запроса /contacts, чтобы получить подсчет общего количества контактов. Мы передадим этот счетчик в шаблон для визуализации нового HTML.

Листинг 88. Добавление счетчика в пользовательский интерфейс

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    page = int(request.args.get("page", 1))
    count = Contact.count() # 1
    if search is not None:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search':
            return render_template("rows.html", contacts=contacts_set, page=page, count=count)
    else:
        contacts_set = Contact.all(page)
    return render_template("index.html", contacts=contacts_set, page=page, count=count) # 2
  1. Получить общее количество контактов из модели контактов.

  2. Передать это количество в шаблон index.html, который будет использоваться при рендеринге.

Как и в остальной части приложения, чтобы сосредоточиться на гипермедийной части Contact.app, мы пропустим детали того, как работает Contact.count(). Нам просто нужно знать, что:

Далее давайте добавим HTML-код в наш index.html, который использует преимущества этого нового фрагмента данных, показывая сообщение рядом со ссылкой «Add Contact» с общим количеством пользователей. Вот как выглядит наш HTML:

Листинг 89. Добавление элемента счетчика контактов в приложение

<p>
  <a href="/contacts/new">Add Contact</a> <span>({{ count }} total Contacts)</span> <!-- 1 -->
</p>
  1. Простой диапазон с текстом, показывающим общее количество контактов.

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

Вот как эта функция выглядит в нашем приложении:

Рисунок 7. Отображение общего количества контактов

Замечательно.

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

Как ни удивительно, проблема заключается в невинно выглядящем вызове Contacts.count(), выполнение которого занимает до полутора секунд. К сожалению, по причинам, выходящим за рамки этой книги, невозможно ни сократить время загрузки, ни кэшировать результат.

Это оставляет нам два варианта:

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

Извлечение дорогостоящего кода

Первым шагом в реализации шаблона отложенной загрузки является удаление дорогостоящего кода — то есть вызова Contacts.count() — из обработчика запроса для эндпоинта /contacts.

Давайте поместим этот вызов функции в отдельный обработчик HTTP-запроса в качестве новой конечной точки HTTP, которую мы поместим в /contacts/count. Для этой новой конечной точки нам вообще не нужно будет отображать шаблон: его единственной задачей будет визуализация небольшого фрагмента текста, который находится в диапазоне «(22 total Contacts)».

Вот как будет выглядеть новый код:

Листинг 90. Извлечение дорогостоящего кода

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    page = int(request.args.get("page", 1)) # 1
    if search is not None:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search':
            return render_template("rows.html", contacts=contacts_set, page=page)
    else:
        contacts_set = Contact.all(page)
    return render_template("index.html", contacts=contacts_set, page=page) # 2

@app.route("/contacts/count")
def contacts_count():
    count = Contact.count() # 3
    return "(" + str(count) + " total Contacts)" # 4
  1. Мы больше не вызываем Contacts.count() в этом обработчике.

  2. Count больше не передается в шаблон для обработки в обработчике /contacts.

  3. Создаем новый обработчик по пути /contacts/count, который выполняет дорогостоящие вычисления.

  4. Возвращаем строку с общим количеством контактов.

Итак, теперь мы устранили проблему производительности из кода обработчика /contacts, который отображает основную таблицу контактов, и создали новую конечную точку HTTP, которая будет создавать для нас эту дорогостоящую строку счетчика.

Теперь нам нужно каким-то образом перенести содержимое из этого нового обработчика в span. Как мы говорили ранее, поведение htmx по умолчанию заключается в размещении любого контента, который он получает по данному запросу, в innerHTML элемента, и это именно то, что мы хотим здесь: мы хотим получить этот текст и поместить его в span. Таким образом, мы можем просто поместить атрибут hx-get в диапазон, указывающий на этот новый путь, и сделать именно это.

Однако помните, что событием по умолчанию, которое запускает запрос элемента span в htmx, является событие click. Ну, это не то, чего мы хотим! Вместо этого мы хотим, чтобы этот запрос запускался немедленно при загрузке страницы.

Для этого мы можем добавить атрибут hx-trigger для обновления триггера запросов элемента и использовать событие load.

Событие load — это специальное событие, которое htmx запускает для всего контента при его загрузке в DOM. Установив hx-trigger для load, мы заставим htmx выдавать запрос GET при загрузке элемента span на страницу.

Вот наш обновленный код шаблона:

Листинг 91. Добавление элемента счетчика контактов в приложение

<p>
  <a href="/contacts/new">Add Contact</a> <span hx-get="/contacts/count" hx-trigger="load"></span> <!-- 1 -->
</p>
  1. Выполнить GET для /contacts/count при возникновении события загрузки.

Обратите внимание, что span стартует пустым: мы удалили из него содержимое и вместо этого разрешаем запросу /contacts/count заполнить его.

И обратите внимание: наша страница /contacts снова работает быстро! Когда вы переходите на страницу, она кажется очень быстрой, и профилирование показывает, что да, страница действительно загружается гораздо быстрее. Почему это? Что ж, мы отложили дорогостоящие вычисления на вторичный запрос, позволив первоначальному запросу завершить загрузку быстрее.

Вы можете сказать: «Хорошо, отлично, но все равно требуется секунда или две, чтобы получить общее количество на странице». Верно, но часто пользователя может не особо интересовать общий подсчет. Они могут просто захотеть зайти на страницу и найти существующего пользователя или, возможно, захотят отредактировать или добавить пользователя. Общее количество контактов в таких случаях — это просто «приятно иметь» немного информации.

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

Да, общее время вывода всей информации на экран занимает столько же времени. На самом деле это будет немного дольше, поскольку теперь нам нужны два HTTP-запроса, чтобы получить всю информацию о странице. Но воспринимаемая производительность для конечного пользователя будет намного лучше: они смогут делать то, что хотят, почти мгновенно, даже если некоторая информация не доступна мгновенно.

Lazy Loading — отличный инструмент, который нужно иметь под рукой при оптимизации производительности веб-приложений.

Добавление индикатора

Недостаток текущей реализации заключается в том, что в настоящее время нет никакой индикации того, что запрос на подсчет выполняется, она просто появляется в какой-то момент, когда запрос завершается.

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

В данном случае у нас есть одноразовый запрос, и как только запрос будет завершен, счетчик нам больше не понадобится. Поэтому нет смысла использовать тот же подход, который мы использовали в примере с активным поиском. Напомним, что в этом случае мы разместили счетчик после span и использовали атрибут hx-indicator, чтобы указать на него.

В этом случае, поскольку счетчик используется только один раз, мы можем поместить его внутри содержимого диапазона. Когда запрос завершится, содержимое ответа будет помещено внутри span, заменяя счетчик вычисленным количеством контактов. Оказывается, htmx позволяет размещать индикаторы с классом htmx-indicator внутри элементов, генерирующих запросы на базе htmx. При отсутствии атрибута hx-indicator эти внутренние индикаторы будут отображаться при выполнении запроса.

Итак, давайте добавим этот счетчик из примера активного поиска в качестве начального содержимого в нашем диапазоне:

Листинг 92. Добавление индикатора к нашему лениво загружаемому содержимому

<span hx-get="/contacts/count" hx-trigger="load">
  <img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/> <!-- 1 -->
</span>
  1. Ну вот и все.

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

Обратите внимание, что все, что нам нужно было сделать, это скопировать и вставить наш индикатор из примера активного поиска в span. Мы еще раз видим, как htmx предоставляет гибкие, компонуемые функции и строительные блоки. Реализация новой функции часто заключается в простом копировании и вставке, возможно, внесении одной-двух изменений, и все готово.

Но это не ленивая загрузка!

Вы можете сказать: «Хорошо, но это не совсем ленивая загрузка. Мы по-прежнему загружаем счетчик сразу при загрузке страницы, мы просто делаем это во втором запросе. На самом деле вы не ждете, пока значение действительно понадобится».

Отлично. Давайте сделаем ленивую загрузку ленивой: мы будем выдавать запрос только тогда, когда диапазон прокручивается в поле зрения.

Для этого давайте вспомним, как мы настроили пример бесконечной прокрутки: мы использовали событие revealed для нашего триггера. Это все, что нам здесь нужно, верно? Когда элемент раскрыт, мы выдаем запрос?

Да, вот и все. Опять же, мы можем смешивать и сопоставлять концепции различных UX-шаблонов, чтобы найти решения новых проблем в htmx.

Листинг 93. Делаем по-настоящему ленивую загрузку

<span hx-get="/contacts/count" hx-trigger="revealed"> <!-- 1 -->
  <img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/>
</span>
  1. Изменить hx-триггер на revealed.

Теперь у нас есть действительно ленивая реализация, откладывающая дорогостоящие вычисления до тех пор, пока мы не будем абсолютно уверены, что они нам нужны. Довольно крутой трюк, и, опять же, простое изменение одного атрибута демонстрирует гибкость как htmx, так и подхода гипермедиа.

Инлайн удаление

Для нашего следующего трюка с гипермедиа мы собираемся реализовать шаблон «Инлайн удаление». Благодаря этой функции контакт можно удалить непосредственно из таблицы всех контактов, вместо того, чтобы требовать от пользователя перехода к режиму редактирования конкретного контакта, чтобы получить доступ к кнопке «Delete Contact», которую мы добавили в прошлой главе.

Напомним, что у нас уже есть ссылки «Edit» и «View» для каждой строки в шаблоне rows.html:

Листинг 94. Существующие действия над строками

<td>
  <a href="/contacts/{{ contact.id }}/edit">Edit</a>
  <a href="/contacts/{{ contact.id }}">View</a>
</td>

Теперь мы хотим также добавить ссылку «Delete». И, поразмыслив над этим, мы хотим, чтобы эта ссылка действовала очень похоже на кнопку «Delete Contact» из edit.html, не так ли? Мы хотели бы выполнить HTTP DELETE для URL-адреса данного контакта, и нам нужен диалог подтверждения, чтобы гарантировать, что пользователь случайно не удалит контакт.

Вот HTML-код кнопки «Delete Contact»:

Листинг 95. Существующие действия над строками

<button hx-delete="/contacts/{{ contact.id }}"
        hx-push-url="true"
        hx-confirm="Are you sure you want to delete this contact?"
        hx-target="body">
  Delete Contact
</button>

Как вы уже могли догадаться, это будет еще одна работа по копированию и вставке.

Следует отметить, что в случае с кнопкой «Delete Contact» мы хотели повторно отобразить весь экран и обновить URL-адрес, поскольку мы собираемся вернуться из представления редактирования контакта в представление списка всех контактов. Однако в случае с этой ссылкой мы уже находимся в списке контактов, поэтому нет необходимости обновлять URL-адрес, и мы можем опустить атрибут hx-push-url.

Вот код нашей встроенной ссылки «Delete»:

Листинг 96. Существующие действия над строками

<td>
  <a href="/contacts/{{ contact.id }}/edit">Edit</a>
  <a href="/contacts/{{ contact.id }}">View</a>
  <a href="#" hx-delete="/contacts/{{ contact.id }}"
              hx-confirm="Are you sure you want to delete this contact?"
              hx-target="body">Delete</a> <!-- 1 -->
</td>
  1. Практически прямая копия кнопки «Delete Contact».

Как видите, мы добавили новый тег привязки и присвоили ему пустую цель (значение # в атрибуте href), чтобы сохранить правильное поведение ссылки при наведении курсора мыши. Мы также скопировали атрибуты hx-delete, hx-confirm и hx-target из кнопки «Delete Contact», но опустили атрибут hx-push-url, поскольку не хотим обновлять URL-адрес браузера.

Теперь у нас работает встроенное удаление, даже с диалоговым окном подтверждения. Пользователь может нажать ссылку «Delete», и строка исчезнет из пользовательского интерфейса, поскольку вся страница будет перерисована.

Боковая панель стиля

Одним из побочных эффектов добавления этой ссылки удаления является то, что мы начинаем накапливать действия в строке контакта:

Рисунок 8. Много действий

Было бы неплохо, если бы мы не показывали действия все подряд, и, кроме того, было бы неплохо, если бы мы показывали действия только тогда, когда пользователь проявляет интерес к данной строке. Мы вернемся к этой проблеме после того, как в следующей главе рассмотрим взаимосвязь между сценариями и приложением, управляемым гипермедиа.

А пока давайте просто мириться с этим неидеальным пользовательским интерфейсом, зная, что мы исправим его позже.

Сужение нашей цели

Однако здесь мы можем пойти еще дальше. Что, если вместо повторного рендеринга всей страницы мы просто удалим строку с контактом? Пользователь все равно просматривает строку, так действительно ли нужно перерисовывать всю страницу?

Для этого нам нужно сделать пару вещей:

Прежде всего, обновите цель нашей ссылки «Delete», чтобы она стала строкой, в которой находится ссылка, а не всем телом. Мы можем снова воспользоваться функцией относительного позиционного closest, чтобы нацелиться на ближайший tr, как мы это делали в наших функциях «Нажмите для загрузки» и «Бесконечная прокрутка»:

Листинг 97. Существующие действия над строками

<td>
  <a href="/contacts/{{ contact.id }}/edit">Edit</a>
  <a href="/contacts/{{ contact.id }}">View</a>
  <a href="#" hx-delete="/contacts/{{ contact.id }}"
              hx-swap="outerHTML"
              hx-confirm="Are you sure you want to delete this contact?"
              hx-target="closest tr">Delete</a> <!-- 1 -->
</td>
  1. Обновить для нацеливания на ближайший охватывающий tr (строку таблицы) ссылки.

Обновление серверной части

Теперь нам нужно обновить серверную часть. Мы хотим, чтобы кнопка «Delete Contact» также работала, и в этом случае текущая логика верна. Поэтому нам понадобится какой-то способ различать запросы DELETE, запускаемые кнопкой, и запросы DELETE, поступающие от этой привязки.

Самый простой способ сделать это — добавить атрибут id к кнопке «Delete Contact», чтобы мы могли проверить заголовок HTTP-запроса HX-Trigger и определить, была ли кнопка удаления причиной запроса. Это простое изменение существующего HTML:

Листинг 98. Добавление идентификатора к кнопке «удалить контакт»

<button id="delete-btn" <!-- 1 -->
        hx-delete="/contacts/{{ contact.id }}"
        hx-push-url="true"
        hx-confirm="Are you sure you want to delete this contact?"
        hx-target="body">
  Delete Contact
</button>
  1. К кнопке добавлен атрибут id.

Присвоив этой кнопке атрибут id, мы теперь имеем механизм различия между кнопкой удаления в шаблоне edit.html и ссылками удаления в шаблоне rows.html. Когда эта кнопка выдает запрос, он будет выглядеть примерно так:

DELETE http://example.org/contacts/42 HTTP/1.1
Accept: text/html,*/*
Host: example.org
...
HX-Trigger: delete-btn
...

Вы можете видеть, что запрос теперь включает id кнопки. Это позволяет нам писать код, очень похожий на тот, что мы делали для шаблона активного поиска, используя условие в заголовке HX-Trigger, чтобы определить, что мы хотим сделать. Если этот заголовок имеет значение delete-btn, то мы знаем, что запрос поступил от кнопки на странице редактирования, и мы можем сделать то, что делаем сейчас: удалить контакт и перенаправить на страницу /contacts.

Если такого значения нет, мы можем просто удалить контакт и вернуть пустую строку. Эта пустая строка заменит цель, в данном случае строку для данного контакта, тем самым удалив строку из пользовательского интерфейса.

Давайте реорганизуем наш серверный код, чтобы сделать это:

Листинг 99. Обновление кода нашего сервера для обработки двух разных шаблонов удаления

@app.route("/contacts/<contact_id>", methods=["DELETE"])
def contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete()
    if request.headers.get('HX-Trigger') == 'delete-btn': # 1
        flash("Deleted Contact!")
        return redirect("/contacts", 303)
    else:
        return "" # 2
  1. Если кнопка удаления на странице редактирования отправила этот запрос, продолжайте выполнять предыдущую логику.

  2. Если нет, просто верните пустую строку, которая удалит строку.

И это наша реализация на стороне сервера: когда пользователь нажимает «Delete» в строке контакта и подтверждает удаление, строка исчезнет из пользовательского интерфейса. И снова мы имеем ситуацию, когда всего лишь изменение нескольких строк простого кода приводит к совершенно иному поведению. Гипермедиа в этом отношении сильна.

Модель подкачки Htmx

Это довольно круто, но есть еще одно улучшение, которое мы можем сделать, если потратим некоторое время на понимание модели замены содержимого htmx: было бы неплохо, если бы вместо того, чтобы просто мгновенно удалять строку, мы затемняли ее перед удалением. Затухание даст понять, что строка удаляется, давая пользователю приятную визуальную информацию об удалении.

Оказывается, мы можем сделать это довольно легко с помощью htmx, но для этого нам нужно разобраться, как именно htmx меняет содержимое.

Вы можете подумать, что htmx просто помещает новый контент в DOM, но на самом деле это работает не так. Вместо этого контент проходит ряд шагов при добавлении в DOM:

В механике подкачки есть еще кое-что (например, расчет — это более сложная тема, которую мы обсудим в следующей главе), но на данный момент этого достаточно.

Здесь есть небольшие задержки в процессе, обычно порядка нескольких миллисекунд. Почему так? Оказывается, эти небольшие задержки позволяют осуществлять переходы CSS.

CSS-переходы
CSS-переходы — это технология, позволяющая анимировать переход от одного стиля к другому. Так, например, если вы изменили высоту чего-либо с 10 пикселей на 20 пикселей, с помощью перехода CSS вы можете плавно анимировать элемент до новой высоты. Подобные анимации доставляют удовольствие, часто повышают удобство использования приложения и являются отличным механизмом для улучшения вашего веб-приложения.

К сожалению, к CSS-переходам сложно получить доступ в простом HTML: обычно вам приходится использовать JavaScript и добавлять или удалять классы, чтобы они сработали. Вот почему модель подкачки htmx сложнее, чем вы думаете на первый взгляд. Заменив классы и добавив небольшие задержки, вы можете получить доступ к переходам CSS исключительно внутри HTML, без необходимости писать какой-либо JavaScript!

Использование преимущества «htmx-swapping»

Хорошо, давайте вернемся и посмотрим на нашу встроенную механику удаления: мы нажимаем ссылку с расширенным htmx, которая удаляет контакт, а затем заменяет пустое содержимое строки. Мы знаем, что перед удалением элемента tr к нему будет добавлен класс htmx-swapping. Мы можем воспользоваться этим, чтобы написать CSS-переход, который уменьшает непрозрачность строки до 0. Вот как выглядит этот CSS:

Листинг 100. Добавление плавного перехода

tr.htmx-swapping { <!-- 1 -->
  opacity: 0; <!-- 2 -->
  transition: opacity 1s ease-out; <!-- 3 -->
}
  1. Мы хотим, чтобы этот стиль применялся к элементам tr с классом htmx-swapping.

  2. opacity будет равна 0, что сделает его невидимым.

  3. opacity перейдет на 0 в течение 1 секунды с использованием ease-out.

Опять же, это не книга о CSS, и мы не собираемся углубляться в детали CSS-переходов, но, надеюсь, сказанное выше имеет для вас смысл, даже если вы впервые видите CSS-переходы.

Итак, подумайте о том, что это означает с точки зрения модели подкачки htmx: когда htmx возвращает контент для замены в строку, он помещает в строку класс htmx-swapping и немного ждет. Это позволит осуществить переход к нулевой непрозрачности, c затуханием строки. Затем будет заменено новое (пустое) содержимое, что фактически удалит строку.

Звучит хорошо, и мы почти у цели. Есть еще одна вещь, которую нам нужно сделать: «задержка подкачки» по умолчанию для htmx очень короткая, несколько миллисекунд. В большинстве случаев это имеет смысл: вам не нужна большая задержка перед помещением нового контента в DOM. Но в данном случае мы хотим дать CSS-анимации время для завершения, прежде чем мы произведем замену, на самом деле мы хотим дать ей секунду.

К счастью, в htmx есть опция для аннотации hx-swap, которая позволяет вам установить задержку подкачки: после типа подкачки вы можете добавить swap: с последующим значением времени, чтобы указать htmx ждать определенное количество времени перед заменой. Давайте обновим наш HTML, чтобы разрешить задержку в одну секунду перед выполнением замены для действия удаления:

Листинг 101. Существующие действия над строками

<td>
  <a href="/contacts/{{ contact.id }}/edit">Edit</a>
  <a href="/contacts/{{ contact.id }}">View</a>
  <a href="#" hx-delete="/contacts/{{ contact.id }}"
              hx-swap="outerHTML swap:1s" <!-- 1 -->
              hx-confirm="Are you sure you want to delete this contact?"
              hx-target="closest tr">Delete</a>
</td>
  1. Задержка замены изменяет продолжительность ожидания htmx перед заменой нового контента.

Благодаря этой модификации существующая строка останется в DOM еще на секунду с классом htmx-swapping. Это даст строке время для перехода к нулевой непрозрачности, давая желаемый эффект затухания.

Теперь, когда пользователь нажимает ссылку «Delete» и подтверждает удаление, строка будет медленно исчезать, а затем, как только ее непрозрачность станет равной 0, она будет удалена. Довольно необычно, и все сделано в декларативной, ориентированной на гипермедиа манере, без необходимости использования JavaScript. (Ну, очевидно, что htmx написан на JavaScript, но вы понимаете, что мы имеем в виду: нам не нужно было писать какой-либо JavaScript для реализации этой функции.)

Массовое удаление

Последняя функция, которую мы собираемся реализовать в этой главе, — это «Bulk Delete» (массовое удаление). Текущий механизм удаления пользователей хорош, но было бы неприятно, если бы пользователь захотел удалить пять или десять контактов за раз, не так ли? Для функции массового удаления мы хотим добавить возможность выбирать строки с помощью чекбоксов и удалять их все за один раз, нажав кнопку «Delete Selected Contacts».

Чтобы начать работу с этой функцией, нам нужно добавить чекбокс в каждую строку шаблона rows.html. Этот инпут будет иметь имя selected_contact_ids, а его значение будет id контакта для текущей строки.

Вот как выглядит обновленный код rows.html:

Листинг 102. Добавление чекбокса в каждую строку

{% for contact in contacts %}
<tr>
  <td><input type="checkbox" name="selected_contact_ids" value="{{ contact.id }}"></td> <!-- 1 -->
  <td>{{ contact.first }}</td>
  ... omitted
</tr>
{% endfor %}
  1. Новая ячейка с полем чекбокс, значение которого равно id текущего контакта.

Нам также нужно будет добавить пустой столбец в заголовок таблицы, чтобы разместить столбец чекбокса. После этого мы получаем серию чекбоксов, по одному для каждой строки, шаблон, несомненно, знакомый вам по Интернету:

Рисунок 9. Чекбоксы для наших строк контактов

Если вы не знакомы или забыли, как работают чекбоксы в HTML: чекбокс отправит свое значение, связанное с именем ввода, тогда и только тогда, когда он отмечен. Так, если, например, вы отметили контакты с id 3, 7 и 9, то все эти три значения будут отправлены на сервер. Поскольку в этом случае все чекбоксы имеют одно и то же имя, selected_contact_ids, все три значения будут отправлены с именем selected_contact_ids.

Кнопка «Delete Selected Contacts»

Следующий шаг — добавить кнопку под таблицей, которая удалит все выбранные контакты. Мы хотим, чтобы эта кнопка, как и наши ссылки удаления в каждой строке, выдавала HTTP-запрос DELETE, а не отправляла его на URL-адрес данного контакта, как мы делаем со встроенными ссылками удаления и с кнопкой удаления на странице редактирования, здесь мы хотим выполнить DELETE для URL-адреса /contacts.

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

Вот как выглядит код кнопки:

Листинг 103. Кнопка «удалить выбранные контакты»

<button hx-delete="/contacts" <!-- 1 -->
        hx-confirm="Are you sure you want to delete these contacts?" <!-- 2 -->
        hx-target="body"> <!-- 3 -->
  Delete Selected Contacts
</button>
  1. Выполнить DELETE для /contacts.

  2. Подтвердить, что пользователь хочет удалить выбранные контакты.

  3. Нацелиться на тело.

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

К счастью, у htmx есть несколько разных способов включить в запрос значения входных данных.

Один из способов — использовать атрибут hx-include, который позволяет вам использовать селектор CSS для указания элементов, которые вы хотите включить в запрос. Здесь это сработало бы нормально, но мы собираемся использовать другой подход, который в данном случае немного проще.

По умолчанию, если элемент является дочерним элементом элемента form и отправляет запрос, отличный от GET, htmx включит все значения входных данных в эту форму. В подобных ситуациях, когда над таблицей выполняется массовая операция, обычно всю таблицу заключают в тег формы, чтобы можно было легко добавлять кнопки, которые работают с выбранными элементами.

Давайте добавим этот тег формы вокруг таблицы и обязательно включим в него кнопку:

Листинг 104. Кнопка «удалить выбранные контакты»

<form> <!-- 1 -->
  <table>
  ... omitted
  </table>
  <button hx-delete="/contacts"
          hx-confirm="Are you sure you want to delete these contacts?"
          hx-target="body">
    Delete Selected Contacts
  </button>
</form> <!-- 2 -->
  1. Тег формы охватывает всю таблицу.

  2. Тег формы также включает кнопку.

Теперь, когда кнопка выдает команду DELETE, она будет включать все идентификаторы контактов, которые были выбраны в качестве переменной запроса selected_contact_ids.

Серверная часть для удаления выбранных контактов

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

Это единственные изменения, которые нам нужно внести! Вот как выглядит серверный код:

Листинг 105. Кнопка «удалить выбранные контакты»

@app.route("/contacts/", methods=["DELETE"]) # 1
def contacts_delete_all():
    contact_ids = list(map(int, request.form.getlist("selected_contact_ids"))) # 2
    for contact_id in contact_ids: # 3
        contact = Contact.find(contact_id)
        contact.delete() # 4
    flash("Deleted Contacts!") # 5
    contacts_set = Contact.all()
    return render_template("index.html", contacts=contacts_set)
  1. Обработать запрос DELETE по пути /contacts/.

  2. Преобразовать значения selected_contact_ids, отправленные на сервер, из списка строк в список целых чисел.

  3. Перебрать все идентификаторы.

  4. Удалить данный контакт с каждым идентификатором.

  5. Кроме того, это тот же код, что и в исходном обработчике удаления: высвечивается сообщение и отображается шаблон index.html.

Итак, мы взяли исходную логику удаления и немного изменили ее, чтобы она работала с массивом идентификаторов, а не с одним идентификатором.

Вы могли заметить еще одно небольшое изменение: мы убрали перенаправление, которое было в исходном коде удаления. Мы сделали это, потому что уже находимся на странице, которую хотим повторно отобразить, поэтому нет причин перенаправлять и обновлять URL-адрес на что-то новое. Мы можем просто перерисовать страницу, и новый список контактов (за исключением удаленных контактов) будет перерисован.

И вот, теперь у нас есть функция массового удаления для нашего приложения. Опять же, это не так уж и много кода, и мы полностью реализуем эти функции путем обмена гипермедиа с сервером традиционным RESTful способом Интернета.

HTML-примечания: доступны по умолчанию?

Проблемы с доступностью могут возникнуть, когда мы пытаемся реализовать элементы управления, не встроенные в HTML.

Ранее, в первой главе, мы рассмотрели пример импровизированного <div>, работающего как кнопка. Давайте рассмотрим другой пример: что, если вы создадите что-то, похожее на набор вкладок, но для его создания вы используете переключатели и хаки CSS? Это изящный хак, который время от времени распространяется в сообществах веб-разработчиков.

Проблема здесь в том, что у вкладок есть требования, помимо нажатия на них для изменения содержимого. В ваших импровизированных вкладках могут отсутствовать функции, что приведет к замешательству и разочарованию пользователей, а также к нежелательному поведению. Из Руководства по практике авторской разработки ARIA на вкладках:

Вам придется написать много кода, чтобы ваши импровизированные вкладки соответствовали всем этим требованиям. Некоторые атрибуты ARIA можно добавить непосредственно в HTML, но они повторяются, а другие (например, aria-selected) необходимо устанавливать с помощью JavaScript, поскольку они являются динамическими. Взаимодействие с клавиатурой также может быть подвержено ошибкам.

Создать собственную реализацию набора вкладок не невозможно, даже не так сложно. Однако трудно поверить, что новая реализация будет работать для всех пользователей во всех средах, поскольку у большинства из нас ограничены ресурсы для тестирования.

Придерживайтесь установленных библиотек для взаимодействия с пользовательским интерфейсом. Если вариант использования требует индивидуального решения, тщательно протестируйте взаимодействие с клавиатурой и ее доступность. Тестируйте вручную. Тестируйте автоматически. Тестируйте с помощью программ чтения с экрана, тестируйте с помощью клавиатуры, тестируйте на разных браузерах и оборудовании и запускайте линтеры (во время написания кода и/или в CI). Тестирование имеет решающее значение для обеспечения машиночитаемости, читаемости человеком или веса страницы.

Также подумайте: нужно ли представлять информацию в виде вкладок? Иногда ответ положительный, но если нет, то последовательность элементов details и раскрытиe элементов details преследует очень похожую цель.

<details><summary>Disclosure 1</summary>
  Disclosure 1 contents
</details>
<details><summary>Disclosure 2</summary>
  Disclosure 2 contents
</details>

Портить UX только ради того, чтобы избежать JavaScript, — это плохая разработка. Но иногда можно добиться такого же (или лучшего!) качества UX, допуская при этом более простую и надежную реализацию.

Глава 7
Динамический архив UI

Contact.app прошел долгий путь от традиционного веб-приложения в стиле Web 1.0: мы добавили активный поиск, массовое удаление, несколько приятных анимаций и множество других функций. Мы достигли уровня интерактивности, который, по мнению большинства веб-разработчиков, требует какой-то инфраструктуры JavaScript для одностраничных приложений, но мы сделали это, используя вместо этого гипермедиа на базе htmx.

Давайте посмотрим, как мы можем добавить в Contact.app еще одну важную функцию: загрузку архива всех контактов.

С точки зрения гипермедиа загрузка файла не совсем сложная задача: используя заголовок ответа HTTP Content-Disposition, мы можем легко указать браузеру загрузить и сохранить файл на локальном компьютере.

Однако давайте сделаем эту проблему более интересной: добавим тот факт, что экспорт может занять некоторое время, от пяти до десяти секунд, а иногда и дольше.

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

Это классическая проблема разработки веб-приложений. Столкнувшись с потенциально длительным процессом, подобным этому, у нас в конечном итоге есть два варианта:

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

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

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

Мы видели несколько разных «простых» подходов в этом сценарии в различных веб-приложениях:

Все это будет работать, но ни одно из них не принесет хорошего пользовательского опыта.

Что нам действительно хотелось бы в этом сценарии, так это что-то похожее на то, что вы видите, например, когда загружаете большой файл через браузер: красивый индикатор выполнения, показывающий, на каком этапе процесса вы находитесь, и, когда процесс завершится, ссылка, по которой можно немедленно щелкнуть, чтобы просмотреть результат процесса.

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

Требования к UI

Прежде чем мы углубимся в реализацию, давайте обсудим в общих чертах, как должен выглядеть наш новый пользовательский интерфейс: нам нужна кнопка в приложении с надписью «Download Contact Archive». Когда пользователь нажимает на эту кнопку, мы хотим заменить эту кнопку пользовательским интерфейсом, который показывает ход процесса архивирования, в идеале с индикатором выполнения. По мере выполнения задания архивирования мы хотим перемещать индикатор выполнения в сторону завершения. Затем, когда задание архивирования будет завершено, мы хотим показать пользователю ссылку для загрузки файла архива контактов.

Чтобы действительно выполнить архивирование, мы собираемся использовать класс Python Archiver, который реализует все необходимые нам функции. Как и в случае с классом Contact, мы не будем вдаваться в подробности реализации Archiver, поскольку это выходит за рамки данной книги. На данный момент вам просто нужно знать, что он обеспечивает все поведение на стороне сервера, необходимое для запуска процесса архивирования контактов и получения результатов после завершения этого процесса.

Archiver предоставляет нам следующие методы для работы:

Довольно несложный API.

Единственным сложным аспектом всего API является то, что метод run() не блокируется. Это означает, что он не создает файл архива сразу, а запускает фоновое задание (в виде потока) для фактического архивирования. Это может сбить с толку, если вы не привыкли к многопоточности кода: вы можете ожидать, что метод run() «блокируется», то есть фактически выполнит весь экспорт и вернется только после его завершения. Но если бы это произошло, мы не смогли бы запустить процесс архивирования и немедленно отобразить желаемый пользовательский интерфейс процесса архивирования.

Начало нашей реализации

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

Итак, для начала обратите внимание, что этот пользовательский интерфейс в значительной степени автономен: мы хотим заменить кнопку индикатором выполнения загрузки, а затем индикатор выполнения ссылкой для загрузки результатов завершенного процесса архивирования.

Тот факт, что весь наш архивный пользовательский интерфейс будет находиться в определенной части пользовательского интерфейса, является убедительным намеком на то, что мы захотим создать новый шаблон для его обработки. Назовем этот шаблон archive_ui.html.

Также обратите внимание, что в нескольких случаях нам понадобится заменить весь пользовательский интерфейс загрузки:

Чтобы обновить пользовательский интерфейс таким образом, нам нужно установить хорошую цель для обновлений. Итак, давайте обернем весь пользовательский интерфейс тегом div, а затем будем использовать этот div в качестве цели для всех наших операций.

Вот начало шаблона нашего нового пользовательского интерфейса архива:

Листинг 106. Наш первоначальный шаблон UI архива

<div id="archive-ui"
     hx-target="this" <!-- 1 -->
     hx-swap="outerHTML"> <!-- 2 -->
</div>
  1. Этот div будет целью для всех элементов внутри него.

  2. Заменять весь div каждый раз, используя outerHTML.

Далее, давайте добавим кнопку «Download Contact Archive» в элемент div, который запустит процесс архивирования и последующей загрузки. Мы будем использовать POST для пути /contacts/archive, чтобы запустить процесс архивирования:

Листинг 107. Добавление кнопки архивирования

<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
  <button hx-post="/contacts/archive"> <!-- 1 -->
    Download Contact Archive
  </button>
</div>
  1. Эта кнопка отправит POST в /contacts/archive.

Наконец, давайте добавим этот новый шаблон в наш основной шаблон index.html над таблицей контактов:

Листинг 108. Наш первоначальный шаблон UI архива

{% block content %}

  {% include 'archive_ui.html' %} <!-- 1 -->

  <form action="/contacts" method="get" class="tool-bar">
  1. Этот шаблон теперь будет включен в основной шаблон.

После этого в нашем веб-приложении появилась кнопка, позволяющая начать загрузку. Поскольку в оборачивающем div есть hx-target="this", кнопка унаследует эту цель и заменит этот включающий div любым HTML-кодом, возвращаемым из POST в /contacts/archive.

Добавление конечной точки архивирования

Наш следующий шаг — обработка POST, который создает наша кнопка. Мы хотим получить Archiver для текущего пользователя и вызвать для него метод run(). Это запустит процесс архивирования. Затем мы отобразим новый контент, указывающий, что процесс запущен.

Для этого мы хотим повторно использовать шаблон archive_ui для обработки рендеринга пользовательского интерфейса архива для обоих состояний: когда архиватор находится в состоянии «Waiting» и когда он «Running». (С состоянием «Complete» мы разберемся чуть позже).

Это очень распространенный шаблон: мы помещаем все различные потенциальные пользовательские интерфейсы для данного фрагмента пользовательского интерфейса в один шаблон и условно отображаем соответствующий интерфейс. Храня все в одном файле, другим разработчикам (или нам, если мы вернемся через некоторое время!) будет намного проще понять, как именно работает пользовательский интерфейс на стороне клиента.

Поскольку мы собираемся условно отображать различные пользовательские интерфейсы в зависимости от состояния архиватора, нам нужно будет передать архиватор в шаблон в качестве параметра. Итак, еще раз: нам нужно вызвать run() для архиватора в нашем контроллере, а затем передать архиватор шаблону, чтобы он мог отображать пользовательский интерфейс, соответствующий текущему состоянию процесса архивирования.

Вот как выглядит код:

Листинг 109. Серверный код для запуска процесса архивирования

@app.route("/contacts/archive", methods=["POST"]) # 1
def start_archive():
    archiver = Archiver.get() # 2
    archiver.run() # 3
    return render_template("archive_ui.html",
archiver=archiver) # 4
  1. Обработка POST в /contacts/archive.

  2. Найти Archiver.

  3. Вызвать для него неблокирующий метод run().

  4. Отобразить шаблон archive_ui.html, передав его в архиватор.

Условный рендеринг пользовательского интерфейса прогресса

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

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

Если архиватор имеет статус Waiting, мы хотим отобразить кнопку «Download Contact Archive». Если статус Running, мы хотим отобразить сообщение, указывающее, что прогресс происходит. Давайте обновим код нашего шаблона, чтобы сделать именно это:

Листинг 110. Добавление условного рендеринга

<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
  {% if archiver.status() == "Waiting" %} <!-- 1 -->
    <button hx-post="/contacts/archive">
      Download Contact Archive
    </button>
  {% elif archiver.status() == "Running" %} <!-- 2 -->
    Running... <!-- 3 -->
  {% end %}
</div>
  1. Отрисовать кнопку архивирования только в том случае, если статус «Waiting».

  2. Отобразить другой контент, когда статус «Running».

  3. На данный момент просто текст о том, что процесс запущен.

Хорошо, отлично, у нас есть некоторая условная логика в представлении шаблона и логика на стороне сервера для поддержки запуска процесса архивирования. У нас пока нет индикатора выполнения, но мы к этому доберемся! Давайте посмотрим, как это работает в нынешнем виде, и обновим главную страницу нашего приложения…

Листинг 111. Что-то пошло не так

UndefinedError
jinja2.exceptions.UndefinedError: 'archiver' is undefined

Ой!

Мы получаем сообщение об ошибке прямо из коробки. Почему? Ах, мы включаем archive_ui.html в шаблон index.html, но теперь шаблон archive_ui.html ожидает передачи ему архиватора, поэтому он может условно отображать правильный пользовательский интерфейс.

Это легко исправить: нам просто нужно передать архиватор при рендеринге шаблона index.html:

Листинг 112. Включение архиватора при рендеринге index.html

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    if search is not None:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search':
            return render_template("rows.html", contacts=contacts_set)
    else:
        contacts_set = Contact.all()
    return render_template("index.html", contacts=contacts_set, archiver=Archiver.get()) # 1
  1. Через архиватор перейти к основному шаблону

Теперь, когда все готово, мы можем загрузить страницу. И, конечно же, мы видим кнопку «Download Contact Archive».

Когда мы нажимаем на нее, кнопка заменяется содержимым «Running…», и мы видим в нашей консоли разработки на стороне сервера, что задание действительно запускается должным образом.

Опрос

Это определенно прогресс, но у нас здесь не лучший индикатор прогресса: просто какой-то статический текст, сообщающий пользователю, что процесс запущен.

Мы хотим обновлять контент по мере продвижения процесса и, в идеале, показывать индикатор выполнения, показывающий, насколько далеко он продвинулся. Как мы можем сделать это в htmx, используя старую добрую гипермедиа?

Метод, который мы хотим здесь использовать, называется «опрос» (polling), при котором мы выдаем запрос через определенный интервал и обновляем пользовательский интерфейс в зависимости от нового состояния сервера.

Опрос? Действительно?

Опрос имеет плохую репутацию, и это не самый популярный метод в мире: сегодня разработчики могут рассмотреть более продвинутый метод, такой как WebSockets или Server Sent Events (SSE), чтобы решить эту ситуацию.

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

Htmx предлагает два типа опроса. Первый — это «опрос с фиксированной частотой», который использует специальный синтаксис hx-trigger, чтобы указать, что что-то должно быть опрошено через фиксированный интервал.

Вот пример:

Листинг 113. Опрос с фиксированным интервалом

<div hx-get="/messages" hx-trigger="every 3s"> <!-- 1 -->
</div>
  1. Запускайте GET для /messages каждые три секунды.

Это отлично работает в ситуациях, когда вы хотите проводить опрос бесконечно, например, если вы хотите постоянно опрашивать новые сообщения для отображения пользователю. Однако опрос с фиксированной частотой не идеален, если у вас есть определенный процесс, после которого вы хотите остановить опрос: он продолжает опрос навсегда, пока элемент, на котором он находится, не будет удален из DOM.

В нашем случае мы имеем определенный процесс с его окончанием. Поэтому лучше использовать второй метод опроса, известный как «опрос нагрузки». При опросе нагрузки мы пользуемся тем фактом, что htmx запускает событие load, когда контент загружается в DOM. Мы можем создать триггер для этого события load и добавить небольшую задержку, чтобы запрос не запускался немедленно.

Благодаря этому мы можем условно отображать hx-trigger при каждом запросе: когда процесс завершается, мы просто не включаем триггер load, и опрос нагрузки прекращается. Это предлагает хороший и простой способ опроса до завершения определенного процесса.

Использование опроса для обновления пользовательского интерфейса архива

Давайте воспользуемся опросом нагрузки, чтобы обновлять наш пользовательский интерфейс по мере работы архиватора. Чтобы показать ход выполнения, давайте воспользуемся индикатором выполнения на основе CSS, воспользовавшись преимуществами метода progress(), который возвращает число от 0 до 1, указывающее, насколько близок процесс архивирования к завершению.

Вот фрагмент HTML, который мы будем использовать:

Листинг 114. Индикатор выполнения на основе CSS

<div class="progress">
  <div class="progress-bar"
       style="width:{{ archiver.progress() * 100 }}%"></div> <!-- 1 -->
</div>
  1. Ширина внутреннего элемента соответствует прогрессу.

Этот индикатор выполнения на основе CSS состоит из двух компонентов: внешнего элемента div, который обеспечивает каркас для индикатора выполнения, и внутреннего элемента div, который является фактическим индикатором индикатора выполнения. Мы устанавливаем ширину внутреннего индикатора выполнения в некоторый процент (обратите внимание, что нам нужно умножить результат progress() на 100, чтобы получить процент), и это сделает индикатор прогресса соответствующей ширины в родительском div.

А как насчет элемента <progress>?

Возможно, здесь мы погружаемся в «суп div», используя тег div, когда есть совершенно хороший тег HTML5, элемент progress, который разработан специально для отображения прогресса.

Мы решили не использовать элемент progress для этого примера, потому что мы хотим, чтобы наш индикатор выполнения обновлялся плавно, и чтобы это произошло, нам нужно будет использовать технику CSS, недоступную для элемента progress. Это прискорбно, но иногда нам приходится играть с теми картами, которые нам сдали.

Однако мы будем использовать соответствующие роли индикатора выполнения, чтобы наш индикатор выполнения на основе div хорошо работал со вспомогательными технологиями.

Давайте обновим наш индикатор выполнения, чтобы он имел правильные роли и значения ARIA:

Листинг 115. Индикатор выполнения на основе CSS

<div class="progress">
  <div class="progress-bar"
       role="progressbar" <!-- 1 -->
       aria-valuenow="{{ archiver.progress() * 100}}}" <!-- 2 -->
       style="width:{{ archiver.progress() * 100 }}%"></div> <!-- 1 -->
</div>
  1. Этот элемент будет действовать как индикатор выполнения.

  2. Прогрессом будет процентная завершенность архиватора, где 100 означает полную завершенность.

Наконец, для полноты картины, вот CSS, который мы будем использовать для этого индикатора выполнения:

Листинг 116. CSS для нашего индикатора выполнения

.progress {
  height: 20px;
  margin-bottom: 20px;
  overflow: hidden;
  background-color: #f5f5f5;
  border-radius: 4px;
  box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
}
.progress-bar {
  float: left;
  width: 0%;
  height: 100%;
  font-size: 12px;
  line-height: 20px;
  color: #fff;
  text-align: center;
  background-color: #337ab7;
  box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
  transition: width .6s ease;
}

В итоге рендеринг выглядит следующим образом:

Рисунок 10. Наш индикатор выполнения на основе CSS

Добавление UI индикатора выполнения

Давайте добавим код нашего индикатора выполнения в наш шаблон archive_ui.html для случая, когда архиватор запущен, и обновим копию, написав «Creating Archive...»:

Листинг 117. Добавление индикатора выполнения

<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
  {% if archiver.status() == "Waiting" %}
    <button hx-post="/contacts/archive">
      Download Contact Archive
    </button>
  {% elif archiver.status() == "Running" %}
    <div>
      Creating Archive...
      <div class="progress" > <!-- 1 -->
        <div class="progress-bar" role="progressbar"
             aria-valuenow="{{ archiver.progress() * 100}}"
             style="width:{{ archiver.progress() * 100 }}%"></div>
      </div>
    </div>
  {% endif %}
</div>
  1. Наш новый блестящий индикатор выполнения

Теперь, когда мы нажимаем кнопку «Download Contact Archive», мы видим индикатор выполнения. Но он по-прежнему не обновляется, потому что мы еще не реализовали опрос нагрузки: он просто находится там, на нуле.

Чтобы обеспечить динамическое обновление индикатора выполнения, нам нужно реализовать опрос нагрузки с помощью hx-trigger. Мы можем добавить это практически к любому элементу внутри условного блока, когда архиватор работает, поэтому давайте добавим его в этот div, который окружает текст «Creating Archive...» и индикатор выполнения.

Давайте сделаем опрос, отправив HTTP GET по тому же пути, что и POST: /contacts/archive.

Листинг 118. Реализация опроса нагрузки

<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
  {% if archiver.status() == "Waiting" %}
    <button hx-post="/contacts/archive">
      Download Contact Archive
    </button>
  {% elif archiver.status() == "Running" %}
    <div hx-get="/contacts/archive" hx-trigger="load delay:500ms"> <!-- 1 -->
      Creating Archive...
      <div class="progress" >
        <div class="progress-bar" role="progressbar"
             aria-valuenow="{{ archiver.progress() * 100}}"
             style="width:{{ archiver.progress() * 100 }}%"></div>
      </div>
    </div>
  {% endif %}
</div>
  1. Выполните GET для /contacts/archive через 500 миллисекунд после загрузки контента.

Когда этот GET передается в /contacts/archive, он заменит div на id archive-ui, а не только на себя. Атрибут hx-target в элементе div с идентификатором archive-ui наследуется всеми дочерними элементами в этом элементе div, поэтому все дочерние элементы будут нацелены на самый внешний элемент div в файле archive_ui.html.

Теперь нам нужно обработать GET в /contacts/archive на сервере. К счастью, это довольно просто: все, что нам нужно сделать, это перерисовать archive_ui.html с помощью архиватора:

Листинг 119. Обработка обновлений хода выполнения

@app.route("/contacts/archive", methods=["GET"]) # 1
def archive_status():
    archiver = Archiver.get()
    return render_template("archive_ui.html", archiver=archiver) # 2
  1. Обработать GET по пути /contacts/archive

  2. Просто перерисовать шаблон archive_ui.html

Как и многое другое в гипермедиа, код очень читабелен и не сложен.

Теперь, когда мы нажимаем «Download Contact Archive», мы действительно получаем индикатор выполнения, который обновляется каждые 500 миллисекунд. В результате вызова archiver.progress() значение постепенно обновляется с 0 до 1, индикатор выполнения перемещается для нас по экрану. Очень круто!

Загрузка результата

Нам нужно обработать одно последнее состояние — случай, когда для archiver.status() установлено значение «Complete» и имеется JSON-архив данных, готовый к загрузке. Когда архиватор завершен, мы можем получить локальный файл JSON на сервере из архиватора с помощью вызова archive_file().

Давайте добавим еще один случай в наш оператор if для обработки состояния «Complete», и, когда задание архивирования будет завершено, давайте отобразим ссылку на новый путь /contacts/archive/file, который ответит заархивированным файлом JSON. Вот новый код:

Листинг 120. Отображение ссылки для скачивания после завершения архивирования

<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
  {% if archiver.status() == "Waiting" %}
    <button hx-post="/contacts/archive">
      Download Contact Archive
    </button>
  {% elif archiver.status() == "Running" %}
    <div hx-get="/contacts/archive" hx-trigger="load delay:500ms">
      Creating Archive...
      <div class="progress" >
        <div class="progress-bar" role="progressbar"
             aria-valuenow="{{ archiver.progress() * 100}}"
             style="width:{{ archiver.progress() * 100 }}%"></div>
      </div>
    </div>
  {% elif archiver.status() == "Complete" %} <!-- 1 -->
    <a hx-boost="false" href="/contacts/archive/file">Archive Ready! Click here to download. &  downarrow;</a> <!-- 2 -->
  {% endif %}
</div>
  1. Если статус «Complete», отобразить ссылку для скачивания.

  2. Ссылка выдаст GET в /contacts/archive/file.

Обратите внимание, что для ссылки hx-boost установлено значение false. Это сделано для того, чтобы ссылка не наследовала поведение повышения, присутствующее для других ссылок, и, следовательно, не была выдана через AJAX. Нам нужно такое «нормальное» поведение ссылки, поскольку запрос AJAX не может загрузить файл напрямую, тогда как простой тег якоря может.

Загрузка готового архива

Последний шаг — обработка запроса GET к /contacts/archive/file. Мы хотим отправить файл, созданный архиватором, клиенту. Нам повезло: у Flask есть механизм отправки файла в качестве загруженного ответа — метод send_file().

Как вы видите в следующем коде, мы передаем в функцию send_file() три аргумента: путь к архивному файлу, созданному архиватором, имя файла, который мы хотим, чтобы браузер создал, и, если мы хотим, чтобы он был отправлен «в качестве вложения". Этот последний аргумент сообщает Flask установить заголовок HTTP-ответа Content-Disposition для прикрепления (attachment) с заданным именем файла; это то, что запускает поведение браузера при загрузке файлов.

Листинг 121. Отправка файла клиенту

@app.route("/contacts/archive/file", methods=["GET"])
def archive_content():
    manager = Archiver.get()
    return send_file(manager.archive_file(), "archive.json", as_attachment=True) # 1
  1. Отправить файл клиенту с помощью метода send_file() Flask.

Идеально. Теперь у нас есть очень удобный пользовательский интерфейс архива. Вы нажимаете кнопку «Download Contacts Archive», и появляется индикатор выполнения. Когда индикатор выполнения достигнет 100%, он исчезнет и появится ссылка для скачивания архивного файла. Затем пользователь может нажать на эту ссылку и загрузить свой архив.

Мы предлагаем пользовательский интерфейс, который гораздо более удобен для пользователя, чем обычный интерфейс многих веб-сайтов, основанный на принципах «нажми и подожди».

Плавность: анимация в Htmx

Каким бы приятным ни был этот пользовательский интерфейс, есть один небольшой недостаток: по мере обновления индикатора выполнения он «прыгает» из одной позиции в другую. Это немного похоже на полное обновление страницы в приложениях в стиле Web 1.0. Есть ли способ это исправить? (Очевидно, что есть, поэтому мы выбрали элемент div, а не элемент progress!)

Давайте рассмотрим причину этой визуальной проблемы и способы ее устранения. (Если вы спешите получить ответ, смело переходите к «нашему решению».)

Оказывается, существует собственная технология HTML для сглаживания изменений элемента из одного состояния в другое: API CSS Transitions, тот самый, который мы обсуждали в главе 4. Используя CSS Transitions, вы можете плавно анимировать элемент между различными состояниями. стилизация с использованием свойства transition.

Если вы снова посмотрите на наше CSS-определение класса .progress-bar, вы увидите следующее определение перехода: transition: width .6s ease;. Это означает, что когда ширина индикатора выполнения изменяется, скажем, с 20% на 30%, браузер будет анимироваться в течение 0,6 секунды с использованием функции «ease» (которая имеет хороший эффект ускорения/замедления).

Так почему же этот переход не применяется в нашем текущем пользовательском интерфейсе? Причина в том, что в нашем примере htmx заменяет индикатор выполнения новым каждый раз при опросе. Он не обновляет ширину существующего элемента. К сожалению, переходы CSS применяются только тогда, когда свойства существующего элемента изменяются внутри, а не при замене элемента.

Именно по этой причине приложения, основанные на чистом HTML, могут показаться прерывистыми и неотшлифованными по сравнению с их аналогами из SPA: сложно использовать CSS-переходы без некоторого количества JavaScript.

Но есть и хорошие новости: у htmx есть способ использовать переходы CSS, даже если он заменяет контент в DOM.

Шаг «Согласования» в Htmx

Когда мы обсуждали модель подкачки htmx в главе 4, мы сосредоточились на классах, которые добавляют и удаляют htmx, но пропустили процесс «согласования». В htmx согласование включает в себя несколько шагов: когда htmx собирается заменить часть контента, он просматривает новый контент и находит все элементы с указанным id. Затем он ищет в существующем контенте элементы с тем же id.

Если они есть, он выполняет следующую довольно сложную перетасовку:

Итак, чего же должен добиться этот странный маленький танец?

Что ж, если элемент имеет стабильный идентификатор между заменами, теперь вы можете писать CSS-переходы между различными состояниями. Поскольку новый контент на короткое время имеет старые атрибуты, при восстановлении фактических значений сработает обычный механизм перехода CSS.

Наше решение для сглаживания

Итак, мы подошли к нашему исправлению.

Все, что нам нужно сделать, это добавить стабильный ID к нашему элементу progress-bar.

Листинг 122. Сглаживание

<div class="progress">
  <div id="archive-progress" class="progress-bar"
       role="progressbar"
       aria-valuenow="{{ archiver.progress() * 100}}"
       style="width:{{ archiver.progress() * 100 }}%"></div> <!-- 1 -->
</div>
  1. Элемент div индикатора выполнения теперь имеет стабильный id для всех запросов.

Несмотря на сложную механику, происходящую за кулисами htmx, решение так же просто, как добавление стабильного атрибута id к элементу, который мы хотим анимировать.

Теперь вместо того, чтобы прыгать при каждом обновлении, индикатор выполнения должен плавно перемещаться по экрану по мере обновления, используя переход CSS, определенный в нашей таблице стилей. Модель подкачки htmx позволяет нам добиться этого, даже если мы заменяем контент новым HTML.

И вуаля: у нас есть красивый, плавно анимированный индикатор выполнения нашей функции архивирования контактов. Результат выглядит как решение на основе JavaScript, но мы сделали это с простотой подхода на основе HTML.

Вот это, дорогой читатель, действительно вызывает радость.

Закрытие пользовательского интерфейса загрузки

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

Для этого добавим кнопку, выдающую DELETE по пути /contacts/archive, указывающую, что текущий архив можно удалить или очистить.

Мы добавим ее после ссылки для скачивания, вот так:

Листинг 123. Очистка загрузки

  <a hx-boost="false" href="/contacts/archive/file">Archive Ready! Click here to download. ↓</a>
  <button hx-delete="/contacts/archive">Clear Download</button> <!-- 1 -->
  1. Простая кнопка, которая выполняет DELETE файл /contacts/archive.

Теперь у пользователя есть кнопка, на которую он может нажать, чтобы закрыть ссылку на скачивание архива. Но нам нужно будет подключить ее на стороне сервера. Как обычно, это довольно просто: мы создаем новый обработчик для HTTP-действия DELETE, вызываем метод reset() в архиваторе и повторно отображаем шаблон archive_ui.html.

Поскольку эта кнопка выбирает ту же конфигурацию hx-target и hx-swap, что и все остальное, она «просто работает».

Вот серверный код:

Листинг 124. Обработчик сброса загрузки

@app.route("/contacts/archive", methods=["DELETE"])
def reset_archive():
    archiver = Archiver.get()
    archiver.reset() # 1
    return render_template("archive_ui.html", archiver=archiver)
  1. Вызов reset() на архиваторе

Это очень похоже на другие наши обработчики, не так ли?

Конечно, да! Вот в чем идея!

Альтернативный UX: автоматическая загрузка

Хотя мы предпочитаем текущий пользовательский интерфейс для архивирования контактов, существуют и другие альтернативы. В настоящее время индикатор выполнения показывает ход процесса, и по его завершении пользователю предоставляется ссылка для фактической загрузки файла. Другой шаблон, который мы видим в Интернете, — это «автоматическая загрузка», когда файл загружается немедленно, без необходимости нажатия пользователем ссылки.

Мы можем легко добавить эту функциональность в наше приложение, написав всего лишь немного сценариев. Мы обсудим сценарии в приложении, управляемом гипермедиа, более подробно в главе 9, но если коротко: сценарии вполне приемлемы в HDA, если они не заменяют основную механику гипермедиа приложения.

Для нашей функции автоматической загрузки мы будем использовать _hyperscript, наш предпочтительный вариант сценария. Здесь также подойдет JavaScript, и он будет почти таким же простым; Опять же, мы подробно обсудим варианты сценариев в главе 9.

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

Код _hyperscript читается почти так же, как и предыдущее предложение (это основная причина, по которой мы любим гиперскрипт):

Листинг 125. Автоматическая загрузка

  <a hx-boost="false" href="/contacts/archive/file" _="on load click() me"> <!-- 1 -->
    Archive Downloading! Click here if the download does not start.
  </a>
  1. Немного _hyperscript для автоматической загрузки файла.

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

Пользовательский интерфейс динамического архива: завершено

В этой главе нам удалось создать динамический пользовательский интерфейс для нашей функции архива контактов с индикатором выполнения и автоматической загрузкой, и мы сделали почти все это — за исключением небольшого количества сценариев для автоматической загрузки — в чистой гипермедиа. Для сборки всего этого потребовалось около 16 строк внешнего кода и 16 строк внутреннего кода.

HTML, с небольшой помощью библиотеки JavaScript, ориентированной на гипермедиа, такой как htmx, на самом деле может быть чрезвычайно мощным и выразительным.

HTML-примечания: суп Markdown

Суп Markdown — менее известный брат супа <div>. Это результат того, что веб-разработчики ограничивают себя набором элементов, для которых язык Markdown обеспечивает сокращение, даже если эти элементы неверны. Если серьезно, важно осознавать всю мощь наших инструментов, включая HTML. Рассмотрим следующий пример цитирования в стиле IEEE:

[1] C.H. Gross, A. Stepinski, and D. Akşimşek, <!--- 1 --->
_Hypermedia Systems_, <!--- 2 --->
Bozeman, MT, USA: Big Sky Software.
Available: <https://hypermedia.systems/>
  1. Номер ссылки указан в скобках.

  2. Подчеркивание вокруг названия книги создает элемент <em>.

Здесь используется <em>, потому что это единственный элемент Markdown, который по умолчанию отображается курсивом. Это указывает на то, что название книги подчеркивается, но цель состоит в том, чтобы обозначить его как название произведения. В HTML есть элемент <cite>, предназначенный именно для этой цели.

Более того, несмотря на то, что это нумерованный список, идеально подходящий для элемента <ol>, который поддерживает Markdown, вместо этого для ссылочных номеров используется простой текст. Почему это могло быть? Стиль цитирования IEEE требует, чтобы эти числа были представлены в квадратных скобках. Этого можно добиться в <ol> с помощью CSS, но в Markdown нет способа добавить класс к элементам, что означает, что квадратные скобки будут применяться ко всем упорядоченным спискам.

Не уклоняйтесь от использования встроенного HTML в Markdown. Для более крупных сайтов также рассмотрите возможность расширения Markdown.

{.ieee-reference-list} <!--- 1 --->
1. C.H. Gross, A. Stepinski, and D. Akşimşek, <!--- 2 --->
    <cite>Hypermedia Systems</cite>, <!--- 3 --->
    Bozeman, MT, USA: Big Sky Software.
    Available: <https://hypermedia.systems/>
  1. Многие диалекты Markdown позволяют добавлять идентификаторы, классы и атрибуты с помощью фигурных скобок.

  2. Теперь мы можем использовать элемент <ol> и создать скобки в CSS.

  3. Мы используем <cite> для обозначения названия цитируемой работы (а не всей цитаты!)

Вы также можете использовать специальные процессоры для создания более подробного HTML-кода вместо того, чтобы писать его вручную:

{% reference_list %} <!--- 1 --->
[hypers2023]: <!--- 2 --->
C.H. Gross, A. Stepinski, and D. Akşimşek, _Hypermedia
Systems_,
Bozeman, MT, USA: Big Sky Software, 2023.
Available: <https://hypermedia.systems/>
{% end %}
  1. reference_list — это макрос, который преобразует простой текст в подробный HTML.

  2. Процессор также может разрешать идентификаторы, поэтому нам не нужно вручную поддерживать порядок в списке литературы и синхронизировать внутритекстовые цитаты.

Глава 8
Уловки мастеров Htmx

Расширенный Htmx

В этой главе мы собираемся более подробно изучить набор инструментов htmx. Мы уже многого добились благодаря тому, что узнали на данный момент. Тем не менее, когда вы разрабатываете приложения, управляемые гипермедиа, могут возникнуть моменты, когда вам потребуется использовать дополнительные параметры и методы.

Мы рассмотрим более сложные атрибуты в htmx, а также подробно остановимся на атрибутах, которые мы уже использовали.

Кроме того, мы рассмотрим функциональность, которую htmx предлагает помимо простых атрибутов HTML: как htmx расширяет стандартные HTTP-запросы и ответы, как htmx работает с событиями (и создает их), и как подходить к ситуациям, когда нет простой единой цели для страница, которую необходимо обновить.

Наконец, мы рассмотрим практические аспекты разработки htmx: как эффективно отлаживать приложения на основе htmx, соображения безопасности, которые необходимо учитывать при работе с htmx, и как настроить поведение htmx.

Благодаря функциям и методам, описанным в этой главе, вы сможете создавать чрезвычайно сложные пользовательские интерфейсы, используя только Htmx и, возможно, немного клиентских сценариев, дружественных к гипермедиа.

Htmx-атрибуты

На данный момент в нашем приложении мы использовали около пятнадцати различных атрибутов htmx. Наиболее важными из них были:

hx-get, hx-post и т. д.
Чтобы указать запрос AJAX, который элемент должен выполнить
hx-trigger
Чтобы указать событие, которое запускает запрос
hx-swap
Чтобы указать, как заменить возвращенное содержимое HTML в DOM
hx-target
Чтобы указать, где в DOM заменить возвращаемое содержимое HTML

Два из этих атрибутов, hx-swap и hx-trigger, поддерживают ряд полезных опций для создания более продвинутых приложений, управляемых гипермедиа.

hx-swap

Начнем с атрибута hx-swap. Он часто не включается в элементы, которые отправляют запросы на основе htmx, поскольку его поведение по умолчанию — innerHTML, которое заменяет внутренний HTML-код элемента — имеет тенденцию охватывать большинство случаев использования.

Ранее мы видели ситуации, когда нам хотелось переопределить поведение по умолчанию и использовать, например, outerHTML. А в главе 2 мы обсуждали некоторые другие варианты замены, помимо этих двух: beforebegin, afterend и т. д.

В главе 5 мы также рассмотрели модификатор задержки swap для hx-swap, который позволял нам затемнять некоторый контент перед его удалением из DOM.

В дополнение к этому, hx-swap предлагает дополнительный контроль со следующими модификаторами:

settle
Как и при замене (swap), он позволяет вам применять определенную задержку между моментом замены содержимого в DOM и моментом «установления» его атрибутов, то есть обновления старых значений (если таковые имеются) до новых значений. Это может дать вам детальный контроль над переходами CSS.
show
Позволяет указать элемент, который должен отображаться — то есть при необходимости прокручиваться в область просмотра браузера — после завершения запроса.
scroll
Позволяет указать прокручиваемый элемент (то есть элемент с полосами прокрутки), который должен быть прокручен вверх или вниз после завершения запроса.
focus-scroll
Позволяет указать, что htmx должен прокручиваться до элемента, находящегося в фокусе, после завершения запроса. По умолчанию для этого модификатора установлено значение «false».

Так, например, если бы у нас была кнопка, которая выдавала запрос GET, и мы хотели бы прокрутить до верхней части элемента body после завершения запроса, мы бы написали следующий HTML-код:

Листинг 126. Прокрутка к началу страницы

<button hx-get="/contacts"
        hx-target="#content-div"
        hx-swap="innerHTML show:body:top"> <!-- 1 -->
  Get Contacts
</button>
  1. Указывает htmx отображать верхнюю часть тела после того, как произойдет замена.

Более подробную информацию и примеры можно найти в Интернете в документации hx-swap.

hx-trigger

Как и hx-swap, hx-trigger часто можно опустить при использовании htmx, поскольку поведение по умолчанию обычно соответствует вашему желанию. Напомним, что триггерные события по умолчанию определяются типом элемента:

Однако бывают случаи, когда вам нужна более подробная спецификация триггера. Классическим примером является пример активного поиска, который мы реализовали в Contact.app:

Листинг 127. Входные данные активного поиска

  <input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
         hx-get="/contacts"
         hx-trigger="search, keyup delay:200ms changed"/> <!-- 1 -->
  1. Подробная спецификация триггера.

В этом примере использованы два модификатора, доступные для атрибута hx-trigger:

delay
Позволяет указать задержку ожидания перед отправкой запроса. Если событие происходит снова, первое событие отбрасывается и таймер сбрасывается. Это позволяет «отклонять» запросы.
changed
Позволяет указать, что запрос должен выполняться только при изменении свойства значения данного элемента.

hx-trigger имеет несколько дополнительных модификаторов. Это имеет смысл, поскольку события довольно сложны, и мы хотим иметь возможность воспользоваться всей мощью, которую они предлагают. Подробнее о событиях мы поговорим ниже.

Вот другие модификаторы, доступные в hx-trigger:

once
Данное событие вызовет запрос только один раз.
throttle
Позволяет регулировать события, выдавая их только один раз в определенный интервал. Это отличается от delay тем, что первое событие сработает немедленно, но любые последующие события не сработают, пока не истечет период времени регулирования.
from
Селектор CSS, который позволяет вам выбрать другой элемент для прослушивания событий. Мы увидим пример его использования позже в этой главе.
target
Селектор CSS, который позволяет фильтровать события, выбирая только те, которые происходят непосредственно с данным элементом. В DOM события «всплывают» к своим родительским элементам, поэтому событие click на кнопке также вызывает событие click в родительском элементе div, вплоть до элемента body. Иногда вам нужно указать событие непосредственно для данного элемента, и этот атрибут позволяет вам это сделать.
consume
Если для этого параметра установлено значение true, событие-триггер будет отменено и не распространится на родительские элементы.
queue
Эта опция позволяет вам указать, как события помещаются в очередь в htmx. По умолчанию, когда htmx получает триггерное событие, он выдает запрос и запускает очередь событий. Если запрос все еще находится в работе, когда получено другое событие, оно будет помещено в очередь и, когда запрос завершится, инициирует новый запрос. По умолчанию он сохраняет только последнее полученное событие, но вы можете изменить это поведение, используя этот параметр: например, вы можете установить для него значение none и игнорировать все триггерные события, которые происходят во время запроса.
Триггерные фильтры

Атрибут hx-trigger также позволяет указать фильтр для событий с помощью квадратных скобок, заключающих выражение JavaScript после имени события.

Допустим, у вас сложная ситуация, когда контакты можно восстановить только в определенных ситуациях. У вас есть функция JavaScript contactRetrievalEnabled(), которая возвращает логическое значение: true, если контакты можно получить, и false в противном случае. Как вы могли бы использовать эту функцию, чтобы разместить шлюз на кнопке, которая отправляет запрос на /contacts?

Чтобы сделать это с помощью фильтра событий в htmx, вы должны написать следующий HTML:

Листинг 128. Входные данные активного поиска

<script>
  function contactRetrievalEnabled() {
    // код для проверки, включен ли поиск контактов
    ...
  }
</script>
<button hx-get="/contacts"
        hx-trigger="click[contactRetrievalEnabled()]"> <!-- 1 -->
  Get Contacts
</button>
  1. Запрос выдается при нажатии только тогда, когда contactRetrievalEnabled() возвращает true.

Кнопка не выдаст запрос, если contactRetrievalEnabled() вернет false, что позволяет вам динамически контролировать время выполнения запроса. Существуют распространенные ситуации, требующие триггера событий, когда вы хотите отправить запрос только при определенных обстоятельствах:

Используя фильтры событий, вы можете использовать любую логику для фильтрации запросов по htmx.

Синтетические события

В дополнение к этим модификаторам hx-trigger предлагает несколько «синтетических» событий, то есть событий, которые не являются частью обычного DOM API. Мы уже видели load и revealed в наших примерах отложенной загрузки и бесконечной прокрутки, но htmx также предоставляет событие intersect (пересечение), которое срабатывает, когда элемент пересекает свой родительский элемент.

Это синтетическое событие использует современный API Intersection Observer, о котором вы можете узнать больше на MDN.

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

Атрибут hx-trigger, безусловно, самый сложный в htmx. Более подробную информацию и примеры можно найти в документации.

Другие атрибуты

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

Вот некоторые из наиболее полезных:

hx-push-url
«Проталкивает» URL-адрес запроса (или какое-либо другое значение) в панель навигации.
hx-preserve
Сохраняет часть DOM между запросами; исходное содержимое будет сохранено независимо от того, что будет возвращено.
hx-sync
Синхронизированные запросы между двумя или более элементами.
hx-disable
Отключает поведение htmx для этого элемента и всех дочерних элементов. Мы вернемся к этому, когда будем обсуждать тему безопасности.

Давайте взглянем на hx-sync, который позволяет нам синхронизировать запросы AJAX между двумя или более элементами. Рассмотрим простой случай, когда у нас есть две кнопки, которые нацелены на один и тот же элемент на экране:

Листинг 129. Две конкурирующие кнопки

<button hx-get="/contacts" hx-target="body"> <!-- 1 -->
  Get Contacts
</button>
<button hx-get="/settings" hx-target="body"> <!-- 1 -->
  Get Settings
</button>

Это нормально и будет работать, но что, если пользователь нажмет кнопку «Get Contacts», а затем на ответ на запрос потребуется некоторое время? И тем временем пользователь нажимает кнопку «Get Settings»? В этом случае у нас будет одновременно выполняться два запроса.

Если запрос /settings завершится первым и отобразит информацию о настройках пользователя, пользователи могут быть очень удивлены, если начнут вносить изменения, а затем внезапно запрос /contacts завершится и вместо этого заменит все содержимое контактами!

Чтобы справиться с этой ситуацией, мы могли бы рассмотреть возможность использования hx-indicator, чтобы предупредить пользователя о том, что что-то происходит, что снижает вероятность того, что он нажмет вторую кнопку. Но если мы действительно хотим гарантировать, что между этими двумя кнопками одновременно выполняется только один запрос, правильно будет использовать атрибут hx-sync. Давайте поместим обе кнопки в div и устраним избыточную спецификацию hx-target, подняв атрибут до этого div. Затем мы можем использовать hx-sync в этом div для координации запросов между двумя кнопками.

Вот наш обновленный код:

Листинг 129a. Синхронизация двух кнопок

<div hx-target="body" <!-- 1 -->
     hx-sync="this"> <!-- 2 -->
  <button hx-get="/contacts"> <!-- 1 -->
    Get Contacts
  </button>
  <button hx-get="/settings"> <!-- 1 -->
    Get Settings
  </button>
</div>
  1. Поднять дубликаты атрибутов hx-target в родительский элемент div.

  2. Синхронизировать с родительским div.

Размещая атрибут hx-sync в элементе div со значением this, мы говорим: «Синхронизируйте все запросы htmx, которые происходят внутри этого элемента div, друг с другом». Это означает, что если у одной кнопки уже есть выполняющийся запрос, другие кнопки внутри div не будут выдавать запросы до тех пор, пока он не завершится.

Атрибут hx-sync поддерживает несколько различных стратегий, которые позволяют, например, заменять существующий запрос в процессе выполнения или ставить запросы в очередь с определенной стратегией организации очереди. Вы можете найти полную документацию, а также примеры на странице htmx.org для hx-sync.

Как видите, htmx предлагает множество функций, основанных на атрибутах, для более продвинутых приложений, управляемых гипермедиа. Полную ссылку на все атрибуты htmx можно найти на веб-сайте htmx.

События

До сих пор мы работали с событиями JavaScript в htmx в основном через атрибут hx-trigger. Этот атрибут оказался мощным механизмом управления нашим приложением с использованием декларативного, дружественного к HTML синтаксиса.

Однако с событиями мы можем сделать гораздо больше. События играют решающую роль как в расширении HTML как гипермедиа, так и, как мы увидим, в сценариях, дружественных к гипермедиа. События — это «клей», который объединяет DOM, HTML, htmx и сценарии. Вы можете думать о DOM как о сложной «шине событий» для приложений.

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

События, генерируемые Htmx

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

Вот некоторые из наиболее часто используемых событий, запускаемых htmx:

htmx:load
Срабатывает, когда новый контент загружается в DOM с помощью htmx.
htmx:configRequest
Срабатывает перед выдачей запроса, что позволяет программно настроить запрос или полностью отменить его.
htmx:afterRequest
Запускается после ответа на запрос.
htmx:abort
Пользовательское событие, которое можно отправить элементу на базе htmx, чтобы прервать открытый запрос.
Использование события htmx:configRequest

Давайте рассмотрим пример того, как работать с событиями, создаваемыми htmx. Мы будем использовать событие htmx:configRequest для настройки HTTP-запроса.

Рассмотрим следующий сценарий: ваша серверная команда решила, что они хотят, чтобы вы включали сгенерированный сервером токен для дополнительной безопасности в каждый запрос. Токен будет храниться в localStorage в браузере, в слоте special-token.

Токен устанавливается с помощью JavaScript (пока не беспокойтесь о деталях), когда пользователь впервые входит в систему:

Листинг 130. Получение токена в JavaScript

let response = await fetch("/token"); // 1
localStorage['special-token'] = await response.text();
  1. Получить значение токена, а затем установить его в localStorage.

Серверная команда хочет, чтобы вы включали этот специальный токен в каждый запрос, сделанный htmx, в качестве заголовка X-SPECIAL-TOKEN. Как вы могли бы этого добиться? Один из способов — перехватить событие htmx:configRequest и обновить объект detail.headers этим токеном из localStorage.

В VanillaJS это будет выглядеть примерно так, помещенное в тег <script> в <head> нашего HTML-документа:

Листинг 131. Добавление заголовка X-SPECIAL-TOKEN

document.body.addEventListener("htmx:configRequest", function(configEvent) {
  configEvent.detail.headers['X-SPECIAL-TOKEN'] = localStorage['special-token']; // 1
});
  1. Получить значение из локального хранилища и поместить его в заголовок.

Как видите, мы добавляем новое значение в свойство headers свойства detail события. После выполнения обработчика событий это свойство headers считывается htmx и используется для создания заголовков запроса AJAX, который он делает.

Свойство detail события htmx:configRequest содержит множество полезных свойств, которые вы можете обновить, чтобы изменить «форму» запроса, в том числе:

detail.parameters
Позволяет добавлять или удалять параметры запроса
detail.target
Позволяет обновлять цель запроса
detail.verb
Позволяет обновлять HTTP-глагол запроса (например, GET).

Так, например, если команда серверной стороны решила, что токен должен быть включен в качестве параметра, а не в качестве заголовка запроса, вы можете обновить свой код, чтобы он выглядел так:

Листинг 132. Добавление параметра token

document.body.addEventListener("htmx:configRequest", function(configEvent) {
  configEvent.detail.parameters['token'] = localStorage['special-token']; // 1
})
  1. Получить значение из локального хранилища и установить его в параметр.

Как видите, это дает вам большую гибкость при обновлении запроса AJAX, который делает htmx.

Полную документацию по событию htmx:configRequest (и другим событиям, которые могут вас заинтересовать) можно найти на веб-сайте htmx.

Отмена запроса с помощью htmx:abort

Мы можем прослушивать любое из множества полезных событий из htmx и реагировать на эти события с помощью hx-trigger. Что еще мы можем делать с событиями?

Оказывается, сам htmx прослушивает одно специальное событие: htmx:abort. Когда htmx получает это событие для элемента, к которому находится запрос, он прерывает запрос.

Рассмотрим ситуацию, когда у нас есть потенциально длительный запрос к /contacts, и мы хотим предложить пользователям возможность отменить запрос. Нам нужна кнопка, которая выдает запрос, конечно же, управляемая htmx, а затем еще одна кнопка, которая отправит событие htmx:abort первой.

Вот как может выглядеть код:

Листинг 133. Кнопка с отменой

<button id="contacts-btn" hx-get="/contacts" hx-target="body"> <!-- 1 -->
  Get Contacts
</button>
<button onclick="document.getElementById('contacts-btn').dispatchEvent(new Event('htmx:abort'))"> <!-- 2 -->
  Cancel
</button>
  1. Обычный GET-запрос на основе htmx к /contacts

  2. JavaScript для поиска кнопки и отправки ей события htxm:abort

Итак, теперь, если пользователь нажимает кнопку «Get Contacts» и запрос занимает некоторое время, он может нажать кнопку «Cancel» и завершить запрос. Конечно, в более сложном пользовательском интерфейсе вы можете захотеть отключить кнопку «Cancel», если только не выполняется HTTP-запрос, но это было бы сложно реализовать на чистом JavaScript.

К счастью, это не так уж плохо реализовать в гиперскрипте, поэтому давайте посмотрим, как это будет выглядеть:

Листинг 134. Кнопка с гиперскриптом и прерыванием

<button id="contacts-btn" hx-get="/contacts" hx-target="body">
  Get Contacts
</button>
<button _="on click send htmx:abort to #contacts-btn
           on htmx:beforeRequest from #contacts-btn remove @disabled from me
           on htmx:afterRequest from #contacts-btn add @disabled to me">
  Cancel
</button>

Теперь у нас есть кнопка «Cancel», которая отключается только тогда, когда выполняется запрос с кнопки contacts-btn. И чтобы это произошло, мы используем преимущества событий, генерируемых и обрабатываемых с помощью htmx, а также дружественного к событиям синтаксиса гиперскрипта. Ловко!

События, генерируемые сервером

В следующем разделе мы поговорим подробнее о различных способах, с помощью которых htmx улучшает регулярные HTTP-запросы и ответы, но, поскольку это связано с событиями, мы собираемся обсудить один заголовок HTTP-ответа, который поддерживает htmx: HX-Trigger. Ранее мы обсуждали, как HTTP-запросы и ответы поддерживают заголовки, пары имя-значение, содержащие метаданные о данном запросе или ответе. Мы воспользовались заголовком запроса HX-Trigger, который включает идентификатор элемента, вызвавшего данный запрос.

В дополнение к этому заголовку запроса htmx также поддерживает заголовок ответа, также называемый HX-Trigger. Этот заголовок ответа позволяет инициировать событие для элемента, отправившего запрос AJAX. Это оказывается мощным способом несвязанной координации элементов в DOM.

Чтобы увидеть, как это может работать, давайте рассмотрим следующую ситуацию: у нас есть кнопка, которая получает новые контакты из некоторой удаленной системы на сервере. Мы проигнорируем детали реализации на стороне сервера, но мы знаем, что если мы отправим POST по пути /sync, это запустит синхронизацию с системой.

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

Чтобы реализовать это, мы могли бы условно добавить заголовок ответа HX-Trigger со значением contact-updated:

Листинг 135. Условный запуск события contacts-updated

@app.route('/sync', methods=["POST"])
def sync_with_server():
    contacts_updated = RemoteServer.sync() # 1
    resp = make_response(render_template('sync.html'))
    if contacts_updated # 2
        resp.headers['HX-Trigger'] = 'contacts-updated'
    return resp
  1. Обращение к удаленной системе, которая синхронизировала с ней нашу базу контактов

  2. Если какие-либо контакты были обновлены, мы условно запускаем событие contacts-updated на клиенте.

Это значение вызовет событие contacts-updated на кнопке, которая отправила запрос AJAX к /sync. Затем мы можем воспользоваться модификатором from: атрибута hx-trigger для прослушивания этого события. С помощью этого шаблона мы можем эффективно запускать запросы htmx со стороны сервера.

Вот как может выглядеть код на стороне клиента:

Листинг 136. Таблица контактов

  <button hx-post="/integrations/1"> <!-- 1 -->
    Pull Contacts From Integration
  </button>

    ...

  <table hx-get="/contacts/table"
         hx-trigger="contacts-updated from:body"> <!-- 2 -->
    ...
  </table>
  1. Ответ на этот запрос может условно вызвать событие contacts-updated.

  2. Эта таблица прослушивает событие и обновляется, когда оно происходит.

Таблица прослушивает событие contacts-updated и делает это в элементе body. Она прослушивает элемент body, так как событие всплывает из кнопки, и это позволяет нам не связывать кнопку и таблицу вместе: мы можем перемещать кнопку и таблицу по своему усмотрению, и с помощью событий желаемое поведение будет продолжать работать нормально. Кроме того, мы можем захотеть, чтобы другие элементы или запросы запускали событие contacts-updated, поэтому это обеспечивает общий механизм обновления таблицы контактов в нашем приложении.

HTTP-запросы и ответы

Мы только что видели расширенную функцию HTTP-ответов, поддерживаемую htmx, заголовок ответа HX-Trigger, но htmx поддерживает еще несколько заголовков как для запросов, так и для ответов. В главе 4 мы обсуждали заголовки, присутствующие в HTTP-запросах. Вот некоторые из наиболее важных заголовков, которые вы можете использовать для изменения поведения htmx с ответами HTTP:

HX-Location
Вызывает перенаправление на стороне клиента в новое место.
HX-Push-Url
Помещает новый URL-адрес в адресную строку
HX-Refresh
Обновляет текущую страницу
HX-Retarget
Позволяет указать новую цель для замены содержимого ответа на стороне клиента.

Вы можете найти ссылку на все запросы и заголовки ответов в документации htmx.

Коды ответа HTTP

Еще более важным, чем заголовки ответа, с точки зрения информации, передаваемой клиенту, является коды ответов HTTP (HTTP Response Code). Мы обсуждали коды ответов HTTP в главе 3. В целом htmx обрабатывает различные коды ответов так, как и следовало ожидать: он меняет содержимое для всех кодов ответа 200-го уровня и ничего не делает для других. Однако есть два «специальных» 200-уровневых кода ответа:

Вы можете переопределить поведение htmx в отношении кодов ответа, как вы уже догадались, отвечая на событие! Событие htmx:beforeSwap позволяет вам изменить поведение htmx по отношению к различным кодам состояния.

Допустим, вместо того, чтобы ничего не делать при возникновении ошибки 404, вы хотели предупредить пользователя о том, что произошла ошибка. Для этого вам нужно вызвать метод JavaScript showNotFoundError(). Давайте добавим код для использования события htmx:beforeSwap, чтобы это произошло:

Листинг 137. Показ диалогового окна 404

document.body.addEventListener('htmx:beforeSwap',
function(evt) { // 1
  if(evt.detail.xhr.status === 404){ // 2
    showNotFoundError();
  }
});
  1. Подключиться к событию htmx:beforeSwap.

  2. Если код ответа — 404, показать пользователю диалоговое окно.

Вы также можете использовать событие htmx:beforeSwap, чтобы настроить, следует ли заменять ответ в DOM и на какой элемент должен быть нацелен ответ. Это дает вам некоторую гибкость в выборе того, как вы хотите использовать коды HTTP-ответов в своем приложении. Полную документацию по событию htmx:beforeSwap можно найти на сайте htmx.org.

Обновление другого контента

Выше мы увидели, как использовать событие, инициируемое сервером, через HTTP-заголовок ответа HX-Trigger, чтобы обновить часть DOM на основе ответа на другую часть DOM. Этот метод решает общую проблему, возникающую в приложениях, управляемых гипермедиа: «Как мне обновить другой контент?» Ведь в обычных HTTP-запросах есть только одна «цель» — весь экран, и, аналогично, в запросах на основе htmx есть только одна цель: либо явная, либо неявная цель элемента.

Если вы хотите обновить другой контент в формате htmx, у вас есть несколько вариантов:

Расширение вашего выбора

Первый и самый простой вариант — «расширить цель». То есть вместо того, чтобы просто заменять небольшую часть экрана, расширьте цель вашего htmx-запроса, пока она не станет достаточно большой, чтобы вместить все элементы, которые необходимо обновить на экране. Это имеет огромное преимущество: простота и надежность. Обратной стороной является то, что он может не обеспечить желаемый пользовательский интерфейс и может не подходить для определенного макета шаблона на стороне сервера. В любом случае, мы всегда рекомендуем сначала подумать хотя бы об этом подходе.

Внеполосные свопы

Второй вариант, немного более сложный, — воспользоваться поддержкой «внеполосного контента» (Out Of Band) в htmx. Когда htmx получает ответ, он проверяет его на наличие содержимого верхнего уровня, включающего атрибут hx-swap-oob. Этот контент будет удален из ответа, поэтому он не будет заменен в DOM обычным способом. Вместо этого он будет заменен на контент, которому он соответствует по идентификатору.

Давайте посмотрим на пример. Рассмотрим ситуацию, которая у нас была ранее, когда таблицу контактов необходимо обновить, если интеграция удаляет какие-либо новые контакты. Ранее мы решали эту проблему, используя события и событие, инициируемое сервером, через заголовок ответа HX-Trigger.

На этот раз мы будем использовать атрибут hx-swap-oob в ответе на POST для /integrations/1. Новое содержимое таблицы контактов будет добавлено к ответу.

Листинг 138. Обновленная таблица контактов

  <button hx-post="/integrations/1"> <!-- 1 -->
  Pull Contacts From Integration
  </button>
  
    ...

  <table id="contacts-table"> <!-- 2 -->
    ...
  </table>
  1. Кнопка по-прежнему отправляет POST в /integrations/1.

  2. Таблица больше не ожидает событий, но теперь у нее есть идентификатор.

Затем ответ на POST для /integrations/1 будет включать в себя содержимое, которое необходимо заменить на кнопку в соответствии с обычным механизмом htmx. Но он также будет включать новую, обновленную версию таблицы контактов, которая будет помечена как hx-swap-oob="true". Этот контент будет удален из ответа и не будет вставлен в кнопку. Вместо этого она заменяется в DOM вместо существующей таблицы, поскольку имеет соответствующий идентификатор.

Листинг 139. Ответ с внеполосным содержимым

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
...

Pull Contacts From Integration <!-- 1 -->

<table id="contacts-table" hx-swap-oob="true"> <!-- 2 -->
  ...
</table>
  1. Этот контент будет помещен в кнопку.

  2. Этот контент будет удален из ответа и заменен по идентификатору.

Используя этот метод объединения, вы можете обновлять контент в любом месте страницы. Атрибут hx-swap-oob поддерживает другие дополнительные функции, все из которых документированы.

В зависимости от того, как именно работает ваша технология шаблонов на стороне сервера и какой уровень интерактивности требуется вашему приложению, внеполосная замена может стать мощным механизмом обновления контента.

События

Наконец, самый сложный механизм обновления контента — это тот, который мы видели в разделе «События»: использование событий, инициируемых сервером, для обновления элементов. Этот подход может быть очень чистым, но также требует более глубоких концептуальных знаний HTML и событий, а также приверженности подходу, управляемому событиями. Хотя нам нравится такой стиль разработки, он подходит не всем. Обычно мы рекомендуем этот шаблон только в том случае, если философия htmx-гипермедиа, управляемой событиями, действительно вам подходит.

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

Быть прагматичным

Все эти подходы к проблеме «Обновление другого контента» будут работать, и часто будут работать хорошо. Однако может наступить момент, когда будет проще использовать другой подход для вашего пользовательского интерфейса, например реактивный. Как бы нам ни нравился подход гипермедиа, реальность такова, что существуют некоторые шаблоны UX, которые просто невозможно легко реализовать с его помощью. Каноническим примером такого рода шаблона, о котором мы упоминали ранее, является что-то вроде живой онлайн-таблицы: это просто слишком сложный пользовательский интерфейс со слишком большим количеством взаимозависимостей, чтобы его можно было эффективно реализовать посредством обмена гипермедиа с сервером.

В подобных случаях и каждый раз, когда вы чувствуете, что решение на основе htmx оказывается более сложным, чем другой подход, мы рекомендуем вам рассмотреть другую технологию. Будьте прагматичны и используйте правильный инструмент для работы. Вы всегда можете использовать htmx для тех частей вашего приложения, которые не так сложны и не требуют всей сложности реактивной среды, и сэкономить этот бюджет сложности для тех частей, которые требуют этого.

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

Отладка

Нам не стыдно признаться: мы большие поклонники событий. Они лежат в основе практически любого интересного пользовательского интерфейса и особенно полезны в DOM, когда они доступны для общего использования в HTML. Они позволяют создавать хорошо изолированное программное обеспечение, часто сохраняя при этом локальность поведения, которая нам так нравится.

Однако события не идеальны. Одна из областей, где с событиями может быть особенно сложно справиться, — это отладка: часто требуется знать, почему событие не происходит. Но где можно установить точку останова для того, чего не происходит? Ответ на данный момент таков: нигде.

Есть два метода, которые могут помочь в этом отношении: один предоставляется htmx, другой — Chrome, браузером Google.

Регистрация событий htmx

Первый метод, предоставляемый самим htmx, — это вызов метода htmx.logAll(). Когда вы это сделаете, htmx будет регистрировать все внутренние события, которые происходят в ходе его работы, загрузки контента, реагирования на события и т. д.

Это может быть сложно, но разумная фильтрация поможет вам сосредоточиться на проблеме. Вот как (примерно) выглядят журналы при нажатии на ссылку «docs» на https://htmx.org с включенной функцией logAll():

Листинг 140. Журналы htmx

htmx:configRequest
<a href="/docs/">
Object { parameters: {}, unfilteredParameters: {}, headers: {...}, target: body, verb: "get", errors: [], withCredentials: false, timeout: 0, path: "/docs/", triggeringEvent: a , ... }
htmx.js:439:29
htmx:beforeRequest
<a href="/docs/">
Object { xhr: XMLHttpRequest, target: body, requestConfig: {...}, etc: {}, pathInfo: {...}, elt: a }
htmx.js:439:29
htmx:beforeSend
<a class="htmx-request" href="/docs/">
Object { xhr: XMLHttpRequest, target: body, requestConfig: {...}, etc: {}, pathInfo: {...}, elt: a.htmx-request }
htmx.js:439:29
htmx:xhr:loadstart
<a class="htmx-request" href="/docs/">
Object { lengthComputable: false, loaded: 0, total: 0, elt: a.htmx-request }
htmx.js:439:29
htmx:xhr:progress
<a class="htmx-request" href="/docs/">
Object { lengthComputable: true, loaded: 4096, total: 19915, elt: a.htmx-request }
htmx.js:439:29
htmx:xhr:progress
<a class="htmx-request" href="/docs/">
Object { lengthComputable: true, loaded: 19915, total: 19915, elt: a.htmx-request }
htmx.js:439:29
htmx:beforeOnLoad
<a class="htmx-request" href="/docs/">
Object { xhr: XMLHttpRequest, target: body, requestConfig: {...}, etc: {}, pathInfo: {...}, elt: a.htmx-request }
htmx.js:439:29
htmx:beforeSwap
<body hx-ext="class-tools, preload">

Не совсем приятно для глаз, не так ли?

Но если вы глубоко вздохнете и прищуритесь, то увидите, что все не так уж и плохо: серия htmx-событий, некоторые из которых мы уже видели раньше (есть htmx:configRequest!), записывается в консоль вместе с с элементом, на котором они срабатывают.

После небольшого чтения и фильтрации вы сможете разобраться в потоке событий, и это может помочь вам отладить проблемы, связанные с htmx.

Мониторинг событий в Chrome

Предыдущий метод полезен, если проблема возникает где-то внутри htmx, но что, если htmx вообще не запускается? Такое случается иногда, например, когда вы случайно где-то неправильно ввели название события.

В подобных случаях вам понадобится воспользоваться инструментом, доступным в самом браузере. К счастью, браузер Chrome от Google предоставляет очень полезную функцию monitorEvents(), которая позволяет отслеживать все события, происходящие на элементе.

Эта функция доступна только в консоли, поэтому вы не можете использовать ее в коде на своей странице. Но если вы работаете с htmx в Chrome и вам интересно, почему событие не запускается для элемента, вы можете открыть консоль разработчика и ввести следующее:

Листинг 141. Журналы htmx

monitorEvents(document.getElementById("some-element"));

Затем это выведет на консоль все события, которые запускаются на элементе с идентификатором some-element. Это может быть очень полезно для понимания того, на какие именно события вы хотите реагировать с помощью htmx, или для устранения неполадок, почему ожидаемое событие не происходит.

Использование этих двух методов поможет вам (мы надеемся, что нечасто) устранять проблемы, связанные с событиями, при разработке с использованием htmx.

Вопросы безопасности

В целом, htmx и гипермедиа, как правило, более безопасны, чем тяжелые подходы JavaScript к созданию веб-приложений. Это связано с тем, что, перенося большую часть обработки на серверную часть, подход с использованием гипермедиа имеет тенденцию не предоставлять конечным пользователям большую часть поверхности вашей системы для манипуляций и махинаций.

Однако даже при использовании гипермедиа существуют ситуации, требующие осторожности при разработке. Особую озабоченность вызывают ситуации, когда пользовательский контент показывается другим пользователям: умный пользователь может попытаться вставить код htmx, который обманным путем заставит других пользователей щелкнуть контент, который инициирует действия, которые они не хотят предпринимать.

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

Чтобы помочь вам лучше спать по ночам, htmx предоставляет атрибут hx-disable. Когда этот атрибут помещается в элемент, все атрибуты htmx внутри этого элемента будут игнорироваться.

Политики безопасности контента и Htmx

Content Security Policy (CSP) — это технология браузера, которая позволяет обнаруживать и предотвращать определенные типы атак, основанных на внедрении контента. Полное обсуждение CSP выходит за рамки этой книги, но за дополнительной информацией мы отсылаем вас к статье Mozilla Developer Network по этой теме.

Распространенной функцией, которую отключают с помощью CSP, является функция JavaScript eval(), которая позволяет выполнять произвольный код JavaScript из строки. Это оказалось проблемой безопасности, и многие команды решили, что не стоит рисковать, оставляя его включенным в своих веб-приложениях.

Htmx не интенсивно использует eval(), поэтому CSP с этим ограничением подойдет. Единственная функция, которая действительно использует eval(), — это фильтры событий, обсуждавшиеся выше. Если вы решите отключить eval() для своего веб-приложения, вы не сможете использовать синтаксис фильтрации событий.

Настройка

Для htmx доступно большое количество вариантов конфигурации. Вот некоторые примеры того, что вы можете настроить:

Полный список параметров конфигурации можно найти в разделе конфигурации основной документации htmx.

Htmx обычно настраивается с помощью тега meta, который находится в заголовке страницы. Имя тега meta должно быть htmx-config, а атрибут содержимого должен содержать переопределения конфигурации в формате JSON. Вот пример:

Листинг 142. Конфигурация htmx через метатег

<meta name="htmx-config" content='{"defaultSwapStyle":"outerHTML"}'>

В этом случае мы переопределяем стиль замены по умолчанию с обычного innerHTML на outerHTML. Это может быть полезно, если вы используете outerHTML чаще, чем innerHTML, и хотите избежать необходимости явно устанавливать это значение замены во всем приложении.

Примечания к HTML: семантический HTML

Совет людям «использовать семантический HTML» вместо «читать спецификацию» привел к тому, что многие люди догадывались о значении тегов: «Мне это кажется довольно семантическим!» — вместо того, чтобы заниматься спецификацией.

Я думаю, что когда меня просят написать осмысленный HTML, это лучше освещает путь к пониманию того, что речь идет не о том, что текст значит для людей, а об использовании тегов для целей, изложенных в спецификациях, для удовлетворения потребностей программного обеспечения, такого как браузеры, вспомогательные технологии и т. д. и поисковые системы.
~ https://t-ravis.com/post/doc/semantic_the_8_letter_s-word/

Мы рекомендуем говорить и писать о совместимом HTML. (Мы всегда можем покататься на велосипеде дальше). Используйте элементы в полной мере, предусмотренные спецификацией HTML, и позвольте программному обеспечению извлечь из них любое значение, которое они могут.

Глава 9
Клиентские сценарии

REST позволяет расширять функциональность клиента путем загрузки и выполнения кода в форме апплетов или сценариев. Это упрощает работу клиентов за счет уменьшения количества функций, которые необходимо реализовать заранее.
~ Рой Филдинг Архитектурные стили и проектирование сетевых архитектур программного обеспечения

До сих пор мы (в основном) избегали написания какого-либо JavaScript (или _hyperscript) в Contact.app, главным образом потому, что реализованная нами функциональность не требовала этого. В этой главе мы рассмотрим сценарии и, в частности, сценарии, дружественные к гипермедиа, в контексте приложения, управляемого гипермедиа.

Разрешено ли создание сценариев?

Распространенной критикой Интернета является то, что этим злоупотребляют. Существует версия, что WWW был создан как система доставки «документов» и стал использоваться для «приложений» только в результате несчастного случая или странных обстоятельств.

Однако концепция гипермедиа бросает вызов разделению документа и приложения. Гипермедийные системы, такие как HyperCard, предшествовавшие Интернету, обладали богатыми возможностями для активного и интерактивного взаимодействия, включая создание сценариев.

HTML в том виде, в котором он указан и реализован, не имеет возможностей, необходимых для создания высокоинтерактивных приложений. Однако это не означает, что цель гипермедиа — «документы», а не «приложения».

Скорее, хотя теоретическая основа и существует, реализация недостаточно развита. Поскольку JavaScript является единственной точкой расширения, а элементы управления гипермедиа плохо интегрированы с JavaScript (почему нельзя щелкнуть ссылку, не останавливая программу?), разработчики не внедрили гипермедиа и вместо этого использовали Интернет как тупой канал для приложений, которые подражают «нативным».

Цель этой книги — показать, что можно создавать сложные веб-приложения, используя оригинальную технологию Интернета — гипермедиа, без необходимости разработчику приложений обращаться к абстракциям, предоставляемым большими популярными фреймворками JavaScript.

Сам htmx, конечно, написан на JavaScript, и одним из его преимуществ является то, что взаимодействия гипермедиа, которые происходят через htmx, предоставляют богатый интерфейс для кода JavaScript с поддержкой конфигурации, событий и собственных расширений htmx.

Htmx настолько расширяет выразительность HTML, что во многих ситуациях устраняется необходимость в написании сценариев. Это делает htmx привлекательным для людей, которые не хотят писать JavaScript, и есть много таких разработчиков, которые опасаются сложности фреймворков одностраничных приложений.

Однако погружение в JavaScript не является целью проекта htmx. Цель htmx — не меньше JavaScript, а меньше кода, более читабельный и дружественный к гипермедиа код.

Сценарии стали мощным мультипликатором силы для Интернета. Используя сценарии, разработчики веб-приложений могут не только улучшать свои HTML-сайты, но и создавать полноценные клиентские приложения, которые часто могут конкурировать с собственными приложениями «толстого» клиента.

Этот ориентированный на JavaScript подход к созданию веб-приложений является свидетельством мощи Интернета и, в частности, сложности веб-браузеров. Он имеет свое место в веб-разработке: бывают ситуации, когда подход гипермедиа просто не может обеспечить тот уровень взаимодействия, который может обеспечить SPA.

Однако в дополнение к этому стилю, более ориентированному на JavaScript, мы хотим разработать стиль сценариев, более совместимый и соответствующий приложениям, управляемым гипермедиа.

Скрипты для гипермедиа

Заимствуя идею Роя Филдинга об «ограничениях», определяющих REST, мы предлагаем два ограничения сценариев, дружественных к гипермедиа. Вы создаете сценарий, совместимый с HDA, если соблюдаются следующие два ограничения:

Цель этих ограничений состоит в том, чтобы ограничить использование сценариев тем, где они проявляются лучше всего и где ничто другое не подходит так хорошо: дизайном взаимодействия. Бизнес-логика и логика представления находятся в ведении сервера, на котором мы можем выбирать языки или инструменты, подходящие для нашей бизнес-области.

Сервер

Сохранение бизнес-логики и логики представления «на сервере» не означает, что эти две «проблемы» смешаны или связаны. Они могут быть модульными на сервере. Фактически, они должны быть модульными на сервере вместе со всеми другими задачами нашего приложения.

Также обратите внимание, что, особенно на языке веб-разработки, скромный «сервер» обычно представляет собой целый парк стоек, виртуальных машин, контейнеров и многого другого. Даже всемирная сеть центров обработки данных сводится к «серверу», когда речь идет о серверной части приложения, управляемого гипермедиа.

Удовлетворение этих двух ограничений иногда требует от нас отхода от того, что обычно считается лучшей практикой для JavaScript. Имейте в виду, что культурная мудрость JavaScript в значительной степени была развита в JavaScript-ориентированных SPA-приложениях.

HDA не может с такой же легкостью вернуться к этой традиции. Эта глава — наш вклад в разработку нового стиля и лучших практик для того, что мы называем приложениями, управляемыми гипермедиа.

К сожалению, простое перечисление «лучших практик» редко бывает убедительным или поучительным. Честно говоря, это скучно.

Вместо этого мы продемонстрируем эти лучшие практики, реализовав функции на стороне клиента в Contact.app. Чтобы охватить различные аспекты создания сценариев, ориентированных на гипермедиа, мы реализуем три различные функции:

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

Инструменты создания сценариев для Интернета

Основным языком сценариев для Интернета, конечно же, является JavaScript, который сегодня повсеместно используется в веб-разработке.

Однако в Интернете есть интересный факт: JavaScript не всегда был единственной встроенной опцией. Как намекает цитата Роя Филдинга в начале этой главы, «апплеты», написанные на других языках, таких как Java, считались частью инфраструктуры сценариев Интернета. Кроме того, был период времени, когда Internet Explorer поддерживал VBScript — язык сценариев, основанный на Visual Basic.

Сегодня у нас есть множество транскомпиляторов (transcompilers) (часто сокращенно transpilers), которые преобразуют многие языки в JavaScript, такие как TypeScript, Dart, Kotlin, ClojureScript, F# и другие. Существует также формат байт-кода WebAssembly (WASM), который поддерживается в качестве цели компиляции для C, Rust и первого WASM-языка AssemblyScript.

Однако большинство этих опций не ориентированы на стиль написания сценариев, ориентированный на гипермедиа. Языки, компилируемые в JS, часто сочетаются с библиотеками, ориентированными на SPA (Dart и AngularDart, ClojureScript и Reagent, F# и Elm), а WASM в настоящее время в основном ориентирован на связывание с библиотеками C/C++ из JavaScript.

Вместо этого мы сосредоточимся на трех технологиях сценариев на стороне клиента, дружественных к гипермедиа:

Давайте кратко рассмотрим каждый из этих вариантов сценариев, чтобы мы знали, с чем имеем дело.

Обратите внимание, что, как и в случае с CSS, мы собираемся показать вам каждый из этих вариантов ровно настолько, чтобы дать представление о том, как они работают, и, мы надеемся, пробудить у вас интерес к более подробному изучению любого из них.

Ванильный JavaScript

Нет кода быстрее, чем его отсутствие.
~ Мерб

Ванильный JavaScript — это просто использование в вашем приложении простого JavaScript без каких-либо промежуточных слоев. Термин «ваниль» вошел в жаргон веб-разработчиков, поскольку считалось, что любое достаточно «продвинутое» веб-приложение будет использовать некоторую библиотеку с именем, заканчивающимся на «.js». Однако по мере того, как JavaScript развивался как язык сценариев, стандартизировался во всех браузерах и предоставлял все больше и больше функциональности, эти платформы и библиотеки становились менее важными.

По иронии судьбы, поскольку JavaScript стал более мощным и устранил необходимость в первом поколении библиотек JavaScript, таких как jQuery, он также позволил людям создавать сложные библиотеки SPA. Эти библиотеки SPA часто даже более сложные, чем исходные библиотеки JavaScript первого поколения.

Цитата с сайта http://vanilla-js.com, который стоит посетить, хотя он и немного устарел, хорошо отражает ситуацию:

VanillaJS — это самый простой и комплексный фреймворк с наименьшими затратами, который я когда-либо использовал.
~ http://vanilla-js.com

Поскольку JavaScript стал языком сценариев, это, безусловно, справедливо для многих приложений. Это особенно верно в случае HDA, поскольку при использовании гипермедиа вашему приложению не потребуются многие функции, обычно предоставляемые более сложными JavaScript-фреймворками для одностраничных приложений:

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

Одна из лучших особенностей VanillaJS — это способ его установки: вам не обязательно это делать!

Вы можете просто начать писать JavaScript в своем веб-приложении, и оно просто будет работать.

Это хорошие новости. Плохая новость заключается в том, что, несмотря на улучшения, произошедшие за последнее десятилетие, JavaScript имеет некоторые существенные ограничения как язык сценариев, которые могут сделать его далеко не идеальным в качестве автономной технологии сценариев для приложений, управляемых гипермедиа:

Разумеется, ни одно из этих ограничений не является поводом для отказа от JavaScript. Многие из них постепенно исправляются, и многие люди предпочитают «близкий к металлу» (из-за отсутствия лучшего термина) характер ванильного JavaScript более сложным подходам к написанию сценариев на стороне клиента.

  1. Под рендерингом здесь понимается генерация HTML. Поддержка платформы для рендеринга на стороне сервера не требуется в HDA, поскольку генерация HTML на сервере используется по умолчанию.

Простой счетчик

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

Виджеты-счетчики — это распространенный пример «Hello World» для фреймворков JavaScript, поэтому рассмотрение того, как это можно сделать в стандартном JavaScript (а также другие варианты, которые мы собираемся рассмотреть), будет поучительным.

Наш виджет счетчика будет очень простым: он будет иметь число, отображаемое в виде текста, и кнопку, которая увеличивает это число.

Одна из проблем с решением этой проблемы в стандартном JavaScript заключается в том, что ему не хватает одной вещи, которую предоставляют большинство фреймворков JavaScript: кода по умолчанию и архитектурного стиля.

С ванильным JavaScript нет никаких правил!

Это не так уж и плохо. Он предоставляет прекрасную возможность совершить небольшое путешествие по различным стилям, которые люди разработали для написания своего JavaScript.

Встроенная реализация

Для начала давайте начнем с самой простой вещи, которую только можно вообразить: весь наш JavaScript будет написан встроенно, непосредственно в HTML. При нажатии кнопки мы будем искать выходной элемент, содержащий число, и увеличивать число, содержащееся в нем.

Листинг 143. Счетчик на ванильном JavaScript, инлайн-версия

<section class="counter">
  <output id="my-output">0</output> <!-- 1 -->
  <button
    onclick=" /* 2 */
      document.querySelector('#my-output') /* 3 */
        .textContent++ /* 4 */
    "
  >Increment</button>
</section>
  1. Наш выходной элемент имеет ID, который поможет нам его найти.

  2. Мы используем атрибут onclick для добавления обработчика событий.

  3. Найдем output с помощью вызова querySelector().

  4. JavaScript позволяет нам использовать оператор ++ для строк.

Не плохо.

Это не самый красивый код, и он может раздражать, особенно если вы не привыкли к DOM API.

Немного раздражает то, что нам нужно было добавить id к элементу output. Функция document.querySelector() немного многословна по сравнению, скажем, с функцией $, предоставляемой jQuery.

Но это работает. Его также достаточно легко понять, и, что особенно важно, он не требует каких-либо других библиотек JavaScript.

Итак, это простой встроенный подход с VanillaJS.

Отделение наших сценариев

Хотя встроенная реализация в некотором смысле проста, более стандартным способом ее написания было бы перемещение кода в отдельный файл JavaScript. Затем этот файл JavaScript будет либо связан с помощью тега <script src>, либо помещен во встроенный тег <script> в процессе сборки.

Здесь мы видим HTML и JavaScript, отделенные друг от друга, в разных файлах. HTML теперь стал «чище», поскольку в нем нет JavaScript.

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

Листинг 144. HTML-счетчик

<section class="counter">
  <output id="my-output">0</output>
  <button class="increment-btn">Increment</button>
</section>

Листинг 145. Счетчик JavaScript

const counterOutput = document.querySelector("#my-output") /* 1 */
const incrementBtn = document.querySelector(".counter .increment-btn") /* 2 */

incrementBtn.addEventListener("click", e => { /* 3 */
  counterOutput.innerHTML++ /* 4 */
})
  1. Найти элемент output.

  2. Найти кнопку.

  3. Используем addEventListener, который предпочтительнее onclick по многим причинам.

  4. Логика остается прежней, меняется только структура вокруг нее.

Перенося JavaScript в другой файл, мы следуем принципу проектирования программного обеспечения, известному как Separation of Concerns (разделение задач) (SoC).

Разделение задач утверждает, что различные «проблемы» (или аспекты) программного проекта должны быть разделены на несколько файлов, чтобы они не «загрязняли» друг друга. JavaScript не является разметкой, поэтому его не должно быть в вашем HTML, он должен быть где-то еще. Информация о стиле также не является разметкой и поэтому также принадлежит отдельному файлу (например, файлу CSS).

В течение некоторого времени такое разделение задач считалось «ортодоксальным» способом создания веб-приложений.

Заявленная цель разделения задач состоит в том, чтобы мы могли модифицировать и развивать каждую задачу независимо, с уверенностью, что мы не нарушим ни одну из других задач.

Однако давайте посмотрим, как именно этот принцип сработал на нашем простом противоположном примере. Если вы внимательно посмотрите на новый HTML, то окажется, что нам пришлось добавить класс к кнопке. Мы добавили этот класс, чтобы можно было найти кнопку в JavaScript и добавить обработчик события «click».

Теперь и в HTML, и в JavaScript это имя класса представляет собой просто строку, и не существует никакого процесса проверки того, что кнопка имеет правильные классы для нее или ее родителей, чтобы гарантировать, что обработчик события действительно добавлен к нужному элементу.

К сожалению, оказалось, что неосторожное использование селекторов CSS в JavaScript может привести к так называемому супу jQuery. Суп jQuery — это ситуация, когда:

Название «суп jQuery» происходит от того факта, что большинство приложений с большим количеством JavaScript раньше создавались на jQuery (многие до сих пор), что, возможно, непреднамеренно, имело тенденцию поощрять этот стиль JavaScript.

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

Чтобы показать, что не просто именование задач может привести к неприятностям, рассмотрим еще одно небольшое изменение в нашем HTML, демонстрирующее проблемы с разделением задач: представьте, что мы решили изменить числовое поле с тега <output> на <input type="number">.

Это небольшое изменение в нашем HTML сломает наш JavaScript, несмотря на то, что мы «разделили» наши задачи.

Решение этой проблемы достаточно простое (нам нужно будет изменить свойство .textContent на свойство .value), но оно демонстрирует сложность синхронизации изменений разметки и кода в нескольких файлах. По мере увеличения размера вашего приложения поддерживать все в синхронизации может стать все труднее.

Тот факт, что небольшие изменения в нашем HTML-коде могут нарушить работу наших сценариев, указывает на то, что они тесно связаны, несмотря на то, что они разбиты на несколько файлов. Эта тесная связь предполагает, что разделение HTML и JavaScript (и CSS) часто является иллюзорным разделением задач: задачи настолько связаны друг с другом, что их нелегко разделить.

В Contact.app нас не интересуют «структура», «стиль» или «поведение»; мы занимаемся сбором контактной информации и предоставлением ее пользователям. SoC, в том виде, в каком его формулируют в ортодоксальной веб-разработке, на самом деле не является незыблемым архитектурным руководством, а скорее стилистическим выбором, который, как мы видим, может даже стать помехой.

Локальность поведения

Оказывается, наблюдается растущая реакция против принципа разделения задач. Рассмотрим следующие веб-технологии и методы:

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

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

Локальность поведения (LoB) — это альтернативный принцип проектирования программного обеспечения, который мы придумали в отличие от разделения ответственности. Он описывает следующие характеристики части программного обеспечения:

Поведение единицы кода должно быть максимально очевидным, если смотреть только на эту единицу кода.
~ https://htmx.org/essays/locality-of-behaviour/

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

Мы продемонстрируем Locality of Behavior во всех наших примерах, как в демонстрациях счетчиков, так и в функциях, которые мы добавляем в Contact.app. Локальность поведения — это явная цель проектирования как _hyperscript, так и Alpine.js (о котором мы поговорим позже), а также htmx.

Все эти инструменты обеспечивают локальность поведения, позволяя встраивать атрибуты непосредственно в HTML, а не искать элементы в документе с помощью селекторов CSS, чтобы добавить к ним обработчики событий.

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

Что делать с нашим счетчиком?

Итак, стоит ли нам вернуться к способу работы с атрибутом onclick? Этот подход, безусловно, выигрывает в локальности поведения и имеет дополнительное преимущество, заключающееся в том, что он встроен в HTML.

Однако, к сожалению, атрибуты on* JavaScript также имеют некоторые недостатки:

Рассмотрим распространенную ситуацию: у вас есть всплывающее окно, и вы хотите, чтобы оно закрывалось, когда пользователь щелкает за его пределами. В этой ситуации слушатель должен находиться в элементе body, вдали от фактической разметки всплывающего окна. Это означает, что к элементу body должны быть прикреплены обработчики, которые работают со многими несвязанными компонентами. Некоторые из этих компонентов могут даже отсутствовать на странице при ее первом отображении, если они добавляются динамически после отображения исходной HTML-страницы.

Таким образом, ванильный JavaScript и Locality of Behavior, похоже, не так уж хорошо взаимодействуют, как нам хотелось бы.

Однако ситуация не безнадежна: важно понимать, что LoB не требует, чтобы поведение было реализовано на месте использования, а просто вызывалось там. То есть нам не нужно писать весь наш код для данного элемента, нам просто нужно дать понять, что данный элемент вызывает некоторый код, который может находиться в другом месте.

Учитывая это, можно улучшить LoB, записывая JavaScript в отдельный файл, при условии, что у нас есть разумная система структурирования нашего JavaScript.

RSJS

RSJS («Reasonable System for JavaScript Structure» (разумная система структуры JavaScript), https://ricostacruz.com/rsjs/) представляет собой набор рекомендаций по архитектуре JavaScript, ориентированный на «типичный веб-сайт, не относящийся к SPA». RSJS обеспечивает решение проблемы отсутствия стандартного стиля кода для стандартного JavaScript, о котором мы упоминали ранее.

Вот рекомендации RSJS, наиболее подходящие для нашего виджета счетчика:

Чтобы следовать рекомендациям RSJS, давайте реструктурируем наши текущие файлы HTML и JavaScript. Во-первых, мы будем использовать атрибуты data, то есть атрибуты HTML, начинающиеся с data-, стандартной функции HTML, чтобы указать, что наш HTML является компонентом счетчика. Затем мы обновим наш JavaScript, чтобы использовать селектор атрибутов, который ищет атрибут data-counter в качестве корневого элемента в нашем компоненте счетчика и подключает соответствующие обработчики событий и логику. Кроме того, давайте переработаем код, чтобы использовать querySelectorAll(), и добавим функциональность счетчика ко всем компонентам счетчика, найденным на странице. (Никогда не знаешь, сколько жетонов вам понадобится!)

Вот как сейчас выглядит наш код:

<section class="counter" data-counter> <!-- 1 -->
  <output id="my-output" data-counter-output>0</output> <!-- 2 -->
  <button class="increment-btn" data-counter-increment>Increment</button>
</section>
  1. Вызов поведения JavaScript с атрибутом данных.

  2. Отметить соответствующие элементы-потомки.

// counter.js // 1
document.querySelectorAll("[data-counter]") // 2
  .forEach(el => {
    const
    output = el.querySelector("[data-counter-output]"),
    increment = el.querySelector("[data-counter-increment]"); // 3
    increment.addEventListener("click", e => output.textContent++); // 4
});
  1. Файл должен иметь то же имя, что и атрибут данных, чтобы мы могли легко его найти.

  2. Получить все элементы, вызывающие это поведение.

  3. Получить любые дочерние элементы, которые нам нужны.

  4. Зарегистрировать обработчики событий.

Использование RSJS решает или, по крайней мере, облегчает многие проблемы, на которые мы указали в нашем первом неструктурированном примере разделения VanillaJS на отдельный файл:

В общем, RSJS — это хороший способ структурировать ваш стандартный JavaScript в приложении, управляемом гипермедиа. Пока JavaScript не взаимодействует с сервером через простой API JSON данных и не хранит кучу внутреннего состояния за пределами DOM, это полностью совместимо с подходом HDA.

Давайте реализуем функцию в Contact.app, используя подход RSJS/ванильный JavaScript.

VanillaJS в действии: дополнительное меню

На нашей домашней странице есть ссылки «Edit», «View» и «Delete» для каждого контакта в нашей таблице. Это занимает много места и создает визуальный беспорядок. Давайте исправим это, поместив эти действия в раскрывающееся меню с кнопкой для его открытия.

Если вы плохо знакомы с JavaScript и код здесь кажется вам слишком сложным, не волнуйтесь; примеры Alpine.js и _hyperscript, которые мы рассмотрим далее, легче понять.

Давайте начнем с наброска разметки, которую мы хотим использовать для нашего раскрывающегося меню. Во-первых, нам нужен элемент, мы будем использовать <div>, чтобы охватить весь виджет и пометить его как компонент меню. Внутри этого div у нас будет стандартный <button>, которsq будет функционировать как механизм отображения и скрытия пунктов меню. Наконец, у нас будет еще один <div>, содержащий пункты меню, которые мы собираемся показать.

Эти пункты меню будут простыми тегами якорей, как и в текущей таблице контактов.

Вот как выглядит наш обновленный HTML-код со структурой RSJS:

<div data-overflow-menu> <!-- 1 -->
  <button type="button" aria-haspopup="menu" aria-controls="contact-menu-{{ contact.id }}">Options</button> <!-- 2 -->
  <div role="menu" hidden id="contact-menu-{{ contact.id }}"> <!-- 3 -->
    <a role="menuitem" href="/contacts/{{ contact.id }}/edit">Edit</a> <!-- 4 -->
    <a role="menuitem" href="/contacts/{{ contact.id }}">View</a>
    <!-- ... -->
  </div>
</div>
  1. Отметить корневой элемент компонента меню

  2. Эта кнопка будет открывать и закрывать наше меню.

  3. Контейнер для позиций нашего меню

  4. Пункты меню

Роли и атрибуты ARIA основаны на шаблонах меню и кнопок меню из ARIA Authoring Practices Guide.

Что такое ARIA?

Поскольку мы, веб-разработчики, создаем более интерактивные веб-сайты, похожие на приложения, в репертуаре элементов HTML не будет всего, что нам нужно. Как мы видели, используя CSS и JavaScript, мы можем наделить существующие элементы расширенным поведением и внешним видом, конкурирующим с собственными элементами управления.

Однако была одна вещь, которую веб-приложения не могли воспроизвести. Хотя эти виджеты могут выглядеть достаточно похожими на реальные, вспомогательные технологии (например, программы чтения с экрана) могут работать только с базовыми элементами HTML.

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

ARIA была создана Web Accessibility Initiative (WAI) W3C в 2008 году для решения этой проблемы. На поверхностном уровне это набор атрибутов, которые вы можете добавить в HTML, чтобы сделать его значимым для вспомогательного программного обеспечения, такого как программа чтения с экрана.

ARIA состоит из двух основных компонентов, которые взаимодействуют друг с другом:

Первым является атрибут role. Этот атрибут имеет предопределенный набор возможных значений: menu, dialog, radiogroup и т. д. Атрибут role не добавляет никакого поведения к элементам HTML. Скорее, это обещание, которое вы даете пользователю. Когда вы аннотируете элемент как role='menu', вы говорите: я заставлю этот элемент работать как меню.

Если вы добавите role к элементу, но не выполните обещание, для многих пользователей впечатления будут хуже, чем если бы у элемента вообще не было role. Так написано:

Никакая ARIA лучше, чем Плохая ARIA.
~ W3C Read Me First | APG https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/

Второй компонент ARIA — это состояния и свойства, все они имеют общий префикс aria-: aria-expanded, aria-controls, aria-label и т. д. Эти атрибуты могут определять различные вещи, такие как состояние виджета, отношения между компонентами и т. д. или дополнительную семантику. Еще раз: эти атрибуты являются обещаниями, а не реализацией.

Вместо того, чтобы изучать все роли и атрибуты и пытаться объединить их в удобный виджет, для большинства разработчиков лучше всего полагаться на ARIA Authoring Practices Guide (APG), веб-ресурс с практической информацией, предназначенный непосредственно для веб-разработчиков.

Если вы новичок в ARIA, посетите следующие ресурсы W3C:

Всегда не забывайте тестировать свой веб-сайт на доступность, чтобы все пользователи могли легко и эффективно взаимодействовать с ним.

После этого краткого введения в ARIA давайте вернемся к раскрывающемуся меню VanillaJS. Мы начнем с шаблона RSJS: запросим все элементы с некоторым атрибутом данных, пройдемся по ним, получим всех соответствующих потомков.

Обратите внимание, что ниже мы немного модифицировали шаблон RSJS для интеграции с htmx; мы загружаем меню переполнения, когда htmx загружает новый контент.

function overflowMenu(subtree = document) {
  document.querySelectorAll("[data-overflow-menu]").forEach(menuRoot => { 1
    const button = menuRoot.querySelector("[aria-haspopup]"), 2
    menu = menuRoot.querySelector("[role=menu]"), 2
    items = [...menu.querySelectorAll("[role=menuitem]")]; 3
  });
}

addEventListener("htmx:load", e => overflowMenu(e.target)); 4
  1. При использовании RSJS вам придется часто писать document.querySelectorAll(...).forEach.

  2. Чтобы сохранить чистоту HTML, мы используем здесь атрибуты ARIA, а не пользовательские атрибуты данных.

  3. Используйте оператор распространения, чтобы преобразовать NodeList в обычный Array.

  4. Инициализируйте все дополнительные меню при загрузке страницы или при вставке содержимого с помощью htmx.

Традиционно мы отслеживали бы, открыто ли меню, используя переменную JavaScript или свойство в объекте состояния JavaScript. Этот подход распространен в больших веб-приложениях с большим количеством JavaScript.

Однако у этого подхода есть некоторые недостатки:

Вместо этого подхода мы будем использовать DOM для хранения нашего состояния. Мы воспользуемся атрибутом hidden элемента меню, чтобы сообщить, что он закрыт. Если HTML-код страницы сделан и восстановлен, меню также можно восстановить, просто перезапустив JS.

  items = [...menu.querySelectorAll("[role=menuitem]")];

  const isOpen = () => !menu.hidden; // 1

});
  1. Атрибут hidden отображается как свойство hidden, поэтому нам не нужно использовать getAttribute.

Мы также сделаем пункты меню недоступными для вкладок, чтобы мы могли самостоятельно управлять их фокусом.


  const isOpen = () => !menu.hidden; // 1

  items.forEach(item => item.setAttribute("tabindex", "-1"));

});

Теперь давайте реализуем переключение меню на JavaScript:

  items.forEach(item => item.setAttribute("tabindex", "-1"));

  function toggleMenu(open = !isOpen()) { // 1
    if (open) {
      menu.hidden = false;
      button.setAttribute("aria-expanded", "true");
      items[0].focus(); // 2
    } else {
      menu.hidden = true;
      button.setAttribute("aria-expanded", "false");
    }
  }

  toggleMenu(isOpen()); // 3
  button.addEventListener("click", () => toggleMenu()); // 4
  menuRoot.addEventListener("blur", e => toggleMenu(false)); // 5

})
  1. Необязательный параметр для указания желаемого состояния. Это позволяет нам использовать одну функцию для открытия, закрытия или переключения меню.

  2. Фокус на первый пункт меню при открытии.

  3. Вызов toggleMenu с текущим состоянием, чтобы инициализировать атрибуты элемента.

  4. Переключение меню при нажатии кнопки.

  5. Закрыть меню, когда фокус переместится.

Давайте также сделаем так, чтобы меню закрывалось, когда мы щелкаем за его пределами. Это хорошее поведение, имитирующее работу встроенных раскрывающихся меню. Для этого потребуется обработчик событий во всем окне.

Обратите внимание, что нам нужно быть осторожными с этим типом обработчиков: вы можете обнаружить, что обработчики накапливаются по мере того, как компоненты их добавляют, и не могут их удалить, когда компонент удаляется из DOM. Это, к сожалению, приводит к трудно отслеживаемым утечкам памяти.

В JavaScript нет простого способа выполнения логики при удалении элемента. Лучшим вариантом является так называемый API MutationObserver. MutationObserver очень полезен, но API довольно тяжелый и немного непонятный, поэтому мы не будем использовать его в нашем примере.

Вместо этого мы будем использовать простой шаблон, чтобы избежать утечки обработчиков событий: когда наш обработчик событий запускается, мы проверяем, находится ли присоединяемый компонент все еще в DOM, и, если элемент больше не находится в DOM, мы удалим обработчик и выйдем.

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

  menuRoot.addEventListener("blur", e => toggleMenu(false));

  window.addEventListener("click", function clickAway(event) {
    if (!menuRoot.isConnected) window.removeEventListener("click", clickAway); // 1
    if (!menuRoot.contains(event.target)) toggleMenu(false); // 2
  });
});
  1. Эта строка — сбор мусора.

  2. Если щелчок находится за пределами меню, закрыть меню.

Теперь давайте перейдем к взаимодействию с клавиатурой для нашего раскрывающегося меню. Все обработчики клавиатуры очень похожи друг на друга и не особенно сложны, поэтому давайте разберем их все за один раз:

      if (!menuRoot.contains(event.target)) toggleMenu(false); // 2
    });
  
    const currentIndex = () => { // 1
    const idx = items.indexOf(document.activeElement);
    if (idx === -1) return 0;
    return idx;
  }
  
  menu.addEventListener("keydown", e => {
    if (e.key === "ArrowUp") {
      items[currentIndex() - 1]?.focus(); // 2

    } else if (e.key === "ArrowDown") {
      items[currentIndex() + 1]?.focus(); // 3

    } else if (e.key === "Space") {
      items[currentIndex()].click(); // 4

    } else if (e.key === "Home") {
      items[0].focus(); // 5

    } else if (e.key === "End") {
      items[items.length - 1].focus(); // 6

    } else if (e.key === "Escape") {
      toggleMenu(false); // 7
      button.focus(); // 8
    }
  });
});
  1. Helper: получить индекс в массиве элементов текущего пункта меню (0, если его нет).

  2. Переместить фокус на предыдущий пункт меню при нажатии клавиши со стрелкой вверх.

  3. Переместить фокус на следующий пункт меню при нажатии клавиши со стрелкой вниз.

  4. Активировать текущий элемент, находящийся в фокусе, при нажатии клавиши пробела.

  5. Переместить фокус на первый пункт меню при нажатии кнопки Home.

  6. Переместить фокус на последний пункт меню при нажатии кнопки End.

  7. Закрыть меню при нажатии Escape.

  8. Вернуть фокус на кнопку меню при закрытии меню.

Это должно охватывать все наши основы, и мы признаем, что это очень много кода. Но, честно говоря, этот код кодирует большую часть поведения.

Наше раскрывающееся меню не идеально и не справляется со многими задачами. Например, мы не поддерживаем подменю или элементы меню, которые динамически добавляются или удаляются в меню. Если бы нам нужно было больше подобных функций меню, возможно, было бы разумнее использовать готовую библиотеку, такую как details-menu-element на GitHub.

Но для нашего относительно простого варианта использования ванильный JavaScript отлично справляется, и при его реализации нам пришлось изучить ARIA и RSJS.

Alpine.js

Итак, это углубленный взгляд на то, как структурировать простой JavaScript в стиле VanillaJS. Давайте обратим внимание на реальную среду JavaScript, которая позволяет использовать другой подход для добавления динамического поведения в ваше приложение — Alpine.js.

Alpine — это относительно новая библиотека JavaScript, которая позволяет разработчикам встраивать код JavaScript непосредственно в HTML, аналогично атрибутам on*, доступным в простом HTML и JavaScript. Однако Alpine развивает концепцию встроенных сценариев гораздо дальше, чем атрибуты on*.

Alpine позиционирует себя как современную замену jQuery, широко используемой старой библиотеки JavaScript. Как вы увидите, он определенно оправдывает это обещание.

Установить Alpine очень просто: это один файл, не имеющий зависимостей, поэтому вы можете просто подключить его через CDN:

Листинг 146. Установка Alpine

<script src="https://unpkg.com/alpinejs"></script>

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

Alpine предоставляет набор атрибутов HTML, все из которых начинаются с префикса x-, основным из которых является x-data. Содержимое x-data представляет собой выражение JavaScript, результатом которого является объект. Таким образом, к свойствам этого объекта можно получить доступ внутри элемента, в котором расположен атрибут x-data.

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

Для счетчика единственное состояние, которое нам нужно отслеживать, — это текущее число, поэтому давайте объявим объект JavaScript с одним свойством count в атрибуте x-data в элементе div для нашего счетчика:

Листинг 147. Счетчик с Alpine, строка 1

<div class="counter" x-data="{ count: 0 }">

Это определяет наше состояние, то есть данные, которые мы будем использовать для динамических обновлений DOM. Объявив такое состояние, мы теперь можем использовать его внутри элемента div, для которого оно объявлено. Давайте добавим элемент output с атрибутом x-text.

Далее мы свяжем атрибут x-text с атрибутом count, который мы объявили в атрибуте x-data родительского элемента div. Это приведет к установке текста элемента output в любое значение count: если count будет обновлен, то же самое произойдет и с текстом output. Это «реактивное» программирование, при котором DOM «реагирует» на изменения исходных данных.

Листинг 148. Счетчик с Alpine, строки 1-2

<div x-data="{ count: 0 }">
  <output x-text="count"></output> <!-- 1 -->
  1. Атрибут x-text.

Далее нам нужно обновить счетчик с помощью кнопки. Alpine позволяет подключать обработчиков событий с помощью атрибута x-on.

Чтобы указать событие для прослушивания, вы добавляете двоеточие, а затем имя события после имени атрибута x-on. Тогда значением атрибута будет код JavaScript, который вы хотите выполнить. Это похоже на обычные атрибуты on*, которые мы обсуждали ранее, но оказывается гораздо более гибким.

Мы хотим прослушивать событие click и хотим увеличивать count при возникновении щелчка, поэтому вот как будет выглядеть код Alpine:

Листинг 149. Счетчик с Alpine полностью

<div x-data="{ count: 0 }">
  <output x-text="count"></output>

  <button x-on:click="count++">Increment</button> <!-- 1 -->
</div>
  1. При использовании x-on мы указываем атрибут в имени атрибута.

И это все, что нужно. Простой компонент, такой как счетчик, должен быть простым в кодировании, и Alpine это обеспечивает.

«x-on:click» против «onclick»

Как мы уже говорили, атрибут Alpine x-on:click (или его сокращение, атрибут @click) аналогичен встроенному атрибуту onclick. Однако у него есть дополнительные функции, которые делают его значительно более полезным:

Реактивность и шаблонизация

Мы надеемся, что вы согласитесь, что версия виджета счетчика AlpineJS в целом лучше, чем реализация VanillaJS, которая была либо несколько хакерской, либо разбросана по нескольким файлам.

Большая часть возможностей AlpineJS заключается в том, что он поддерживает понятие «реактивных» переменных, позволяя вам привязать счетчик элемента div к переменной, на которую могут ссылаться как output, так и button, и правильно обновлять все зависимости при возникновении мутации. Alpine позволяет выполнять гораздо более сложные привязки данных, чем мы продемонстрировали здесь, и это отличная библиотека сценариев на стороне клиента общего назначения.

Alpine.js в действии: панель инструментов массовых действий

Давайте реализуем функцию в Contact.app с помощью Alpine. В настоящее время в Contact.app есть кнопка «Delete Selected Contacts» в самом низу страницы. У этой кнопки длинное название, ее нелегко найти, и она занимает много места. Если бы мы хотели добавить дополнительные «массовые» действия, это не было бы хорошо масштабируемо визуально.

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

Первое, что нам нужно будет добавить, — это атрибут x-data, который будет хранить состояние, которое мы будем использовать для определения, видна ли панель инструментов или нет. Нам нужно будет поместить это в родительский элемент как панели инструментов, которую мы собираемся добавить, так и флажков, которые будут обновлять состояние, когда они установлены и сняты. Лучший вариант, учитывая наш текущий HTML, — поместить атрибут в элемент form, окружающий таблицу контактов. Мы объявим свойство, selected, которое будет представлять собой массив, содержащий выбранные идентификаторы контактов на основе установленных флажков.

Вот как будет выглядеть наш тег формы:

<form x-data="{ selected: [] }"> <!-- 1 -->
  1. Эта форма располагается вокруг таблицы контактов.

Далее вверху таблицы контактов мы добавим тег template. Тег template по умолчанию не отображается браузером, поэтому вы можете быть удивлены тем, что мы его используем. Однако, добавив атрибут Alpine x-if, мы можем сказать Alpine: если условие истинно, отобразить HTML в этом шаблоне.

Напомним, что мы хотим показывать панель инструментов тогда и только тогда, когда выбран один или несколько контактов. Но мы знаем, что в свойстве selected у нас будут идентификаторы выбранных контактов. Таким образом, мы можем довольно легко проверить длину этого массива, чтобы увидеть, есть ли какие-либо выбранные контакты:

<template x-if="selected.length > 0"> <!-- 1 -->
  <div class="box info tool-bar">
    <slot x-text="selected.length"></slot>
    contacts selected

    <button type="button" class="bad bg color border">Delete</button> <!-- 2 -->
    <hr aria-orientation="vertical">
    <button type="button">Cancel</button>
  </div>
</template>
  1. Показывать этот HTML-код, если выбран один или несколько контактов.

  2. Мы реализуем эти кнопки буквально через мгновение.

Следующий шаг — убедиться, что переключение флажка для данного контакта добавляет (или удаляет) идентификатор данного контакта из свойства selected. Для этого нам нужно будет использовать новый атрибут Alpine — x-model. Атрибут x-model позволяет вам привязать данный элемент к некоторым базовым данным или его «модели».

В этом случае мы хотим привязать значения полей чекбокса к свойству selected. Вот как мы это делаем:

<td>
  <input type="checkbox" name="selected_contact_ids" value="{{ contact.id }}" x-model="selected"> <!-- 1 -->
</td>
  1. Атрибут x-model привязывает value этого ввода к свойству selected.

Теперь, когда флажок установлен или снят, массив selected будет обновлен с использованием идентификатора контакта данной строки. Более того, изменения, которые мы вносим в массив selected, аналогичным образом будут отражаться на состоянии полей чекбокса. Это известно как двусторонняя привязка.

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

Очень ловко.

Прежде чем мы продолжим, вы, возможно, заметили, что наш код включает в себя несколько ссылок «class=». Они предназначены для стилей CSS и не являются частью Alpine.js. Мы включили их только как напоминание о том, что для хорошей работы строки меню, которую мы создаем, потребуется CSS. Классы в приведенном выше коде относятся к минимальной библиотеке CSS под названием Missing.css. Если вы используете другие библиотеки CSS, такие как Bootstrap, Tailwind, Bulma, Pico.css и т. д., ваш код стилей будет другим.

Реализация действий

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

Давайте сначала реализуем кнопку «Clear», потому что это довольно просто. Все, что нам нужно сделать, это при нажатии кнопки очистить массив selected. Из-за двусторонней привязки, которую предоставляет Alpine, это снимет флажки со всех выбранных контактов (а затем скроет панель инструментов)!

Для кнопки Cancel наша работа проста:

<button type="button" @click="selected=[]">Cancel</button> <!-- 1 -->
  1. Сбросить выбранный массив.

И снова AlpineJS делает это очень легко.

Однако кнопка «Delete» будет немного сложнее. Alpine нужно будет сделать две вещи: во-первых, подтвердить, действительно ли пользователь намерен удалить выбранные контакты. Затем, если пользователь подтвердит действие, нужно будет использовать API JavaScript htmx для выдачи запроса DELETE.

<button type="button" class="bad bg color border"
  @click="confirm(`Delete ${selected.length} contacts?`) && <!-- 1 -->
  htmx.ajax('DELETE', '/contacts', { source: $root, target: document.body })" <!-- 2 -->
>Delete</button>
  1. Подтвердить, что пользователь желает удалить выбранное количество контактов.

  2. Выполнить команду DELETE, используя API JavaScript htmx.

Обратите внимание, что мы используем укороченное поведение оператора && в JavaScript, чтобы избежать вызова htmx.ajax(), если вызов submit() возвращает false.

Функция htmx.ajax() — это всего лишь способ доступа к обычному обмену гипермедиа на основе HTML, который HTML-атрибуты htmx предоставляют вам непосредственно из JavaScript.

Глядя на то, как мы вызываем htmx.ajax, мы сначала сообщаем, что хотим выполнить DELETE для /contacts. Затем мы передаем две дополнительные части информации: source и target. Свойство source — это элемент, из которого htmx будет собирать данные для включения в запрос. Мы устанавливаем для него значение $root, которое является специальным символом в Alpine и будет элементом, для которого объявлен атрибут x-data. В данном случае это будет форма, содержащая все наши контакты. Свойством target или местом размещения HTML-кода ответа является только тело всего документа, поскольку обработчик DELETE возвращает целую страницу после завершения.

Обратите внимание, что здесь мы используем Alpine для совместимости с HDA. Мы могли бы отправить запрос AJAX непосредственно из Alpine и, возможно, обновить свойство x-data в зависимости от результатов этого запроса. Но вместо этого мы делегировали JavaScript API htmx, который осуществил обмен гипермедиа с сервером.

Это ключ к написанию сценариев, дружественных к гипермедиа, в приложении, управляемом гипермедиа.

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

_hyperscript

Последняя технология сценариев, которую мы собираемся рассмотреть, находится немного дальше: _hyperscript. Авторы этой книги изначально создали _hyperscript как родственный проект htmx. Мы чувствовали, что JavaScript недостаточно ориентирован на события, поэтому добавление небольших улучшений сценариев в htmx-приложения было затруднительным.

Хотя предыдущие два примера ориентированы на JavaScript, _hyperscript имеет совершенно другой синтаксис, чем JavaScript, и основан на более старом языке HyperTalk. HyperTalk был языком сценариев для технологии HyperCard, старой гипермедийной системы, доступной на ранних компьютерах Macintosh.

Самое примечательное в _hyperscript то, что он больше напоминает английскую прозу, чем другие языки программирования.

Как и Alpine, _hyperscript — это современная замена jQuery. Также, как и Alpine, _hyperscript позволяет вам писать сценарии в HTML.

Однако, в отличие от Alpine, _hyperscript не является реактивным. Вместо этого он фокусируется на упрощении записи и чтения манипуляций с DOM в ответ на события. Он имеет встроенные языковые конструкции для многих операций DOM, что избавляет вас от необходимости перемещаться по иногда многословным API-интерфейсам JavaScript DOM.

Мы дадим небольшое представление о том, что представляет собой создание сценариев на языке _hyperscript, чтобы вы могли изучить этот язык более подробно позже, если он покажется вам интересным.

Как и htmx и AlpineJS, _hyperscript можно установить через CDN или из npm (имя пакета hyperscript.org):

Листинг 150. Установка _hyperscript через CDN

<script src="//unpkg.com/hyperscript.org"></script>

_hyperscript использует атрибут \_ (подчеркивание) для размещения сценариев в элементах DOM. Вы также можете использовать атрибуты script или data-script, в зависимости от ваших потребностей в проверке HTML.

Давайте посмотрим, как реализовать простой компонент счетчика, который мы рассматривали ранее, с помощью _hyperscript. Мы разместим элемент output и button внутри div. Чтобы реализовать счетчик, нам нужно добавить к кнопке небольшой фрагмент _hyperscript. При нажатии кнопка должна увеличивать текст предыдущего тега output.

Как вы увидите, последнее предложение близко к реальному коду _hyperscript:

<div class="counter">
  <output>0</output>
  <button _="on click increment the textContent of the previous <output/>">Increment</button> <!-- 1 -->
</div>
  1. К кнопке добавлен код _hyperscript.

Давайте пройдемся по каждому компоненту этого скрипта:

В этом коде ключевое слово previous (и сопровождающее его ключевое слово next) является примером того, как _hyperscript упрощает операции с DOM: в стандартном API DOM нет такой встроенной функциональности, и реализовать ее в VanillaJS сложнее, чем вы могли бы подумать!

Итак, как видите, _hyperscript очень выразителен, особенно когда дело касается манипуляций с DOM. Это упрощает встраивание сценариев непосредственно в HTML: поскольку язык сценариев более мощный, сценарии, написанные на нем, обычно короче и их легче читать.

Программирование на естественном языке?

Опытные программисты могут с подозрением относиться к _hyperscript: существует множество проектов «программирования на естественном языке» (natural language programming) (NLP), ориентированных на непрограммистов и начинающих программистов, предполагающих, что способность читать код на своем «естественном языке» даст им возможность также писать его. Это привело к появлению плохо написанного и структурированного кода, который не оправдал (часто чрезмерной) шумихи.

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

Как вы можете видеть в приведенном выше примере, при использовании ссылки на запрос <output/> _hyperscript не уклоняется от использования неестественного языка, специфичного для DOM, когда это необходимо.

_hyperscript в действии: сочетание клавиш

Хотя демонстрация счетчика — хороший способ сравнить различные подходы к написанию сценариев, резина встречается с дорогой, когда вы пытаетесь реально реализовать полезную функцию с помощью подхода. Для _hyperscript давайте добавим сочетание клавиш в Contact.app: когда пользователь нажимает Alt+S в нашем приложении, мы фокусируем поле поиска.

Поскольку наше сочетание клавиш фокусируется на вводе поиска, давайте поместим для него код на этот ввод поиска, удовлетворяющий локальности.

Вот исходный HTML-код для ввода поиска:

<input id="search" name="q" type="search" placeholder="Search Contacts">

Мы добавим обработчик событий, используя синтаксис on keydown, который будет срабатывать всякий раз, когда происходит нажатие клавиши. Кроме того, мы можем использовать синтаксис фильтра _event в _hyperscript, используя квадратные скобки после события. В квадратных скобках мы можем поместить выражение фильтра, которое будет отфильтровывать события keydown, которые нас не интересуют. В нашем случае мы хотим учитывать только события, когда клавиша Alt удерживается нажатой и когда нажимается клавиша «S». . Для достижения этой цели мы можем создать логическое выражение, которое проверяет свойство altKey (на предмет true) и свойство code (на предмет того, является ли оно «KeyS») события.

Пока что наш _hyperscript выглядит так:

Листинг 151. Начало использования сочетания клавиш

on keydown[altKey and code is 'KeyS'] ...

Теперь по умолчанию _hyperscript будет прослушивать данное событие в элементе, в котором оно объявлено. Итак, с помощью имеющегося у нас сценария мы будем получать события keydown только в том случае, если поле поиска уже находится в фокусе. Это не то, чего мы хотим! Мы хотим, чтобы этот ключевой элемент работал глобально, независимо от того, какой элемент находится в центре внимания.

Не проблема! Мы можем прослушивать событие keyDown в другом месте, используя предложение from в нашем обработчике событий. В этом случае мы хотим прослушивать команду keyDown из окна, и наш код, естественно, выглядит следующим образом:

Листинг 152. Глобальное прослушивание

on keydown[altKey and code is 'KeyS'] from window ...

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

Теперь, когда мы выбрали событие, которое хотим использовать для фокусировки поля поиска, давайте реализуем фактическую фокусировку, вызвав стандартный метод .focus().

Вот весь скрипт, встроенный в HTML:

Листинг 153. Наш окончательный сценарий

<input id="search" name="q" type="search" placeholder="Search Contacts" _="on keydown[altKey and code is 'KeyS'] from the window me.focus()"> <!-- 1 -->
  1. «me» относится к элементу, на котором написан сценарий.

Учитывая всю функциональность, он на удивление краток и, как английский язык программирования, довольно легко читается.

Почему новый язык программирования?

Это все хорошо, но вы можете подумать: «Совершенно новый язык сценариев? Это кажется чрезмерным». И в каком-то смысле вы правы: JavaScript — достойный язык сценариев, он очень хорошо оптимизирован и широко используется в веб-разработке. С другой стороны, создав совершенно новый интерфейсный язык сценариев, мы получили свободу решать некоторые проблемы, с которыми мы столкнулись при создании уродливого и многословного кода в JavaScript:

Асинхронная прозрачность
В _hyperscript асинхронные функции (т. е. функции, возвращающие экземпляры Promise) могут вызываться так, как если бы они были синхронными. Изменение функции с синхронной на асинхронную не нарушает код _hyperscript, который ее вызывает. Это достигается путем проверки промиса при оценке любого выражения и приостановки работающего сценария, если он существует (приостанавливается только текущий обработчик событий, а основной поток не блокируется). Вместо этого JavaScript требует либо явного использования обратных вызовов, либо явных async аннотаций (которые нельзя смешивать с синхронным кодом).
Доступ к свойству массива
В _hyperscript доступ к свойству массива (кроме length или номера) вернет массив значений свойств каждого члена этого массива, что делает доступ к свойству массива похожим на операцию с плоским ассоциативным массивом. jQuery имеет аналогичную функцию, но только для собственной структуры данных.
Собственный синтаксис CSS
В _hyperscript вы можете использовать такие вещи, как литералы классов и идентификаторов CSS или литералы запросов CSS, непосредственно в языке, вместо необходимости обращаться к многословному API DOM, как вы это делаете в JavaScript.
Глубокая поддержка событий
Работать с событиями в _hyperscript гораздо приятнее, чем работать с ними в JavaScript, благодаря встроенной поддержке реагирования и отправки событий, а также общих шаблонов обработки событий, таких как «отключение» или ограничение скорости событий. _hyperscript также предоставляет декларативные механизмы для синхронизации событий внутри данного элемента и между несколькими элементами.

Мы еще раз хотим подчеркнуть, что в этом примере мы не выходим за рамки HDA: мы только добавляем клиентскую функциональность с помощью наших сценариев. Мы не создаем и не управляем большим объемом состояния за пределами самого DOM и не общаемся с сервером посредством обмена без гипермедиа.

Кроме того, поскольку _hyperscript так хорошо встраивается в HTML, он фокусируется на гипермедиа, а не на логике сценариев.

Возможно, он не соответствует всем стилям и потребностям сценариев, но _hyperscript может обеспечить превосходные возможности создания сценариев для приложений, управляемых гипермедиа. Это небольшой и малоизвестный язык программирования, на который стоит обратить внимание, чтобы понять, чего он пытается достичь.

Использование готовых (Off-the-Shelf) компонентов

На этом мы завершаем рассмотрение трех различных вариантов вашей инфраструктуры сценариев, то есть кода, который вы пишете для улучшения своего приложения, управляемого гипермедиа. Однако есть еще одна важная область, которую следует учитывать при обсуждении сценариев на стороне клиента: «готовые» компоненты. То есть библиотеки JavaScript, созданные другими людьми и предлагающие какую-то функциональность, например показ модальных диалогов.

Компоненты стали очень популярны в мире веб-разработки: такие библиотеки, как DataTables, обеспечивают богатый пользовательский опыт с минимальным использованием кода JavaScript со стороны пользователя. К сожалению, если эти библиотеки плохо интегрированы в веб-сайт, из-за них приложение может выглядеть «склеенным вместе». Более того, некоторые библиотеки выходят за рамки простой манипуляции с DOM и требуют интеграции с конечной точкой сервера, почти всегда с API данных JSON. Это означает, что вы больше не создаете приложение, управляемое гипермедиа, просто потому, что конкретный виджет требует чего-то другого. Позор!

Веб-компоненты/Пользовательские элементы

Веб-компоненты — это собирательное название нескольких стандартов; Пользовательские элементы (Custom Elements) и Shadow DOM, а также <template> и <slot>.

Все эти стандарты предоставляют полезные возможности. Элементы <template> удаляют свое содержимое из документа, при этом анализируя его как HTML (в отличие от комментариев) и делая его доступным для JavaScript. Пользовательские элементы позволяют нам инициализировать и удалять поведение при добавлении или удалении элементов, что раньше требовало ручной работы или MutationObservers. Теневой DOM позволяет нам инкапсулировать элементы, оставляя «легкий» (не теневой) DOM чистым.

Однако попытки воспользоваться этими преимуществами часто разочаровывают. Некоторые трудности — это просто проблемы роста новых стандартов (например, проблемы доступности Shadow DOM), над которыми активно работают. Другие являются результатом того, что веб-компоненты пытаются одновременно быть слишком многими вещами:

  • Механизмом расширения для HTML. С этой целью каждый пользовательский элемент представляет собой тег, который мы добавляем в язык.

  • Механизмом жизненного цикла поведения. Такие методы, как createCallback, linkedCallback и т. д., позволяют добавлять поведение к элементам без необходимости вызывать их вручную при добавлении этих элементов.

  • Единицей инкапсуляции. Shadow DOM изолирует элементы от их окружения.

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

Когда нам следует использовать веб-компоненты? Хорошее практическое правило — спросить себя: «Может ли это быть встроенным элементом HTML?» Например, хорошим кандидатом является редактор кода, поскольку в HTML уже есть элементы <textarea> и contenteditable. Кроме того, полнофункциональный редактор кода будет иметь множество дочерних элементов, которые в любом случае не предоставляют много информации. Мы можем использовать такие функции, как Shadow DOM, для инкапсуляции этих элементов[3]. Мы можем создать пользовательский элемент <code-area>, который можно будет разместить на нашей странице, когда захотим.

  1. Помните, что Shadow DOM — это новая функция веб-платформы, которая на момент написания все еще находится в разработке. В частности, существуют некоторые ошибки доступности, которые могут возникнуть при взаимодействии элементов внутри и снаружи теневого корня.

Варианты интеграции

Лучшие библиотеки JavaScript для работы при создании приложения, управляемого гипермедиа, — это те, которые:

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

Давайте рассмотрим два разных подхода к написанию сценариев: один с использованием обратных вызовов JavaScript, а другой с использованием событий.

Чтобы конкретизировать ситуацию, давайте реализуем улучшенное диалоговое окно подтверждения для кнопки DELETE, которую мы создали в Alpine в предыдущем разделе. В исходном примере мы использовали функцию confirm(), встроенную в JavaScript, которая показывает довольно простой диалог подтверждения системы. Мы заменим эту функцию популярной библиотекой JavaScript SweetAlert2, которая отображает гораздо более красивый диалог подтверждения. В отличие от функции confirm(), которая блокирует и возвращает логическое значение ((true, если пользователь подтвердил, и false в противном случае), Sweet Alert 2 возвращает объект Promise, который представляет собой механизм JavaScript для подключения обратного вызова после завершения асинхронного действия (например, ожидания подтверждения или отклонения действия пользователем).

Интеграция с использованием обратных вызовов

Если SweetAlert2 установлен в виде библиотеки, у вас есть доступ к объекту Swal, в котором есть функция fire() для запуска показа оповещения. Вы можете передать аргументы методу fire(), чтобы точно настроить внешний вид кнопок в диалоговом окне подтверждения, заголовок диалогового окна и т. д. Мы не будем слишком углубляться в эти детали, но вы вскоре увидите, как выглядит диалог.

Итак, поскольку мы установили библиотеку SweetAlert2, мы можем заменить ее вместо вызова функции confirm(). Затем нам нужно реструктурировать код, чтобы передать обратный вызов методу then() для Promise, который возвращает Swal.fire(). Глубокое погружение в промисы выходит за рамки этой главы, но достаточно сказать, что этот обратный вызов будет вызываться, когда пользователь подтверждает или отклоняет действие. Если пользователь подтвердил действие, то свойство result.isConfirmed будет иметь значение true.

Учитывая все это, наш обновленный код будет выглядеть так:

Листинг 154. Диалоговое окно подтверждения на основе обратного вызова

<button type="button" class="bad bg color border"
  @click="Swal.fire({ <!-- 1 -->
                     title: 'Delete these contacts?', <!-- 2 -->
                     showCancelButton: true,
                     confirmButtonText: 'Delete'
                   }).then((result) => { <!-- 3 -->
                     if (result.isConfirmed) {
                       htmx.ajax('DELETE', '/contacts', { source: $root, target: document.body })
                     }
                   });"
>Delete</button>
  1. Вызовать функцию Swal.fire().

  2. Настроить диалог

  3. Обработать результат выбора пользователя

И теперь, когда нажимаем эту кнопку, мы получаем красивый диалог в нашем веб-приложении:

Гораздо приятнее, чем диалог подтверждения системы. Тем не менее, это кажется немного неправильным. Нужно написать много кода только для того, чтобы вызвать метод чуть более приятный, чем confirm(), не так ли? И код JavaScript htmx, который мы здесь используем, кажется неуклюжим. Было бы более естественно перенести htmx в атрибуты кнопки, как мы это делали, а затем инициировать запрос через события.

Итак, давайте воспользуемся другим подходом и посмотрим, как это выглядит.

Интеграция с использованием событий

Чтобы очистить этот код, мы перенесем код Swal.fire() в созданную нами пользовательскую функцию JavaScript под названием sweetConfirm(). sweetConfirm() примет параметры диалога, переданные в метод fire(), а также элемент, подтверждающий действие. Большая разница здесь в том, что новая функция sweetConfirm(), вместо того, чтобы вызывать какой-либо htmx напрямую, вызовет подтвержденное событие на кнопке, когда пользователь подтвердит, что он хочет удалить.

Вот как выглядит наша функция JavaScript:

Листинг 155. Диалоговое окно подтверждения на основе событий

function sweetConfirm(elt, config) {
  Swal.fire(config) // 1
    .then((result) => {
      if (result.isConfirmed) {
        elt.dispatchEvent(new Event('confirmed')); // 2
      }
    });
}
  1. Передать конфигурацию функции fire().

  2. Если пользователь подтвердил действие, инициировать событие confirmed.

Благодаря этому методу мы теперь можем немного ужесточить нашу кнопку удаления. Мы можем удалить весь код SweetAlert2, который был у нас в атрибуте @click Alpine, и просто вызвать этот новый метод sweetConfirm(), передав аргументы $el, что является синтаксисом Alpine для получения «текущего элемента», на котором работает скрипт, а затем точную конфигурацию, которую мы хотим для нашего диалога.

Если пользователь подтвердит действие, на кнопке сработает событие confirmed. Это означает, что мы можем вернуться к использованию наших проверенных атрибутов htmx! А именно, мы можем переместить DELETE в атрибут hx-delete и можем использовать hx-target для нацеливания на body. И затем, и это решающий шаг, мы можем использовать событие confirmed, которое запускается в функции sweetConfirm(), чтобы инициировать запрос, но добавив для него hx-trigger.

Вот как выглядит наш код:

Листинг 156. Диалоговое окно подтверждения на основе события

<button type="button" class="bad bg color border"
        hx-delete="/contacts" hx-target="body" hx-trigger="confirmed" <!-- 1 -->
        @click="sweetConfirm($el, <!-- 2 -->
                { title: 'Delete these contacts?', <!-- 3 -->
                showCancelButton: true,
                confirmButtonText: 'Delete'})">
  1. Наши атрибуты htmx вернулись.

  2. Передаем кнопку в функцию, чтобы на ней можно было вызвать событие.

  3. Передаем информацию о конфигурации SweetAlert2.

Как видите, этот код, основанный на событиях, намного чище и определенно более «HTML-подобен». Ключом к этой более чистой реализации является то, что наша новая функция sweetConfirm() запускает событие, которое может прослушивать htmx.

Вот почему при выборе библиотеки для работы важно учитывать богатую модель событий, как с htmx, так и с приложениями, управляемыми гипермедиа, в целом.

К сожалению, из-за распространенности и доминирования сегодня подхода, ориентированного на JavaScript, многие библиотеки похожи на SweetAlert2: они ожидают, что вы передадите обратный вызов в первом стиле. В этих случаях вы можете использовать метод, который мы продемонстрировали здесь, заключая библиотеку в функцию, которая запускает события в обратном вызове, чтобы сделать библиотеку более гипермедиа и htmx-дружественной.

Прагматическое написание сценариев

В случае конфликта отдавайте предпочтение пользователям, а не авторам, разработчикам, спецификаторам и теоретической чистоте.
~ W3C HTML Design Principles § 3.2 Priority of Constituencies

Мы рассмотрели несколько инструментов и методов создания сценариев в приложении, управляемом гипермедиа. Как следует выбирать между ними? Печальная истина заключается в том, что на этот вопрос никогда не будет единственного и всегда правильного ответа.

Вы привержены использованию только ванильного JavaScript, возможно, из-за политики компании? Что ж, вы можете эффективно использовать ванильный JavaScript для создания сценариев вашего приложения, управляемого гипермедиа.

У вас больше свободы действий и вам нравится внешний вид Alpine.js? Это отличный способ добавить в ваше приложение более структурированный, локализованный JavaScript, который также предлагает несколько приятных реактивных функций.

Вы немного смелее в своем техническом выборе? Возможно, стоит обратить внимание на _hyperscript. (Мы, конечно, предпочитаем этот вариант.)

Иногда вы можете даже подумать о выборе двух (или более) из этих подходов в приложении. У каждого есть свои сильные и слабые стороны, и все они относительно небольшие и автономные, поэтому лучшим подходом может быть выбор правильного инструмента для конкретной работы.

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

И даже по этим темам иногда веб-разработчику приходится делать то, что должен делать веб-разработчик. Что если идеальный виджет для вашего приложения существует, но использует API данных JSON? Это нормально.

Только не делайте это привычкой.

Примечания к HTML: HTML предназначен для приложений.

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

Когда я говорю «гипертекст», я имею в виду одновременное представление информации и элементов управления, так что информация становится возможностью, посредством которой пользователь получает возможность выбора и выбирает действия.
~ Рой Филдинг A little REST and Relaxation

HTML позволяет документам содержать богатые мультимедиа, включая изображения, аудио, видео, программы JavaScript, векторную графику и (с некоторой помощью) трехмерную среду. Однако, что еще более важно, он позволяет встраивать интерактивные элементы управления в эти документы, позволяя самой информации быть приложением, через которое к ней осуществляется доступ.

Подумайте: разве не ошеломляет тот факт, что одно приложение, работающее на всех типах компьютеров и операционных систем, может позволить вам читать новости, совершать видеозвонки, составлять документы, входить в виртуальные миры и выполнять практически любые другие повседневные вычислительные задачи?

К сожалению, именно интерактивные возможности HTML являются его наименее развитым аспектом. По неизвестным нам причинам, несмотря на то, что HTML дошел до версии 5 и стал стандартом жизни, прибавив по пути множество революционных функций, взаимодействие с данными в нем по-прежнему в основном ограничивается ссылками и формами. Разработчики должны расширять HTML, и мы хотим сделать это таким образом, чтобы не абстрагироваться от его простоты и не подражать классическим «родным» инструментариям.

Глава 10
API данных JSON и приложения на основе гипермедиа

До сих пор мы фокусировались на использовании гипермедиа для создания приложений, управляемых гипермедиа (HDA). При этом мы следуем и используем преимущества собственной сетевой архитектуры Интернета и создаем систему RESTful в первоначальном смысле этого термина.

Однако сегодня мы должны признать, что многие веб-приложения часто создаются без использования этого подхода. Вместо этого они используют интерфейсную библиотеку одностраничного приложения, такую как React, для создания своего приложения и взаимодействуют с сервером через JSON API. Этот JSON API почти никогда не использует концепции гипермедиа. Скорее API JSON, как правило, являются API данных, то есть API, который просто возвращает клиенту структурированные данные домена без какой-либо информации управления гипермедиа. Сам клиент должен знать, как интерпретировать данные JSON: какие конечные точки связаны с данными, как следует интерпретировать определенные поля и так далее.

Теперь, хотите верьте, хотите нет, мы создаем API для Contact.app.

Это может показаться вам запутанным: API? Мы только что создали веб-приложение с обработчиками, которые просто возвращают HTML.

Как это API?

Оказывается, Contact.app действительно предоставляет API. Просто это API гипермедиа, который понимает клиент гипермедиа, то есть браузер. Мы создаем API для взаимодействия браузера через HTTP, и благодаря магии HTML и гипермедиа браузеру не нужно ничего знать о нашем API гипермедиа, кроме URL-адреса точки входа: все действия и отображаемая информация поставляется автономно в ответах HTML.

Создание подобных RESTful веб-приложений настолько естественно и просто, что вы можете вообще не думать об этом как об API, но мы уверяем вас, что это API.

API Hypermedia и API данных JSON

Итак, у нас есть гипермедийный API для Contact.app. Должны ли мы также включить API данных для Contact.app?

Конечно! Существование API гипермедиа никоим образом не означает, что у вас не может быть также API данных. Фактически, это обычная ситуация в традиционных веб-приложениях: существует «веб-приложение», вход в который осуществляется через URL-адрес точки входа, скажем, https://mywebapp.example.com/. Также существует отдельный API JSON, доступный через другой URL-адрес, например https://api.mywebapp.example.com/v1.

Это вполне разумный способ разделить гипермедийный интерфейс вашего приложения и API данных, который вы предоставляете другим клиентам, не являющимся гипермедиа.

Почему мы хотим включить API данных вместе с API гипермедиа? Ну, потому что клиенты, не являющиеся гипермедийными, также могут захотеть взаимодействовать с вашим приложением.

Например:

Для всех этих случаев использования API данных JSON имеет смысл: в каждом случае API не используется клиентом гипермедиа, поэтому представление API гипермедиа на основе HTML было бы неэффективным и сложным для клиента. Простой API данных JSON соответствует всем нашим требованиям, и, как всегда, мы рекомендуем использовать правильный инструмент для этой работы.

"Что!?! Вы хотите, чтобы я парсил HTML!?!!»

Путаница, с которой мы часто сталкиваемся в онлайн-дискуссиях, когда защищаем гипермедийный подход к созданию веб-приложений, заключается в том, что люди думают, что мы имеем в виду, что они должны анализировать HTML-ответы с сервера, а затем выгружать данные в свою структуру SPA или мобильные приложения.

Это, конечно, глупо.

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

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

Различия между API Hypermedia и API данных

Давайте на мгновение признаем, что у нас будет API данных для нашего приложения в дополнение к нашему API гипермедиа. На этом этапе некоторые разработчики могут задаться вопросом: зачем использовать оба? Почему бы не иметь единый API, API данных JSON, и не позволить нескольким клиентам использовать этот API для связи с ним?

Не является ли излишним наличие обоих типов API для нашего приложения?

Это разумный момент: мы выступаем за использование нескольких API для вашего веб-приложения, если это необходимо, и да, это может привести к некоторой избыточности в коде. Однако у обоих типов API есть явные преимущества и, более того, разные требования к обоим типам API.

Поддерживая оба этих типа API по отдельности, вы можете воспользоваться преимуществами обоих, сохраняя при этом четкое разделение различных стилей кода и потребностей инфраструктуры.

Давайте сравним потребности API JSON с API Hypermedia:

Потребности JSON API Hypermedia API
Он должен оставаться стабильным с течением времени: вы не можете менять API волей-неволей, иначе вы рискуете сломать клиенты, которые используют API и ожидают, что определенные конечные точки будут вести себя определенным образом. Нет необходимости сохранять стабильность с течением времени: все URL-адреса обнаруживаются через ответы HTML, поэтому вы можете гораздо более агрессивно изменять форму API гипермедиа.
Он должен иметь версию: что касается первого пункта, когда вы вносите серьезные изменения, вам необходимо изменить версию API, чтобы клиенты, использующие старый API, продолжали работать. Управление версиями не является проблемой, это еще одна сильная сторона подхода гипермедиа.
Скорость должна быть ограничена: поскольку API данных часто используются другими клиентами, а не только вашим собственным внутренним веб-приложением, скорость запросов должна быть ограничена, часто пользователем, чтобы избежать перегрузки системы одним клиентом. Ограничение скорости, вероятно, не так важно, как предотвращение атак распределенного отказа в обслуживании (DDoS).
Это должен быть общий API: поскольку API предназначен для всех клиентов, а не только для вашего веб-приложения, вам следует избегать специализированных конечных точек, которые определяются потребностями вашего собственного приложения. Вместо этого API должен быть достаточно общим и выразительным, чтобы удовлетворить как можно больше потенциальных потребностей клиентов. API может быть очень специфичным для нужд вашего приложения: поскольку он предназначен только для вашего конкретного веб-приложения и поскольку API обнаруживается через гипермедиа, вы можете добавлять и удалять тщательно настроенные конечные точки для конкретных функций или потребностей оптимизации в вашем приложении.
Аутентификация для такого рода API обычно основана на токенах, о которых мы поговорим более подробно позже. Аутентификация обычно управляется с помощью файла cookie сеанса, установленного на странице входа.

Эти два разных типа API имеют разные преимущества и потребности, поэтому имеет смысл использовать оба. Для вашего веб-приложения можно использовать подход гипермедиа, что позволяет вам специализировать API в соответствии с «формой» вашего приложения. Подход Data API можно использовать для других клиентов, не относящихся к гипермедиа, таких как мобильные устройства, партнеры по интеграции и т. д.

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

Это ключевое преимущество отделения API данных от API Hypermedia.

API данных .JSON и API JSON «REST»

К сожалению, сегодня по историческим причинам то, что мы называем API данных JSON, в отрасли часто называют «REST API». Это иронично, потому что при любом разумном прочтении работы Роя Филдинга, определяющего, что означает REST, подавляющее большинство API JSON не являются RESTful. Даже не близко.

Меня расстраивает количество людей, называющих любой HTTP-интерфейс REST API. Сегодняшний пример — REST API SocialSite. Это RPC. Оно кричит, что это RPC. Здесь так много взаимосвязи, что ей следует присвоить рейтинг X.
Что нужно сделать, чтобы в архитектурном стиле REST было ясно, что гипертекст является ограничением? Другими словами, если механизм состояния приложения (и, следовательно, API) не управляется гипертекстом, то он не может быть RESTful и не может быть REST API. Период. Есть ли где-нибудь сломанное руководство, которое нужно починить?
~ Рой Филдинг https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven

История того, как «REST API» в отрасли стала означать «JSON API», длинная и грязная, и выходит за рамки этой книги. Однако, если вам интересно, вы можете обратиться к эссе одного из авторов этой книги под названием «Как REST стал означать противоположность REST?» на сайте htmx.

В этой книге мы используем термин «API данных» (Data API) для описания этих JSON API, признавая при этом, что многие люди в отрасли в обозримом будущем будут продолжать называть их «REST API».

Добавление API данных JSON в Contact.app

Хорошо, как мы добавим API данных JSON в наше приложение? Один из подходов, популяризированный веб-инфраструктурой Ruby on Rails, заключается в использовании тех же конечных точек URL-адресов, что и в вашем гипермедийном приложении, но с использованием заголовка HTTP Accept, чтобы определить, хочет ли клиент представление JSON или представление HTML. Заголовок HTTP Accept позволяет клиенту указать, какие типы многоцелевых расширений Интернет-почты (MIME), то есть типы файлов, он хочет получить от сервера: JSON, HTML, текст и т. д.

Итак, если клиенту нужно представление всех контактов в формате JSON, он может отправить запрос GET, который выглядит следующим образом:

Листинг 157. Запрос JSON-представления всех контактов

Accept: application/json

GET /contacts

Если бы мы приняли этот шаблон, то наш обработчик запросов для /contacts/ необходимо было бы обновить, чтобы проверять этот заголовок и, в зависимости от значения, возвращать JSON, а не HTML-представление для контактов. Ruby on Rails поддерживает этот шаблон, встроенный в инфраструктуру, что позволяет очень легко включить запрошенный тип MIME.

К сожалению, наш опыт работы с этим шаблоном не был большим по причинам, которые должны быть ясны, учитывая различия, которые мы обрисовали между API данных и гипермедиа: они имеют разные потребности и часто принимают совершенно разные «формы», и пытаются втиснуть их в один и тот же набор URL-адресов в конечном итоге создает большую напряженность в коде приложения.

Учитывая разные потребности двух API и наш опыт управления несколькими подобными API, мы считаем, что их разделение и, следовательно, разделение API данных JSON на собственный набор URL-адресов является правильным выбором. Это позволит нам развивать два API отдельно друг от друга и даст нам возможность улучшать каждый из них независимо, в соответствии с их индивидуальными сильными сторонами.

Выбор корневого URL-адреса для нашего API

Учитывая, что мы собираемся отделить наши маршруты API данных JSON от наших обычных маршрутов гипермедиа, где нам следует их разместить? Одним из важных соображений здесь является то, что мы хотим быть уверены, что можем каким-то образом корректно версионировать наш API, независимо от выбранного нами шаблона.

Оглядываясь вокруг, можно сказать, что многие места используют поддомен для своих API, что-то вроде https://api.mywebapp.example.com, и фактически часто кодируют управление версиями в поддомене: https://v1.api.mywebapp.example.com.

Хотя это имеет смысл для крупных компаний, для нашего скромного маленького приложения Contact.app это кажется излишним. Вместо использования поддоменов, которые усложняют локальную разработку, мы будем использовать подпути внутри существующего приложения:

Если и когда мы решим обновить версию API, мы сможем перейти на /api/v2 и так далее.

Этот подход, конечно, не идеален, но он будет работать для нашего простого приложения и может быть адаптирован к субдоменному подходу или различным другим методам позже, когда наше приложение Contact.app захватит Интернет и мы сможем позволить себе большую команду разработчиков API. :)

Наша первая конечная точка JSON: список всех контактов

Давайте добавим нашу первую конечную точку Data API. Она будет обрабатывать запрос HTTP GET к /api/v1/contacts и возвращать список всех контактов в системе в формате JSON. В некотором смысле это будет похоже на наш исходный код для гипермедийного маршрута /contacts: мы загрузим все контакты из базы данных контактов, а затем отобразим некоторый текст в качестве ответа.

Мы также собираемся воспользоваться приятной особенностью Flask: если вы просто возвращаете объект из обработчика, он сериализует (то есть преобразует) этот объект в ответ JSON. Это позволяет очень легко создавать простые API-интерфейсы JSON в flask!

Листинг 158. API данных JSON для возврата всех контактов

@app.route("/api/v1/contacts", methods=["GET"]) # 1
def json_contacts():
    contacts_set = Contact.all()
    contacts_dicts = [c.__dict__ for c in contacts_set] # 2
    return {"contacts": contacts_dicts} # 3
  1. JSON API получает собственный путь, начинающийся с /api.

  2. Преобразовать массив контактов в массив простых объектов словаря (map).

  3. Вернуть словарь, содержащий свойство contacts всех контактов.

Этот код Python может показаться вам немного странным, если вы не являетесь разработчиком Python, но все, что мы делаем, — это преобразуем наши контакты в массив простых пар имя/значение и возвращаем этот массив во включающем объекте в качестве свойства contacts. Flask автоматически сериализует этот объект в ответ JSON.

При этом, если мы отправим HTTP-запрос GET к /api/v1/contacts, мы увидим ответ, который выглядит примерно так:

Листинг 159. Некоторые примеры данных из нашего API

{
  "contacts": [
    {
      "email": "carson@example.com",
      "errors": {},
      "first": "Carson",
      "id": 2,
      "last": "Gross",
      "phone": "123-456-7890"
    },
    {
      "email": "joe@example2.com",
      "errors": {},
      "first": "",
      "id": 3,
      "last": "",
      "phone": ""
    },
    ...
  ]
}

Итак, как видите, теперь у нас есть способ получить относительно простое JSON-представление наших контактов с помощью HTTP-запроса. Не идеально, но это хорошее начало. Конечно, этого достаточно, чтобы написать несколько базовых автоматизированных сценариев. Например, вы можете использовать этот API данных для:

Наличие этого небольшого API данных JSON открывает множество возможностей автоматизации, которых было бы сложнее достичь с помощью нашего существующего API гипермедиа.

Добавление контактов

Перейдем к следующему функционалу: возможности добавления нового контакта. Еще раз: наш код будет в чем-то похож на код, который мы написали для нашего обычного веб-приложения. Однако здесь мы также увидим, что API JSON и API гипермедиа для нашего веб-приложения начинают явно расходиться.

В веб-приложении нам нужен был отдельный путь /contacts/new для размещения HTML-формы для создания нового контакта. В веб-приложении мы приняли решение отправить POST по тому же пути, чтобы обеспечить согласованность.

В случае с JSON API такой путь не требуется: JSON API «просто есть»: ему не нужно предоставлять какое-либо гипермедийное представление для создания нового контакта. Вы просто знаете, где отправить POST для создания контакта — вероятно, через некоторую документацию, предоставленную об API — и все.

По этой причине мы можем поместить обработчик «create» по тому же пути, что и обработчик «list»: /api/v1/contacts, но заставить его отвечать только на запросы HTTP POST.

Код здесь относительно прост: заполните новый контакт информацией из запроса POST, попытайтесь сохранить его и — если это не удалось — отобразите сообщения об ошибках. Вот код:

Листинг 160. Добавление контактов с помощью нашего JSON API

@app.route("/api/v1/contacts", methods=["POST"]) # 1
def json_contacts_new():
    c = Contact(None, request.form.get('first_name'), request.form.get('last_name'), request.form.get('phone'), request.form.get('email')) # 2
    if c.save(): # 3
        return c.__dict__
    else:
        return {"errors": c.errors}, 400 # 4
  1. Этот обработчик находится на том же пути, что и первый для нашего JSON API, но обрабатывает запросы POST.

  2. Мы создаем новый контакт на основе значений, отправленных вместе с запросом.

  3. Мы пытаемся сохранить контакт и, в случае успеха, отобразить его как объект JSON.

  4. Если сохранение не удалось, мы отображаем объект, показывающий ошибки, с кодом ответа 400 (Bad Request).

В некотором смысле это похоже на обработчик contact_new() из нашего веб-приложения; мы создаем контакт и пытаемся его сохранить. В остальном все совсем иначе:

Подобные различия со временем накапливаются и делают идею сохранения JSON и API-интерфейсов гипермедиа на одном и том же наборе URL-адресов все менее и менее привлекательной.

Просмотр сведений о контакте

Далее давайте предоставим клиенту JSON API возможность загружать сведения об одном контакте. Мы, естественно, будем использовать HTTP GET для этой функции и будем следовать соглашению, которое мы установили для нашего обычного веб-приложения, и поместим путь в /api/v1/contacts/<contact id>. Так, например, если вы хотите просмотреть подробную информацию о контакте с ID 42, вы должны выполнить HTTP GET для /api/v1/contacts/42.

Этот код довольно прост:

Листинг 161. Получение сведений о контакте в формате JSON

@app.route("/api/v1/contacts/<contact_id>",
methods=["GET"]) # 1
def json_contacts_view(contact_id=0):
    contact = Contact.find(contact_id) # 2
    return contact.__dict__ # 3
  1. Добавить новый маршрут GET по пути, который мы хотим использовать для просмотра контактной информации.

  2. Найдти контакт по ID, переданному по пути.

  3. Преобразовать контакт в словарь, чтобы его можно было отображать как ответ JSON.

Ничего сложного: ищем контакт по ID, указанному в пути к контроллеру. Затем мы визуализируем его как JSON. Вы должны оценить простоту этого кода!

Далее добавим также обновление и удаление контакта.

Обновление и удаление контактов

Как и в случае с конечной точкой API создания контактов, поскольку для них не существует пользовательского интерфейса HTML, мы можем повторно использовать путь /api/v1/contacts/<contact id>. Мы будем использовать HTTP-метод PUT для обновления контакта и метод DELETE для его удаления.

Наш код обновления будет выглядеть почти идентично обработчику создания, за исключением того, что вместо создания нового контакта мы будем искать контакт по ID и обновлять его поля. В этом смысле мы просто объединяем код обработчика создания и обработчика подробного представления.

Листинг 162. Обновление контакта с помощью нашего JSON API

@app.route("/api/v1/contacts/<contact_id>",
methods=["PUT"]) # 1
def json_contacts_edit(contact_id):
    c = Contact.find(contact_id) # 2
    c.update(request.form['first_name'],
request.form['last_name'], request.form['phone'],
request.form['email']) # 3
    if c.save(): # 4
        return c.__dict__
    else:
        return {"errors": c.errors}, 400
  1. Мы обрабатываем запросы PUT к URL-адресу данного контакта.

  2. Найдти контакт по ID, переданному по пути.

  3. Обновить данные контакта из значений, включенных в запрос.

  4. Отсюда логика идентична обработчику json_contacts_create().

Опять же, благодаря встроенным функциям Flask, их легко реализовать.

Давайте теперь посмотрим на удаление контакта. Это оказывается еще проще: как и в случае с обработчиком обновления, мы будем искать контакт по ID, а затем, вы не поверите, удалять его. На этом этапе мы можем вернуть простой объект JSON, указывающий на успех.

Листинг 163. Удаление контакта с помощью нашего JSON API

@app.route("/api/v1/contacts/<contact_id>",
methods=["DELETE"]) # 1
def json_contacts_delete(contact_id=0):
    contact = Contact.find(contact_id)
    contact.delete() # 2
    return jsonify({"success": True}) # 3
  1. Мы обрабатываем запросы DELETE к URL-адресу данного контакта.

  2. Найдти контакт и вызвать для него метод delete().

  3. Вернуть простой объект JSON, указывающий, что контакт был успешно удален.

И при этом у нас есть простой небольшой API данных JSON, который будет жить рядом с нашим обычным веб-приложением, красиво отделенным от основного веб-приложения, поэтому его можно будет развивать отдельно по мере необходимости.

Дополнительные соображения по API данных

Теперь нам нужно было бы сделать гораздо больше, если бы мы хотели сделать этот JSON API готовым к производству. Как минимум нам нужно будет добавить:

Эти темы выходят за рамки этой книги, но мы хотели бы сосредоточиться на одной из них, а именно на аутентификации, чтобы показать разницу между нашей гипермедиа и JSON API. Чтобы обезопасить наше приложение, нам нужно добавить аутентификацию, некоторый механизм определения того, от кого исходит запрос, а также авторизацию, определяющую, имеет ли право данный пользователь выполнить запрос.

Мы пока оставим авторизацию и рассмотрим только аутентификацию.

Аутентификация в веб-приложениях

В мире веб-приложений HTML аутентификация традиционно выполнялась через страницу входа, на которой у пользователя запрашивается имя пользователя (часто адрес электронной почты) и пароль. Затем этот пароль сверяется с базой данных (хешированных) паролей, чтобы установить, является ли пользователь тем, кем он себя называет. Если пароль верен, создается файл cookie сеанса, указывающий, кто является пользователем. Этот файл cookie затем отправляется с каждым запросом, который пользователь делает к веб-приложению, позволяя приложению узнать, какой пользователь делает данный запрос.

HTTP-файлы cookie

HTTP-файлы cookie - это своего рода странная особенность HTTP. В некотором смысле они нарушают цель сохранения состояния без изменений, основной компонент архитектуры RESTful: сервер часто использует сессионный файл cookie в качестве указателя состояния, хранящегося на сервере “на стороне”, например, кэш последнего действия, выполненного пользователем.

Тем не менее, файлы cookie оказались чрезвычайно полезными, и поэтому люди, как правило, не слишком жалуются на этот их аспект (мы не уверены, какие у нас могут быть другие варианты!) Интересный пример прагматизма, ушедшего (относительно) в веб-разработку.

По сравнению со стандартным подходом к аутентификации веб-приложений, JSON API обычно использует своего рода аутентификацию на основе токена: токен аутентификации будет установлен с помощью такого механизма, как OAuth, и затем этот токен аутентификации будет передан, часто в виде HTTP-заголовка, с каждым запросом, который делает клиент.

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

Эти разные механизмы установления аутентификации являются еще одной веской причиной для разделения наших API-интерфейсов JSON и гипермедиа.

«Форма» наших двух API

Когда мы разрабатывали наш API, мы отметили, что во многих случаях JSON API не требует такого количества конечных точек, как наш гипермедийный API: нам не нужен был обработчик /contacts/new, например, для предоставления гипермедийного представления для создания контактов.

Еще одним аспектом нашего гипермедийного API, который следует учитывать, было улучшение производительности: мы вынесли общее количество контактов на отдельную конечную точку и внедрили шаблон «Lazy Load», чтобы улучшить воспринимаемую производительность нашего приложения.

Теперь, если бы у нас были и гипермедиа, и JSON API, использующие одни и те же пути, захотели бы мы также опубликовать этот API как конечную точку JSON?

Может быть, а может и нет. Это была довольно специфическая потребность для нашего веб-приложения, и при отсутствии запроса от пользователя нашего API JSON нет смысла включать ее для потребителей JSON.

А что, если каким-то чудом проблемы с производительностью Contact.count(), которые мы решали с помощью шаблона отложенной загрузки, исчезнут? Что ж, в нашем приложении, управляемом гипермедиа, мы можем просто вернуться к старому коду и включить счетчик непосредственно в запрос к /contacts. Мы можем удалить конечную точку contacts/count и всю связанную с ней логику. Благодаря единообразному интерфейсу гипермедиа система продолжит работать нормально.

Но что, если бы мы связали вместе наш JSON API и гипермедийный API и опубликовали /contacts/count как поддерживаемую конечную точку для нашего JSON API? В этом случае мы бы не могли просто удалить конечную точку: на нее мог полагаться (не гипермедийный) клиент.

Вы еще раз можете увидеть гибкость подхода гипермедиа и то, почему отделение вашего JSON API от вашего гипермедийного API позволяет вам максимально использовать преимущества этой гибкости.

Парадигма контроллера представления модели (MVC)

В обработчиках нашего JSON API вы, возможно, заметили одну вещь: они относительно просты и регулярны. Большая часть тяжелой работы по обновлению данных и т. д. выполняется внутри самой модели контакта: обработчики действуют как простые соединители, обеспечивающие посредник между HTTP-запросами и моделью.

Это идеальный контроллер парадигмы Модель-Представление-Контроллер (MVC), которая была так популярна на заре Интернета: контроллер должен быть «тонким», а модель содержать большую часть логики в системе.

Шаблон «Контроллер представления модели»

Шаблон проектирования Model View Controller — это классический архитектурный шаблон в разработке программного обеспечения, оказавший большое влияние на раннюю веб-разработку. Этому больше не придают такого большого значения, поскольку веб-разработка разделилась на фронтенд и бэкенд, но большинство веб-разработчиков все еще знакомы с этой идеей.

Традиционно шаблон MVC отображается в веб-разработке следующим образом:

  • Модель — Коллекция «доменных» классов, которые реализуют всю логику и правила для конкретного домена, для которого разработано ваше приложение. Модель обычно предоставляет «ресурсы», которые затем представляются клиентам в виде «представлений» HTML.

  • Представление — Обычно представления представляют собой своего рода систему шаблонов на стороне клиента и отображают вышеупомянутое HTML-представление для данного экземпляра модели.

  • Контроллер — Задача контроллера — принимать HTTP-запросы, преобразовывать их в разумные запросы к модели и перенаправлять эти запросы соответствующим объектам модели. Затем он передает HTML-представление обратно клиенту в качестве ответа HTTP.

Тонкие контроллеры позволяют легко разделить ваши API-интерфейсы JSON и гипермедиа, поскольку вся важная логика находится в модели предметной области, которая является общей для обоих. Это позволяет вам развивать оба по отдельности, сохраняя при этом синхронизацию логики друг с другом.

При правильно построенных «тонких» контроллерах и «толстых» моделях синхронизировать два отдельных API и при этом продолжать развиваться отдельно не так сложно и не так безумно, как может показаться.

HTML-примечания: микроформаты

Микроформаты — это стандарт для внедрения машиночитаемых структурированных данных в HTML. Он использует классы, чтобы пометить определенные элементы как содержащие информацию, подлежащую извлечению, с соглашениями для извлечения общих свойств, таких как имя, URL-адрес и фотография, без классов. Добавляя эти классы в HTML-представление объекта, мы позволяем восстанавливать свойства объекта из HTML. Например, этот простой HTML:

<a class="h-card" href="https://john.example">
  <img src="john.jpg" alt=""> John Doe
</a>

может быть преобразован в эту JSON-подобную структуру с помощью анализатора микроформатов:

{
  "type": ["h-card"],
  "properties": {
    "name": ["John Doe"],
    "photo": ["john.jpg"],
    "url": ["https://john.example"]
  }
}

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

Как объяснялось в главе выше, пытаться использовать один и тот же механизм для взаимодействия человека и машины — не лучшая идея. Ваши интерфейсы, ориентированные на человека и на компьютер, могут оказаться ограниченными друг другом. Если вы хотите предоставить пользователям и разработчикам данные и действия, специфичные для домена, JSON API — отличный вариант.

Однако микроформаты гораздо проще внедрить. Протокол или стандарт, который требует от веб-сайтов реализации API JSON, имеет высокий технический барьер. Для сравнения, любой веб-сайт можно дополнить микроформатами, просто добавив несколько классов. Другие форматы данных, встроенные в HTML, такие как микроданные и Open Graph, также легко внедрить. Это делает микроформаты полезными для межсайтовых (осмелимся сказать, веб-масштабных) систем, таких как IndieWeb, которая использует их повсеместно.

Часть III
Перенос гипермедиа на мобильные устройства

Глава 11
Hyperview: мобильная гипермедиа

Вас можно простить за то, что вы думаете, что архитектура гипермедиа является синонимом Интернета, веб-браузеров и HTML. Без сомнения, Интернет — это крупнейшая система гипермедиа, а веб-браузеры — самый популярный клиент гипермедиа. Доминирование Интернета в дискуссиях о гипермедиа позволяет легко забыть, что гипермедиа — это общая концепция, которую можно применять ко всем типам платформ и приложений. В этой главе мы увидим архитектуру гипермедиа, примененную не к веб-платформе, а к нативным мобильным приложениям.

Мобильная платформа как платформа имеет другие ограничения, чем Интернет. Это требует различных компромиссов и дизайнерских решений. Тем не менее, концепции гипермедиа, HATEOAS и REST можно напрямую применять для создания восхитительных мобильных приложений.

В этой главе мы рассмотрим недостатки текущего состояния разработки мобильных приложений и то, как архитектура гипермедиа может решить эти проблемы. Затем мы рассмотрим путь к гипермедиа на мобильных устройствах: Hyperview, платформу мобильных приложений, использующую архитектуру гипермедиа. В заключение мы дадим обзор HXML, формата гипермедиа, используемого Hyperview.

Состояние разработки мобильных приложений

Прежде чем мы сможем обсудить, как применять гипермедиа к мобильным платформам, нам необходимо понять, как обычно создаются нативные мобильные приложения. Я использую слово «нативный» для обозначения кода, написанного на основе SDK, предоставляемого операционной системой телефона (обычно Android или iOS). Этот код упаковывается в исполняемый двоичный файл, загружается и принимается в магазинах приложений, контролируемых Google и Apple. Когда пользователи устанавливают или обновляют приложение, они загружают этот исполняемый файл и запускают код непосредственно в ОС своего устройства. В этом смысле мобильные приложения имеют много общего со старыми настольными приложениями для Mac, Windows или Linux. Есть одно важное различие между настольными приложениями для ПК прошлых лет и сегодняшними мобильными приложениями. Сегодня почти все мобильные приложения являются «сетевыми». По сети, мы имеем в виду, что приложению необходимо читать и записывать данные через Интернет, чтобы обеспечить свою основную функциональность. Другими словами, сетевое мобильное приложение должно реализовать архитектуру клиент-сервер.

При реализации клиент-серверной архитектуры разработчику необходимо принять решение: должно ли приложение быть спроектировано как тонкий или толстый клиент? Нынешние мобильные экосистемы настоятельно подталкивают разработчиков к подходу с «толстым клиентом». Почему? Помните, что Android и iOS требуют, чтобы нативное мобильное приложение было упаковано и распространялось в виде исполняемого двоичного файла. Обойти это невозможно. Поскольку разработчику необходимо написать код для упаковки в исполняемый файл, кажется логичным реализовать часть логики приложения в этом коде. Код также может инициировать HTTP-вызовы к серверу для получения данных, а затем отображать эти данные с помощью библиотек пользовательского интерфейса платформы. Таким образом, разработчики естественным образом приходят к шаблону толстого клиента, который выглядит примерно так:

Как и в случае с SPA в Интернете, у этой архитектуры есть большой недостаток: логика приложения распространяется на клиент и сервер. Иногда это означает, что логика дублируется (например, проверка данных формы). В других случаях клиент и сервер реализуют непересекающиеся части общей логики приложения. Чтобы понять, что делает приложение, разработчику необходимо проследить взаимодействие между двумя совершенно разными базами кода.

Есть еще один недостаток, который затрагивает мобильные приложения больше, чем SPA: отток API. Помните, что магазины приложений контролируют распространение и обновление вашего приложения. Пользователи могут даже контролировать, будут ли они получать обновленные версии вашего приложения и когда это произойдет. Как мобильный разработчик, вы не можете рассчитывать на то, что каждый пользователь будет использовать последнюю версию вашего приложения. Код вашего внешнего интерфейса фрагментируется по многим версиям, и теперь вашему внутреннему интерфейсу необходимо поддерживать их все.

Гипермедиа для мобильных приложений

Мы увидели, что архитектура гипермедиа может устранить недостатки SPA в сети. Но может ли гипермедиа работать и для мобильных приложений? Ответ – да!

Как и в Интернете, мы можем использовать форматы гипермедиа на мобильных устройствах и позволить им служить двигателем состояния приложения. Вся логика контролируется из серверной части, а не распределяется между двумя базами кода. Архитектура гипермедиа также решает раздражающую проблему смены API в мобильных приложениях. Поскольку серверная часть предоставляет ответ гипермедиа, содержащий как данные, так и действия, данные и пользовательский интерфейс не могут рассинхронизироваться. Больше не нужно беспокоиться об обратной совместимости или поддержке нескольких версий API.

Итак, как вы можете использовать гипермедиа для своего мобильного приложения? Сегодня существует два подхода к использованию гипермедиа для создания и распространения нативных мобильных приложений:

WebView

Самый простой способ использовать архитектуру гипермедиа на мобильных устройствах — использовать веб-технологии. SDK для Android и iOS предоставляют WebView: веб-браузеры без Chrome, которые можно встраивать в нативные приложения. Такие инструменты, как Apache Cordova, позволяют легко получить URL-адрес веб-сайта и создать нативные приложения для iOS и Android на основе WebView. Если у вас уже есть адаптивное веб-приложение, вы можете бесплатно получить «родное» мобильное HDA. Звучит слишком хорошо, чтобы быть правдой, не так ли?

Конечно, у этого подхода есть фундаментальное ограничение. Веб-платформа и мобильные платформы имеют разные возможности и соглашения UX. HTML изначально не поддерживает общие шаблоны пользовательского интерфейса мобильных приложений. Одно из самых больших различий заключается в том, как каждая платформа обрабатывает навигацию. В Интернете навигация основана на страницах: одна страница заменяет другую, а браузер предоставляет кнопки «назад» и «вперед» для навигации по истории страниц. На мобильных устройствах навигация более сложна и настроена на физическое взаимодействие на основе жестов.

Архитектура навигации является основным отличием между функционированием мобильных и веб-приложений. Но это не единственный случай. Многие другие шаблоны UX присутствуют в мобильных приложениях, но не поддерживаются в Интернете:

Хотя эти взаимодействия изначально не поддерживаются веб-браузерами, их можно моделировать с помощью JS-библиотек. Конечно, эти библиотеки никогда не будут иметь такого же ощущения и производительности, как нативные жесты. И их использование обычно требует использования архитектуры SPA с большим количеством JS, такой как React. Это возвращает нас на точку 1! Чтобы избежать использования типичной архитектуры «толстого клиента» собственных мобильных приложений, мы обратились к WebView. WebView позволяет нам использовать старый добрый HTML на основе гипермедиа. Но чтобы получить желаемый внешний вид мобильного приложения, нам приходится создавать SPA на JS, теряя при этом преимущества Hypermedia.

Чтобы создать мобильное HDA, которое действует и ощущается как нативное приложение, HTML не поможет. Нам нужен формат, предназначенный для представления взаимодействий и шаблонов собственных мобильных приложений. Это именно то, что делает Hyperview.

Hyperview

Hyperview — это гипермедийная система с открытым исходным кодом, которая обеспечивает:

Формат

HXML был разработан так, чтобы он был знаком веб-разработчикам, привыкшим работать с HTML. Таким образом, выбор XML в качестве базового формата. Помимо привычной эргономики, XML совместим с библиотеками рендеринга на стороне сервера. Например, Jinja2 идеально подходит в качестве библиотеки шаблонов для рендеринга HXML. Знакомство с XML и простота интеграции с серверной частью упрощают его внедрение как в новые, так и в существующие базы кода. Взгляните на приложение «Hello World», написанное на HXML. Синтаксис должен быть знаком каждому, кто работал с HTML:

Листинг 164. Hello World

<doc xmlns="https://hyperview.org/hyperview">
  <screen>
    <styles />
    <body>
      <header>
        <text>My first app</text>
      </header>
      <view>
        <text>Hello World!</text>
      </view>
    </body>
  </screen>
</doc>

Но HXML — это не просто прямой порт HTML с тегами с разными именами. В предыдущих главах мы видели, как htmx расширяет HTML с помощью нескольких новых атрибутов. Эти дополнения сохраняют декларативную природу HTML, давая разработчикам возможность создавать многофункциональные веб-приложения. В HXML концепции htmx встроены в спецификацию. В частности, HXML не ограничивается взаимодействиями «щелкнуть ссылку» и «отправить форму», как базовый HTML. Он поддерживает ряд триггеров и действий для изменения содержимого на экране. Эти взаимодействия объединены в мощную концепцию «поведения». Разработчики могут даже определять новые поведенческие действия, чтобы добавить новые возможности в свое приложение без необходимости создания сценариев. Мы узнаем больше о поведении позже в этой главе.

Клиент

Hyperview предоставляет клиентскую библиотеку HXML с открытым исходным кодом, написанную на React Native. После небольшой настройки и нескольких шагов в командной строке эта библиотека компилируется в собственные двоичные файлы приложений для iOS или Android. Пользователи устанавливают приложение на свое устройство через магазин приложений. При запуске приложение отправляет HTTP-запрос к настроенному URL-адресу и отображает ответ HXML на первом экране.

Может показаться немного странным, что для разработки HDA с использованием Hyperview требуется специализированный клиентский двоичный файл. В конце концов, мы не просим пользователей сначала загрузить и установить двоичный файл для просмотра веб-приложения. Нет, пользователи просто вводят URL-адрес в адресную строку универсального веб-браузера. Один HTML-клиент отображает приложения с любого HTML-сервера.

Рисунок 11. Один HTML-клиент, несколько HTML-серверов

Теоретически возможно создать эквивалентный универсальный «Hyperview-браузер». Этот клиент HXML будет отображать приложения с любого сервера HXML, и пользователи будут вводить URL-адрес, чтобы указать приложение, которое они хотят использовать. Но iOS и Android построены на концепции специализированных приложений. Пользователи ожидают, что смогут найти и установить приложения из магазина приложений и запустить их с главного экрана своего устройства. Hyperview охватывает эту ориентированную на приложения парадигму современных популярных мобильных платформ. Это означает, что клиент HXML (двоичный файл приложения) отображает свой пользовательский интерфейс с одного предварительно настроенного сервера HXML:

Рисунок 12. Один клиент HXML, один сервер HXML.

К счастью, разработчикам не нужно писать HXML-клиент с нуля; клиентская библиотека с открытым исходным кодом выполняет 99% работы. И, как мы увидим в следующем разделе, управление клиентом и сервером в HDA дает большие преимущества.

Расширяемость

Чтобы понять преимущества архитектуры Hyperview, нам нужно сначала обсудить недостатки веб-архитектуры. В Интернете любой веб-браузер может отображать HTML с любого веб-сервера. Такой уровень совместимости возможен только при наличии четко определенных стандартов, таких как HTML5. Однако определение и развитие стандартов — трудоемкий процесс. Например, W3C потребовалось более 7 лет, чтобы пройти путь от первого проекта до рекомендации по спецификации HTML5. Это неудивительно, учитывая уровень вдумчивости, который необходим для изменения, которое затронет так много людей. Но это означает, что прогресс происходит медленно. Вам как веб-разработчику, возможно, придется годами ждать, пока браузеры не получат широкую поддержку необходимой вам функции.

Так в чем же преимущества архитектуры Hyperview? В приложении Hyperview ваше мобильное приложение отображает HXML только с вашего сервера. Вам не нужно беспокоиться о совместимости вашего сервера с другими мобильными приложениями или между вашим мобильным приложением и другими серверами. Не существует органа по стандартизации, с которым можно было бы консультироваться. Если вы хотите добавить функцию мигания в свое мобильное приложение, реализуйте элемент <blink> в клиенте и начните возвращать элементы <blink> в ответах HXML с вашего сервера. Фактически, клиентская библиотека Hyperview была создана с учетом такого типа расширяемости. Существуют точки расширения для пользовательских элементов пользовательского интерфейса и пользовательских действий поведения. Мы ожидаем и поощряем разработчиков использовать эти расширения, чтобы сделать HXML более выразительным и адаптированным к функциональности их приложений.

А благодаря расширению формата HXML и самого клиента Hyperview не нужно включать уровень сценариев в HXML. Функции, требующие логики на стороне клиента, «встраиваются» в двоичный файл клиента. Ответы HXML остаются чистыми, а пользовательский интерфейс и взаимодействия представлены в декларативном XML.

Какую архитектуру гипермедиа следует использовать?

Мы обсудили два подхода к созданию мобильных приложений с использованием гипермедиа-систем:

Я намеренно описал два подхода, чтобы подчеркнуть их сходство. Ведь они оба основаны на гипермедиа-системах, просто с разными форматами и клиентами. Оба подхода решают фундаментальные проблемы традиционной разработки мобильных приложений наподобие SPA:

Итак, какой подход следует использовать для мобильного HDA? Основываясь на нашем опыте создания приложений обоих типов, мы считаем, что подход Hyperview обеспечивает лучшее взаимодействие с пользователем. WebView всегда будет неуместен на iOS и Android; просто не существует хорошего способа воспроизвести шаблоны навигации и взаимодействия, которые ожидают мобильные пользователи. Hyperview был создан специально для устранения ограничений подходов с толстым клиентом и WebView. После первоначальных инвестиций в изучение Hyperview вы получите все преимущества архитектуры Hypermedia без недостатков, связанных с ухудшением пользовательского опыта.

Конечно, если у вас уже есть простое, удобное для мобильных устройств веб-приложение, то использование подхода WebView имеет смысл. Вы, безусловно, сэкономите время, поскольку вам не придется использовать свое приложение в формате HXML в дополнение к HTML. Но, как мы покажем в конце этой главы, преобразование существующего веб-приложения на основе Hypermedia в мобильное приложение Hyperview не требует больших усилий. Но прежде чем мы доберемся до этого, нам нужно представить концепции элементов и поведения в Hyperview. Затем мы перестроим наше приложение контактов в Hyperview.

Когда не следует использовать Hypermedia для создания мобильного приложения?
Гипермедиа не всегда является правильным выбором для создания мобильного приложения. Как и в Интернете, приложения, требующие высокодинамичного пользовательского интерфейса (например, приложение для работы с электронными таблицами), лучше реализовывать с помощью клиентского кода. Кроме того, некоторые приложения должны работать в автономном режиме. Поскольку для HDA требуется сервер для рендеринга пользовательского интерфейса, мобильные приложения, ориентированные на офлайн-режим, не подходят для этой архитектуры. Однако, как и в Интернете, разработчики могут использовать гибридный подход для создания своих мобильных приложений. Высокодинамичные экраны могут быть созданы с использованием сложной логики на стороне клиента, тогда как менее динамичные экраны могут быть созданы с помощью веб-представлений или Hyperview. Таким образом, разработчики могут потратить свой бюджет сложности на ядро приложения и сохранить простые экраны простыми.

Введение в HXML

Hello World!

HXML был разработан так, чтобы веб-разработчики чувствовали себя естественно, переходя на HTML. Давайте подробнее рассмотрим приложение «Hello World», определенное в HXML:

Листинг 165. Hello World, еще раз

<doc xmlns="https://hyperview.org/hyperview"> <!-- 1 -->
  <screen> <!-- 2 -->
    <styles />
    <body> <!-- 3 -->
      <header> <!-- 4 -->
        <text>My first app</text>
      </header>
      <view> <!-- 5 -->
        <text>Hello World!</text> <!-- 6 -->
      </view>
    </body>
  </screen>
</doc>
  1. Корневой элемент приложения HXML.

  2. Элемент, представляющий экран приложения

  3. Элемент, представляющий пользовательский интерфейс экрана

  4. Элемент, представляющий верхний заголовок экрана.

  5. Элемент-обертка вокруг содержимого, отображаемого на экране.

  6. Текстовое содержимое, отображаемое на экране

Здесь нет ничего странного, правда? Как и в HTML, синтаксис определяет дерево элементов с помощью начальных тегов (<screen>) и конечных тегов (</screen>). Элементы могут содержать другие элементы (<view>) или текст (Hello World!). Элементы также могут быть пустыми, представленными пустым тегом (<styles/>). Однако вы заметите, что имена элемента HXML отличаются от имен в HTML. Давайте подробнее рассмотрим каждый из этих элементов, чтобы понять, что они делают.

<doc> — это корень приложения HXML. Думайте об этом как об эквиваленте элемента <html> в HTML. Обратите внимание, что элемент <doc> содержит атрибут xmlns="https://hyperview.org/hyperview". Это определяет пространство имен по умолчанию для документа. Пространства имен — это функция XML, которая позволяет одному документу содержать элементы, определенные разными разработчиками. Чтобы предотвратить конфликты, когда два разработчика используют одно и то же имя для своего элемента, каждый разработчик определяет уникальное пространство имен. Мы поговорим больше о пространствах имен, когда будем обсуждать пользовательские элементы и поведение позже в этой главе. На данный момент достаточно знать, что элементы в документе HXML без явного пространства имен считаются частью пространства имен https://hyperview.org/hyperview.

<screen> представляет пользовательский интерфейс, который отображается на одном экране мобильного приложения. Один <doc> может содержать несколько элементов <screen>, но сейчас мы не будем вдаваться в подробности. Обычно элемент <screen> содержит элементы, определяющие содержимое и стиль экрана.

<styles> определяет стили пользовательского интерфейса на экране. В этой главе мы не будем вдаваться в подробности стилей в Hyperview. Достаточно сказать, что в отличие от HTML, Hyperview не использует отдельный язык (CSS) для определения стилей. Вместо этого правила стиля, такие как цвета, интервалы, макет и шрифты, определяются в HXML. На эти правила затем явно ссылаются элементы пользовательского интерфейса, подобно использованию классов в CSS.

<body> определяет фактический пользовательский интерфейс экрана. Тело (body) включает в себя весь текст, изображения, кнопки, формы и т. д., которые будут показаны пользователю. Это эквивалентно элементу <body> в HTML.

<header> определяет заголовок экрана. Обычно в мобильных приложениях заголовок включает в себя некоторую навигацию (например, кнопку «Назад») и заголовок экрана. Полезно определять заголовок отдельно от остальной части тела. В некоторых мобильных ОС для заголовка будет использоваться другой переход, чем для остального содержимого экрана.

<view> — это основной строительный блок для макетов и структуры тела экрана. Думайте об этом как о <div> в HTML. Обратите внимание: в отличие от HTML, <div> не может напрямую содержать текст.

Элементы <text> — единственный способ отобразить текст в пользовательском интерфейсе. В этом примере «Hello World» содержится в элементе <text>.

Это все, что нужно для определения базового приложения «Hello World» в HXML. Конечно, это не очень интересно. Давайте рассмотрим некоторые другие встроенные элементы дисплея.

Элементы пользовательского интерфейса
Списки

Очень распространенный шаблон в мобильных приложениях — прокрутка списка элементов. Физические свойства экрана телефона (длинный и вертикальный) и интуитивно понятный жест пролистывания большого пальца вверх и вниз делают его хорошим выбором для многих экранов.

В HXML есть специальные элементы для представления списков и элементов списков.

Листинг 166. Элемент списка

<list> <!-- 1 -->
  <item key="item1"> <!-- 2 -->
    <text>My first item</text> <!-- 3 -->
  </item>
  <item key="item2">
    <text>My second item</text>
  </item>
</list>
  1. Элемент, представляющий список

  2. Элемент, представляющий элемент в списке, с уникальным ключом.

  3. Содержимое элемента в списке.

Списки представлены двумя новыми элементами. <list> оборачивает все элементы списка. Его можно стилизовать как обычный <view> (ширина, высота и т. д.). Элемент <list> содержит только элементы <item>. Конечно, они представляют каждый уникальный элемент в списке. Обратите внимание, что <item> должен иметь ключевой атрибут, который уникален среди всех элементов в списке.

Вы можете спросить: «Зачем нам нужен собственный синтаксис для списков элементов? Разве мы не можем просто использовать кучу элементов <view>?». Да, для списков с небольшим количеством элементов использование вложенных <views> будет работать вполне хорошо. Однако часто количество элементов в списке может быть достаточно длинным, чтобы требовать оптимизации для обеспечения плавного взаимодействия с прокруткой. Рассмотрите возможность просмотра ленты публикаций в приложении для социальных сетей. Когда вы продолжаете прокручивать ленту, приложение нередко показывает сотни, если не тысячи сообщений. В любой момент вы можете щелкнуть пальцем, чтобы перейти практически к любой части ленты. Мобильные устройства, как правило, имеют ограниченный объем памяти. Хранение полностью визуализированного списка элементов в памяти может потребовать больше ресурсов, чем доступно. Вот почему и iOS, и Android предоставляют API для оптимизированных пользовательских интерфейсов списков. Эти API знают, какая часть списка в данный момент отображается на экране. Чтобы сэкономить память, они удаляют невидимые элементы списка и повторно используют объекты пользовательского интерфейса элементов для экономии памяти. Используя явные элементы <list> и <item> в HXML, клиент Hyperview знает, как использовать эти оптимизированные API-интерфейсы списков, чтобы повысить производительность вашего приложения.

Также стоит отметить, что HXML поддерживает списки разделов. Списки разделов полезны для создания пользовательских интерфейсов на основе списков, в которых элементы списка можно группировать для удобства пользователя. Например, пользовательский интерфейс, показывающий меню ресторана, может группировать предложения по типу блюда:

Листинг 167. Элемент списка разделов

<section-list> <!-- 1 -->
  <section> <!-- 2 -->
    <section-title> <!-- 3 -->
      <text>Appetizers</text>
    </section-title>
    <item key="1"> <!-- 4 -->
      <text>French Fries</text>
    </item>
    <item key="2">
      <text>Onion Rings</text>
    </item>
  </section>

  <section> <!-- 5 -->
    <section-title>
      <text>Entrees</text>
    </section-title>
    <item key="3">
      <text>Burger</text>
    </item>
  </section>
</section-list>
  1. Элемент, представляющий список с разделами

  2. Первый раздел предложений закусок

  3. Элемент заголовка раздела, отображающий текст «Appetizers»

  4. Предмет, представляющий закуску

  5. Раздел с предложениями первых блюд

Вы заметите пару различий между <list> и <section-list>. Элемент списка разделов содержит только элементы <section>, представляющие группу элементов. Раздел может содержать элемент <section-title>. Это используется для рендеринга некоторого пользовательского интерфейса, который действует как заголовок раздела. Этот заголовок является «прикрепленным», то есть он остается на экране при прокрутке элементов, принадлежащих соответствующему разделу. Наконец, элементы <item> действуют так же, как и в обычном списке, но могут появляться только внутри <section>.

Изображения

Отображение изображений в Hyperview очень похоже на HTML, но есть несколько отличий.

Листинг 168. Элемент изображения

<image source="/profiles/1.jpg" style="avatar" />

Атрибут source указывает, как загрузить изображение. Как и в HTML, источником может быть абсолютный или относительный URL-адрес. Кроме того, источником может быть URI закодированных данных, например data:image/png;base64,iVBORw. Однако источником также может быть «локальный» URL-адрес, ссылающийся на изображение, которое включено в качестве ресурса в мобильное приложение. Локальный URL-адрес имеет префикс ./:

Листинг 169. Элемент изображения, указывающий на локальный источник

<image source="./logo.png" style="logo" />

Использование локальных URL-адресов — это оптимизация. Поскольку изображения находятся на мобильном устройстве, они не требуют запроса к сети и появляются быстро. Однако связывание изображения с двоичным файлом мобильного приложения увеличивает размер двоичного файла. Использование локальных изображений — хороший компромисс для изображений, к которым часто обращаются, но редко меняются. Хорошими примерами являются логотип приложения или обычные значки кнопок.

Еще одна вещь, на которую следует обратить внимание, — это наличие атрибута style в элементе <image>. В HXML изображения должны иметь стиль, который имеет правила для width и height изображения. Это отличается от HTML, где элементам <img> не нужно явно задавать ширину и высоту. веб-браузеры повторно обрабатывают содержимое веб-страницы после получения изображения и определения его размеров. Хотя перераспределение содержимого является разумным поведением для веб-документов, пользователи не ожидают, что мобильные приложения будут перераспределять содержимое при загрузке содержимого. Чтобы поддерживать статический макет, HXML требует, чтобы размеры были известны до загрузки изображения.

Поля ввода

О полях ввода в Hyperview можно рассказать очень многое. Поскольку это введение, а не исчерпывающий ресурс, я выделю лишь несколько типов материалов. Начнем с примера простейшего типа ввода — текстового поля.

Листинг 170. Элемент текстового поля

<text-field
  name="first_name" <!-- 1 -->
  style="input" <!-- 2 -->
  value="Adam" <!-- 3 -->
  placeholder="First name" <!-- 4 -->
/>
  1. Имя, используемое при сериализации данных этого поля.

  2. Класс стиля, примененный к элементу пользовательского интерфейса.

  3. Текущее значение, заданное в поле

  4. Заполнитель для отображения, когда значение пусто

Этот элемент должен быть знаком каждому, кто создавал текстовое поле в HTML. Единственное отличие состоит в том, что большинство инпутов в HTML используют элемент <input> с атрибутом типа, например <input type="text">. В Hyperview каждый вход имеет уникальное имя, в данном случае <text-field>. Используя разные имена, мы можем использовать более выразительный XML для представления полей ввода.

Например, давайте рассмотрим случай, когда мы хотим отобразить пользовательский интерфейс, который позволяет пользователю выбирать один из нескольких вариантов. В HTML мы бы использовали поле переключателя, что-то вроде <input type="radio" name="choice" value="option1" />. Каждый выбор представлен как уникальный инпут элемент. Это никогда не казалось мне идеальным. В большинстве случаев переключатели группируются вместе, чтобы иметь одно и то же имя. Подход HTML приводит к большому количеству шаблонов (дублирование type="radio" и name="choice" для каждого варианта). Кроме того, в отличие от переключателей на настольных компьютерах, мобильные ОС не предоставляют четкого стандартного пользовательского интерфейса для выбора одного параметра. Большинство мобильных приложений используют для этих взаимодействий более богатые пользовательские интерфейсы. Итак, в HXML мы реализуем этот пользовательский интерфейс с помощью элемента <select-single>:

Листинг 171. Элемент select-single

<select-single name="choice"> <!-- 1 -->
  <option value="option1"> <!-- 2 -->
    <text>Option 1</text> <!-- 3 -->
  </option>
  <option value="option2">
    <text>Option 2</text>
  </option>
</select-single>
  1. Элемент, представляющий инпут, где выбран один вариант. Имя выбора определяется здесь один раз.

  2. Элемент, представляющий один из вариантов. Здесь определяется значение выбора.

  3. Пользовательский интерфейс выбора. В этом примере мы используем текст, но можем использовать любые элементы пользовательского интерфейса.

Элемент <select-single> является родительским элементом ввода для выбора одного варианта из многих. Этот элемент содержит атрибут имени, используемый при сериализации выбранного варианта. Элементы <option> внутри <select-single> представляют доступные варианты. Обратите внимание, что каждый элемент <option> имеет атрибут value. При нажатии это будет выбранное значение поля. Элемент <option> может содержать любые другие элементы пользовательского интерфейса. Это означает, что нам не мешает отображать входные данные в виде списка переключателей с метками. Мы можем отображать параметры в виде радио, тегов, изображений или чего-либо еще, что будет интуитивно понятно для нашего интерфейса. Стилизация HXML поддерживает модификаторы для нажатого и выбранного состояний, что позволяет нам настраивать пользовательский интерфейс для выделения выбранной опции.

Описание всех возможностей ввода в HXML заняло бы целую главу. Вместо этого я резюмирую несколько других элементов ввода и их особенности.

Еще две вещи, которые следует упомянуть об инпутах. Во-первых, это элемент <form>. Элемент <form> используется для группировки входных данных для сериализации. Когда пользователь выполняет действие, которое запускает серверный запрос, клиент Hyperview сериализует все входные данные в окружающем <form> и включает их в запрос. Это справедливо как для запросов GET, так и для POST. Мы рассмотрим это более подробно, когда будем говорить о поведении далее в этой главе. Также позже в этой главе я расскажу о поддержке пользовательских элементов в HXML. С помощью пользовательских элементов вы также можете создавать свои собственные элементы ввода. Пользовательские элементы ввода позволяют создавать невероятно мощные взаимодействия с помощью простого синтаксиса XML, который хорошо интегрируется с остальной частью HXML.

Стилизация

До сих пор мы не упомянули, как применить стиль ко всем элементам HXML. В приложении Hello World мы видели, что каждый <screen> может содержать элемент <styles>. Давайте вернемся к приложению Hello World и заполним элемент <styles>.

Листинг 172. Пример оформления пользовательского интерфейса

<doc xmlns="https://hyperview.org/hyperview">
  <screen>
    <styles> <!-- 1 -->
      <style class="body" flex="1" flexDirection="column" /> <!-- 2 -->
      <style class="header" borderBottomWidth="1" borderBottomColor="#ccc" />
      <style class="main" margin="24" />
      <style class="h1" fontSize="32" />
      <style class="info" color="blue" />
    </styles>

    <body style="body"> <!-- 3 -->
      <header style="header">
        <text style="info">My first app</text>
      </header>
      <view style="main">
        <text style="h1 info">Hello World!</text> <!-- 4 -->
      </view>
    </body>
  </screen>
</doc>
  1. Элемент, инкапсулирующий все стили экрана.

  2. Пример определения класса стиля для «body»

  3. Применение класса стиля «body» к элементу пользовательского интерфейса

  4. Пример применения нескольких классов стиля (h1 и info) к элементу

Вы заметите, что в HXML стилизация является частью формата XML, а не использует отдельный язык, такой как CSS. Однако мы можем провести некоторые параллели между правилами CSS и элементом <style>. Правило CSS состоит из селектора и объявлений. В текущей версии HXML единственным доступным селектором является имя класса, указанное атрибутом class. Остальные атрибуты элемента <style> являются объявлениями, состоящими из свойств и значений свойств.

Элементы пользовательского интерфейса внутри <screen> могут ссылаться на правила <style>, добавляя имена классов в их свойство <style>. Обратите внимание, что элемент <text> вокруг «Hello World!» ссылается на два класса стилей: h1 и info. Стили соответствующих классов объединяются в том порядке, в котором они появляются в элементе. Стоит отметить, что свойства стиля аналогичны свойствам CSS (цвет, поля/отступы, границы и т. д.). В настоящее время единственный доступный движок компоновки основан на flexbox.

Правила стиля могут быть весьма многословными. Для краткости мы не будем включать элемент <styles> в остальные примеры этой главы без необходимости.

Пользовательские элементы

Основные элементы пользовательского интерфейса, поставляемые с Hyperview, довольно просты. Большинству мобильных приложений требуются более богатые элементы, чтобы обеспечить удобство работы с пользователем. К счастью, HXML может легко включать в свой синтаксис пользовательские элементы. Это потому, что HXML на самом деле представляет собой просто XML, также известный как «Extensible Markup Language» (расширяемый язык разметки). Расширяемость уже встроена в формат! Разработчики могут свободно определять новые элементы и атрибуты для представления пользовательских элементов.

Давайте посмотрим это в действии на конкретном примере. Предположим, мы хотим добавить элемент карты в наше приложение Hello World. Мы хотим, чтобы на карте отображалась определенная область и один или несколько маркеров с определенными координатами в этой области. Переведем эти требования в XML:

Используя эти пользовательские элементы XML, экземпляр карты в нашем приложении может выглядеть следующим образом:

Листинг 173. Пользовательские элементы в HXML

<doc xmlns="https://hyperview.org/hyperview">
  <screen>
    <body>
      <view>
        <text>Hello World!</text>
        <area latitude="37.8270" longitude="122.4230" latitude-delta="0.1" longitude-delta="0.1"> <!-- 1 -->
          <marker latitude="37.8118" longitude="-122.4177" /> <!-- 2 -->
        </area>
      </view>
    </body>
  </screen>
</doc>
  1. Пользовательский элемент, представляющий область, отображаемую на карте.

  2. Пользовательский элемент, представляющий маркер, отображаемый в определенных координатах на карте.

Синтаксис чувствует себя как дома среди основных элементов HXML. Однако существует потенциальная проблема. «Area» и «marker» — довольно общие названия. Я мог видеть элементы <area> и <marker>, используемые в настройке для отображения диаграмм и графиков. Если наше приложение отображает и карты, и диаграммы, разметка HXML будет неоднозначной. Что должен визуализировать клиент, когда он видит <area> или <marker>?

Именно здесь на помощь приходят пространства имен XML. Пространства имен XML устраняют двусмысленность и конфликты между элементами и атрибутами, используемыми для представления разных вещей. Помните, что элемент <doc> объявляет, что https://hyperview.org/hyperview является пространством имен по умолчанию для всего документа. Поскольку никакие другие элементы не определяют пространства имен, каждый элемент в приведенном выше примере является частью пространства имен https://hyperview.org/hyperview.

Давайте определим новое пространство имен для наших элементов карты. Поскольку это пространство имен не будет пространством имен по умолчанию для документа, нам также необходимо назначить пространство имен префиксу, который мы добавим к нашим элементам:

<doc xmlns="https://hyperview.org/hyperview"
xmlns:map="https://mycompany.com/hyperview-map">

Этот новый атрибут объявляет, что префикс map: связан с пространством имен «https://mycompany.com/hyperview-map». Это пространство имен может быть любым, но помните, что цель — использовать что-то уникальное, не допускающее коллизий. Использование домена вашей компании/приложения — хороший способ гарантировать уникальность. Теперь, когда у нас есть пространство имен и префикс, нам нужно использовать его для наших элементов:

Листинг 174. Пространство имен пользовательских элементов

<doc xmlns="https://hyperview.org/hyperview" xmlns:map="https://mycompany.com/hyperview-map"> <!-- 1 -->
  <screen>
    <body>
      <view>
        <text>Hello World!</text>
        <map:area latitude="37.8270" longitude="122.4230" latitude-delta="0.1" longitude=delta="0.1"> <!-- 2 -->
          <map:marker latitude="37.8118" longitude="-122.4177" /> <!-- 3 -->
        </map:area> <!-- 4 -->
      </view>
    </body>
  </screen>
</doc>
  1. Определение пространства имен, псевдонима «map»

  2. Добавление пространства имен в начальный тег «область»

  3. Добавление пространства имен в самозакрывающийся тег «marker»

  4. Добавление пространства имен в закрывающий тег «область»

Вот и все! Если бы мы представили собственную библиотеку диаграмм с элементами «area» и «marker», мы бы также создали уникальное пространство имен для этих элементов. В документе HXML мы могли бы легко устранить неоднозначность <map:area> и <chart:area>.

На этом этапе у вас может возникнуть вопрос: «Как клиент Hyperview узнает, что нужно визуализировать карту, если мой документ включает <map:area>?» Это правда, до сих пор мы определяли только формат пользовательского элемента, но не реализовали этот элемент как функцию в нашем приложении. В следующей главе мы углубимся в детали реализации пользовательских элементов.

Поведение

Как обсуждалось в предыдущих главах, HTML поддерживает два основных типа взаимодействий:

Нажатия на гиперссылки и отправки форм достаточно для создания простых веб-приложений. Но использование только этих двух взаимодействий ограничивает нашу способность создавать более богатые пользовательские интерфейсы. Что, если мы хотим, чтобы что-то произошло, когда пользователь наводит указатель мыши на определенный элемент или, возможно, когда он прокручивает какой-то контент в область просмотра? Мы не можем сделать это с помощью базового HTML. Кроме того, как клики, так и отправка формы приводят к загрузке полностью новой веб-страницы. Что, если мы хотим обновить только небольшую часть текущей страницы? Это очень распространенный сценарий в многофункциональных веб-приложениях, где пользователи ожидают получения и обновления контента без перехода на новую страницу.

Таким образом, в базовом HTML взаимодействия (клики и отправки) ограничены и тесно связаны с одним действием (загрузка новой страницы). Конечно, используя JavaScript, мы можем расширить HTML и добавить новый синтаксис для поддержки желаемого взаимодействия. Htmx делает именно это с новым набором атрибутов:

Разделяя элементы, триггеры и действия, htmx позволяет нам создавать многофункциональные приложения на основе Hypermedia таким образом, чтобы они были очень совместимы с синтаксисом HTML и веб-разработкой на стороне сервера.

HXML берет идею определения взаимодействий с помощью триггеров и действий и встраивает их в спецификацию. Мы называем эти взаимодействия «поведениями» (behaviors). Для их определения мы используем специальный элемент <behavior>. Вот пример простого поведения, которое помещает новый экран мобильного устройства в стек навигации:

Листинг 175. Базовое поведение

<text>
  <behavior <!-- 1 -->
    trigger="press" <!-- 2 -->
    action="push" <!-- 3 -->
    href="/next-screen" <!-- 4 -->
  />
  Press me!
</text>
  1. Элемент, инкапсулирующий взаимодействие с родительским элементом <text>.

  2. Триггер, который будет выполнять взаимодействие, в данном случае нажатие элемента <text>.

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

  4. href для загрузки на новом экране.

Давайте разберем, что происходит в этом примере. Во-первых, у нас есть элемент <text> с содержимым «Press me!». Мы уже показывали элементы <text> в примерах HXML, так что в этом нет ничего нового. Но теперь элемент <text> содержит новый дочерний элемент <behavior>. Этот элемент <behavior> определяет взаимодействие с родительским элементом <text>. Он содержит два атрибута, которые необходимы для любого поведения:

В этом примере trigger настроен на press, то есть это взаимодействие произойдет, когда пользователь нажмет элемент <text>. Атрибут action установлен на push. push — это действие, которое помещает новый экран в стек навигации. Наконец, Hyperview должен знать, какой контент загружать на новый экран. Здесь на помощь приходит атрибут href. Обратите внимание, что нам не нужно определять полный URL-адрес. Как и в HTML, href может быть абсолютным или относительным URL-адресом.

Итак, это первый пример поведения в HXML. Вы можете подумать, что этот синтаксис кажется довольно многословным. Действительно, нажатие элементов для перехода на новый экран — одно из наиболее распространенных действий в мобильном приложении. Было бы неплохо иметь более простой синтаксис для общего случая. К счастью, атрибуты trigger и action имеют значения по умолчанию — press и push соответственно. Поэтому их можно опустить, чтобы очистить синтаксис:

Листинг 176. Базовое поведение со значениями по умолчанию

<text>
  <behavior href="/next-screen" /> <!-- 1 -->
  Press me!
</text>
  1. При нажатии это поведение откроет новый экран с заданным URL-адресом.

Эта разметка для <behavior> будет производить то же взаимодействие, что и предыдущий пример. С атрибутами по умолчанию элемент <behavior> выглядит аналогично якорю <a> в HTML. Но полный синтаксис достигает наших целей по разделению элементов, триггеров и действий:

Кроме того, использование специального элемента <behavior> означает, что один элемент может определять несколько вариантов поведения. Это позволяет нам выполнять несколько действий из одного триггера. Или мы можем выполнять разные действия для разных триггеров на одном и том же элементе. В конце этой главы мы покажем примеры силы множественного поведения. Сначала нам нужно показать разнообразие поддерживаемых действий и триггеров.

Действия

Поведенческие действия в Hyperview делятся на четыре основные категории:

Действия навигации

Мы уже видели самый простой тип действия — push. Мы классифицируем push как «действие навигации», поскольку оно связано с навигацией по экранам мобильного приложения. Добавление экрана в стек навигации — это лишь одно из нескольких действий навигации, поддерживаемых в Hyperview. Пользователи также должны иметь возможность возвращаться к предыдущим экранам, открывать и закрывать модальные окна, переключаться между вкладками или переходить на произвольные экраны. Каждый из этих типов навигации поддерживается различным значением атрибута action:

push, new и navigate — все это загружает новый экран. Таким образом, этим действиям требуется атрибут href, чтобы Hyperview знал, какой контент запрашивать для нового экрана. back и close не загружают новые экраны и, следовательно, не требуют атрибута href. reload — интересный случай. По умолчанию это действие будет использовать URL-адрес экрана при повторном запросе содержимого экрана. Однако, если вы хотите заменить экран другим, вы можете предоставить атрибут href с reload элемента поведения.

Давайте посмотрим на пример приложения «widgets», которое использует несколько действий навигации на одном экране:

Листинг 177. Примеры действий навигации

<screen>
  <body>
    <header>
      <text>
        <behavior action="back" /> <!-- 1 -->
        Back
      </text>

      <text>
        <behavior action="new" href="/widgets/new" /> <!-- 2 -->
        New Widget
      </text>
    </header>
    <text>
      <behavior action="reload" /> <!-- 3 -->
      Check for new widgets
    </text>
    <list>
      <item key="widget1">
        <behavior action="push" href="/widgets/1" /> <!-- 4 -->
      </item>
    </list>
  </body>
</screen>
  1. Переводит пользователя на предыдущий экран

  2. Открывает новое модальное окно для добавления виджета.

  3. Перезагружает содержимое экрана, показывая новые виджеты из серверной части.

  4. Открывает новый экран с подробной информацией о конкретном виджете.

Большинству экранов вашего приложения потребуется возможность возврата пользователя к предыдущему экрану. Обычно это делается с помощью кнопки в заголовке, которая использует действие «назад» или «закрыть», в зависимости от того, как был открыт экран. В этом примере мы предполагаем, что экран виджетов был помещен в стек навигации, поэтому действие «назад» является подходящим. Заголовок содержит вторую кнопку, которая позволяет пользователю ввести данные для нового виджета. Нажатие этой кнопки откроет модальное окно с экраном «New Widget». Поскольку этот экран «New Widget» откроется как модальный, ему потребуется соответствующее действие «закрыть», чтобы закрыться и снова отобразить наш экран «widgets». Наконец, чтобы просмотреть более подробную информацию о конкретном виджете, каждый элемент <item> содержит поведение с действием «push». Это действие переместит экран «Widget Detail» в текущий стек навигации. Как и на экране «Widgets», для «Widget Detail» потребуется кнопка в заголовке, которая использует действие «назад», чтобы позволить пользователю вернуться назад.

В Интернете браузер выполняет основные задачи навигации, такие как переход назад/вперед, перезагрузка текущей страницы или переход к закладке. iOS и Android не предоставляют такую универсальную навигацию для собственных мобильных приложений. Разработчики приложений должны решить эту проблему самостоятельно. Действия навигации в HXML предоставляют разработчикам простой, но мощный способ создания архитектуры, подходящей для их приложения.

Действия обновления

Поведенческие действия не ограничиваются только навигацией между экранами. Их также можно использовать для изменения содержимого текущего экрана. Мы называем это «действиями обновления». Подобно действиям навигации, действия обновления отправляют запрос на серверную часть. Однако ответ представляет собой не весь документ HXML, а его фрагмент. Этот фрагмент добавляется в HXML текущего экрана, что приводит к обновлению пользовательского интерфейса. Атрибут действия <behavior> определяет, как фрагмент будет включен в HXML. Нам также необходимо ввести новый атрибут target в <behavior>, чтобы определить, где фрагмент будет включен в существующий документ. Атрибут target — это ссылка на идентификатор существующего элемента на экране.

Hyperview в настоящее время поддерживает следующие действия обновления, представляющие различные способы включения фрагмента в экран:

Давайте рассмотрим несколько примеров, чтобы сделать это более конкретным. В этих примерах предположим, что наш сервер принимает запросы GET к /fragment, а ответом является фрагмент HXML, который выглядит как <text>My fragment</text>.

Листинг 178. Примеры действий обновления

<screen>
  <body>
    <text>
      <behavior action="replace" href="/fragment" target="area1" /> <!-- 1 -->
      Replace
    </text>
    <view id="area1">
      <text>Existing content</text>
    </view>

    <text>
      <behavior action="replace-inner" href="/fragment" target="area2" /> <!-- 2 -->
      Replace-inner
    </text>
    <view id="area2">
      <text>Existing content</text>
    </view>

    <text>
      <behavior action="append" href="/fragment" target="area3" /> <!-- 3 -->
      Append
    </text>
    <view id="area3">
      <text>Existing content</text>
    </view>

    <text>
      <behavior action="prepend" href="/fragment" target="area4" /> <!-- 4 -->
      Prepend
    </text>
    <view id="area4">
      <text>Existing content</text>
    </view>

  </body>
</screen>
  1. Заменяет элемент area1 полученным фрагментом.

  2. Заменяет дочерние элементы area2 выбранным фрагментом.

  3. Добавляет полученный фрагмент в area3.

  4. Добавляет извлеченный фрагмент в area4.

В этом примере у нас есть экран с четырьмя кнопками, соответствующими четырем действиям обновления: replace, replace-inner, append, prepend. Под каждой кнопкой есть соответствующий <view>, содержащий текст. Обратите внимание, что идентификатор каждого представления соответствует целевому значению поведения соответствующей кнопки.

Когда пользователь нажимает первую кнопку, клиент Hyperview отправляет запрос на /fragment. Далее он ищет цель, то есть элемент с идентификатором «area1». Наконец, он заменяет элемент <view id="area1"> выбранным фрагментом <text>My fragment</text>. Существующее представление и текст, содержащийся в этом представлении, будут заменены. Для пользователя это будет выглядеть так, будто «Existing content» был изменен на «My fragment». В HXML элементе <view id="area1"> также исчезнет.

Вторая кнопка ведет себя аналогично первой. Однако действие replace-inner не удаляет целевой элемент с экрана, а только заменяет дочерние элементы. Это означает, что результирующая разметка будет выглядеть так: <view id="area2"><text>My fragment</text></view>.

Третья и четвертая кнопки не удаляют контент с экрана. Вместо этого фрагмент будет добавлен либо после (в случае append), либо перед (prepend) дочерних элементов целевого элемента.

Для полноты картины давайте посмотрим на состояние экрана после того, как пользователь нажмет все четыре кнопки:

Листинг 179. Действия обновления после нажатия кнопок

<screen>
  <body>
    <text>
      <behavior action="replace" href="/fragment" target="area1" />
      Replace
    </text>
    <text>My fragment</text> <!-- 1 -->

    <text>
      <behavior action="replace-inner" href="/fragment" target="area2" />
      Replace-inner
    </text>
    <view id="area2">
      <text>My fragment</text> <!-- 2 -->
    </view>

    <text>
      <behavior action="append" href="/fragment" target="area3" />
      Append
    </text>
    <view id="area3">
      <text>Existing content</text>
    <text>My fragment</text> <!-- 3 -->
    </view>

    <text>
      <behavior action="prepend" href="/fragment" target="area4" />
      Prepend
    </text>
    <view id="area4">
      <text>My fragment</text> <!-- 4 -->
    <text>Existing content</text>
    </view>

  </body>
</screen>
  1. Фрагмент полностью заменил цель с помощью действия replace.

  2. Фрагмент заменил дочерние элементы цели с помощью действия replace-inner.

  3. Фрагмент добавлен как последний дочерний элемент цели с помощью действия append.

  4. Фрагмент добавлен как первый дочерний элемент цели с помощью действия prepend

В приведенных выше примерах показаны действия, отправляющие GET-запросы на серверную часть. Но эти действия также могут отправлять запросы POST, установив verb="post" в элементе <behavior>. Как для запросов GET, так и для POST данные из родительского элемента <form> будут сериализованы и включены в запрос. Для запросов GET содержимое будет закодировано в URL-адресе и добавлено в качестве параметров запроса. Для запросов POST содержимое будет закодировано в виде URL-адреса и установлено в теле запроса. Поскольку они поддерживают POST и формируют данные, действия обновления часто используются для отправки данных на серверную часть.

Пока что наш пример действий по обновлению требует получения нового контента из серверной части и добавления его на экран. Но иногда нам просто хочется изменить состояние существующих элементов. Наиболее распространенное состояние элемента, которое необходимо изменить, — это его видимость. В Hyperview есть действия hide, show и toggle, которые делают именно это. Как и другие действия, hide, show и toggle используют атрибут target, чтобы применить действие к элементу на текущем экране.

Листинг 180. Действия show, hide и toggle

<screen>
  <body>
    <text>
      <behavior action="hide" target="area" /> <!-- 1 -->
      Hide
    </text>

    <text>
      <behavior action="show" target="area" /> <!-- 2 -->
      Show
    </text>

    <text>
      <behavior action="toggle" target="area" /> <!-- 3 -->
      Toggle
    </text>

    <view id="area"> <!-- 4 -->
      <text>My fragment</text>
    </view>
  </body>
</screen>
  1. Скрывает элемент с идентификатором «area».

  2. Показывает элемент с идентификатором «area».

  3. Переключает видимость элемента с идентификатором «area».

  4. Элемент, на который направлены действия.

В этом примере три кнопки с надписью «Hide», «Show» и «Toggle» изменят состояние отображения <view> с идентификатором «area». Нажатие «Hide» несколько раз не окажет никакого влияния, если представление будет скрыто. Аналогично, нажатие кнопки «Show» несколько раз не повлияет на отображение представления. Нажатие «Toggle» будет продолжать переключать статус видимости элемента между отображением и скрытием.

Hyperview включает в себя и другие действия, которые изменяют существующий HXML. Мы не будем рассматривать их подробно, но я кратко упомяну их здесь:

Системные действия

Некоторые стандартные действия Hyperview вообще не взаимодействуют с HXML. Вместо этого они предоставляют функциональные возможности, предоставляемые мобильной ОС. Например, и Android, и iOS поддерживают пользовательский интерфейс «Share» (поделиться) на системном уровне. Этот пользовательский интерфейс позволяет обмениваться URL-адресами и сообщениями из одного приложения в другое. Hyperview имеет действие «Share» для поддержки этого взаимодействия. Он включает в себя собственное пространство имен и атрибуты, специфичные для общего ресурса.

Листинг 181. Действие с общим доступом к системе

<behavior
  xmlns:share="https://instawork.com/hyperview-share" <!-- 1 -->
  trigger="press"
  action="share" <!-- 2 -->
  share:url="https://www.instawork.com" <!-- 3 -->
  share:message="Check out this website!" <!-- 4 -->
/>
  1. Определяет пространство имен для действия общего доступа.

  2. Действие этого поведения приведет к появлению таблицы общего доступа.

  3. URL-адрес для совместного использования.

  4. Сообщение, которым нужно поделиться.

Мы видели пространства имен XML, когда говорили о пользовательских элементах. Здесь мы используем пространство имен для атрибутов url и message в <behavior>. Эти имена атрибутов являются общими и, вероятно, используются другими компонентами и поведениями, поэтому пространство имен гарантирует отсутствие двусмысленности. При нажатии сработает действие «поделиться». Значения атрибутов url и message будут переданы в системный общий интерфейс. Оттуда пользователь сможет поделиться URL-адресом и сообщением через SMS, электронную почту или другие коммуникационные приложения.

Действие share показывает, как действие поведения может использовать пользовательские атрибуты для передачи дополнительных данных, необходимых для взаимодействия. Но некоторые действия требуют еще более структурированных данных. Это можно обеспечить через дочерние элементы <behavior>. Hyperview использует это для реализации действия alert. Действие alert отображает настроенное диалоговое окно системного уровня. Это диалоговое окно требует настройки заголовка и сообщения, а также настраиваемых кнопок. Каждая кнопка должна затем вызывать другое поведение при нажатии. Этот уровень конфигурации невозможно реализовать только с помощью атрибутов, поэтому мы используем специальные дочерние элементы для представления поведения каждой кнопки.

Листинг 182. Действие системного оповещения

<behavior
  xmlns:alert="https://hyperview.org/hyperview-alert" <!-- 1 -->
  trigger="press"
  action="alert" <!-- 2 -->
  alert:title="Continue to next screen?" <!-- 3 -->
  alert:message="Are you sure you want to navigate to the next screen?" <!-- 4 -->
>
  <alert:option alert:label="Continue"> <!-- 5 -->
    <behavior action="push" href="/next" /> <!-- 6 -->
  </alert:option>
  <alert:option alert:label="Cancel" /> <!-- 7 -->
</behavior>
  1. Определяет пространство имен для действия alert.

  2. Действие этого поведения приведет к появлению системного диалогового окна.

  3. Название диалогового окна.

  4. Содержимое диалогового окна.

  5. Параметр «continue» в диалоговом окне

  6. При нажатии «continue» в стек навигации добавляется новый экран.

  7. Опция «cancel», которая закрывает диалоговое окно.

Как и поведение share, alert использует пространство имен для определения некоторых атрибутов и элементов. Сам элемент <behavior> содержит атрибуты title и message для диалогового окна. Параметры кнопки для диалогового окна определяются с помощью нового элемента <option>, вложенного в <behavior>. Обратите внимание, что каждый элемент <option> имеет метку, а затем, при необходимости, содержит сам <behavior>! Эта структура HXML позволяет системному диалогу инициировать любое взаимодействие, которое можно определить как <behavior>. В приведенном выше примере нажатие кнопки «Continue» откроет новый экран. Но мы могли бы с тем же успехом вызвать действие обновления, чтобы изменить текущий экран. Мы могли бы даже открыть общий лист или второе диалоговое окно. Но, пожалуйста, не делайте этого в реальном приложении! С большой властью приходит большая ответственность.

Пользовательские действия

Вы можете создать множество мобильных пользовательских интерфейсов с помощью стандартной навигации, обновления и системных действий Hyperview. Но стандартный набор может не охватывать все взаимодействия, необходимые для вашего мобильного приложения. К счастью, система действий расширяема. Точно так же, как вы можете добавлять в Hyperview собственные элементы, вы также можете добавлять настраиваемые действия по поведению. Пользовательские действия имеют синтаксис, аналогичный действиям share и alert, используя пространства имен для атрибутов, передающих дополнительные данные. Пользовательские действия также имеют полный доступ к HXML текущего экрана, поэтому они могут изменять состояние или добавлять/удалять элементы с текущего экрана. В следующей главе мы создадим настраиваемое поведенческое действие для улучшения нашего приложения для мобильных контактов.

Триггеры

Мы уже видели самый простой тип триггера — press на элемент. Hyperview поддерживает множество других распространенных триггеров, используемых в мобильных приложениях.

Длительное нажатие

С нажатием тесно связано длительное нажатие. Поведение с trigger="longPress" будет срабатывать, когда пользователь нажимает и удерживает элемент. Взаимодействия «длительного нажатия» часто используются для ярлыков и дополнительных функций. Иногда элементы поддерживают разные действия как для нажатия, так и для длительного нажатия. Это делается с помощью нескольких элементов <behavior> в одном элементе пользовательского интерфейса.

Листинг 183. Пример триггера длительного нажатия

<text>
  <behavior trigger="press" action="push" href="/next-screen" /> <!-- 1 -->
  <behavior trigger="longPress" action="push" href="/secret-screen" /> <!-- 2 -->
  Press (or long-press) me!
</text>
  1. Обычное нажатие откроет следующий экран.

  2. Длительное нажатие откроет другой экран.

В этом примере обычное нажатие откроет новый экран и запросит содержимое из /next-screen. Однако долгое нажатие откроет новый экран с содержимым из /secret-screen. Это надуманный пример для краткости. Лучшим UX было бы, чтобы при длительном нажатии вызывалось контекстное меню с ярлыками и дополнительными параметрами. Этого можно добиться, используя action="alert" и открыв системное диалоговое окно с помощью ярлыков.

Загрузка

Иногда нам нужно, чтобы действие запускалось сразу после загрузки экрана. Код trigger="load" делает именно это. Один из вариантов использования — быстро загрузить оболочку экрана, а затем заполнить основной контент на экране вторым действием обновления.

Листинг 184. Пример загрузки триггера

<body>
  <view>
    <text>My app</text>
    <view id="container"> <!-- 1 -->
      <behavior trigger="load" action="replace" href="/content" target="container"> <!-- 2 -->
    <text>Loading...</text> <!-- 3 -->
    </view>
  </view>

  1. Элемент-контейнер без фактического содержимого

  2. Поведение, которое немедленно запускает запрос /content на замену контейнера.

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

В этом примере мы загружаем экран с заголовком («My app»), но без контента. Вместо этого мы показываем <view> с ID «container» и текстом «Loading...». Как только этот экран загружается, поведение с trigger="load" запускает действие replace. Оно запрашивает содержимое по пути /content и заменяет представление контейнера ответом.

Видимость

В отличие от load, триггер visible будет выполнять поведение только тогда, когда элемент с поведением прокручивается в область просмотра на мобильном устройстве. Действие visible обычно используется для реализации взаимодействия с бесконечной прокруткой над элементами <list> из <item>. Последний элемент в списке включает поведение с trigger="visible". Действие append извлекает следующую страницу элементов и добавляет их в список.

Обновление

Этот триггер фиксирует действие «обновить по запросу» для элементов <list> и <view>. Это взаимодействие связано с получением актуального контента из серверной части. Таким образом, оно обычно сочетается с действием обновления или перезагрузки для отображения последних данных на экране.

Листинг 185. Пример триггера «обновить по запросу»

<body>
  <view scroll="true">
    <behavior trigger="refresh" action="reload" /> <!-- 1 -->
    <text>No items yet</text>
  </view>
</body>
  1. Когда представление опустится до триггера refresh, перезагрузить экран.

Обратите внимание, что добавление поведения с trigger="refresh" к <view> или <list> добавит к элементу взаимодействие притягивания и обновления, включая отображение счетчика при перемещении элемента вниз.

Фокус, потеря фокуса и изменение

Эти триггеры связаны с взаимодействием с элементами ввода. Таким образом, они будут запускать только поведение, связанное с такими элементами, как <text-field>. Триггеры focus и blur срабатывают, когда пользователь получает и теряет фокус элемента ввода соответственно. Триггер change сработает, когда значение элемента ввода изменится, например, когда пользователь вводит букву в текстовое поле. Эти триггеры часто используются с поведениями, которые требуют выполнения некоторой проверки полей формы на стороне сервера. Например, когда пользователь вводит имя пользователя, а затем переводит фокус, при потере фокуса может сработать поведение, требующее выполнения запроса к серверной части и проверки уникальности имени пользователя. Если введенное имя пользователя не уникально, ответ может включать сообщение об ошибке, сообщающее пользователю, что ему нужно выбрать другое имя пользователя.

Использование нескольких вариантов поведения

В большинстве приведенных выше примеров к элементу прикрепляется одиночный <behavior>. Но в Hyperview такого ограничения нет; элементы могут определять несколько вариантов поведения. Мы уже видели пример, когда один элемент вызывал разные действия при press и longPress. Но мы также можем запускать несколько действий по одному и тому же триггеру.

В этом, по общему признанию, надуманном примере мы хотим скрыть два элемента на экране при нажатии кнопки «Hide». Эти два элемента в HXML находятся далеко друг от друга, и их нельзя скрыть, скрыв общий родительский элемент. Но мы можем одновременно активировать два поведения, каждое из которых выполняет действие «hide», но нацелено на разные элементы.

Листинг 186. Несколько вариантов поведения, запускаемых при нажатии

<screen>
  <body>
    <text id="area1">Area 1</text>

    <text>
      <behavior trigger="press" action="hide" target="area1" /> <!-- 1 -->
      <behavior trigger="press" action="hide" target="area2" /> <!-- 2 -->
      Hide
    </text>

    <text id="area2">Area 2</text>
  </body>
</screen>
  1. Скрыть элемент с ID «area1» при нажатии.

  2. Скрыть элемент с ID «area2» при нажатии.

Hyperview обрабатывает поведение в том порядке, в котором оно появляется в разметке. В этом случае сначала будет скрыт элемент с ID «area1», а затем элемент с ID «area2». Поскольку «hide» — это мгновенное действие (т. е. оно не отправляет HTTP-запрос), оба элемента будут выглядеть спрятанными одновременно. Но что, если мы запустим два действия, которые зависят от ответов HTTP-запросов (например, «replace-inner»)? В этом случае каждое отдельное действие обрабатывается, как только Hyperview получает ответ HTTP. В зависимости от задержки в сети эти два действия могут вступить в силу в любом порядке, и их одновременное применение не гарантируется.

Мы видели элементы с различным поведением и разными триггерами. И мы видели элементы с несколькими вариантами поведения с одним и тем же триггером. Эти понятия также можно смешивать. В рабочем приложении Hyperview нет ничего необычного в том, что он содержит несколько вариантов поведения, некоторые из которых срабатывают одновременно, а другие — при разных взаимодействиях. Использование нескольких вариантов поведения с настраиваемыми действиями сохраняет декларативность HXML без ущерба для функциональности.

Резюме

Здесь мы рассматриваем множество новых концепций, и это введение в HXML лишь поверхностно. Чтобы узнать больше о HXML, мы рекомендуем обратиться к официальной справочной документации. А пока мы надеемся, что вы вынесете несколько ключевых выводов.

Во-первых, HXML выглядит и ощущается как HTML. Веб-разработчики, знакомые со средами рендеринга на стороне сервера, могут использовать те же методы для написания HXML. В дополнение к базовым элементам пользовательского интерфейса (<view>, <text>, <image>) HXML определяет элементы для реализации пользовательского интерфейса, специфичного для мобильных устройств. Сюда входят шаблоны макета (<screen>, <list>, <section-list>) и элементы ввода (<switch>, <select-single>, <select-multiple>).

Во-вторых, взаимодействия в HXML определяются с помощью поведения. Вдохновленные htmx, элементы <behavior> отделяют взаимодействия пользователя (триггеры) от результирующих действий. Существует три широкие категории поведенческих действий:

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

Гипермедиа для мобильных устройств

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

Hyperview, основанный на новом формате под названием HXML, предлагает такой путь. Он предоставляет мобильный тонкий клиент с открытым исходным кодом для рендеринга HXML. А HXML открывает набор элементов и шаблонов, соответствующих мобильным пользовательским интерфейсам. Разработчики могут развивать Hyperview в соответствии с требованиями своих приложений, полностью используя архитектуру гипермедиа. Это победа.

Да, гипермедиа может работать и для мобильных приложений. В следующих двух главах мы покажем, как превратить веб-приложение Contact.app в собственное мобильное приложение с помощью Hyperview.

Заметки о Hypermedia: максимизируйте сильные стороны сервера

Поскольку мы не используем HTML, в разделах книги, посвященных Hyperview, мы собираемся сделать более широкие наблюдения по гипермедиа, а не предлагать советы и мысли, специфичные для HTML.

Большим преимуществом подхода, основанного на гипермедиа, является то, что он делает серверную среду гораздо более важной при создании вашего веб-приложения. Вместо того, чтобы просто создавать JSON, ваша серверная часть является неотъемлемым компонентом пользовательского опыта вашего гипермедийного приложения.

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

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

Хорошее практическое правило — стремиться к тому, чтобы ответы сервера в вашем приложении, управляемом гипермедиа, занимали менее 100 мс, и в зрелых серверных платформах есть инструменты, которые помогут это сделать.

Серверные среды часто имеют чрезвычайно зрелые механизмы для правильной факторизации (или организации) вашего кода. Шаблон Model/View/Controller хорошо развит в большинстве сред, а такие инструменты, как модули, пакеты и т. д., обеспечивают отличный способ организации вашего кода.

В то время как современные SPA и мобильные пользовательские интерфейсы обычно организуются с помощью компонентов, приложения, управляемые гипермедиа, обычно организуются посредством включения шаблонов, где шаблоны на стороне сервера разбиваются в соответствии с потребностями приложения в рендеринге гипермедиа, а затем включаются друг в друга. по мере необходимости. Это, как правило, приводит к меньшему количеству и более объемным файлам, чем в компонентном приложении.

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

Похожий совет — воспользоваться прямым доступом к хранилищу данных. Когда приложение создается с использованием подхода «толстого клиента», хранилище данных обычно находится за API данных (например, JSON). Этот уровень косвенности часто не позволяет разработчикам внешнего интерфейса в полной мере использовать инструменты, доступные в хранилище данных. GraphQL, например, может помочь решить эту проблему, но имеет проблемы, связанные с безопасностью, которые, похоже, не совсем понятны многим разработчикам.

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

Это дает гораздо больше выразительных возможностей непосредственно в руки разработчика, создающего окончательную гипермедиа. Поскольку ваш гипермедийный API может быть структурирован в соответствии с вашими потребностями пользовательского интерфейса, вы можете настроить каждую конечную точку так, чтобы она выдавала как можно меньше запросов к хранилищу данных.

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

Глава 12
Создание приложения контактов с помощью Hyperview

В предыдущих главах этой книги объяснялись преимущества создания приложений с использованием архитектуры гипермедиа. Эти преимущества были продемонстрированы при создании надежного веб-приложения Contacts. Затем в главе 11 утверждалось, что концепции гипермедиа могут и должны применяться к платформам, отличным от Интернета. Мы представили Hyperview как пример гипермедийного формата и клиента, специально предназначенного для создания мобильных приложений. Но вам все еще может быть интересно: каково это — создать полнофункциональное, готовое к работе мобильное приложение с помощью Hyperview? Должны ли мы изучать совершенно новый язык и структуру? В этой главе мы покажем Hyperview в действии, перенеся веб-приложение Contacts в собственное мобильное приложение. Вы увидите, что многие методы веб-разработки (и большая часть кода) полностью идентичны при разработке с помощью Hyperview. Как это возможно?

  1. Наше веб-приложение Contacts было построено по принципу HATEOAS (Hypermedia as the Engine of Application State (гипермедиа как двигатель состояния приложения)). Все функции приложения (получение, поиск, редактирование и создание контактов) реализованы в бэкэнде (классе Contacts Python). Наше мобильное приложение, созданное с помощью Hyperview, также использует HATEOAS и использует серверную часть для всей логики приложения. Это означает, что класс Python Contacts может обеспечивать работу нашего мобильного приложения так же, как и веб-приложение, без каких-либо изменений.

  2. Взаимодействие клиент-сервер в веб-приложении происходит с использованием HTTP. HTTP-сервер для нашего веб-приложения написан с использованием инфраструктуры Flask. Hyperview также использует HTTP для связи клиент-сервер. Таким образом, мы можем повторно использовать маршруты и представления Flask из веб-приложения и для мобильного приложения.

  3. Веб-приложение использует HTML в качестве формата гипермедиа, а Hyperview использует HXML. HTML и HXML — это разные форматы, но базовый синтаксис схож (вложенные теги с атрибутами). Это означает, что мы можем использовать одну и ту же библиотеку шаблонов (Jinja) для HTML и HXML. Кроме того, многие концепции htmx встроены в HXML. Мы можем напрямую переносить функции веб-приложений, реализованные с помощью htmx (поиск, бесконечная загрузка), в HXML.

По сути, мы можем повторно использовать практически все из серверной части веб-приложения, но нам нужно будет заменить шаблоны HTML шаблонами HXML. В большинстве разделов этой главы предполагается, что у нас есть приложение веб-контактов, работающее локально и прослушивающее порт 5000. Готовы? Давайте создадим новые шаблоны HXML для пользовательского интерфейса нашего мобильного приложения.

Создание мобильного приложения

Чтобы начать работу с HXML, есть одно неприятное требование: клиент Hyperview. При разработке веб-приложений вам нужно беспокоиться только о сервере, поскольку клиент (веб-браузер) доступен повсеместно. На каждом мобильном устройстве не установлен эквивалентный клиент Hyperview. Вместо этого мы создадим собственный клиент Hyperview, настроенный только для взаимодействия с нашим сервером. Этот клиент можно упаковать в мобильное приложение для Android или iOS и распространить через соответствующие магазины приложений.

К счастью, нам не нужно начинать с нуля, чтобы реализовать клиент Hyperview. Репозиторий кода Hyperview поставляется с демонстрационным сервером и демонстрационным клиентом, созданным с использованием Expo. Мы будем использовать этот демонстрационный клиент, но в качестве отправной точки укажем его на серверную часть нашего приложения контактов.

> git clone git@github.com:Instawork/hyperview.git
> cd hyperview/demo
> yarn # 1
> yarn start # 2
  1. Установить зависимости для демонстрационного приложения

  2. Запустить сервер Expo, чтобы запустить мобильное приложение в симуляторе iOS.

После запуска yarn start вам будет предложено открыть мобильное приложение с помощью эмулятора Android или симулятора iOS. Выберите вариант в зависимости от того, какой SDK разработчика у вас установлен. (Снимки экрана в этой главе взяты из симулятора iOS.) Если повезет, вы увидите мобильное приложение Expo, установленное в симуляторе. Мобильное приложение автоматически запустится и отобразит экран с надписью «Network request failed» (ошибка сетевого запроса). Это связано с тем, что по умолчанию это приложение настроено на отправку запроса по адресу http://0.0.0.0:8085/index.xml, но наша серверная часть прослушивает порт 5000. Чтобы это исправить, мы можем внести простое изменение конфигурации в файл demo/src/constants.js:

//export const ENTRY_POINT_URL = 'http://0.0.0.0:8085/index.xml'; // 1
export const ENTRY_POINT_URL = 'http://0.0.0.0:5000/'; // 2
  1. URL-адрес точки входа по умолчанию в демонстрационном приложении.

  2. Настройка URL-адреса, указывающего на наше приложение контактов

Мы еще не приступили к работе. Теперь, когда наш клиент Hyperview указывает на правильную конечную точку, мы видим другую ошибку — «ParseError». Это связано с тем, что серверная часть отвечает на запросы содержимым HTML, а клиент Hyperview ожидает ответа в формате XML (в частности, HXML). Итак, пришло время обратить внимание на наш сервер Flask. Мы рассмотрим представления Flask и заменим шаблоны HTML шаблонами HXML. В частности, давайте поддержим следующие функции в нашем мобильном приложении:

Нулевая конфигурация клиента в приложениях Hypermedia

Для многих мобильных приложений, использующих клиент Hyperview, настройка этого URL-адреса точки входа — единственный код на устройстве, который необходимо написать для доставки полнофункционального приложения. Рассматривайте URL-адрес точки входа как адрес, который вы вводите в веб-браузер, чтобы открыть веб-приложение. За исключением Hyperview, здесь нет адресной строки, а браузер жестко запрограммирован на открытие только одного URL-адреса. Этот URL-адрес будет загружать первый экран, когда пользователь запускает приложение. Любое другое действие, которое может предпринять пользователь, будет объявлено в HXML этого первого экрана. Эта минимальная конфигурация является одним из преимуществ архитектуры на основе Hypermedia.

Конечно, вам может потребоваться написать больше кода на устройстве, чтобы поддерживать больше функций в вашем мобильном приложении. Мы продемонстрируем, как это сделать, позже в этой главе, в разделе «Расширение клиента».

Список контактов с возможностью поиска

Мы начнем создавать наше приложение Hyperview с экрана точки входа и списка контактов. Для начальной версии этого экрана давайте поддержим следующие функции веб-приложения:

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

Если вы помните, все страницы веб-приложения Contacts имеют общий базовый шаблон — layout.html. Нам нужен аналогичный базовый шаблон для экранов мобильного приложения. Этот шаблон будет содержать правила стиля нашего пользовательского интерфейса и базовую структуру, общую для всех экранов. Назовем его layout.xml.

Листинг 187. Базовый шаблон hv/layout.xml

<doc xmlns="https://hyperview.org/hyperview">
  <screen>
    <styles><!-- omitted for brevity --></styles>
    <body style="body" safe-area="true">
      <header style="header">
        {% block header %} <!-- 1 -->
          <text style="header-title">Contact.app</text>
        {% endblock %}
      </header>

      <view style="main">
        {% block content %}{% endblock %} <!-- 2 -->
      </view>
    </body>
  </screen>
</doc>
  1. Раздел заголовка шаблона с заголовком по умолчанию.

  2. Раздел содержимого шаблона, предоставляемый другими шаблонами.

Мы используем теги и атрибуты HXML, описанные в предыдущей главе. Этот шаблон устанавливает базовый макет экрана с помощью тегов <doc>, <screen>, <body>, <header> и <view>. Обратите внимание, что синтаксис HXML хорошо сочетается с библиотекой шаблонов Jinja. Здесь мы используем блоки Jinja для определения двух разделов (header и content), которые будут содержать уникальное содержимое экрана. Закончив наш базовый шаблон, мы можем создать шаблон специально для экрана списка контактов.

Листинг 188. Начало файла hv/index.xml

{% extends 'hv/layout.xml' %} <!-- 1 -->

{% block content %} <!-- 2 -->
  <form> <!-- 3 -->
    <text-field name="q" value="" placeholder="Search..." style="search-field" />
    <list id="contacts-list"> <!-- 4 -->
      {% include 'hv/rows.xml' %}
    </list>
  </form>
{% endblock %}
  1. Расширение базового шаблона макета

  2. Переопределить блок content шаблона макета

  3. Создать форму поиска, которая будет отправлять HTTP GET в /contacts.

  4. Список контактов с использованием тега include Jinja.

Этот шаблон расширяет базовый файл layout.xml и переопределяет блок content с помощью <form>. На первый взгляд может показаться странным, что форма охватывает элементы <text-field> и <list>. Но помните: в Hyperview данные формы включаются в любой запрос, исходящий от дочернего элемента. Вскоре мы добавим в список взаимодействия (потянуть, чтобы обновить), для которых потребуются данные формы. Обратите внимание на использование тега include Jinja для отображения HXML для строк контактов в списке (hv/rows.xml). Как и в шаблонах HTML, мы можем использовать include, чтобы разбить наш HXML на более мелкие части. Это также позволяет серверу отвечать только с помощью шаблона rows.xml для таких взаимодействий, как поиск, бесконечная прокрутка и потянуть, чтобы обновить.

Листинг 189. hv/rows.xml

<items xmlns="https://hyperview.org/hyperview"> <!-- 1 -->
  {% for contact in contacts %} <!-- 2 -->
    <item key="{{ contact.id }}" style="contact-item"> <!-- 3 -->
      <text style="contact-item-label">
        {% if contact.first %}
          {{ contact.first }} {{ contact.last }}
        {% elif contact.phone %}
          {{ contact.phone }}
        {% elif contact.email %}
          {{ contact.email }}
        {% endif %}
      </text>
    </item>
  {% endfor %}
</items>
  1. Элемент HXML, который группирует набор элементов <item> в одном родительском элементе.

  2. Перебрать контакты, которые были переданы в шаблон.

  3. Отобразить <item> для каждого контакта, отображая имя, номер телефона или адрес электронной почты.

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

Теперь у нас есть шаблоны для базового макета, экрана контактов и строк контактов. Но нам все равно придется обновить представления Flask, чтобы использовать эти шаблоны. Давайте посмотрим на представление contacts() в его текущей форме, написанное для веб-приложения:

Листинг 190. app.py

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    page = int(request.args.get("page", 1))
    if search:
        contacts_set = Contact.search(search)
        if request.headers.get('HX-Trigger') == 'search':
            return render_template("rows.html", contacts=contacts_set, page=page)
    else:
        contacts_set = Contact.all(page)
    return render_template("index.html",
contacts=contacts_set, page=page)

Это представление поддерживает выборку набора контактов на основе двух параметров запроса: q и page. Оно также решает, следует ли отображать всю страницу (index.html) или только строки контактов (rows.html) на основе заголовка HX-Trigger. Это представляет собой небольшую проблему. Заголовок HX-Trigger устанавливается библиотекой htmx; в Hyperview нет эквивалентной функции. Более того, в Hyperview существует несколько сценариев, которые требуют от нас ответа только строками контактов:

Поскольку мы не можем зависеть от такого заголовка, как HX-Trigger, нам нужен другой способ определить, нужен ли клиенту полноэкранный режим или только строки в ответе. Мы можем сделать это, введя новый параметр запроса rows_only. Если этот параметр имеет значение true, представление ответит на запрос, отобразив шаблон rows.xml. В противном случае он ответит шаблоном index.xml:

Листинг 191. app.py

@app.route("/contacts")
def contacts():
    search = request.args.get("q")
    page = int(request.args.get("page", 1))
    rows_only = request.args.get("rows_only") == "true" # 1
    if search:
        contacts_set = Contact.search(search)
    else:
        contacts_set = Contact.all(page)

    template_name = "hv/rows.xml" if rows_only else "hv/index.xml" # 2
    return render_template(template_name,
contacts=contacts_set, page=page)
  1. Проверить наличие нового параметра запроса rows_only.

  2. Отобразить соответствующий шаблон HXML на основе rows_only.

Нам нужно внести еще одно изменение. Flask предполагает, что большинство представлений будут отвечать HTML. Поэтому Flask по умолчанию присваивает заголовку ответа Content-Type значение text/html. Но клиент Hyperview ожидает получить контент HXML, указанный в заголовке ответа Content-Type со значением application/vnd.hyperview+xml. Клиент будет отклонять ответы с другим типом контента. Чтобы это исправить, нам нужно явно установить заголовок ответа Content-Type в наших представлениях Flask. Мы сделаем это, представив новую вспомогательную функцию render_to_response():

Листинг 192. app.py

def render_to_response(template_name, *args, **kwargs):
    content = render_template(template_name, *args, **kwargs) # 1
    response = make_response(content) # 2
    response.headers['Content-Type'] = 'application/vnd.hyperview+xml' # 3
    return response
  1. Отобразить заданный шаблон с предоставленными аргументами и аргументами ключевого слова.

  2. Создать явный объект ответа с помощью визуализированного шаблона.

  3. Установить заголовок Content-Type ответа в XML.

Как видите, эта вспомогательная функция «под капотом» использует render_template(). render_template() возвращает строку. Эта вспомогательная функция использует эту строку для создания явного объекта Response. Объект ответа имеет атрибут headers, позволяющий нам устанавливать и изменять заголовки ответа. В частности, render_to_response() устанавливает для Content-Type значение application/vnd.hyperview+xml, чтобы клиент Hyperview распознавал содержимое. Этот помощник является заменой render_template в наших представлениях. Итак, все, что нам нужно сделать, это обновить последнюю строку функции contacts().

Листинг 193. Функция contact()

return render_to_response(template_name, contacts=contacts_set, page=page) # 1
  1. Преобразовать шаблон HXML в ответ XML.

Благодаря этим изменениям в представлении contact() мы наконец можем увидеть плоды нашего труда. После перезапуска серверной части и обновления экрана в нашем мобильном приложении мы видим экран контактов!

Рисунок 13. Экран контактов

Поиск контактов

На данный момент у нас есть мобильное приложение, которое отображает экран со списком контактов. Но наш пользовательский интерфейс не поддерживает никаких взаимодействий. Ввод запроса в поле поиска не фильтрует список контактов. Давайте добавим поведение в поле поиска, чтобы реализовать взаимодействие поиска по мере ввода. Для этого необходимо расширить <text-field> и добавить элемент <behavior>.

Листинг 194. Фрагмент файла hv/index.xml

<text-field name="q" value="" placeholder="Search..." style="search-field">
  <behavior
    trigger="change" <!-- 1 -->
    action="replace-inner" <!-- 2 -->
    target="contacts-list" <!-- 3 -->
    href="/contacts?rows_only=true" <!-- 4 -->
    verb="get" <!-- 5 -->
  />
</text-field>
  1. Это поведение сработает при изменении значения текстового поля.

  2. Когда поведение сработает, действие заменит содержимое внутри целевого элемента.

  3. Целью действия является элемент с ID contacts-list.

  4. Содержимое замены будет получено по этому URL-пути.

  5. Содержимое замены будет получено с помощью HTTP-метода GET.

Первое, что вы заметите, это то, что мы изменили текстовое поле с использования самозакрывающегося тега (<text-field />) на использование открывающего и закрывающего тегов (<text-field>...</text-field>). Это позволяет нам добавить дочерний элемент <behavior> для определения взаимодействия.

Атрибут trigger="change" сообщает Hyperview, что изменение значения текстового поля вызовет действие. Каждый раз, когда пользователь редактирует содержимое текстового поля, добавляя или удаляя символы, срабатывает действие.

Остальные атрибуты элемента <behavior> определяют действие. action="replace-inner" означает, что действие обновит содержимое на экране, заменив содержимое HXML элемента новым содержимым. Чтобы replace-inner выполнил свою задачу, нам нужно знать две вещи: текущий элемент на экране, на который будет нацелено действие, и контент, который будет использоваться для замены. target="contacts-list" сообщает нам идентификатор текущего элемента. Обратите внимание, что мы установили id="contacts-list" для элемента <list> в index.xml. Поэтому, когда пользователь вводит поисковый запрос в текстовое поле, Hyperview заменит содержимое <list> (набор элементов <item>) новым содержимым (элементами <item>, которые соответствуют поисковому запросу), полученным в ответе в относительной ссылке href. Домен здесь выводится из домена, используемого для получения экрана. Обратите внимание, что href включает наш параметр запроса rows_only; мы хотим, чтобы ответ включал только строки, а не весь экран.

Рисунок 14. Поиск контактов

Это все, что нужно, чтобы добавить в наше мобильное приложение функцию поиска по мере ввода! Когда пользователь вводит поисковый запрос, клиент будет отправлять запросы к серверной части и заменять список результатами поиска. Вам может быть интересно, откуда серверная часть узнает, какой запрос использовать? Атрибут href в поведении не включает параметр q, ожидаемый нашим сервером. Но помните, что в index.xml мы обернули элементы <text-field> и <list> родительским элементом <form>. Элемент <form> определяет группу входных данных, которые будут сериализованы и включены в любые HTTP-запросы, инициированные его дочерними элементами. В этом случае элемент <form> окружает поведение поиска и текстовое поле. Таким образом, значение <text-field> будет включено в наш HTTP-запрос результатов поиска. Поскольку мы делаем запрос GET, имя и значение текстового поля будут сериализованы как параметр запроса. Любые существующие параметры запроса в href будут сохранены. Это означает, что фактический HTTP-запрос к нашему серверу выглядит так: GET /contacts?rows_only=true&q=Car. Наша серверная часть уже поддерживает параметр q для поиска, поэтому ответ будет включать строки, соответствующие строке «Car».

Бесконечная прокрутка

Если у пользователя сотни или тысячи контактов, загрузка их всех одновременно может привести к снижению производительности приложения. Вот почему большинство мобильных приложений с длинными списками реализуют взаимодействие, известное как «бесконечная прокрутка». Приложение загружает в список фиксированное количество начальных элементов, скажем, 100 элементов. Если пользователь прокручивает список до конца, он видит счетчик, указывающий, что загружается больше контента. Как только контент станет доступен, счетчик заменяется следующей страницей из 100 элементов. Эти элементы добавляются в список, а не заменяют первый набор элементов. Итак, список теперь содержит 200 пунктов. Если пользователь снова прокрутит список до конца, он увидит еще один счетчик, и приложение загрузит следующий набор контента. Бесконечная прокрутка повышает производительность приложения двумя способами:

Наш бэкэнд Flask уже поддерживает нумерацию страниц в конечной точке /contacts через параметр запроса page. Нам просто нужно изменить наши шаблоны HXML, чтобы использовать этот параметр. Для этого давайте отредактируем rows.xml, добавив новый <item> под циклом for Jinja:

Листинг 195. Фрагмент файла hv/rows.xml

<items xmlns="https://hyperview.org/hyperview">
  {% for contact in contacts %}
    <item key="{{ contact.id }}" style="contact-item">
      <!-- omitted for brevity -->
    </item>
  {% endfor %}
  {% if contacts|length > 0 %}
    <item key="load-more" id="load-more" style="load-more-item"> <!-- 1 -->
      <behavior
        trigger="visible" <!-- 2 -->
        action="replace" <!-- 3 -->
        target="load-more" <!-- 4 -->
        href="/contacts?rows_only=true&page={{ page + 1 }}" <!-- 5 -->
        verb="get"
      />
      <spinner /> <!-- 6 -->
    </item>
  {% endif %}
</items>
  1. Добавить в список дополнительный элемент <item>, чтобы отобразить счетчик.

  2. Поведение элемента срабатывает, когда он виден в области просмотра.

  3. При срабатывании поведение заменит элемент на экране.

  4. Заменяемым элементом является сам элемент (ID load-more).

  5. Заменить элемент следующей страницей содержимого.

  6. Спиннерный элемент.

Если текущий список контактов, переданный в шаблон, пуст, мы можем предположить, что больше нет контактов, которые можно было бы получить из серверной части. Поэтому мы используем условие Jinja, чтобы включать этот новый <item> только в том случае, если список контактов не пуст. Этот новый элемент <item> получает идентификатор и поведение. Поведение определяет взаимодействие с бесконечной прокруткой.

До сих пор мы видели у trigger значения change и refresh. Но для реализации бесконечной прокрутки нам нужен способ запускать действие, когда пользователь прокручивает список до конца. Триггер visible можно использовать именно для этой цели. Это вызовет действие, когда элемент с таким поведением будет виден в окне просмотра устройства. В этом случае новый элемент <item> является последним элементом в списке, поэтому действие сработает, когда пользователь прокрутит вниз достаточно далеко, чтобы элемент попал в область просмотра. Как только элемент станет видимым, действие выполнит HTTP-запрос GET и заменит загружаемый элемент <item> содержимым ответа.

Обратите внимание, что наш href должен включать параметр запроса rows_only=true, чтобы наш ответ включал HXML только для элементов контакта, а не для всего экрана. Кроме того, мы передаем параметр запроса page, увеличивая номер текущей страницы, чтобы гарантировать загрузку следующей страницы.

Что происходит, когда элементов имеется более одной страницы? Начальный экран будет включать первые 100 элементов, а также элемент «load-more» внизу. Когда пользователь прокручивает экран до нижней части, Hyperview запросит вторую страницу элементов (&page=2) и заменит элемент «load-more» новыми элементами. Но на этой второй странице элементов будет новый элемент «load-more». Поэтому, как только пользователь прокрутит все элементы второй страницы, Hyperview снова запросит дополнительные элементы (&page=3). И снова пункт «load-more» будет заменен на новые элементы. Это будет продолжаться до тех пор, пока все элементы не будут загружены на экран. В этот момент больше не будет контактов для возврата, ответ не будет включать еще один элемент «load-more», и наша нумерация страниц будет завершена.

Обновление по запросу (pull-to-refresh)

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

Рисунок 15. Обновление по запросу

Этот шаблон настолько распространен и полезен, что встроен в Hyperview с помощью действия refresh. Давайте добавим функцию обновления по запросу в наш список контактов, чтобы увидеть ее в действии.

Листинг 196. Фрагмент файла hv/index.xml

<list id="contacts-list"
  trigger="refresh" <!-- 1 -->
  action="replace-inner" <!-- 2 -->
  target="contacts-list" <!-- 3 -->
  href="/contacts?rows_only=true" <!-- 4 -->
  verb="get" <!-- 5 -->
>
  {% include 'hv/rows.xml' %}
</list>
  1. Это поведение сработает, когда пользователь выполнит жест «потянуть, чтобы обновить».

  2. Когда поведение сработает, это действие заменит содержимое внутри элемента target.

  3. Целью действия является сам элемент <list>.

  4. Содержимое замены будет получено по этому URL-пути.

  5. Содержимое замены будет получено с помощью HTTP-метода GET.

В приведенном выше фрагменте вы заметите кое-что необычное: вместо добавления элемента <behavior> в <list> мы добавили атрибуты поведения непосредственно в элемент <list>. Это сокращенное обозначение, которое иногда полезно для указания отдельного поведения элемента. Это эквивалентно добавлению элемента <behavior> в <list> с теми же атрибутами.

Так почему же мы использовали здесь сокращенный синтаксис? Это связано с действием replace-inner. Помните, что это действие заменяет все дочерние элементы цели новым содержимым. Сюда также входят элементы <behavior>! Допустим, наш <list> содержит <behavior>. Если пользователь выполнил поиск или обновление по запросу, мы заменим содержимое <list> содержимым из rows.xml. <behavior> больше не будет определено в <списке>, и последующие попытки обновления по запросу не сработают. Определив поведение как атрибуты <list>, поведение будет сохраняться даже при замене элементов в списке. Обычно мы предпочитаем использовать явные элементы <behavior> в HXML. Это упрощает определение нескольких вариантов поведения и их перемещение во время рефакторинга.

Просмотр сведений о контакте

Теперь, когда наш экран списка контактов находится в хорошей форме, мы можем начать добавлять в наше приложение другие экраны. Следующим естественным шагом будет создание экрана подробностей, который появляется, когда пользователь касается элемента в списке контактов. Давайте обновим шаблон, отображающий элементы контакта <item>, и добавим поведение для отображения экрана сведений.

Листинг 197. hv/rows.xml

<items xmlns="https://hyperview.org/hyperview">
  {% for contact in contacts %}
    <item key="{{ contact.id }}" style="contact-item">
      <behavior trigger="press" action="push" href="/contacts/{{ contact.id }}" /> <!-- 1 -->
      <text style="contact-item-label">
        <!-- omitted for brevity -->
      </text>
    </item>
  {% endfor %}
</items>
  1. Поведение, позволяющее помещать экран сведений о контакте в стек при нажатии.

В нашем бэкэнде Flask уже есть маршрут для предоставления контактной информации в /contacts/<contact_id>. В нашем шаблоне мы используем переменную Jinja для динамического создания URL-пути для текущего контакта в цикле for. Мы также использовали действие «push», чтобы показать детали, помещая новый экран в стек. Если вы перезагрузите приложение, вы сможете коснуться любого контакта в списке, и Hyperview откроет новый экран. Однако на новом экране появится сообщение об ошибке. Это потому, что наш сервер по-прежнему возвращает в ответ HTML, а клиент Hyperview ожидает HXML. Давайте обновим серверную часть, чтобы она отвечала HXML и соответствующими заголовками.

Листинг 198. app.py

@app.route("/contacts/<contact_id>")
def contacts_view(contact_id=0):
    contact = Contact.find(contact_id)
    return render_to_response("hv/show.xml", contact=contact) # 1
  1. Создать ответ XML из нового файла шаблона.

Как и представление contacts(), contact_view() использует render_to_response() для установки заголовка Content-Type в ответе. Мы также генерируем ответ из нового шаблона HXML, который мы можем создать сейчас:

Листинг 199. hv/show.xml

{% extends 'hv/layout.xml' %} <!-- 1 -->

{% block header %} <!-- 2 -->
  <text style="header-button">
    <behavior trigger="press" action="back" /> <!-- 3 -->
    Back
  </text>
{% endblock %}

{% block content %} <!-- 4 -->
<view style="details">
  <text style="contact-name">{{ contact.first }} {{ contact.last }}</text>

  <view style="contact-section">
    <text style="contact-section-label">Phone</text>
    <text style="contact-section-info">{{contact.phone}}</text>
  </view>

  <view style="contact-section">
    <text style="contact-section-label">Email</text>
    <text style="contact-section-info">{{contact.email}}</text>
</view>
</view>
{% endblock %}
  1. Расширить базовый шаблон макета.

  2. Переопределить блок header шаблона макета, включив в него кнопку «Back».

  3. Поведение для перехода к предыдущему экрану при нажатии.

  4. Переопределить блок content, чтобы отобразить полную информацию о выбранном контакте.

Экран сведений о контактах расширяет базовый шаблон layout.xml, как мы это сделали в index.xml. На этот раз мы переопределяем контент как в блоке header, так и в блоке content. Переопределение блока заголовка позволяет нам добавить кнопку «Back» с поведением. При нажатии клиент Hyperview отмотает стек навигации и вернет пользователя в список контактов.

Обратите внимание, что запуск этого поведения — не единственный способ вернуться назад. Клиент Hyperview соблюдает соглашения о навигации на разных платформах. В iOS пользователи также могут перейти к предыдущему экрану, проведя пальцем вправо от левого края устройства. На Android пользователи также могут перейти к предыдущему экрану, нажав аппаратную кнопку «Назад». Нам не нужно указывать что-либо дополнительно в HXML, чтобы получить эти взаимодействия.

Рисунок 16. Экран сведений о контакте

С помощью всего лишь нескольких простых изменений мы перешли от одноэкранного приложения к многоэкранному. Обратите внимание: нам не нужно было ничего менять в коде мобильного приложения для поддержки нашего нового экрана. Это большое дело. При разработке традиционных мобильных приложений добавление экранов может оказаться серьезной задачей. Разработчикам необходимо создать новый экран, вставить его в соответствующее место иерархии навигации и написать код для открытия нового экрана из существующих экранов. В Hyperview мы только добавили поведение с action="push".

Редактирование контакта

На данный момент наше приложение позволяет нам просматривать список контактов и просматривать сведения о конкретном контакте. Разве не было бы неплохо обновить имя, номер телефона или адрес электронной почты контакта? Давайте добавим пользовательский интерфейс для редактирования контактов в качестве нашего следующего улучшения.

Сначала нам нужно выяснить, как мы хотим отображать пользовательский интерфейс редактирования. Мы могли бы поместить в стек новый экран редактирования точно так же, как мы поместили экран с контактными данными. Но это не лучший дизайн с точки зрения пользовательского опыта. Добавление новых экранов имеет смысл при детализации данных, например при переходе от списка к одному элементу. Но редактирование — это не «детализация», а переключение режима между просмотром и редактированием. Поэтому вместо того, чтобы создавать новый экран, давайте заменим текущий экран пользовательским интерфейсом редактирования. Это означает, что нам нужно добавить кнопку и поведение, использующее действие перезагрузки. Эту кнопку можно добавить в заголовок экрана сведений о контакте.

Листинг 200. Фрагмент файла hv/show.xml

{% block header %}
  <text style="header-button">
    <behavior trigger="press" action="back" />
    Back
  </text>

  <text style="header-button"> <!-- 1 -->
    <behavior trigger="press" action="reload" href="/contacts/{{contact.id}}/edit" /> <!-- 2 -->
    Edit
  </text>
{% endblock %}
  1. Новая кнопка «Edit».

  2. Поведение для перезагрузки текущего экрана с экраном редактирования при нажатии.

Мы снова используем существующий маршрут Flask (/contacts/<contact_id>/edit) для пользовательского интерфейса редактирования и заполняем идентификатор контакта, используя данные, переданные в шаблон Jinja. Нам также необходимо обновить представление contact_edit_get(), чтобы оно возвращало ответ XML на основе шаблона HXML (hv/edit.xml). Мы пропустим пример кода, поскольку необходимые изменения идентичны тем, которые мы применили к contact_view() в предыдущем разделе. Вместо этого давайте сосредоточимся на шаблоне экрана редактирования.

Листинг 201. hv/edit.xml

{% extends 'hv/layout.xml' %}

{% block header %}
  <text style="header-button">
    <behavior trigger="press" action="back" href="#" />
    Back
  </text>
{% endblock %}

{% block content %}
<form> <!-- 1 -->
  <view id="form-fields"> <!-- 2 -->
    {% include 'hv/form_fields.xml' %} <!-- 3 -->
  </view>

  <view style="button"> <!-- 4 -->
    <behavior
      trigger="press"
      action="replace-inner"
      target="form-fields"
      href="/contacts/{{contact.id}}/edit"
      verb="post"
    />
    <text style="button-label">Save</text>
  </view>
</form>
{% endblock %}
  1. Форма, обертывающая поля ввода и кнопки.

  2. Контейнер с ID, содержащий поля ввода.

  3. Шаблон включает в себя отображение полей ввода.

  4. Кнопка для отправки данных формы и обновления контейнера полей ввода.

Поскольку экрану редактирования необходимо отправлять данные на серверную часть, мы заключаем весь раздел содержимого в элемент <form>. Это гарантирует, что данные полей формы будут включены в HTTP-запросы к нашему серверу. Внутри элемента <form> наш пользовательский интерфейс разделен на две части: поля формы и кнопку «Save». Фактические поля формы определяются в отдельном шаблоне (form_fields.xml) и добавляются на экран редактирования с помощью тега включения Jinja.

Листинг 202. hv/form_fields.xml

<view style="edit-group">
  <view style="edit-field">
    <text-field name="first_name" placeholder="First name" value="{{ contact.first }}" /> <!-- 1 -->
    <text style="edit-field-error">{{ contact.errors.first }}</text> <!-- 2 -->
  </view>

  <view style="edit-field"> <!-- 3 -->
    <text-field name="last_name" placeholder="Last name" value="{{ contact.last }}" />
    <text style="edit-field-error">{{ contact.errors.last }}</text>
  </view>

  <!-- same markup for contact.email and contact.phone -->
</view>
  1. Инпут, содержащий текущее значение имени контакта.

  2. Текстовый элемент, который может отображать ошибки из модели контакта.

  3. Еще одно текстовое поле, на этот раз для фамилии контакта.

Мы опустили код номера телефона и адреса электронной почты контакта, поскольку они соответствуют тому же шаблону, что и имя и фамилия. Каждое поле контакта имеет свой собственный <text-field> и элемент <text> под ним для отображения возможных ошибок. <text-field> имеет два важных атрибута:

Вам может быть интересно, почему мы решили определить поля формы в отдельном шаблоне (form_fields.xml)? Чтобы понять это решение, нам нужно сначала обсудить кнопку «Save». При нажатии клиент Hyperview отправит HTTP POST-запрос к contacts/<contact_id>/edit с данными формы, сериализованными из входных данных <text-field>. Ответ HXML заменит содержимое контейнера полей формы (ID form-fields). Но каким должен быть этот ответ? Это зависит от достоверности данных формы:

  1. Если данные недействительны (например, повторяющийся адрес электронной почты), наш пользовательский интерфейс останется в режиме редактирования и будет отображать сообщения об ошибках в недопустимых полях. Это позволяет пользователю исправить ошибки и повторить попытку сохранения.

  2. Если данные действительны, наша серверная часть сохранит изменения, а наш пользовательский интерфейс снова переключится в режим отображения (пользовательский интерфейс контактных данных).

Поэтому нашему серверу необходимо различать допустимое и недействительное редактирование. Чтобы поддержать эти два сценария, давайте внесем некоторые изменения в существующее представление contact_edit_post() в приложении Flask.

Листинг 203. app.py

@app.route("/contacts/<contact_id>/edit", methods=["POST"])
def contacts_edit_post(contact_id=0):
    c = Contact.find(contact_id)
    c.update(request.form['first_name'], request.form['last_name'], request.form['phone'], request.form['email']) # 1
    if c.save(): # 2
        flash("Updated Contact!")
        return render_to_response("hv/form_fields.xml", contact=c, saved=True) # 3
    else:
        return render_to_response("hv/form_fields.xml", contact=c) # 4
  1. Обновить объект контакта из данных формы запроса.

  2. Попытаться сохранить обновления. Это возвращает False для недопустимых данных.

  3. В случае успеха отобразить шаблон полей формы и передать флаг saved в шаблон.

  4. В случае неудачи отобразить шаблон полей формы. На объекте контакта присутствуют сообщения об ошибках.

Это представление уже содержит условную логику, основанную на успешном выполнении метода save() модели контакта. Если метод save() завершается неудачей, мы отображаем шаблон form_fields.xml. contact.errors будет содержать сообщения об ошибках для недопустимых полей, которые будут отображаться в элементах <text style="edit-field-error">. Если метод save() завершится успешно, мы также отобразим шаблон form_fields.xml. Но на этот раз шаблон получит флаг saved, указывающий на успех. Мы обновим шаблон, чтобы использовать этот флаг для реализации желаемого пользовательского интерфейса: переключение пользовательского интерфейса обратно в режим отображения.

Листинг 204. hv/form_fields.xml

<view style="edit-group">
  {% if saved %} <!-- 1 -->
    <behavior
      trigger="load" <!-- 2 -->
      action="reload" <!-- 3 -->
      href="/contacts/{{contact.id}}" <!-- 4 -->
    />
  {% endif %}

  <view style="edit-field">
    <text-field name="first_name" placeholder="First name" value="{{ contact.first }}" />
    <text style="edit-field-error">{{ contact.errors.first }}</text>
  </view>

  <!-- same markup for the other fields -->
</view>
  1. Включить это поведение только после успешного сохранения контакта.

  2. Немедленно запустить поведение.

  3. Поведение перезагрузит весь экран.

  4. Экран будет перезагружен с экраном контактной информации.

Условие шаблона Jinja гарантирует, что наше поведение отображается только при успешном сохранении, а не при первом открытии экрана (или при отправке пользователем неверных данных). В случае успеха шаблон включает поведение, которое срабатывает немедленно благодаря trigger="load". Действие перезагружает текущий экран с экраном Contact Details (из маршрута /contacts/<contact_id>).

Результат? Когда пользователь нажимает «Save», наша серверная часть сохраняет новые контактные данные, и экран переключается обратно на экран Details. Поскольку приложение отправит новый HTTP-запрос для получения контактной информации, оно гарантированно покажет только что сохраненные изменения.

Почему бы не перенаправить?

Возможно, вы помните, что версия этого кода для веб-приложения вела себя немного иначе. При успешном сохранении представление вернуло redirect("/contacts/" + str(contact_id)). Это перенаправление HTTP сообщит веб-браузеру перейти на страницу контактных данных.

Этот подход не поддерживается в Hyperview. Почему? Навигационный стек веб-приложения прост: линейная последовательность страниц, в которой одновременно активна только одна страница. Навигация в мобильном приложении значительно сложнее. Мобильные приложения используют вложенную иерархию стеков навигации, модальных окон и вкладок. Все экраны в этой иерархии активны и могут отображаться мгновенно в ответ на действия пользователя. Как в этом мире клиент Hyperview интерпретирует перенаправление HTTP? Должен ли он перезагрузить текущий экран, отправить новый или перейти к экрану в стеке с тем же URL-адресом?

Вместо того, чтобы делать выбор, который был бы неоптимальным для многих сценариев, Hyperview использует другой подход. Перенаправления, управляемые сервером, невозможны, но серверная часть может отображать поведение навигации в HXML. Это то, что мы делаем, чтобы переключиться с пользовательского интерфейса редактирования на пользовательский интерфейс сведений в приведенном выше коде. Думайте об этом как о перенаправлениях на стороне клиента или, еще лучше, о навигации на стороне клиента.

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

Давайте добавим еще одно улучшение в интерфейс редактирования. Было бы неплохо позволить пользователю выходить из режима редактирования без необходимости сохранять контакт. Обычно это делается с помощью действия «Cancel». Мы можем добавить это как новую кнопку под кнопкой «Save».

Листинг 205. Фрагмент файла hv/edit.xml

<view style="button">
  <behavior trigger="press" action="replace-inner" target="form-fields" href="/contacts/{{contact.id}}/edit" verb="post" />
  <text style="button-label">Save</text>
</view>
<view style="button"> <!-- 1 -->
  <behavior
    trigger="press"
    action="reload" <!-- 2 -->
    href="/contacts/{{contact.id}}" <!-- 3 -->
  /><text style="button-label">Cancel</text>
</view>
  1. Новая кнопка «Cancel» на экране редактирования.

  2. При нажатии перезагрузка всего экрана.

  3. Экран будет перезагружен с экраном контактной информации.

Это тот же метод, который мы использовали для переключения с пользовательского интерфейса редактирования на пользовательский интерфейс сведений после успешного редактирования контакта. Но нажатие «Cancel» обновит пользовательский интерфейс быстрее, чем нажатие «Save». При сохранении приложение сначала выполнит запрос POST для сохранения данных, а затем запрос GET для экрана сведений. Отмена пропускает POST и немедленно выполняет запрос GET.

Рисунок 17. Экран редактирования контакта

Обновление списка контактов

На данный момент мы можем утверждать, что полностью реализовали интерфейс редактирования. Но есть проблема. На самом деле, если бы мы остановились на этом, пользователи могли бы даже счесть приложение глючным! Почему? Это связано с синхронизацией состояния приложения на нескольких экранах. Давайте пройдемся по этой серии взаимодействий:

  1. Запустить приложение в списке контактов.

  2. Нажать на контакт «Joe Blow», чтобы загрузить его контактную информацию.

  3. Нажать «Edit», чтобы переключиться в режим редактирования, и изменить имя контакта на «Joseph».

  4. Нажать «Save», чтобы вернуться в режим просмотра. Имя контакта теперь «Joseph Blow».

  5. Нажать кнопку «Back», чтобы вернуться в список контактов.

Вы уловили проблему? В нашем списке контактов по-прежнему отображается тот же список имен, что и при запуске приложения. Контакт, который мы только что переименовали в «Joseph», по-прежнему отображается в списке как «Joe». Это общая проблема в гипермедийных приложениях. Клиент не имеет понятия об общих данных в разных частях пользовательского интерфейса. Обновления в одной части приложения не будут автоматически обновлять другие части приложения.

К счастью, в Hyperview есть решение этой проблемы: события. События встроены в систему поведения и обеспечивают упрощенное взаимодействие между различными частями пользовательского интерфейса.

Поведение событий

События — это клиентская функция Hyperview. В разделе «Клиентские сценарии» мы обсуждали события при работе с HTML, _hyperscript и DOM. Элементы DOM будут отправлять события в результате взаимодействия с пользователем. Скрипты могут прослушивать эти события и реагировать на них, запуская произвольный код JavaScript.

События в Hyperview намного проще, но они не требуют каких-либо сценариев и могут быть определены декларативно в HXML. Это делается через систему поведения. События требуют добавления нового атрибута поведения, типа действия и типа триггера:

  • event-name: Этот атрибут <behavior> определяет имя события, которое будет либо отправлено, либо будет прослушиваться.

  • action="dispatch-event": при срабатывании это поведение отправляет событие с именем, определенным атрибутом event-name. Это событие отправляется глобально по всему приложению Hyperview.

  • trigger="on-event": это поведение сработает, если другое поведение в приложении отправит событие, соответствующее атрибуту event-name.

Если элемент <behavior> использует action="dispatch-event" или trigger="on-event", он также должен определить event-name. Обратите внимание, что несколько вариантов поведения могут отправлять событие с одним и тем же именем. Аналогично, одно и то же событие может активировать несколько вариантов поведения.

Давайте посмотрим на это простое поведение:

<behavior trigger="press" target="container" />

Нажатие на элемент, содержащий это поведение, переключит видимость элемента с ID «container». Но что, если элемент, который мы хотим переключить, находится на другом экране? Действие «toggle» и поиск ID цели работают только на текущем экране, поэтому это решение не будет работать. Решение состоит в том, чтобы создать два поведения, по одному на каждом экране, взаимодействующие через события:

  • Экран A: <behavior trigger="press" event-name="button-pressed" />

  • Экран Б: <behavior trigger="on-event" event-name="button-pressed" action="toggle" target="container" />

Нажатие на элемент, содержащий первое поведение (на экране A), отправит событие с именем «button-pressed». Второе поведение (на экране B) сработает при событии с этим именем и переключит видимость элемента с ID «container».

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

Теперь мы знаем достаточно о системе событий Hyperview, чтобы устранить ошибку в нашем приложении. Когда пользователь сохраняет изменение контакта, нам нужно отправить событие с экрана «Details». И экран «Contacts» должен прослушивать это событие и перезагружаться, чтобы отразить изменения. Поскольку шаблон form_fields.xml уже получает флаг сохранения, когда серверная часть успешно сохраняет контакт, это хорошее место для отправки события:

Листинг 206. Фрагмент файла hv/form_fields.xml

{% if saved %}
  <behavior
    trigger="load" <!-- 1 -->
    action="dispatch-event" <!-- 2 -->
    event-name="contact-updated" <!-- 3 -->
  />
  <behavior <!-- 4 -->
    trigger="load"
    action="reload"
    href="/contacts/{{contact.id}}"
  />
{% endif %}
  1. Немедленно запустить поведение.

  2. Поведение отправит событие.

  3. Название события — «contact-updated».

  4. Существующее поведение для отображения пользовательского интерфейса Details.

Теперь нам просто нужен список контактов, чтобы прослушивать событие обновления контактов и перезагрузиться:

Листинг 207. Фрагмент файла hv/index.xml

<form>
  <behavior
    trigger="on-event" <!-- 1 -->
    event-name="contact-updated" <!-- 2 -->
    action="replace-inner" <!-- 3 -->
    target="contacts-list"
    href="/contacts?rows_only=true"
    verb="get"
  />
  <!-- text-field omitted -->
  <list id="contacts-list">
    {% include 'hv/rows.xml' %}
  </list>
</form>
  1. Запустить поведение при отправке события.

  2. Запустить поведение для отправленных событий с именем «contact-updated».

  3. При срабатывании заменить содержимое элемента <list> строками из серверной части.

Каждый раз, когда пользователь редактирует контакт, экран списка контактов обновляется, отражая изменения. Добавление этих двух элементов <behavior> исправляет ошибку: на экране списка контактов правильно отображается «Joseph Blow». Обратите внимание: мы намеренно добавили новое поведение внутри элемента <form>. Это гарантирует, что инициируемый запрос сохранит любой поисковый запрос.

Чтобы показать, что мы имеем в виду, давайте вернемся к набору шагов, демонстрирующих ошибочное поведение. Предположим, что прежде чем нажать «Joseph Blow», пользователь выполнил поиск контактов, введя «Joe» в поле поиска. Когда позже пользователь обновляет контакт на «Joseph Blow», наш шаблон отправляет событие «contact-updated», которое запускает внутреннее поведение замены на экране списка контактов. Благодаря родительскому элементу <form> поисковый запрос «Joe» будет сериализован с помощью запроса: GET /contacts?rows_only=true&q=Joe. Поскольку имя «Joseph» не соответствует запросу «Joe», отредактированный нами контакт не появится в списке (пока пользователь не очистит запрос). Состояние нашего приложения остается неизменным на всей серверной части и на всех активных экранах.

События привносят в поведение определенный уровень абстракции. До сих пор мы видели, что редактирование контакта приводит к обновлению списка контактов. Но список контактов также должен обновляться после других действий, например, удаления контакта или добавления нового контакта. Пока наши ответы HXML на удаление или создание включают поведение для отправки события contact-updated, мы получим желаемое поведение обновления на экране списка контактов.

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

Удаление контакта

Говоря об удалении контакта, это хорошая следующая функция, которую стоит реализовать. Мы разрешим пользователям удалять контакт из пользовательского интерфейса редактирования. Итак, давайте добавим новую кнопку в файл edit.xml.

Листинг 208. Фрагмент файла hv/edit.xml

<view style="button">
  <behavior trigger="press" action="replace-inner" target="form-fields" href="/contacts/{{contact.id}}/edit" verb="post" />
  <text style="button-label">Save</text>
</view>
<view style="button">
  <behavior trigger="press" action="reload" href="/contacts/{{contact.id}}" />
  <text style="button-label">Cancel</text>
</view>
<view style="button"> <!-- 1 -->
  <behavior
    trigger="press"
    action="append" <!-- 2 -->
    target="form-fields"
    href="/contacts/{{contact.id}}/delete" <!-- 3 -->
    verb="post"
  />
  <text style="button-label button-label-delete">Delete Contact</text>
</view>
  1. Новая кнопка «Delete Contact» на экране редактирования.

  2. При нажатии добавить HXML в контейнер на экране.

  3. HXML будет получен путем выполнения запроса POST /contacts/<contact_id>/delete.

HXML для кнопки «Delete» очень похож на кнопку «Save», но есть несколько тонких отличий. Помните, что нажатие кнопки «Save» приводит к одному из двух ожидаемых результатов: сбой и отображение ошибок проверки в форме или успех и переключение на экран контактных данных. Для поддержки первого результата (сбой и отображение ошибок проверки) поведение сохранения заменяет содержимое контейнера <view id="form-fields"> повторно обработанной версией form_fields.xml. Поэтому использование действия replace-inner имеет смысл.

Удаление не требует этапа проверки, поэтому ожидаемый результат только один: успешное удаление контакта. После успешного удаления контакт больше не существует. Нет смысла показывать интерфейс редактирования или контактные данные для несуществующего контакта. Вместо этого наше приложение вернется к предыдущему экрану (списку контактов). Наш ответ будет включать только действия, которые срабатывают немедленно, пользовательский интерфейс менять не нужно. Таким образом, использование действия append сохранит текущий пользовательский интерфейс, пока Hyperview выполняет действия.

Листинг 209. Фрагмент файла hv/deleted.xml

<code><view>
  <behavior trigger="load" action="dispatch-event" event-name="contact-updated" /> <!-- 1 -->
  <behavior trigger="load" action="back" /> <!-- 2 -->
</view>
  1. При загрузке отправить событие обновления контактов, чтобы обновить экран списков контактов.

  2. Вернуться к экрану списка контактов.

Обратите внимание, что помимо поведения для перехода назад, этот шаблон также включает поведение для отправки события contact-updated. В разделе предыдущей главы мы добавили в index.xml поведение, позволяющее обновлять список при отправке этого события. Отправляя событие после удаления, мы гарантируем, что удаленный контакт будет удален из списка.

Еще раз пропустим изменения в бэкэнде Flask. Достаточно сказать, что нам нужно будет обновить представление contact_delete(), чтобы оно отвечало шаблоном hv/deleted.xml. И нам нужно обновить маршрут для поддержки POST в дополнение к DELETE, поскольку клиент Hyperview понимает только GET и POST.

Теперь у нас есть полнофункциональная функция удаления! Но она не самая удобная: для окончательного удаления контакта достаточно одного случайного нажатия. Для деструктивных действий, таких как удаление контакта, всегда полезно запросить у пользователя подтверждение.

Мы можем добавить подтверждение к поведению удаления, используя действие системы alert, описанное в предыдущей главе. Как вы помните, действие alert отобразит системное диалоговое окно с кнопками, которые могут запускать другие действия. Все, что нам нужно сделать, это обернуть <behavior> удаления в поведение, использующее action="alert".

Листинг 210. Кнопка «Delete» в hv/edit.xml

<view style="button">
  <behavior <!-- 1 -->
    xmlns:alert="https://hyperview.org/hyperview-alert"
    trigger="press"
    action="alert"
    alert:title="Confirm delete"
    alert:message="Are you sure you want to delete {{ contact.first }}?"
  >
    <alert:option alert:label="Confirm"> <!-- 2 -->
      <behavior <!-- 3 -->
        trigger="press"
        action="append"
        target="form-fields"
        href="/contacts/{{contact.id}}/delete"
        verb="post"
      />
    </alert:option>
    <alert:option alert:label="Cancel" /> <!-- 4 -->
  </behavior>
  <text style="button-label button-label-delete">Delete Contact</text>
</view>
  1. Нажатие «Delete» запускает действие по отображению системного диалога с заданным заголовком и сообщением.

  2. Первая нажимаемая опция в системном диалоге.

  3. Нажатие первой опции приведет к удалению контакта.

  4. Вторая нажимаемая опция не имеет никакого поведения, поэтому она только закрывает диалоговое окно.

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

Рисунок 18. Подтверждение удаления контакта

Добавление нового контакта

Добавление нового контакта — последняя функция, которую мы хотим поддерживать в нашем мобильном приложении. И, к счастью, она еще и самая простая. Мы можем повторно использовать концепции (и даже некоторые шаблоны) из уже реализованных функций. В частности, добавление нового контакта очень похоже на редактирование существующего контакта. Обе функции должны:

Поскольку функциональные возможности очень похожи, мы суммируем здесь изменения, не показывая код.

  1. Обновить index.xml.

    • Переопределите блок header, чтобы добавить новую кнопку «Add».

    • Включить поведение в кнопку. При нажатии создайте новый экран в качестве модели, используя action="new", и запросите содержимое экрана из /contacts/new.

  2. Создайть шаблон hv/new.xml.

    • Переопределить блок заголовка, включив в него кнопку, которая закрывает модальное окно, используя action="close".

    • Включить шаблон hv/form_fields.xml для отображения пустых полей формы.

    • Добавить кнопку «Add Contact» под полями формы.

    • Включить поведение в кнопку. При нажатии сделайте POST-запрос к /contacts/new и используйте action="replace-inner" для обновления полей формы.

  3. Обновить представление Flask.

    • Изменить contact_new_get(), чтобы использовать render_to_response() с шаблоном hv/new.xml.

    • Изменить contact_new(), чтобы использовать render_to_response() с шаблоном hv/form_fields.xml. Передать save=True при отрисовке шаблона после успешного сохранения нового контакта.

Повторно используя form_fields.xml как для редактирования, так и для добавления контакта, мы можем повторно использовать некоторый код и гарантировать, что обе функции имеют согласованный пользовательский интерфейс. Кроме того, наш экран «Add Contact» выиграет от «сохраненной» логики, которая уже является частью form_fields.xml. После успешного добавления нового контакта на экран будет отправлено событие contact-updated, которое обновит список контактов и отобразит вновь добавленный контакт. Экран перезагрузится, чтобы отобразить контактные данные.

Рисунок 19. Модальное окно «Add Contact»

Развертывание приложения

После завершения пользовательского интерфейса создания контактов у нас есть полностью реализованное мобильное приложение. Оно поддерживает поиск по списку контактов, просмотр сведений о контакте, редактирование и удаление контакта, а также добавление нового контакта. Но до сих пор мы разрабатывали приложение с помощью симулятора на нашем настольном компьютере. Как мы можем увидеть его работающим на мобильном устройстве? И как мы можем передать его в руки наших пользователей?

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

  1. Загрузите приложение Expo Go на устройство Android или iOS.

  2. Перезапустите приложение Flask, привязавшись к интерфейсу, доступному в вашей сети. Это может выглядеть примерно так: flask run --host 192.168.7.229, где хост — это IP-адрес вашего компьютера в сети.

  3. Обновите код клиента Hyperview, чтобы ENTRY_POINT_URLdemo/src/constants.js) указывал на IP-адрес и порт, к которым привязан сервер Flask.

  4. После запуска yarn start в демонстрационном приложении Hyperview вы увидите напечатанный в консоли QR-код с инструкциями по его сканированию на Android и iOS.

После сканирования QR-кода на устройстве запустится полная версия приложения. При взаимодействии с приложением вы увидите HTTP-запросы, отправленные на сервер Flask. Вы даже можете использовать физическое устройство во время разработки. Каждый раз, когда вы вносите изменения в HXML, просто перезагрузите экран, чтобы увидеть обновления пользовательского интерфейса.

Итак, у нас есть приложение, работающее на физическом устройстве, но оно еще не готово к работе. Чтобы передать приложение в руки наших пользователей, нам нужно сделать несколько вещей:

  1. Разверните наш бэкэнд в продакшене. Нам нужно использовать веб-сервер производственного уровня, такой как Gunicorn, вместо сервера разработки Flask. И нам следует запустить наше приложение на машине, доступной через Интернет, скорее всего, с использованием облачного провайдера, такого как AWS или Heroku.

  2. Создавайте автономные бинарные приложения. Следуя инструкциям проекта Expo, мы можем создать файл .ipa или .apk для платформ iOS и Android. Не забудьте обновить ENTRY_POINT_URL в клиенте Hyperview, чтобы он указывал на рабочий сервер.

  3. Отправьте наши двоичные файлы в iOS App Store или Google Play Store и дождитесь одобрения приложения.

Как только приложение будет одобрено, поздравляем! Наше мобильное приложение могут скачать пользователи Android и iOS. И вот что самое приятное: поскольку наше приложение использует архитектуру гипермедиа, мы можем добавлять в него функции, просто обновляя серверную часть. Пользовательский интерфейс и взаимодействие полностью определяются с помощью HXML, созданного на основе серверных шаблонов. Хотите добавить новый раздел на экран? Просто обновите существующий шаблон HXML. Хотите добавить в приложение новый тип экрана? Создайте новый маршрут, представление и шаблон HXML. Затем добавьте к существующему экрану поведение, которое будет открывать новый экран. Чтобы донести эти изменения до своих пользователей, вам просто нужно повторно развернуть серверную часть. Наше приложение умеет интерпретировать HXML, и этого достаточно, чтобы понять, как обращаться с новыми функциями.

Один бэкэнд, несколько форматов гипермедиа

Чтобы создать мобильное приложение с использованием архитектуры гипермедиа, мы начали с веб-приложения контактов и внесли несколько изменений, в первую очередь заменив шаблоны HTML шаблонами HXML. Но в процессе портирования бэкенда для обслуживания нашего мобильного приложения мы потеряли функциональность веб-приложения. Действительно, если вы попытаетесь посетить http://0.0.0.0:5000 в веб-браузере, вы увидите мешанину текста и XML-разметки. Это связано с тем, что веб-браузеры не знают, как отображать простой XML, и они, конечно, не знают, как интерпретировать теги и атрибуты HXML для отображения приложения. Это досадно, потому что код Flask для веб-приложения и мобильного приложения практически идентичен. Логика базы данных и модели является общей, и большинство представлений также не изменилось.

На этом этапе вы наверняка задаетесь вопросом: можно ли использовать один и тот же бэкэнд для обслуживания как веб-приложения, так и мобильного приложения? Ответ – да! Фактически, это одно из преимуществ использования архитектуры гипермедиа на нескольких платформах. Нам не нужно переносить какую-либо клиентскую логику с одной платформы на другую, нам просто нужно отвечать на запросы в соответствующем формате Hypermedia. Для этого мы будем использовать согласование контента, встроенное в HTTP.

Что такое согласование контента?

Представьте, что человек, говорящий по-немецки, и по-японски посещают https://google.com в своем веб-браузере. Они увидят домашнюю страницу Google, локализованную на немецкий и японский языки соответственно. Как Google узнает, что нужно вернуть другую версию главной страницы в зависимости от предпочитаемого языка пользователя? Ответ кроется в архитектуре REST и в том, как она разделяет концепции ресурсов и представлений.

В архитектуре REST домашняя страница Google считается единым «ресурсом», представленным уникальным URL-адресом. Однако этот единственный ресурс может иметь несколько «представлений». Представления — это варианты того, как содержимое ресурса представляется клиенту. Немецкая и японская версии главной страницы Google представляют собой два представления одного и того же ресурса. Чтобы определить наилучшее представление возвращаемого ресурса, HTTP-клиенты и серверы участвуют в процессе, называемом «согласованием контента». Это работает следующим образом:

В примере с домашней страницей Google говорящий по-немецки использует браузер, в котором настроено предпочтение контента, локализованного на немецкий язык. Каждый HTTP-запрос, сделанный веб-браузером, будет включать заголовок Accept-Language: de-DE. Сервер видит заголовок запроса и вернет ответ, локализованный для немецкого языка (если сможет). Ответ HTTP будет включать заголовок Content-Language: de-DE, чтобы сообщить клиенту язык содержимого ответа.

Язык — это лишь один из факторов представления ресурсов. Что еще более важно для нас, ресурсы могут быть представлены с использованием различных типов контента, таких как HTML или HXML. Согласование контента по типу контента выполняется с использованием заголовка запроса Accept и заголовка ответа Content-Type. Веб-браузеры устанавливают text/html в качестве предпочтительного типа контента в заголовке Accept. Клиент Hyperview устанавливает application/vnd.hyperview+xml в качестве предпочтительного типа контента. Это дает нашему серверу возможность различать запросы, поступающие от веб-браузера или клиента Hyperview, и предоставлять каждому соответствующий контент.

Существует два основных подхода к согласованию содержания: детальный и глобальный.

Подход 1: Template Switching

Когда мы портировали приложение Contacts из Интернета на мобильные устройства, мы сохранили все представления Flask, но внесли некоторые незначительные изменения. В частности, мы представили новую функцию render_to_response() и вызывали ее в операторе возврата каждого представления. Вот еще раз функция, чтобы освежить вашу память:

Листинг 211. app.py

def render_to_response(template_name, *args, **kwargs):
    content = render_template(template_name, *args, **kwargs)
    response = make_response(content)
    response.headers['Content-Type'] = 'application/vnd.hyperview+xml'
    return response

render_to_response() отображает шаблон с заданным контекстом и превращает его в объект ответа Flask с соответствующим заголовком Content-Type Hyperview. Очевидно, что реализация очень специфична для обслуживания нашего мобильного приложения Hyperview. Но мы можем изменить функцию для согласования содержимого на основе заголовка Accept запроса:

Листинг 212. app.py

HTML_MIME = 'text/html'
HXML_MIME = 'application/vnd.hyperview+xml'

def render_to_response(html_template_name,
hxml_template_name, *args, **kwargs): # 1
    response_type = request.accept_mimetypes.best_match([HTML_MIME, HXML_MIME], default=HTML_MIME) # 2
    template_name = hxml_template_name if response_type == HXML_MIME else html_template_name # 3
    content = render_template(template_name, *args, **kwargs)
    response = make_response(content)
    response.headers['Content-Type'] = response_type # 4
    return response
  1. Сигнатура функции принимает два шаблона: один для HTML и один для HXML.

  2. Определить, хочет ли клиент HTML или HXML.

  3. Выберать шаблон, наиболее подходящий для клиента.

  4. Установить заголовок Content-Type на основе наилучшего соответствия клиенту.

Объект запроса Flask предоставляет свойство accept_mimetypes, помогающее при согласовании содержимого. Мы передаем два наших типа MIME контента в request.accept_mimetypes.best_match() и получаем тип MIME, который работает для нашего клиента. В зависимости от наиболее соответствующего типа MIME мы выбираем отображение шаблона HTML или шаблона HXML. Мы также обязательно устанавливаем для заголовка Content-Type соответствующий тип MIME. Единственная разница в наших представлениях Flask заключается в том, что нам нужно предоставить шаблон как HTML, так и HXML:

Листинг 213. app.py

@app.route("/contacts/<contact_id>")
def contacts_view(contact_id=0):
    contact = Contact.find(contact_id)
    return render_to_response("show.html", "hv/show.xml", contact=contact) # 1
  1. Переключение шаблонов между шаблонами HTML и HXML в зависимости от клиента.

После обновления всех представлений Flask для поддержки обоих шаблонов наш бэкэнд будет поддерживать как веб-браузеры, так и наше мобильное приложение! Этот метод хорошо работает для приложения Contacts, поскольку экраны мобильного приложения напрямую связаны со страницами веб-приложения. Каждое приложение имеет специальную страницу (или экран) для перечисления контактов, отображения и редактирования сведений, а также создания нового контакта. Это означало, что представления Flask можно было оставить без каких-либо серьезных изменений.

Но что, если мы захотим переосмыслить пользовательский интерфейс приложения Contacts для нашего мобильного приложения? Возможно, мы хотим, чтобы мобильное приложение использовало один экран со строками, которые расширялись по ходу дела для поддержки просмотра и редактирования информации? В ситуациях, когда пользовательский интерфейс на разных платформах различается, переключение шаблонов становится затруднительным или невозможным. Нам нужен другой подход, чтобы один сервер обслуживал оба формата гипермедиа.

Подход 2: Redirect Fork

Если вы помните, веб-приложение Contacts имеет индексное представление, маршрутизируемое из корневого пути /:

Листинг 214. app.py

@app.route("/")
def index():
    return redirect("/contacts") # 1
  1. Перенаправить запросы с «/» на «/contacts»

Когда кто-то запрашивает корневой путь веб-приложения, Flask перенаправляет его на путь /contacts. Это перенаправление также работает в нашем мобильном приложении Hyperview. ENTRY_POINT_URL клиента Hyperview указывает на http://0.0.0.0:5000/, а сервер перенаправляет его на http://0.0.0.0:5000/contacts. Но не существует закона, который бы предписывал нам перенаправляться на один и тот же путь в нашем веб-приложении и мобильном приложении. Что, если мы используем заголовок Accept для перенаправления, чтобы выбрать путь перенаправления?

Листинг 215. app.py

HTML_MIME = 'text/html'
HXML_MIME = 'application/vnd.hyperview+xml'

@app.route("/")
def index():
    response_type = request.accept_mimetypes.best_match([HTML_MIME, HXML_MIME], default=HTML_MIME) # 1
    if response_type == HXML_MIME:
        return redirect("/mobile/contacts") # 2
    else:
        return redirect("/web/contacts") # 3
  1. Определите, хочет клиент HTML или HXML.

  2. Если клиенту нужен HXML, перенаправить его в /mobile/contacts.

  3. Если клиенту нужен HTML, перенаправить его в /web/contacts.

Точка входа — это развилка: если клиенту нужен HTML, мы перенаправляем его по одному пути. Если клиенту нужен HXML, мы перенаправляем его по другому пути. Эти перенаправления будут обрабатываться различными представлениями Flask:

Листинг 216. app.py

@app.route("/mobile/contacts")
def mobile_contacts():
  # Отобразить HXML-ответ

@app.route("/web/contacts")
def web_contacts():
  # Отобразить HTML-ответ

Представление mobile_contacts() отображает шаблон HXML со списком контактов. Нажатие на элемент контакта откроет экран, запрошенный из /mobile/contacts/1, обрабатываемый представлением mobile_contacts_view. После первоначального разветвления все последующие запросы от нашего мобильного приложения передаются по путям с префиксом /mobile/ и обрабатываются представлениями Flask, специфичными для мобильных устройств. Аналогичным образом, все последующие запросы из веб-приложения передаются по путям с префиксом /web/ и обрабатываются веб-представлениями Flask. (Обратите внимание, что на практике нам бы хотелось разделить веб-представления и мобильные представления на отдельные части нашей кодовой базы: web_app.py и mobile_app.py. Мы также можем отказаться от префикса веб-путей с /web/, если хотим получать более элегантные URL-адреса, отображаемые в адресной строке браузера.)

Вы можете подумать, что Redirect Fork приводит к большому дублированию кода. Ведь нам нужно прописать двойное количество представлений: один набор для веб-приложения и один набор для мобильного приложения. Это правда, поэтому Redirect Fork предпочтителен только в том случае, если двум платформам требуется несвязанный набор логики представления. Если приложения на обеих платформах одинаковы, Template Switching сэкономит много времени и обеспечит единообразие приложений. Даже если нам понадобится использовать Redirect Fork, основная часть логики в наших моделях может использоваться обоими наборами представлений.

На практике вы можете начать использовать Template Switching, но затем осознаете, что вам нужно реализовать форк для функций, специфичных для платформы. Фактически, мы уже делаем это в приложении Contacts. При переносе приложения с веб-версии на мобильное устройство мы не задействовали некоторые функции, такие как функция архивирования. Пользовательский интерфейс динамического архива — это мощная функция, которая не имеет смысла на мобильном устройстве. Поскольку наши шаблоны HXML не предоставляют никаких точек входа в функцию архива, мы можем рассматривать ее как «только для Интернета» и не беспокоиться о ее поддержке в Hyperview.

Contact.app в Hyperview

В этой главе мы рассмотрели очень многое.

Переведите дух и подведите итоги того, как далеко мы продвинулись: мы перенесли основные функции веб-приложения Contact.app на мобильные устройства. И мы сделали это, повторно используя большую часть нашей серверной части Flask и придерживаясь шаблонов Jinja. Мы снова увидели полезность событий для связи различных аспектов приложения.

Мы еще не закончили. В следующей главе мы реализуем пользовательское поведение и элементы пользовательского интерфейса, чтобы завершить наше мобильное приложение Contact.app.

Примечания к Hypermedia: конечные точки API

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

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

Связанный с этим совет заключается в том, что, когда у вас есть API на основе гипермедиа, вы можете агрессивно реорганизовать свой API способом, который категорически не рекомендуется при написании SPA или мобильных клиентов на основе JSON API. Поскольку приложения на основе гипермедиа используют гипермедиа в качестве движка состояния приложения, вы можете и, фактически, поощряетесь изменять их форму по мере того, как меняются разработчики ваших приложений и варианты использования.

Большим преимуществом подхода гипермедиа является то, что вы можете полностью переработать свой API, чтобы со временем адаптироваться к новым потребностям, без необходимости изменять версию API или даже документировать его. Воспользуйтесь этим!

Глава 13
Расширение клиента Hyperview

В предыдущей главе мы создали полнофункциональную мобильную версию нашего приложения Contacts. Помимо настройки URL-адреса точки входа, нам не нужно было трогать какой-либо код, который работает на мобильном устройстве. Мы полностью определили пользовательский интерфейс и логику нашего мобильного приложения во внутреннем коде, используя шаблоны Flask и HXML. Это возможно, поскольку стандартный клиент Hyperview поддерживает все основные функции мобильных приложений.

Но стандартный клиент Hyperview не может делать все «из коробки». Как разработчики приложений, мы хотим, чтобы приложения имели уникальные особенности, такие как настраиваемые пользовательские интерфейсы или глубокую интеграцию с возможностями платформы. Для удовлетворения этих потребностей клиент Hyperview был разработан с возможностью расширения за счет настраиваемых поведенческих действий и элементов пользовательского интерфейса. В этом разделе мы улучшим наше мобильное приложение, используя примеры того и другого.

Прежде чем углубиться, давайте кратко рассмотрим стек технологий, которые мы будем использовать. Клиент Hyperview написан на React Native, популярной кроссплатформенной платформе для создания мобильных приложений. Он использует тот же компонентный API, что и React. Это означает, что разработчики, знакомые с JavaScript и React, могут быстро освоить React Native. React Native имеет здоровую экосистему библиотек с открытым исходным кодом. Мы будем использовать эти библиотеки для создания собственных расширений для клиента Hyperview.

Добавление телефонных звонков и электронной почты

Начнем с самой очевидной функции, отсутствующей в нашем приложении Contacts: телефонных звонков. Мобильные устройства могут совершать телефонные звонки. Контакты в нашем приложении имеют номера телефонов. Разве наше приложение не должно поддерживать вызов этих телефонных номеров? И пока мы этим занимаемся, наше приложение также должно поддерживать отправку контактов по электронной почте.

В Интернете вызов телефонных номеров поддерживается с помощью схемы URI tel:, а электронная почта поддерживается с помощью схемы URI mailto::

Листинг 217. Схемы tel и mailto в HTML

<a href="tel:555-555-5555">Call</a> <!-- 1 -->
<a href="mailto:joe@example.com">Email</a> <!-- 2 -->
  1. При нажатии предложить пользователю позвонить по указанному номеру телефона.

  2. При нажатии открывается почтовый клиент с указанным адресом, указанным в поле «Кому:».

Клиент Hyperview не поддерживает схемы URI tel: и mailto:. Но мы можем добавить эти возможности клиенту с помощью настраиваемых поведенческих действий. Помните, что поведение — это взаимодействия, определенные в HXML. Поведения имеют триггеры («press», «refresh») и действия («update», «share»). Значения «action» не ограничиваются набором, который входит в библиотеку Hyperview. Итак, давайте определим два новых действия: «open-phone» и «open-email».

Листинг 218. Действия по телефону и электронной почте

<view xmlns:comms="https://hypermedia.systems/hyperview/communications"> <!-- 1 -->
  <text>
    <behavior action="open-phone" comms:phone-number="555-555-5555" /> <!-- 2 -->
      Call
  </text>
  <text>
    <behavior action="open-email" comms:email-address="joe@example.com" /> <!-- 3 -->
    Email
  </text>
</view>
  1. Определить псевдоним для пространства имен XML, используемого нашими новыми атрибутами.

  2. При нажатии предложить пользователю позвонить по указанному номеру телефона.

  3. При нажатии открыть почтовый клиент с указанным адресом, указанным в поле to:.

Обратите внимание, что мы определили фактический номер телефона и адрес электронной почты, используя отдельные атрибуты. В HTML схема и данные втиснуты в атрибут href. Элементы <behavior> HXML предоставляют больше возможностей для представления данных. Мы решили использовать атрибуты, но могли бы представить номер телефона или адрес электронной почты, используя дочерние элементы. Мы также используем пространство имен, чтобы избежать потенциальных конфликтов в будущем с другими клиентскими расширениями.

Пока все хорошо, но откуда клиент Hyperview знает, как интерпретировать open-phone и open-email и как ссылаться на атрибуты phone-number и email-address? Здесь нам, наконец, нужно написать немного JavaScript.

Во-первых, мы собираемся добавить стороннюю библиотеку (react-native-communication) в наше демонстрационное приложение. Эта библиотека предоставляет простой API, который взаимодействует с функциями уровня ОС для звонков и электронной почты.

> cd hyperview/demo
> yarn add react-native-communications # 1
> yarn start # 2
  1. Добавить зависимость react-native-communications

  2. Перезапустить мобильное приложение

Далее мы создадим новый файл phone.js, который будет реализовывать код, связанный с действием open-phone:

Листинг 219. demo/src/phone.js

import { phonecall } from 'react-native-communications'; // 1

const namespace = "https://hypermedia.systems/hyperview/communications";

export default {
  action: "open-phone", // 2
  callback: (behaviorElement) => { // 3
    const number = behaviorElement.getAttributeNS(namespace, "phone-number"); // 4
    if (number != null) {
      phonecall(number, false); // 5
    }
  },
};
  1. Импортировать нужную нам функцию из сторонней библиотеки.

  2. Название действия.

  3. Обратный вызов, который запускается при срабатывании действия.

  4. Получить номер телефона из элемента <behavior>.

  5. Передать номер телефона функции из сторонней библиотеки.

Пользовательские действия определяются как объект JavaScript с двумя ключами: action и callback. Вот как клиент Hyperview связывает настраиваемое действие в HXML с нашим настраиваемым кодом. Значение обратного вызова — это функция, которая принимает один параметр, behaviorElement. Этот параметр представляет собой XML-представление DOM элемента <behavior>, который инициировал действие. Это означает, что мы можем вызывать его методы, например getAttribute, или получать доступ к атрибутам, например childNodes. В этом случае мы используем getAttributeNS для чтения номера телефона из атрибута номера телефона в элементе <behavior>. Если номер телефона определен в элементе, мы можем вызвать функцию phonecall(), предоставляемую библиотекой react-native-communications.

Прежде чем мы сможем использовать наше настраиваемое действие, нужно сделать еще одну вещь: зарегистрировать действие в клиенте Hyperview. Клиент Hyperview представлен как компонент React Native под названием Hyperview. Этот компонент принимает свойство под названием behaviors, которое представляет собой массив объектов настраиваемых действий, таких как наше действие «open-phone». Давайте передадим нашу реализацию «open-phone» компоненту Hyperview в нашем демонстрационном приложении.

Листинг 220. demo/src/HyperviewScreen.js

import React, { PureComponent } from 'react';
import Hyperview from 'hyperview';
import OpenPhone from './phone'; // 1

export default class HyperviewScreen extends PureComponent {
  // ... omitted for brevity

  behaviors = [OpenPhone]; // 2

  render() {
    return (
      <Hyperview
        behaviors={this.behaviors} // 3
        entrypointUrl={this.entrypointUrl}
        // more props...
      />
    );
  }
}
  1. Импортировать действие open-phone.

  2. Создать массив настраиваемых действий.

  3. Передать пользовательские действия компоненту Hyperview в качестве свойства, называемого behaviors.

Под капотом компонент Hyperview отвечает за преобразование HXML в элементы мобильного пользовательского интерфейса. Он также обрабатывает запуск поведенческих действий на основе взаимодействия с пользователем.

Передав действие «open-phone» в Hyperview, мы теперь можем использовать его в качестве значения атрибута action в элементах <behavior>. Собственно, давайте сделаем это сейчас, обновив шаблон show.xml в нашем приложении Flask:

Листинг 221. Фрагмент файла hv/show.xml

{% block content %}
<view style="details">
  <text style="contact-name">{{ contact.first }}
{{ contact.last }}</text>

  <view style="contact-section">
    <behavior <!-- 1 -->
      xmlns:comms="https://hypermedia.systems/hyperview/communications"
      trigger="press"
      action="open-phone" <!-- 2 -->
      comms:phone-number="{{contact.phone}}" <!-- 3 -->
    />
    <text style="contact-section-label">Phone</text>
    <text style="contact-section-info">{{contact.phone}}</text>
  </view>

  <view style="contact-section">
    <behavior <!-- 4 -->
      xmlns:comms="https://hypermedia.systems/hyperview/communications"
      trigger="press"
      action="open-email"
      comms:email-address="{{contact.email}}"
    />
    <text style="contact-section-label">Email</text>
    <text style="contact-section-info">{{contact.email}}</text>
  </view>
</view>
{% endblock %}
  1. Добавить в раздел номера телефона поведение, которое срабатывает на «press».

  2. Запустить новое действие «open-phone».

  3. Установить атрибут, ожидаемый действием «open-phone».

  4. Та же идея, но с другим действием («open-email»).

Мы пропустим реализацию второго дополнительного действия — «open-email». Как вы можете догадаться, это действие откроет композитор электронной почты системного уровня, позволяющий пользователю отправить электронное письмо своему контакту. Реализация «open-email» практически идентична реализации «open-phone». Библиотека react-native-communications предоставляет функцию email(), поэтому мы просто обертываем ее и передаем ей аргументы таким же образом.

Теперь у нас есть полный пример расширения клиента с помощью настраиваемых поведенческих действий. Мы выбрали новые имена для наших действий («open-phone» и «open-email») и сопоставили эти имена с функциями. Функции принимают элементы <behavior> и могут запускать любой произвольный код React Native. Мы обернули существующую стороннюю библиотеку и прочитали атрибуты, установленные в элементе <behavior>, чтобы передать данные в библиотеку. После перезапуска нашего демонстрационного приложения у нашего клиента появляются новые возможности, которые мы можем немедленно использовать, ссылаясь на действия из наших шаблонов HXML.

Добавление сообщений

Действия по телефону и электронной почте, добавленные в предыдущем разделе, являются примерами «системных действий». Действия системы запускают некоторый пользовательский интерфейс или возможности, предоставляемые ОС устройства. Но пользовательские действия не ограничиваются взаимодействием с API уровня ОС. Помните, что обратные вызовы, реализующие действия, могут запускать произвольный код, включая код, отображающий наши собственные элементы пользовательского интерфейса. Следующий пример специального действия сделает именно это: отобразит элемент пользовательского интерфейса пользовательского сообщения с подтверждением.

Если вы помните, наше веб-приложение Contacts отображает сообщения об успешных действиях, таких как удаление или создание контакта. Эти сообщения генерируются в бэкэнде Flask с помощью функции flash(), вызываемой из представлений. Затем базовый шаблон layout.html отображает сообщения на конечной веб-странице.

Листинг 222. Фрагмент templates/layout.html

{% for message in get_flashed_messages() %}
  <div class="flash">{{ message }}</div>
{% endfor %}

Наше приложение Flask по-прежнему включает вызовы flash(), но приложение Hyperview не получает доступа к отображаемому сообщению для отображения пользователю. Давайте добавим эту поддержку сейчас.

Мы могли бы просто показывать сообщения, используя метод, аналогичный веб-приложению: прокручивать сообщения и отображать некоторые элементы <text> в файле layout.xml. У этого подхода есть серьезный недостаток: отображаемые сообщения будут привязаны к определенному экрану. Если этот экран был скрыт действием навигации, сообщение также будет скрыто. Чего мы действительно хотим, так это чтобы наш пользовательский интерфейс сообщений отображался «над» всеми экранами в стеке навигации. Таким образом, сообщение останется видимым (исчезнет через несколько секунд), даже если стопка экранов изменится ниже. Чтобы отобразить некоторый пользовательский интерфейс за пределами элементов <screen>, нам понадобится расширить клиент Hyperview новым настраиваемым действием show-message. Это еще одна возможность использовать библиотеку с открытым исходным кодом, response-native-root-toast. Давайте добавим эту библиотеку в наше демонстрационное приложение.

> cd hyperview/demo
> yarn add react-native-root-toast # 1
> yarn start # 2
  1. Добавить зависимость react-native-root-toast

  2. Перезапустить мобильное приложение

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

Листинг 223. demo/src/message.js

import Toast from 'react-native-root-toast'; // 1

const namespace = "https://hypermedia.systems/hyperview/message";

export default {
  action: "show-message", // 2
  callback: (behaviorElement) => { // 3
    const text = behaviorElement.getAttributeNS(namespace, "text");
    if (text != null) {
      Toast.show(text, {position: Toast.positions.TOP, duration: 2000}); // 4
    }
  },
};
  1. Импортировать Toast API.

  2. Название действия.

  3. Обратный вызов, который запускается при срабатывании действия.

  4. Передать сообщение в библиотеку Toast.

Этот код очень похож на реализацию open-phone. Оба обратных вызова следуют одинаковой схеме: считывают атрибуты пространства имен из элемента <behavior> и передают эти значения в стороннюю библиотеку. Для простоты мы жестко запрограммировали параметры, позволяющие отображать сообщение в верхней части экрана, исчезающее через 2 секунды. Но react-native-root-toast предоставляет множество вариантов позиционирования, времени анимации, цветов и многого другого. Мы могли бы указать эти параметры, используя дополнительные атрибуты объекта behaviorElement, чтобы сделать действие более настраиваемым. Для наших целей мы просто будем придерживаться простой реализации.

Теперь нам нужно зарегистрировать наше пользовательское действие в компоненте <Hyperview>, передав его в свойство behaviors.

Листинг 224. demo/src/HyperviewScreen.js

import React, { PureComponent } from 'react';
import Hyperview from 'hyperview';
import OpenEmail from './email';
import OpenPhone from './phone';
import ShowMessage from './message'; // 1

export default class HyperviewScreen extends PureComponent {
  // ... omitted for brevity

  behaviors = [OpenEmail, OpenPhone, ShowMessage]; // 2

  // ... omitted for brevity
}
  1. Импортировать действие show-message.

  2. Передать действие компоненту Hyperview как свойство, называемое behaviors.

Все, что осталось сделать, это запустить действие show-message из нашего HXML. Существует три действия пользователя, которые приводят к отображению сообщения:

  1. Создание нового контакта

  2. Обновление существующего контакта

  3. Удаление контакта

Первые два действия реализованы в нашем приложении с использованием одного и того же шаблона HXML — form_fields.xml. После успешного создания или обновления контакта этот шаблон перезагрузит экран и инициирует событие, используя поведение, которое срабатывает при «load». Действие удаления также использует поведение, которое срабатывает при «load», определенное в шаблоне delete.xml. Поэтому и form_fields.xml, и delete.xml необходимо изменить, чтобы они также отображали сообщения при загрузке. Поскольку фактическое поведение в обоих шаблонах будет одинаковым, давайте создадим общий шаблон для повторного использования HXML.

Листинг 225. hv/templates/messages.xml

{% for message in get_flashed_messages() %}
  <behavior <!-- 1 -->
    xmlns:message="https://hypermedia.systems/hyperview/message"
    trigger="load" <!-- 2 -->
    action="show-message" <!-- 3 -->
    message:text="{{ message }}" <!-- 4 -->
  />
{% endfor %}
  1. Определить поведение для каждого отображаемого сообщения.

  2. Запустить это поведение, как только элемент загрузится.

  3. Запустить новое действие «show-message».

  4. Действие «show-message» отобразит мигающее сообщение в пользовательском интерфейсе.

Как и в файле layout.html веб-приложения, мы просматриваем все отображаемые сообщения и отображаем некоторую разметку для каждого сообщения. Однако в веб-приложении сообщение было непосредственно отображено на веб-странице. В приложении Hyperview каждое сообщение отображается с использованием поведения, которое запускает наш пользовательский интерфейс. Теперь нам просто нужно включить этот шаблон в form_fields.xml:

Листинг 226. Фрагмент файла hv/templates/form_fields.xml

<view xmlns="https://hyperview.org/hyperview" style="edit-group">
  {% if saved %}
    {% include "hv/messages.xml" %} <!-- 1 -->
    <behavior trigger="load" once="true" action="dispatch-event" event-name="contact-updated" />
    <behavior trigger="load" once="true" action="reload" href="/contacts/{{contact.id}}" />
  {% endif %}
  <!-- omitted for brevity -->
</view>
  1. Показывать сообщения сразу после загрузки экрана.

И мы можем сделать то же самое в файле delete.xml:

Листинг 227. hv/templates/deleted.xml

<view xmlns="https://hyperview.org/hyperview">
  {% include "hv/messages.xml" %} <!-- 1 -->
  <behavior trigger="load" action="dispatch-event" event-name="contact-updated" />
  <behavior trigger="load" action="back" />
</view>
  1. Показывать сообщения сразу после загрузки экрана.

Как в form_fields.xml, так и в delete.xml при «load» запускается несколько вариантов поведения. В файле delete.xml мы сразу возвращаемся к предыдущему экрану. В form_fields.xml мы немедленно перезагружаем текущий экран, чтобы отобразить сведения о контакте. Если бы мы отображали элементы пользовательского интерфейса сообщений прямо на экране, пользователь едва мог бы их увидеть, прежде чем экран исчезнет или перезагрузится. Используя настраиваемое действие, пользовательский интерфейс сообщений остается видимым, даже если экраны под ним меняются.

Рисунок 20. Сообщение, отображаемое во время обратной навигации.

Жест смахивания по контактам

Чтобы добавить возможности связи и пользовательский интерфейс сообщений, мы расширили клиент настраиваемыми действиями поведения. Но клиент Hyperview также можно расширить с помощью пользовательских компонентов пользовательского интерфейса, отображаемых на экране. Пользовательские компоненты реализуются как компоненты React Native. Это означает, что все, что возможно в React Native, можно сделать и в Hyperview! Пользовательские компоненты открывают безграничные возможности для создания многофункциональных мобильных приложений с архитектурой Hypermedia.

Чтобы проиллюстрировать эти возможности, мы расширим клиент Hyperview в нашем мобильном приложении, добавив компонент «swipeable row» (перелистываемой строки). Как это работает? Компонент «swipeable row» поддерживает жест горизонтального пролистывания. Когда пользователь проводит по этому компоненту справа налево, он перемещается, открывая ряд кнопок действий. Каждая кнопка действия сможет запускать стандартное поведение Hyperview при нажатии. Мы будем использовать этот пользовательский компонент на экране списка контактов. Каждый элемент контакта будет представлять собой «swipeable row», а действия обеспечат быстрый доступ к действиям по редактированию и удалению контакта.

Рис. 21. Перелистываемый контактный элемент

Проектирование компонента

Вместо того, чтобы реализовывать жест смахивания с нуля, мы снова будем использовать стороннюю библиотеку с открытым исходным кодом: react-native-swipeable.

> cd hyperview/demo
> yarn add react-native-swipeable # 1
> yarn start # 2
  1. Добавить зависимость react-native-swipeable.

  2. Перезапустить мобильное приложение.

Эта библиотека предоставляет компонент React Native под названием Swipeable. Она может отображать любые компоненты React Native в качестве основного содержимого (той части, которую можно перелистывать). Она также использует массив компонентов React Native в качестве реквизита для отображения в виде кнопок действий.

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

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

<swipe:row xmlns:swipe="https://hypermedia.systems/hyperview/swipeable"> <!-- 1 -->
  <swipe:main> <!-- 2 -->
    <!-- main content shown here -->
  </swipe:main>

  <swipe:button> <!-- 3 -->
    <!-- first button that appears when swiping -->
  </swipe:button>

  <swipe:button> <!-- 4 -->
    <!-- second button that appears when swiping -->
  </swipe:button>
</swipe:row>
  1. Родительский элемент, инкапсулирующий всю прокручиваемую строку, с настраиваемым пространством имен.

  2. Основное содержимое прокручиваемой строки может содержать любой HXML.

  3. Первая кнопка, которая появляется при пролистывании, может содержать любой HXML.

  4. Вторая кнопка, которая появляется при пролистывании, может содержать любой HXML.

Эта структура четко отделяет основной контент от кнопок. Она также поддерживает одну, две или более кнопок. Кнопки появляются в порядке определения, что позволяет легко менять порядок.

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

Реализация компонента

Теперь, когда мы знаем структуру HXML нашего пользовательского компонента, мы можем написать код для его реализации. Как выглядит этот код? Компоненты Hyperview написаны как компоненты React Native. Эти компоненты React Native сопоставлены с уникальным пространством имен XML и именем тега. Когда клиент Hyperview встречает это пространство имен и имя тега в HXML, он делегирует рендеринг элемента HXML соответствующему компоненту React Native. В рамках делегирования клиент Hyperview передает несколько реквизитов компоненту React Native:

Наш компонент строки с возможностью перелистывания представляет собой контейнер со слотами для отображения произвольного основного контента и кнопок. Это означает, что для рендеринга этих частей пользовательского интерфейса необходимо делегировать обратно клиенту Hyperview. Это делается с помощью общедоступной функции, предоставляемой клиентом Hyperview, Hyperview.renderChildren().

Теперь, когда мы знаем, как реализованы пользовательские компоненты Hyperview, давайте напишем код для нашей перелистываемой строки.

Листинг 228. demo/src/swipeable.js

import React, { PureComponent } from 'react';
import Hyperview from 'hyperview';
import Swipeable from 'react-native-swipeable';

const NAMESPACE_URI = 'https://hypermedia.systems/hyperview/swipeable';

export default class SwipeableRow extends PureComponent { // 1
  static namespaceURI = NAMESPACE_URI; // 2
  static localName = "row"; // 3

  getElements = (tagName) => {
    return Array.from(this.props.element.getElementsByTagNameNS(NAMESPACE_URI,
tagName));
  };

  getButtons = () => { // 4
    return this.getElements("button").map((buttonElement) => {
      return Hyperview.renderChildren(buttonElement,
      this.props.stylesheets, this.props.onUpdate,
      this.props.options); // 5
    });
  };

  render() {
    const [main] = this.getElements("main");
    if (!main) {
      return null;
    }

    return (
      <Swipeable rightButtons={this.getButtons()}> // 6
        {Hyperview.renderChildren(main, this.props.stylesheets, this.props.onUpdate, this.props.options)} // 7
      </Swipeable>
    );
  }
}
  1. Компонент React Native на основе классов.

  2. Сопоставить этот компонент с заданным пространством имен HXML.

  3. Сопоставить этот компонент с данным именем тега HXML.

  4. Функция, которая возвращает массив компонентов React Native для каждого элемента <button>.

  5. Делегировать клиенту Hyperview обработку каждой кнопки.

  6. Передать кнопки и основной контент в стороннюю библиотеку.

  7. Делегировать клиенту Hyperview рендеринг основного контента.

Класс SwipeableRow реализует компонент React Native. В верхней части класса мы устанавливаем статическое свойство namespaceURI и свойство localName. Эти свойства сопоставляют компонент React Native с уникальной парой пространства имен и имен тегов в HXML. Именно так клиент Hyperview знает, что нужно делегировать SwipeableRow при обнаружении пользовательских элементов в HXML. В нижней части класса вы увидите метод render(). render() вызывается React Native для возврата визуализированного компонента. Поскольку React Native построен на принципе композиции, render() обычно возвращает композицию других компонентов React Native. В этом случае мы возвращаем компонент Swipeable (предоставляемый библиотекой react-native-swipeable), состоящий из компонентов React Native для кнопок и основного контента.

Рисунок 22. Делегирование рендеринга между клиентом и пользовательскими компонентами

Этот код может быть трудным для понимания, если вы никогда не работали с React или React Native. Это нормально. Важный вывод: мы можем написать код для перевода произвольного HXML в компоненты React Native. Структура HXML (как атрибуты, так и элементы) может использоваться для представления нескольких аспектов пользовательского интерфейса (в данном случае — кнопок и основного контента). Наконец, код может делегировать рендеринг дочерних компонентов обратно клиенту Hyperview.

Результат: этот компонент строки с возможностью перелистывания является полностью универсальным. Фактическая структура, стиль и взаимодействие основного контента и кнопок могут быть определены в HXML. Создание универсального компонента означает, что мы можем повторно использовать его на нескольких экранах для разных целей. Если в будущем мы добавим больше пользовательских компонентов или новых поведенческих действий, они будут работать с нашей реализацией перелистываемых строк.

Последнее, что нужно сделать, это зарегистрировать этот новый компонент в клиенте Hyperview. Этот процесс аналогичен регистрации пользовательских действий. Пользовательские компоненты передаются как отдельные компоненты в компонент Hyperview.

Листинг 229. demo/src/HyperviewScreen.js

import React, { PureComponent } from 'react';
import Hyperview from 'hyperview';
import OpenEmail from './email';
import OpenPhone from './phone';
import ShowMessage from './message';
import SwipeableRow from './swipeable'; // 1

export default class HyperviewScreen extends PureComponent {
  // ... omitted for brevity

  behaviors = [OpenEmail, OpenPhone, ShowMessage];
  components = [SwipeableRow]; // 2

  render() {
    return (
      <Hyperview
        behaviors={this.behaviors}
        components={this.components} // 3
        entrypointUrl={this.entrypointUrl}
        // more props...
      />
    );
  }
}
  1. Импортировать компонент SwipeableRow.

  2. Создать массив пользовательских компонентов.

  3. Передать пользовательский компонент компоненту Hyperview в качестве свойства, называемого components.

Теперь мы готовы обновить наши шаблоны HXML, чтобы использовать новый компонент перелистываемых строк.

Использование компонента

В настоящее время HXML для элемента контакта в списке состоит из элементов <behavior> и <text>:

Листинг 230. Фрагмент файла hv/rows.xml

<item key="{{ contact.id }}" style="contact-item">
  <behavior trigger="press" action="push" href="/contacts/{{ contact.id }}" /> <!-- 1 -->
  <text style="contact-item-label">
    <!-- omitted for brevity -->
  </text>
</item>

Благодаря нашему компоненту перелистываемых строк эта разметка станет «основным» пользовательским интерфейсом. Итак, давайте начнем с добавления <row> и <main> в качестве родительских элементов.

Листинг 231. Добавление перелистываемой строки hv/rows.xml

<item key="{{ contact.id }}">
  <swipe:row xmlns:swipe="https://hypermedia.systems/hyperview/swipeable"> <!-- 1 -->
    <swipe:main> <!-- 2 -->
      <view style="contact-item"> <!-- 3 -->
        <behavior trigger="press" action="push" href="/contacts/{{ contact.id }}" /> <!-- 1 -->
        <text style="contact-item-label">
          <!-- omitted for brevity -->
        </text>
      </view>
    </swipe:main>
  </swipe:row>
</item>
  1. Добавлен родительский элемент <swipe:row> с псевдонимом пространства имен для swipe.

  2. Добавлен элемент <swipe:main> для определения основного содержимого.

  3. Существующие элементы <behavior> и <text> обернуты в <view>.

Ранее стиль contact-item устанавливался для элемента <item>. Это имело смысл, когда элемент <item> был контейнером для основного содержимого элемента списка. Теперь, когда основной контент является дочерним элементом <swipe:main>, нам нужно добавить новый <view>, к которому мы применим стили.

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

Листинг 232. Добавление перелистываемой строки hv/rows.xml

<item key="{{ contact.id }}">
  <swipe:row xmlns:swipe="https://hypermedia.systems/hyperview/swipeable"> <!-- 1 -->
    <swipe:main>
      <!-- omitted for brevity -->
    </swipe:main>

    <swipe:button> <!-- 1 -->
      <view style="swipe-button">
        <text style="button-label">Edit</text>
      </view>
    </swipe:button>

    <swipe:button> <!-- 2 -->
      <view style="swipe-button">
        <text style="button-label-delete">Delete</text>
      </view>
    </swipe:button>
  </swipe:row>
</item>
  1. Добавлена кнопка <swipe:button> для действия редактирования.

  2. Добавлена кнопка <swipe:button> для действия удаления.

Теперь, если мы воспользуемся нашим мобильным приложением, мы сможем увидеть прокручиваемую строку в действии! Когда вы проводите по элементу контакта, появляются кнопки «Edit» и «Delete». Но они пока ничего не делают. Нам нужно добавить к этим кнопкам некоторые варианты поведения. Кнопка «Edit» проста: нажатие на нее должно открыть экран сведений о контакте в режиме редактирования.

Листинг 233. Фрагмент файла hv/rows.xml

<swipe:button>
  <view style="swipe-button">
    <behavior trigger="press" action="push" href="/contacts/{{ contact.id }}/edit" /> <!-- 1 -->
    <text style="button-label">Edit</text>
  </view>
</swipe:button>
  1. При нажатии открывается новый экран с пользовательским интерфейсом редактирования контакта.

Кнопка «Delete» немного сложнее. Нет экрана, который можно было бы открыть для удаления, так что же должно произойти, когда пользователь нажмет эту кнопку? Возможно, мы используем то же взаимодействие, что и кнопка «Delete» на экране «Edit Contact». В результате этого взаимодействия открывается системный диалог, в котором пользователю предлагается подтвердить удаление. Если пользователь подтвердит, клиент Hyperview отправляет POST-запрос к /contacts/<contact_id>/delete и добавляет ответ на экран. Ответ немедленно запускает несколько действий, чтобы перезагрузить список контактов и отобразить сообщение. Это взаимодействие будет работать и для нашей кнопки действия:

Листинг 234. Фрагмент файла hv/rows.xml

<swipe:button>
  <view style="swipe-button">
    <behavior <!-- 1 -->
      xmlns:alert="https://hyperview.org/hyperview-alert"
      trigger="press"
      action="alert"
      alert:title="Confirm delete"
      alert:message="Are you sure you want to delete {{ contact.first }}?"
    >
      <alert:option alert:label="Confirm">
        <behavior <!-- 2 -->
          trigger="press"
          action="append"
          target="item-{{ contact.id }}"
          href="/contacts/{{ contact.id }}/delete"
          verb="post"
        />
      </alert:option>
      <alert:option alert:label="Cancel" />
    </behavior>
    <text style="button-label-delete">Delete</text>
  </view>
</swipe:button>
  1. При нажатии открывается системное диалоговое окно с просьбой подтвердить действие.

  2. В случае подтверждения сделать запрос POST к конечной точке удаления и добавить ответ к родительскому <item>.

Теперь, когда мы нажимаем «Delete», мы получаем диалоговое окно подтверждения, как и ожидалось. После нажатия кнопки подтверждения ответ серверной части запускает поведение, которое отображает сообщение с подтверждением и перезагружает список контактов. Элемент удаленного контакта исчезнет из списка.

Рисунок 23. Кнопка «Delete с помощью смахивания»

Обратите внимание, что кнопки действий могут поддерживать любой тип поведенческого действия, от push до alert. Если бы мы захотели, мы могли бы сделать так, чтобы кнопки действий запускали наши специальные действия, такие как open-phone и open-email. Пользовательские компоненты и действия можно свободно смешивать со стандартными компонентами и действиями, которые входят в стандартную комплектацию Hyperview. Благодаря этому расширения клиента Hyperview кажутся первоклассными функциями.

На самом деле, мы откроем вам секрет. В клиенте Hyperview стандартные компоненты и действия реализованы так же, как и пользовательские компоненты и действия! Код рендеринга обрабатывает <view> не иначе, чем <swipe:row>. Код поведения не рассматривает alert иначе, чем open-phone. Оба они реализованы с использованием одних и тех же методов, описанных в этом разделе. Стандартные компоненты и действия — это именно те, которые необходимы всем мобильным приложениям. Но они являются лишь отправной точкой.

Большинству мобильных приложений потребуются некоторые расширения клиента Hyperview, чтобы обеспечить удобство работы с пользователем. Расширения превращают клиент из обычного «клиента Hyperview» в клиент, специально созданный для вашего приложения. И что немаловажно, эта эволюция сохраняет Hypermedia, серверную архитектуру и все ее преимущества.

Мобильные гипермедийные приложения

На этом наша сборка мобильного Contact.app завершена. Отойдите от деталей кода и рассмотрите более широкую картину:

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

Да, есть история с приложениями, управляемыми гипермедиа, на мобильных устройствах.

Заметки о Hypermedia: достаточно хороший UX и острова интерактивности

Проблема, с которой сталкиваются многие разработчики SPA и мобильных приложений при переходе к подходу HDA, заключается в том, что они смотрят на свое текущее приложение и представляют себе его реализацию именно с использованием гипермедиа. Несмотря на то, что htmx и HyperView значительно улучшают взаимодействие с пользователем, доступное благодаря подходу, основанному на гипермедиа, все же бывают случаи, когда получить конкретный пользовательский интерфейс будет непросто.

Как мы видели во второй главе, Рой Филдинг отметил этот компромисс в отношении сетевой архитектуры RESTful в Интернете, где «информация передается в стандартизированной форме, а не в форме, специфичной для нужд приложения».

Принятие немного менее эффективного и интерактивного решения для конкретного UX может существенно сэкономить вам время при создании приложений.

Не позволяйте лучшему быть врагом хорошего. В некоторых случаях можно получить множество преимуществ, приняв в некоторых случаях чуть менее сложный пользовательский интерфейс, а такие инструменты, как htmx и HyperView, делают этот компромисс гораздо более приемлемым при правильном использовании.

Часть IV
Заключение

Заключение

Переосмысление гипермедиа

Мы надеемся убедить вас в том, что гипермедиа — это не «устаревшая» технология или технология, подходящая только для «документов» со ссылками, текстом и изображениями, а, по сути, мощная технология для создания приложений. В этой книге вы узнали, как создавать сложные пользовательские интерфейсы — как для Интернета с помощью HTML, так и для мобильных приложений с использованием Hyperview — используя гипермедиа в качестве основной базовой технологии приложений.

Многие веб-разработчики рассматривают ссылки и формы «простого» HTML как инструменты, ушедшие в прошлое. И в некотором смысле они правы: в оригинальной сети были определенные проблемы с удобством использования. Однако сейчас существуют библиотеки JavaScript, которые расширяют HTML, устраняя его основные ограничения.

Htmx, например, позволил нам:

Благодаря этому мы смогли создать пользовательские интерфейсы для Contact.app, которые, по мнению многих разработчиков, требуют значительного количества клиентского JavaScript, и мы сделали это, используя концепции гипермедиа.

Подход к приложениям, управляемым гипермедиа, подходит не для каждого приложения. Однако для многих приложений повышенная гибкость и простота гипермедиа может оказаться огромным преимуществом. Даже если ваше приложение не выиграет от такого подхода, стоит понять этот подход, его сильные и слабые стороны, а также то, чем он отличается от подхода, который вы используете. Оригинальная сеть росла быстрее, чем любая распределенная система в истории; веб-разработчики должны знать, как использовать мощь базовых технологий, которые сделали этот рост возможным.

Пауза и размышление

Сообщество JavaScript и, как следствие, сообщество веб-разработчиков, как известно, хаотично: новые фреймворки и технологии появляются ежемесячно, а иногда даже еженедельно. Быть в курсе новейших и лучших технологий может быть утомительно, и в то же время страшно, что мы не успеваем за ними и отстаем в своей карьере.

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

Нам не следует приукрашивать реалии нашей отрасли. С другой стороны, мы также не должны игнорировать обратную сторону, которую создают эти реалии. Это создает напряженную среду, в которой все ждут «нового нового», то есть новейших и величайших технологий, которые изменят все. Это создает давление, когда вы заявляете, что ваша технология изменит все. И это, как правило, отдает предпочтение утонченности, а не простоте. Люди боятся спрашивать: «Это слишком сложно?» потому что это очень похоже на «Я недостаточно умен, чтобы это понять».

Индустрия программного обеспечения имеет тенденцию, особенно в веб-разработке, больше склоняться к инновациям, а не к пониманию существующих технологий и развитию их или внутри них. Мы склонны искать новые, гениальные решения, а не искать устоявшиеся идеи. Это понятно: мир технологий обязательно является перспективной отраслью.

С другой стороны — как мы видели на примере формулировки REST Роя Филдинга — у некоторых ранних архитекторов Интернета были отличные идеи, которые были упущены из виду. Мы достаточно взрослые, чтобы видеть, как гипермедиа приходит и уходит как «новая новая» идея. Нас немного шокировало то, что индустрия так бесцеремонно отвергла такие мощные идеи, как REST. К счастью, эти концепции все еще существуют и ждут, чтобы их открыли заново и вдохнули новую жизнь. Оригинальная RESTful архитектура Интернета, если посмотреть на нее свежим взглядом, может решить многие проблемы, с которыми сталкиваются сегодня веб-разработчики.

Возможно, следуя совету Марка Твена, пришло время остановиться и задуматься. Возможно, на несколько минут тишины мы сможем отбросить бесконечный водоворот «нового нового», оглянуться назад, откуда взялась сеть, и поучиться.

Возможно, пришло время дать гипермедиа шанс.