让我们来制作最糟糕的 Htmx。
Let's make the worst Htmx

原始链接: https://zserge.com/posts/worst-htmx-ever/

本文探讨了构建一个轻量级 **htmx** 克隆版的过程,旨在了解该库如何为后端开发者降低前端复杂性。其核心理念依赖于简单的 **“SSS”(扫描、发送、交换)** 模式:扫描 DOM 中的声明式属性,通过 AJAX 获取数据,并将内容交换到目标元素中。 作者仅用约 40 行代码,演示了如何支持标准 HTTP 方法、可自定义的交换模式(如追加、前置、删除)以及动态目标解析。通过事件驱动架构,该实现进一步支持了 `beforeRequest` 和 `afterSwap` 等生命周期钩子。 作者强调,这种插件式方法(即指示器、确认对话框和历史记录管理等功能通过事件监听器而非核心逻辑处理)保持了库的模块化与轻量化。尽管该项目是一个模拟 htmx 主要功能的教学工具,但作者总结认为,对于健壮的错误处理、基于 Promise 的取消机制及浏览器原生优化等复杂的生产环境需求,使用原始的 htmx 库仍然是更实际的选择。该实验的源代码可在 GitHub 上获取。

抱歉。
相关文章

原文

This is a continuation of a series of posts about building tiny “clones” of popular web frameworks.

Web is changing rapidly, and so the tools we use to build web apps (yet, I’m sincerely happy that my React/Vue posts are now seven years old, and those frameworks are still popular and relevant).

Today the hot topic is htmx, basically a frontend library for backend developers who couldn’t care less about JavaScript. HTMX started as intercooler, then evolved and grown more features, and is there is some beauty to it:

<button hx-post="/clicked"
    hx-trigger="click"
    hx-target="#parent-div"
    hx-swap="outerHTML">
    Click Me!
</button>

In a declarative manner, it says: when the button is clicked, send a POST request to /clicked, and replace the outer HTML of the element with id #parent-div with the response data. HTMX wires together events, AJAX requests, and DOM updates hiding away frontend complexity. As long as your backend can send HTML templates you can go pretty far with it and build complete apps without a single line of JavaScript.

Method, trigger, target, swap

Looking at the example above, seems like our library should fetch() some URLs based on some triggers and update (swap) contents of some target elements. Something like this:

<button x-get="/click">Click me</button>

document.querySelectorAll('[x-get]').forEach(el => {
    el.addEventListener('click', async (e) => {
        e.preventDefault();
        const url = el.getAttribute('x-get');
        const response = await fetch(url);
        const data = await response.text();
        el.outerHTML = data;
        console.log(data);
    });
});

If our backend sends “Button clicked” in response - it should work. Open a page, click the button, buttons disappears, text is shown instead. That’s HTMX in 10 lines of code.

A few things to improve here. First, it’s nice to sanitise the response to make sure it’s HTML. Second, we should allow the user to specify a target element to swap content into. Third, we should allow the user to specify how exactly to swap the content in a target: replace, append, prepend, delete the element etc:

const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name);

const SWAP = {
  outerHTML: (t, f) => t.replaceWith(f),
  beforebegin: (t, f) => t.before(f),
  afterbegin: (t, f) => t.prepend(f),
  beforeend: (t, f) => t.append(f),
  afterend: (t, f) => t.after(f),
  delete: t => t.remove(),
  none: () => {},
};

const swap = (mode, target, html) => {
  const tpl = document.createElement('template');
  tpl.innerHTML = html;
  (SWAP[mode] || ((t, f) => t.replaceChildren(f)))(target, tpl.content);
};

Now we can write a more generic “fetcher” function to handle different methods, triggers, targets and swap modes:

const send = async (el, method, url) => {
  const sel = attr(el, 'x-target');
  const target = sel ? document.querySelector(sel) : el;
  const mode = attr(el, 'x-swap') || 'innerHTML';
  const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } };
  if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el));
  const res = await fetch(url, opts);
  swap(mode, target, await res.text());
};

By default we update the content of the element, unless x-target or x-swap is specified. We also follow HTMX convention and send a custom header to the backend with the request. To bind it all together we add a simple event listener than dispatches the request based on the trigger:

const METHODS = ['get', 'post', 'put', 'patch', 'delete'];

const defaultTrigger = el =>
  el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click';

