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

Комментарии (5)


  1. Yaschik
    02.04.2024 22:53

    Ну, справедливости ради, 10 лет назад многие адепты писали себе код на turbolinks и были крайне довольны.

    (Например, вот статья на хвбре 11 летней свежести - https://habr.com/ru/articles/167161/)


  1. init0
    02.04.2024 22:53
    +1

    htmx — это библиотека, которая предоставляет доступ к AJAX, переходам CSS

    Переходы CSS? Что это?


    1. SystemXFiles
      02.04.2024 22:53

      Видимо имеются ввиду CSS transitions


  1. DeskundigeICT
    02.04.2024 22:53
    +1

    Я вижу здесь код на HTML/CSS/JS. Зачем нужен HTMX тогда?


  1. dedmagic
    02.04.2024 22:53
    +1

    HTML-код находится в строковых константах, т.е. никаких подсветки синтаксиса, проверки на корректность, автодополнения в IDE и т.д.?