Let's make the worst Htmx

Hacker News Top 工具

摘要

A blog post that humorously builds a minimal 'worst' clone of the htmx library, showing how to recreate its core behavior (triggers, fetches, and swaps) in vanilla JavaScript.

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/31 23:00

# Let's make the worst htmx ever! Source: [https://zserge.com/posts/worst-htmx-ever/](https://zserge.com/posts/worst-htmx-ever/) This is a continuation of a[series](https://zserge.com/posts/worst-react-ever/)of[posts](https://zserge.com/posts/worst-vuejs-ever)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 ishtmx, basically a frontend library for backend developers who couldn’t care less about JavaScript\. HTMX started as[intercooler](https://htmx.org/migration-guide-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](https://github.com/bigskysoftware/htmx/blob/v4.0.0-beta6/src/htmx.js#L1898-L1931)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](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](https://github.com/zserge),[Mastodon](https://mastodon.social/@zserge),[Twitter](https://twitter.com/zsergo)or subscribe via[rss](https://zserge.com/rss.xml)\. *Jul 27, 2026* See also:[Let's make the worst VueJS ever\!](https://zserge.com/posts/worst-vuejs-ever/)and[more](https://zserge.com/posts/)\.

相似文章

HTMX 太酷了,我自己写了一个

Lobsters Hottest

本文探讨了 HTMX 这个库,它偏爱服务器端渲染的 HTML,而非大量使用 JavaScript 的前端。作者发现 HTMX 对于持久化的 UI 组件很有用,但最终出于个人对最小依赖的偏好,决定自己实现一套解决方案。

bigskysoftware/htmx

GitHub Trending (daily)

htmx 是一个 JavaScript 库,通过简单的 HTML 属性支持构建现代用户界面,实现 AJAX、CSS 过渡和其他交互,从而简化 Web 开发。