const scan = (root = document.body) =>
  METHODS.forEach(m =>
    root.querySelectorAll(`[x-${m}]`).forEach(el => {
      if (el.$hx) return;
      el.$hx = true;
      const evt = attr(el, 'x-trigger') || defaultTrigger(el);
      el.addEventListener(evt, e => {
        e.preventDefault();
        send(el, m, el.getAttribute(`hx-${m}`));
      });
    })
  );

scan();
// we should also call scan() after each 
// swap() inside send() at the end of it

And that’s it, in 40 lines of code we get a working HTMX clone that support all HTTP methods, custom targets and swapping strategies. A small optimisation would be avoid re-scanning the entire DOM after each swap, but instead only scan the newly added content:

new MutationObserver(ms => {
  for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); });
}).observe(document.body, { childList: true, subtree: true });

Now it’s small and performant.

Better triggers

We can introduce a custom syntax for triggers, allowing comma-separated list of events, with optional delays, “changed” or “once” modifiers, etc:

const parseTriggers = (s) => {
  const triggers = s.split(',').map(s => s.trim());
  return triggers.map(trigger => {
    const [event, ...rest] = trigger.split(' ');
    const options = {};
    rest.forEach(opt => {
      const [key, value = true] = opt.split(':');
      options[key] = value;
    });
    return { event, options };
  });
}

This returns a list of events with options (keys/values, separated by colon in HTML attribute). Now we can write things like x-trigger="load, click one, change changed delay:500". We should also handle some events in our scan() function to handle them in a special way, i.e. load is basically setTimeout(() => send(...), 0), changed caches previous value and skips send if the value remains unchanged, once removes the event listener after the first trigger, and delay is another setTimeout with the specified delay. Your fantasy is the limit here, we could go as far as HTMX went and support viewport interaction triggers, queueing, debouncing, periodic events and much more. But we’re building a bad HTMX clone here.

Better targets

So far we use simple querySelector to find which element to swap content into. In practice, we might want to support a few more modifiers, like matching the closest ancestor or the following sibling.

const resolve = (el, sel) => {
  if (!sel) return el;
  if (sel === 'this') return el;
  if (sel === 'next') return el.nextElementSibling;
  if (sel === 'previous') return el.previousElementSibling;
  if (sel === 'document') return document;
  if (sel === 'body') return document.body;
  if (sel === 'window') return window;
  if (sel.startsWith('closest ')) return el.closest(sel.slice(8));
  if (sel.startsWith('find ')) return el.querySelector(sel.slice(5));
  return document.querySelector(sel);
};

We call it from send() instead of document.querySelector(sel) and we can now write things like x-target="closest .container" or x-target="next" to target more specific elements than a global query would target. HTMX does in fact roughly the same, if you want to inspect the code.

Better events

HTMX emits custom events like htmx:beforeRequest, allowing developers to intercept them, modify and control the request flow. We can do the same. In total we emit 4 events: beforeRequest, afterRequest, beforeSwap, afterSwap. Each event is emitted on the element that triggered the request, with full context of the current send(). Events can be cancelled by calling event.preventDefault().

Let’s also try to support server-side events, like HX-Trigger or HX-Redirect. If the server sends a HX-Trigger header, we dispatch a custom event with the contents from that header. If server sent HX-Redirect, we simply redirect the browser to that URL. I kept HX prefix here, because X-... headers is a common convention for standard HTTP headers.

const send = async (el, method, url) => {
  let target = resolve(el, attr(el, "x-target"));
  let mode = attr(el, "x-swap") || "innerHTML";
  const opts = {
    method: method.toUpperCase(),
    headers: { "HX-Request": "true" },
  };
  if (el.matches("form")) opts.body = new URLSearchParams(new FormData(el));
  if (!fire(el, "x:beforeSend", { el, url, target, mode, opts }, true))
    return;
  const response = await fetch(url, opts);
  const hdrTrigger = response.headers.get("HX-Trigger");
  if (hdrTrigger) {
    try {
      const data = JSON.parse(hdrTrigger);
      Object.entries(data).forEach(([ev, d]) => fire(document.body, ev, d));
    } catch {
      fire(document.body, hdrTrigger);
    }
  }
  if (response.headers.get("HX-Redirect")) {
    window.location.href = response.headers.get("HX-Redirect");
    return;
  }
  if (response.headers.get("HX-Refresh") === "true") {
    window.location.reload();
    return;
  }
  if (response.headers.get("HX-Retarget"))
    target = resolve(el, response.headers.get("HX-Retarget"));
  if (response.headers.get("HX-Reswap"))
    mode = response.headers.get("HX-Reswap");
  const html = await response.text();
  fire(el, "x:afterSend", { el, url, opts, target, mode, response, html });
  const detail = { el, url, target, mode, html, response };
  if (!fire(el, "x:beforeSwap", detail, true)) return;
  swap(detail.mode, detail.target, detail.html);
  fire(el, "x:afterSwap", detail);
  scan(detail.target); // rebind anything the swap just brought in
};

