Pull to refresh
2027.48
Timeweb Cloud
То самое облако

Анатомия htmx

Level of difficultyMedium
Reading time15 min
Views9.2K



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

Tags:
Hubs:
Total votes 23: ↑21 and ↓2+28
Comments8

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud