
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-канале ↩