Our final send() got bigger, but it allows us to write plugins now but intercepting requests and swaps. Note how we propagate event details from one event to another, meaning that event handlers can modify the request and swap flow. For example, a plugin could intercept x:beforeSwap and change the target or swap mode based on some conditions or attributes.

Plugins

My initial impression from HTMX was that it does only one thing: if an event happens on some element with declarative configuration – send an AJAX request to some URL and swap the response into some other element. But if you check the reference you’ll see hx-on, hx-vals, hx-headers, hx-swap-oob, hx-params, hx-push-url, hx-history-elt, hx-sse, hx-ext and many more attributes that make it less straightforward to understand.

Can we achieve some feature parity without modifying the core “loop”? So that the “essence” of our library would be “SSS”: scan + send + swap.

For example, HTMX supports “boosting” – enhancing links and forms to use AJAX instead of full page reloads (to avoid writing x-get and x-target on each navigation link). This doesn’t even have to be a plugin, just a JS snippet:

document.addEventListener('click', e => {
  const boosted = e.target.closest('[x-boost]');
  if (!boosted) return;
  const link = e.target.closest('a');
  if (!link) return;
  const href = link.getAttribute('href');
  if (!href || href.startsWith('#') || link.getAttribute('target')) return;
  e.preventDefault();
  window.x.send(boosted, 'get', href);
});

Similarly we can “boost” forms on submit. There’s many more possible plugins to implement:

  • x-confirm — pops a native confirm() before sending, allowing user to cancel it. Needs an x:beforeSend listener, e.preventDefault() on decline.
  • x-indicator — shows a spinner while a request is in-flight. Needs x:beforeSend to toggle class/style, and x:afterSwap to undo it.
  • x-disable — disables the element for the duration of the request (avoid double-submission). Same logic as an indicator.
  • x-headers — extra request headers from a JSON attribute. Parse, merge into opts.headers in x:beforeSend.
  • x-vals / x-include — inject extra values into requests. Needs to mutate opts.body in x:beforeSend.
  • x-select — swap a fragment of the response instead of the whole HTML (keeps backend code simpler). Use querySelector the part we want, replace detail.html in x:beforeSwap to use that part.
  • x-sync — at most one request in flight: abort the old one, use the new one, or queue it. A map keyed by target.
  • x-validate — runs the browser native constraint validation before sending. All done in in x:beforeSend.
  • x-push-url / x-replace-url — updates browser history from an attribute or HX-Push-Url/HX-Replace-Url headers. Needs history.pushState/replaceState in x:afterSwap.
  • x-sse / x-ws — use streaming messages like a response and swap their content. No request/response cycle at all here, simply call x.swap() from onmessage.

None of these requires any changes to the core x.js, and each plugin is a dozen lines of code, easy to reason about and to debug. Seems like event-based plugin architecture and “SSS” API work better than I hoped (and it’s still notably smaller than htmx).

And yet, to make it fully compatible with HTMX the core needs some extra work. We need proper error handling for failed fetch(), non-2XX responses etc. This likely would need more custom events to be fired. We also likely need to expose resolve() to plugins, so that they could use the same x-target syntax everywhere. We need some promise-based cancellation, too. One obvious use case would be custom confirmation dialogs, that need to be asynchronous. View transitions likely need to be asynchronous as well, so a plugin could delay a swap until the transition is done. I’m sure there’s more things that I’m missing.

Those are left as exercises for the reader. Or you can just use htmx?

Full code of this experiment is at https://github.com/zserge/x, if you spot any bugs or willing to contribute - please do so!

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Jul 27, 2026

See also: Let's make the worst VueJS ever! and more.

联系我们 contact @ memedata.com