40 行代码实现 HTMX 克隆版!还能打造多种插件,快来探索!

📅 2026/8/2 17:58:56
40 行代码实现 HTMX 克隆版!还能打造多种插件,快来探索!
让我们打造史上最简陋的 htmx 库本文是一系列构建热门 Web 框架微型“克隆版”文章的延续此前相关文章有《让我们打造史上最简陋的 React》和《让我们打造史上最简陋的 Vue.js》。Web 技术发展日新月异构建 Web 应用的工具也在不断变化不过让我欣慰的是我关于 React 和 Vue 的文章已经发布七年了这两个框架至今依然广受欢迎且颇具影响力。如今htmx 成为了热门话题。它本质上是一款为不太关心 JavaScript 的后端开发者打造的前端库。HTMX 起源于 intercooler之后不断发展并增添了更多功能其魅力可见一斑例如下面这段代码button hx - post/clicked hx - triggerclick hx - target#parent - div hx - swapouterHTMLClick Me!/button这段代码以声明式的方式表明当按钮被点击时向 /clicked 发送一个 POST 请求然后用响应数据替换 id 为 #parent - div 的元素的外部 HTML。HTMX 将事件、AJAX 请求和 DOM 更新巧妙地结合在一起隐藏了前端的复杂性。只要后端能够发送 HTML 模板就可以借助 HTMX 构建完整的应用而无需编写一行 JavaScript 代码。方法、触发条件、目标元素和替换方式从上面的示例可以看出我们要实现的库应该能够根据某些“触发条件”来 fetch() 特定的 URL并更新“替换”某些“目标”元素的内容类似下面这样button x - get/clickClick me/buttonscriptdocument.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);});});/script如果后端返回的响应是 “Button clicked”这段代码就能正常工作。打开页面点击按钮按钮会消失取而代之的是显示的文本。用 10 行代码就实现了 HTMX 的基本功能。不过还有一些地方可以改进。首先最好对响应进行清理确保它是有效的 HTML。其次应该允许用户指定要替换内容的目标元素。第三要让用户能够明确指定在目标元素中替换内容的具体方式比如替换、追加、前置、删除元素等示例代码如下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);};现在我们可以编写一个更通用的 “fetcher” 函数来处理不同的方法、触发条件、目标元素和替换模式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());};默认情况下我们会更新元素的内容除非指定了 x - target 或 x - swap。同时我们遵循 HTMX 的约定在请求中向后端发送一个自定义头部。为了将所有功能整合起来我们添加一个简单的事件监听器根据触发条件来分发请求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(x - ${m})); }); })); scan(); // 我们还应该在 send() 函数中的每次 swap() 之后调用 scan()这样用 40 行代码就实现了一个支持所有 HTTP 方法、自定义目标元素和替换策略的 HTMX 克隆版。一个小的优化是避免在每次替换后重新扫描整个 DOM而是只扫描新添加的内容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 });现在这个克隆版既小巧又高效。更强大的触发条件我们可以引入一种自定义的触发条件语法允许使用逗号分隔的事件列表还可以添加可选的延迟、“changed” 或 “once” 修饰符等示例代码如下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 }; }); }这个函数会返回一个包含事件和选项键值对在 HTML 属性中用冒号分隔的列表。现在我们可以编写像 x - triggerload, click one, change changed delay:500 这样的代码。在 scan() 函数中我们还需要特殊处理一些事件比如 load 实际上就是 setTimeout(() send(...), 0)changed 会缓存之前的值如果值没有改变就跳过发送请求once 会在第一次触发后移除事件监听器delay 则是另一个带有指定延迟的 setTimeout。这里发挥的空间很大我们甚至可以像 HTMX 那样支持视口交互触发、队列、防抖、周期性事件等等。不过我们这里打造的是一个简陋的 HTMX 克隆版。更灵活的目标元素到目前为止我们使用简单的 querySelector 来查找要替换内容的元素。在实际应用中我们可能需要支持更多的修饰符比如匹配最近的祖先元素或下一个兄弟元素示例代码如下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); };我们在 send() 函数中调用这个函数而不是直接使用 document.querySelector(sel)。现在我们可以编写像 x - targetclosest .container 或 x - targetnext 这样的代码从而更精准地定位元素而不仅仅依赖全局查询。实际上HTMX 也做了类似的事情。更丰富的事件HTMX 会触发一些自定义事件比如 htmx:beforeRequest开发者可以拦截这些事件对请求流程进行修改和控制。我们也可以实现类似的功能。总共触发 4 个事件beforeRequest、afterRequest、beforeSwap 和 afterSwap。每个事件都会在触发请求的元素上触发并包含当前 send() 操作的完整上下文。通过调用 event.preventDefault() 可以取消这些事件。我们还尝试支持一些服务器端事件比如 HX - Trigger 或 HX - Redirect。如果服务器发送了 HX - Trigger 头部我们会根据头部内容触发一个自定义事件。如果服务器发送了 HX - Redirect我们就直接将浏览器重定向到指定的 URL。这里保留 HX 前缀是因为 X - ... 头部是标准 HTTP 头部的常见约定。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); // 重新绑定替换后引入的元素 };最终的 send() 函数代码变长了但它让我们可以通过拦截请求和替换操作来编写插件。注意我们在不同事件之间传递事件细节这意味着事件处理程序可以修改请求和替换流程。例如一个插件可以拦截 x:beforeSwap 事件根据某些条件或属性来更改目标元素或替换模式。插件我最初对 HTMX 的印象是它只做一件事当带有声明式配置的元素上发生某个事件时向某个 URL 发送一个 AJAX 请求并将响应内容替换到另一个元素中。但查看其参考文档后会发现有 hx - on、hx - vals、hx - headers、hx - swap - oob、hx - params、hx - push - url、hx - history - elt、hx - sse、hx - ext 等众多属性这让它变得不那么容易理解。我们能否在不修改核心“循环”的情况下实现一些功能上的对等呢让我们的库的“精髓”保持为 “SSS”即 scan send swap。例如HTMX 支持 “boosting” 功能即增强链接和表单使其使用 AJAX 而不是整页刷新这样就不用在每个导航链接上都写 x - get 和 x - target 了。这甚至不需要做成插件只需一段 JavaScript 代码即可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); });同样我们可以在表单提交时实现 “boosting” 功能。还有很多其他可能的插件可以实现x - confirm在发送请求前弹出一个原生的 confirm() 对话框允许用户取消请求。需要监听 x:beforeSend 事件在用户拒绝时调用 e.preventDefault()。x - indicator在请求进行时显示一个加载指示器。需要在 x:beforeSend 事件中切换类名或样式在 x:afterSwap 事件中恢复原状。x - disable在请求期间禁用元素避免重复提交。逻辑与加载指示器类似。x - headers从 JSON 属性中获取额外的请求头部。在 x:beforeSend 事件中解析并合并到 opts.headers 中。x - vals / x - include向请求中注入额外的值。需要在 x:beforeSend 事件中修改 opts.body。x - select只替换响应中的一部分内容而不是整个 HTML这样可以简化后端代码。使用 querySelector 选择我们需要的部分在 x:beforeSwap 事件中替换 detail.html。x - sync确保同一时间最多只有一个请求在进行取消旧的请求使用新的请求或者将其放入队列。可以使用一个以目标元素为键的映射来实现。x - validate在发送请求前运行浏览器的原生约束验证。所有操作都在 x:beforeSend 事件中完成。x - push - url / x - replace - url根据属性或 HX - Push - Url/HX - Replace - Url 头部更新浏览器历史记录。需要在 x:afterSwap 事件中使用 history.pushState/replaceState。x - sse / x - ws将流式消息作为响应并替换其内容。这里没有传统的请求/响应周期只需在 onmessage 事件中调用 x.swap() 即可。这些插件都不需要对核心的 x.js 文件做任何修改每个插件只需十几行代码易于理解和调试。基于事件的插件架构和 “SSS” API 的效果比我预期的还要好而且它仍然比 htmx 小很多。不过要使其与 HTMX 完全兼容核心部分还需要做一些额外的工作。我们需要对失败的 fetch() 请求、非 2XX 响应等情况进行适当的错误处理这可能需要触发更多的自定义事件。我们还需要向插件暴露 resolve() 函数以便它们在任何地方都能使用相同的 x - target 语法。此外还需要实现基于 Promise 的取消机制一个明显的用例是自定义确认对话框它需要是异步的。视图过渡可能也需要是异步的这样插件就可以在过渡完成后再进行替换操作。我相信还有很多我没考虑到的地方。这些就留给读者自行探索了。或者你也可以直接使用 htmx。