Анатомия htmx

от автора

Hello world!

По данным 2023 JavaScript Rising Stars библиотека htmx заняла второе место в разделе Front-end Frameworks (первое место вполне ожидаемо принадлежит React) и десятое место в разделе Most Popular Projects Overall.

htmx — это библиотека, которая предоставляет доступ к AJAX, переходам CSS, WebSockets и Server Sent Events прямо из HTML через атрибуты, что позволяет создавать современные пользовательские интерфейсы (насколько сложные — другой вопрос), пользуясь простотой и мощью гипертекста. На сегодняшний день у библиотеки почти 30 000 звезд на Github. Удивительно, что до такого решения мы додумались только сейчас, учитывая, что весь функционал был доступен уже 10 лет назад (вы сами убедитесь в этом, когда мы изучим исходный код htmx).

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

Код проекта, который мы создадим, включая выдержки из исходного кода htmx (файл public/source-code.js), можно найти здесь.

Пример

Возьмем пример из раздела quick start на главной странице официального сайта htmx и немного его модифицируем.

Создаем новую директорию, переходим в нее и инициализируем проект Node.js:

mkdir htmx-testing cd htmx-testing npm i -yp

Устанавливаем express и nodemon:

npm i express npm i -D nodemon

Определяем тип кода сервера и скрипт для запуска сервера для разработки в файле package.json:

"scripts": {   "dev": "nodemon" }, "type": "module"

Создаем файл index.js с таким кодом сервера:

import express from 'express'  // Создаем приложение `express` const app = express() // Указываем директорию со статичными файлами app.use(express.static('public'))  // Разметка 1 const html1 = `<div>   <p>hello world</p>   <button     name="my-button"     value="some-value"     hx-get="/clicked"   >     click me   </button> </div>` // Разметка 2 const html2 = `<span>no more swaps</span>`  // Обработчик POST-запроса app.post('/clicked', (req, res) => {   // Отправляем в ответ разметку 1   res.send(html1) }) // Обработчик GET-запроса app.get('/clicked', (req, res) => {   // Отправляем в ответ разметку 2   res.send(html2) })  app.listen(3000)

Создаем директорию public и в ней 2 файла:

  • index.html

<!DOCTYPE html> <html lang="en">   <head>     <meta charset="UTF-8" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Htmx test</title>     <!-- Хак для фавиконки -->     <link rel="icon" href="data:." />     <!-- Стили -->     <link rel="stylesheet" href="style.css" />     <!-- Подключаем htmx -->     <script       src="https://unpkg.com/htmx.org@1.9.10"       integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"       crossorigin="anonymous"     ></script>   </head>   <body>     <button       name="my-button"       value="some-value"       hx-post="/clicked"       hx-swap="outerHTML"     >       click me     </button>   </body> </html>

  • style.css

