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