body {   background-color: #333; }  p {   color: #ddd; }  /* Кнопка, содержащая `span`, становится некликабельной */ button:has(span) {   pointer-events: none;   user-select: none;   color: rgba(0, 0, 0, 0.5); }

Запускаем сервер для разработки с помощью команды npm run dev и переходим по адресу http://localhost:3000.

При нажатии кнопки по адресу http://localhost:3000/clicked отправляется POST-запрос. В ответ на этот запрос возвращается разметка 1, которая заменяет outerHTML кнопки. Новая разметка содержит параграф и новую кнопку.

При нажатии новой кнопки по адресу http://localhost:3000/clicked отправляется GET-запрос. В ответ на этот запрос возвращается разметка 2, содержащая элемент span с текстом. Новая разметка заменяет innerHTML (текст) кнопки, и благодаря стилям кнопка становится некликабельной.

Обратите внимание на наличие атрибутов name и value у кнопок.

Начальное состояние приложения:

Состояние приложения после нажатия первой кнопки:

Состояние приложения после нажатия второй кнопки:

Полезная нагрузка POST-запроса (содержится в теле запроса в формате application/x-www-form-urlencoded):

Ответ на POST-запрос:

Параметры GET-запроса (http://localhost:3000/clicked?my-button=some-value):

Ответ на GET-запрос:

Отлично. Начнем погружаться в исходный код htmx.

Реверс-инжиниринг

Весь код htmx содержится в одном файле src/htmx.js и занимает 3905 строк. Краткая характеристика — varы и тысяча и одна утилита ?

Я копировал весь код htmx в файл public/source-code.js и оставил только код, необходимый для работы нашего приложения — получилось 1300 строк. С этим можно работать ?

Обратите внимание: дальнейший разбор кода актуален для htmx@1.9.10. В будущем код может и наверняка изменится, возможно, до неузнаваемости ?

Также обратите внимание, что с целью упрощения кода для облегчения его восприятия я беспощадно удалял строки и даже целые блоки кода ?

var htmx = {   // Дефолтные настройки `htmx`   config: {     historyEnabled: true,     historyCacheSize: 10,     refreshOnHistoryMiss: false,     // важно! ---     defaultSwapStyle: 'innerHTML',     // --- !     defaultSwapDelay: 0,     defaultSettleDelay: 20,     includeIndicatorStyles: true,     indicatorClass: 'htmx-indicator',     // ! ---     requestClass: 'htmx-request',     addedClass: 'htmx-added',     settlingClass: 'htmx-settling',     swappingClass: 'htmx-swapping',     // --- !     allowEval: true,     allowScriptTags: true,     inlineScriptNonce: '',     // ! ---     attributesToSettle: ['class', 'style', 'width', 'height'],     // --- !     withCredentials: false,     timeout: 0,     wsReconnectDelay: 'full-jitter',     wsBinaryType: 'blob',     disableSelector: '[hx-disable], [data-hx-disable]',     useTemplateFragments: false,     scrollBehavior: 'smooth',     defaultFocusScroll: false,     getCacheBusterParam: false,     globalViewTransitions: false,     // !     methodsThatUseUrlParams: ['get'],     //     selfRequestsOnly: false,     ignoreTitle: false,     scrollIntoViewOnBoost: true,     triggerSpecsCache: null,   }, }  function getDocument() {   return document }  var isReady = false getDocument().addEventListener('DOMContentLoaded', function () {   isReady = true })  function ready(fn) {   if (isReady || getDocument().readyState === 'complete') {     fn()   } else {     getDocument().addEventListener('DOMContentLoaded', fn)   } }  ready(function () {   var body = getDocument().body   processNode(body)    setTimeout(function () {     triggerEvent(body, 'htmx:load', {})     body = null   }, 0) })

При готовности документа (возникновении события DOMContentLoaded) тело документа (body) передается для обработки в функцию processNode. С помощью функции triggerEvent запускается событие htmx:load.

Сначала рассмотрим triggerEvent и ее вспомогательные функции:

function triggerEvent(elt, eventName, detail) {   // Параметр `elt` - это HTML-элемент или строка   elt = resolveTarget(elt)   if (detail == null) {     detail = {}   }   detail['elt'] = elt   // Создаем кастомное событие   // https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent   var event = makeEvent(eventName, detail)   // Запускаем кастомное событие   // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent   var eventResult = elt.dispatchEvent(event)   return eventResult }  function resolveTarget(arg2) {   if (isType(arg2, 'String')) {     return find(arg2)   } else {     return arg2   } }  function isType(o, type) {   return Object.prototype.toString.call(o) === '[object ' + type + ']' }  function find(eltOrSelector, selector) {   if (selector) {     return eltOrSelector.querySelector(selector)   } else {     return find(getDocument(), eltOrSelector)   } }  function makeEvent(eventName, detail) {   var evt   if (window.CustomEvent && typeof window.CustomEvent === 'function') {     evt = new CustomEvent(eventName, {       bubbles: true,       cancelable: true,       detail: detail,     })   } else {     evt = getDocument().createEvent('CustomEvent')     evt.initCustomEvent(eventName, true, true, detail)   }   return evt }

Посмотрим, какие события запускаются при старте нашего приложения:

function triggerEvent(elt, eventName, detail) {   console.log({ elt, eventName, detail })   // ... }

Результат:

Посмотрим, какие события возникают при нажатии кнопки:

По этим логам можно понять общую логику работы htmx, но не будем спешить.

Рассмотрим функцию processNode:

function processNode(elt) {   elt = resolveTarget(elt)   initNode(elt)   forEach(findElementsToProcess(elt), function (child) {     initNode(child)   }) }

Сначала body, затем все элементы из функции findElementsToProcess передаются в функцию initNode.

findElementsToProcess возвращает все элементы, подлежащие обработке htmx (не только элементы с атрибутами htmx):

var VERBS = ['get', 'post', 'put', 'delete', 'patch'] var VERB_SELECTOR = VERBS.map(function (verb) {   return '[hx-' + verb + '], [data-hx-' + verb + ']' }).join(', ')  function findElementsToProcess(elt) {   var boostedSelector =     ', [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]'   var results = elt.querySelectorAll(     VERB_SELECTOR +       boostedSelector +       ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +       ' [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]',   )   return results }

Финальный селектор выглядит так:

[hx-get], [data-hx-get], [hx-post], [data-hx-post], [hx-put], [data-hx-put], [hx-delete], [data-hx-delete], [hx-patch], [data-hx-patch], [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost], form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws], [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]

Функция initNode:

function initNode(elt) {   // Получаем внутренние данные   var nodeData = getInternalData(elt)   // Если изменились атрибуты элемента (при повторном рендеринге)   if (nodeData.initHash !== attributeHash(elt)) {     // Удаляем предыдущие внутренние данные     deInitNode(elt)     // Сохраняем строку хеша     nodeData.initHash = attributeHash(elt)      triggerEvent(elt, 'htmx:beforeProcessNode')      // Если у элемента есть атрибут `value`     if (elt.value) {       nodeData.lastValue = elt.value     }      // Извлекаем триггеры     var triggerSpecs = getTriggerSpecs(elt)     // Обрабатываем триггеры     var hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs)      triggerEvent(elt, 'htmx:afterProcessNode')   } }  function getInternalData(elt) {   var dataProp = 'htmx-internal-data'   var data = elt[dataProp]   if (!data) {     data = elt[dataProp] = {}   }   return data }

Ключевыми здесь являются функции getTriggerSpecs и processVerbs, но о них позже.

Состояние элемента хранится в самом элементе. Элемент, как почти все в JavaScript, является объектом. Состояние элемента-объекта хранится в свойстве htmx-internal-data. Взглянем на внутренние данные кнопки:

function initNode(elt) {   var nodeData = getInternalData(elt)   console.log({ nodeData })   // ... }

Результат:

Мы получаем такой результат при запуске приложения из-за мутируемости (изменяемости) nodeData. Это не очень хороший паттерн.

Посмотрим на значения, возвращаемые функциями getTriggerSpecs и processVerbs, а также на getTriggerSpecs:

function initNode(elt) {   // ...   if (nodeData.initHash !== attributeHash(elt)) {     // ...     var triggerSpecs = getTriggerSpecs(elt)     var hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs)     console.log({ triggerSpecs, hasExplicitHttpAction })     // ...   } }  function getTriggerSpecs(elt) {   var triggerSpecs = []    if (triggerSpecs.length > 0) {     return triggerSpecs   } else if (matches(elt, 'form')) {     return [{ trigger: 'submit' }]   } else if (matches(elt, 'input[type="button"], input[type="submit"]')) {     return [{ trigger: 'click' }]   } else if (matches(elt, 'input, textarea, select')) {     return [{ trigger: 'change' }]   } else {     // Дефолтный триггер - наш случай     return [{ trigger: 'click' }]   } }

Результат:

Функция processVerbs:

function processVerbs(elt, nodeData, triggerSpecs) {   var explicitAction = false   // Перебираем глаголы (get, post, put и т.д.)   forEach(VERBS, function (verb) {     // Если у элемента имеется соответствующий атрибут,     // например, `hx-post`     if (hasAttribute(elt, 'hx-' + verb)) {       // Извлекаем путь, например, `/clicked`       var path = getAttributeValue(elt, 'hx-' + verb)       explicitAction = true       nodeData.path = path       nodeData.verb = verb       // Перебираем триггеры       triggerSpecs.forEach(function (triggerSpec) {         // Регистрируем обработчик каждого триггера         addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) {           // В нашем случае обработка триггера сводится к отправке HTTP-запроса           issueAjaxRequest(verb, path, elt, evt)         })       })     }   })   return explicitAction }

Функция регистрации обработчика выглядит следующим образом:

function addTriggerHandler(elt, triggerSpec, nodeData, handler) {   addEventListener(elt, handler, nodeData, triggerSpec) }  function addEventListener(elt, handler, nodeData, triggerSpec) {   var eltsToListenOn = [elt]    forEach(eltsToListenOn, function (eltToListenOn) {     // Обработчик     var eventListener = function (evt) {       var eventData = getInternalData(evt)       eventData.triggerSpec = triggerSpec       if (eventData.handledFor == null) {         eventData.handledFor = []       }       if (eventData.handledFor.indexOf(elt) < 0) {         eventData.handledFor.push(elt)          triggerEvent(elt, 'htmx:trigger')         // Отправка HTTP-запроса         handler(elt, evt)       }     }     if (nodeData.listenerInfos == null) {       nodeData.listenerInfos = []     }     // Работа с внутренними данными     nodeData.listenerInfos.push({       trigger: triggerSpec.trigger,       listener: eventListener,       on: eltToListenOn,     })     // Регистрация обработчика     eltToListenOn.addEventListener(triggerSpec.trigger, eventListener)   }) }

Функция issueAjaxRequest и используемая в ней функция handleAjaxResponse являются основными функциями htmx. Любопытно, что запросы отправляются не с помощью Fetch API, как можно было ожидать, а с помощью XMLHttpRequest.

Начнем с issueAjaxRequest (с вашего позволения, я прокомментирую только основные моменты):

function issueAjaxRequest(verb, path, elt, event, etc, confirmed) {   console.log({ verb, path, elt, event, etc, confirmed })   var resolve = null   var reject = null   etc = etc != null ? etc : {}    var promise = new Promise(function (_resolve, _reject) {     resolve = _resolve     reject = _reject   })    // Обработчик ответа   var responseHandler = etc.handler || handleAjaxResponse   var select = etc.select || null    var target = etc.targetOverride || elt    var eltData = getInternalData(elt)    var abortable = false    // Создаем экземпляр `XMLHttpRequest`   var xhr = new XMLHttpRequest()   eltData.xhr = xhr   eltData.abortable = abortable    var endRequestLock = function () {     eltData.xhr = null     eltData.abortable = false     if (eltData.queuedRequests != null && eltData.queuedRequests.length > 0) {       var queuedRequest = eltData.queuedRequests.shift()       queuedRequest()     }   }    // Формируем заголовки запроса   var headers = getHeaders(elt, target)    if (verb !== 'get' && !usesFormData(elt)) {     headers['Content-Type'] = 'application/x-www-form-urlencoded'   }    // Подготовка данных для отправки в теле или параметрах запроса   var results = getInputValues(elt, verb)   var errors = results.errors   var rawParameters = results.values   // `hx-vars`, `hx-vals`   // var expressionVars = getExpressionVars(elt)   var expressionVars = {}   var allParameters = mergeObjects(rawParameters, expressionVars)   // `hx-params`   // var filteredParameters = filterValues(allParameters, elt)   var filteredParameters = allParameters   console.log({ results, filteredParameters })    // var requestAttrValues = getValuesForElement(elt, 'hx-request')   var requestAttrValues = {}    var eltIsBoosted = getInternalData(elt).boosted    var useUrlParams = htmx.config.methodsThatUseUrlParams.indexOf(verb) >= 0    var requestConfig = {     boosted: eltIsBoosted,     useUrlParams: useUrlParams,     parameters: filteredParameters,     unfilteredParameters: allParameters,     headers: headers,     target: target,     verb: verb,     errors: errors,     withCredentials:       etc.credentials ||       requestAttrValues.credentials ||       htmx.config.withCredentials,     timeout: etc.timeout || requestAttrValues.timeout || htmx.config.timeout,     path: path,     triggeringEvent: event,   }    // На случай, если объект был перезаписан   path = requestConfig.path   verb = requestConfig.verb   headers = requestConfig.headers   filteredParameters = requestConfig.parameters   errors = requestConfig.errors   useUrlParams = requestConfig.useUrlParams    var splitPath = path.split('#')   var pathNoAnchor = splitPath[0]   var anchor = splitPath[1]    var finalPath = path    // Параметры GET-запроса   if (useUrlParams) {     finalPath = pathNoAnchor     var values = Object.keys(filteredParameters).length !== 0     if (values) {       if (finalPath.indexOf('?') < 0) {         finalPath += '?'       } else {         finalPath += '&'       }       finalPath += urlEncode(filteredParameters)       if (anchor) {         finalPath += '#' + anchor       }     }   }    // Инициализируем запрос   xhr.open(verb.toUpperCase(), finalPath, true)   xhr.overrideMimeType('text/html')   xhr.withCredentials = requestConfig.withCredentials   xhr.timeout = requestConfig.timeout    if (requestAttrValues.noHeaders) {     // Игнорируем все заголовки   } else {     for (var header in headers) {       if (headers.hasOwnProperty(header)) {         var headerValue = headers[header]         safelySetHeaderValue(xhr, header, headerValue)       }     }   }    var responseInfo = {     xhr: xhr,     target: target,     requestConfig: requestConfig,     etc: etc,     boosted: eltIsBoosted,     select: select,     pathInfo: {       requestPath: path,       finalRequestPath: finalPath,       anchor: anchor,     },   }    // Обработчик успешного запроса   xhr.onload = function () {     try {       var hierarchy = hierarchyForElt(elt)       responseInfo.pathInfo.responsePath = getPathFromResponse(xhr)       console.log({ hierarchy, responseInfo })       // важно! Обработка ответа       responseHandler(elt, responseInfo)        maybeCall(resolve)       endRequestLock()     } catch (e) {       console.error(         elt,         'htmx:onLoadError',         mergeObjects({ error: e }, responseInfo),       )       throw e     }   }    // Параметры не GET-запроса   var params = useUrlParams     ? null     : encodeParamsForBody(xhr, elt, filteredParameters)   console.log({ params })    // Отправляем запрос   xhr.send(params)    return promise }

Результаты логирования при нажатии первой кнопки и отправке POST-запроса:

Результаты логирования при нажатии второй кнопки и отправке GET-запроса:

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

function handleAjaxResponse(elt, responseInfo) {   var xhr = responseInfo.xhr   var target = responseInfo.target   var etc = responseInfo.etc   var select = responseInfo.select    // Определение необходимости замены старой разметки на новую   var shouldSwap = xhr.status >= 200 && xhr.status < 400 && xhr.status !== 204   var serverResponse = xhr.response   var isError = xhr.status >= 400   var ignoreTitle = htmx.config.ignoreTitle   var beforeSwapDetails = mergeObjects(     {       shouldSwap: shouldSwap,       serverResponse: serverResponse,       isError: isError,       ignoreTitle: ignoreTitle,     },     responseInfo,   )    target = beforeSwapDetails.target // изменение цели   serverResponse = beforeSwapDetails.serverResponse // обновление содержимого   isError = beforeSwapDetails.isError // обновление ошибки   ignoreTitle = beforeSwapDetails.ignoreTitle // обновление игнорирования заголовка    responseInfo.target = target   responseInfo.failed = isError   responseInfo.successful = !isError    if (beforeSwapDetails.shouldSwap) {     var swapOverride = etc.swapOverride      // Характер замены разметки, определяемый атрибутом `hx-swap` (наш `POST-запрос`),     // по умолчанию - `innerHTML` (наш `GET-запрос`)     var swapSpec = getSwapSpecification(elt, swapOverride)     // для первой кнопки - { swapStyle: 'outerHTML', swapDelay: 0, settleDelay: 20 }     // для второй кнопки - { swapStyle: 'innerHTML', swapDelay: 0, settleDelay: 20 }     console.log(swapSpec)      target.classList.add(htmx.config.swappingClass)      var settleResolve = null     var settleReject = null      // Функция замены     var doSwap = function () {       try {         var activeElt = document.activeElement         var selectionInfo = {}         try {           selectionInfo = {             elt: activeElt,             // @ts-ignore             start: activeElt ? activeElt.selectionStart : null,             // @ts-ignore             end: activeElt ? activeElt.selectionEnd : null,           }         } catch (e) {           // safari issue - see https://github.com/microsoft/playwright/issues/5894         }          var selectOverride         if (select) {           selectOverride = select         }          // Функция определения задач и элементов для очистки после замены разметки         var settleInfo = makeSettleInfo(target)         // важно! Функция замены         selectAndSwap(           swapSpec.swapStyle,           target,           elt,           serverResponse,           settleInfo,           selectOverride,         )          target.classList.remove(htmx.config.swappingClass)         forEach(settleInfo.elts, function (elt) {           if (elt.classList) {             elt.classList.add(htmx.config.settlingClass)           }           triggerEvent(elt, 'htmx:afterSwap', responseInfo)         })          // Функция очистки после замены разметки         var doSettle = function () {           forEach(settleInfo.tasks, function (task) {             task.call()           })           forEach(settleInfo.elts, function (elt) {             if (elt.classList) {               elt.classList.remove(htmx.config.settlingClass)             }             triggerEvent(elt, 'htmx:afterSettle', responseInfo)           })            if (responseInfo.pathInfo.anchor) {             var anchorTarget = getDocument().getElementById(               responseInfo.pathInfo.anchor,             )             if (anchorTarget) {               anchorTarget.scrollIntoView({                 block: 'start',                 behavior: 'auto',               })             }           }            if (settleInfo.title && !ignoreTitle) {             var titleElt = find('title')             if (titleElt) {               titleElt.innerHTML = settleInfo.title             } else {               window.document.title = settleInfo.title             }           }            maybeCall(settleResolve)         }          // Функция очистки, как и функция замены может вызываться с задержкой         if (swapSpec.settleDelay > 0) {           setTimeout(doSettle, swapSpec.settleDelay)         } else {           // Вызываем функцию очистки           doSettle()         }       } catch (e) {         console.error(elt, 'htmx:swapError', responseInfo)         maybeCall(settleReject)         throw e       }     }      if (swapSpec.swapDelay > 0) {       setTimeout(doSwap, swapSpec.swapDelay)     } else {       // Вызываем функцию замены       doSwap()     }   } }

Функция selectAndSwap:

function selectAndSwap(swapStyle, target, elt, responseText, settleInfo) {   console.log({     swapStyle,     target,     elt,     responseText,     settleInfo,   })   // `body`   var fragment = makeFragment(responseText)   if (fragment) {     return swap(swapStyle, elt, target, fragment, settleInfo)   } }

Наконец, функция swap, отвечающая за замену разметки в зависимости от выбранного способа рендеринга:

function swap(swapStyle, elt, target, fragment, settleInfo) {   console.log({ swapStyle, elt, target, fragment, settleInfo })   switch (swapStyle) {     case 'none':       return     // Первая кнопка     case 'outerHTML':       swapOuterHTML(target, fragment, settleInfo)       return     case 'afterbegin':       // swapAfterBegin(target, fragment, settleInfo)       return     case 'beforebegin':       // swapBeforeBegin(target, fragment, settleInfo)       return     case 'beforeend':       // swapBeforeEnd(target, fragment, settleInfo)       return     case 'afterend':       // swapAfterEnd(target, fragment, settleInfo)       return     case 'delete':       // swapDelete(target, fragment, settleInfo)       return     default:       // Вторая кнопка       if (swapStyle === 'innerHTML') {         swapInnerHTML(target, fragment, settleInfo)       } else {         swap(htmx.config.defaultSwapStyle, elt, target, fragment, settleInfo)       }   } }  // Функция замены внешнего (всего) `HTML` элемента function swapOuterHTML(target, fragment, settleInfo) {   if (target.tagName === 'BODY') {     // return swapInnerHTML(target, fragment, settleInfo)   } else {     var newElt     var eltBeforeNewContent = target.previousSibling     // Вставляем новый элемент перед целевым     insertNodesBefore(parentElt(target), target, fragment, settleInfo)     // Выполняем очистку     if (eltBeforeNewContent == null) {       newElt = parentElt(target).firstChild     } else {       newElt = eltBeforeNewContent.nextSibling     }     settleInfo.elts = settleInfo.elts.filter(function (e) {       return e != target     })     while (newElt && newElt !== target) {       if (newElt.nodeType === Node.ELEMENT_NODE) {         settleInfo.elts.push(newElt)       }       newElt = newElt.nextElementSibling     }     cleanUpElement(target)     parentElt(target).removeChild(target)   } }  function swapInnerHTML(target, fragment, settleInfo) {   var firstChild = target.firstChild   // Вставляем целевой элемент перед его первым потомком   insertNodesBefore(target, firstChild, fragment, settleInfo)   // Выполняем очистку   if (firstChild) {     while (firstChild.nextSibling) {       cleanUpElement(firstChild.nextSibling)       target.removeChild(firstChild.nextSibling)     }     cleanUpElement(firstChild)     target.removeChild(firstChild)   } }  function insertNodesBefore(parentNode, insertBefore, fragment, settleInfo) {   console.log({ parentNode, insertBefore, fragment, settleInfo })   while (fragment.childNodes.length > 0) {     var child = fragment.firstChild     addClassToElement(child, htmx.config.addedClass)     parentNode.insertBefore(child, insertBefore)     if (       child.nodeType !== Node.TEXT_NODE &&       child.nodeType !== Node.COMMENT_NODE     ) {       settleInfo.tasks.push(makeAjaxLoadTask(child))     }   } }

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

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

Полагаю, теперь вы понимаете, как работает htmx (ловкость рук и никакого мошенничества ?), и убедились в справедливости моего утверждения, сделанного в начале статьи, о том, что htmx был возможен уже как минимум 10 лет назад, но удивительным образом «выстрелил» только сейчас.

Пожалуй, это все, о чем я хотел рассказать вам в этой статье.

Happy coding!


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *