跳转至

HTMX 前端交互完全指南

为什么要学 HTMX

  1. 回归 HTML 本质:HTMX 让 HTML 重新成为超媒体的主角,用声明式属性替代命令式 JavaScript,减少 90% 前端代码量。你不需要学 React/Vue/Angular 就能构建动态 Web 应用。

  2. 后端框架无关:HTMX 不绑定任何后端语言或框架。无论你用 Python/Django、Go/Gin、Ruby/Rails、Java/Spring Boot,只要返回 HTML 片段即可,让后端开发者零门槛获得现代交互能力。

  3. 极低的学习曲线:整个库只有 ~14KB(gzip 后),核心 API 就 4-5 个属性,30 分钟即可上手。相比 React 生态需要学 JSX + Hooks + State Management + Router + SSR,HTMX 的认知负荷极低。

  4. SEO 与可访问性友好:返回的是真实 HTML,搜索引擎可直接索引,屏幕阅读器原生支持。不存在 SPA 的 SEO 困境和首屏空白问题。

  5. 与渐进增强完美契合:HTMX 遵循 HATEOAS 原则(Hypermedia As The Engine Of Application State),在没有 JavaScript 的情况下依然可用基本功能,适合构建高可靠性应用。


核心概念详解

HTMX 是什么(白话解释)

想象你在浏览网页时,点击链接会刷新整个页面。HTMX 让你可以说:"嘿,只替换页面中的这一小块"。传统上需要写 JavaScript fetch + DOM 操作才能实现的 AJAX 效果,HTMX 只需在 HTML 标签上加几个属性。

比如:

<button hx-get="/api/data" hx-target="#result" hx-swap="innerHTML">
  加载数据
</button>
<div id="result">数据会出现在这里</div>

点击按钮 → 向 /api/data 发 GET 请求 → 把返回的 HTML 放到 #result 里。就这么简单。

技术定义

HTMX 是一个轻量级 JavaScript 库(~14KB gzip),通过扩展 HTML 属性使任何 HTML 元素都能发起 AJAX 请求、触发 CSS 过渡动画、使用 WebSocket 和 Server-Sent Events。它实现了超媒体驱动应用(Hypermedia-Driven Application, HDA)的架构模式。

核心属性详解

hx-get / hx-post / hx-put / hx-patch / hx-delete

这五个属性对应 HTTP 的五种方法:

<!-- GET 请求:获取数据 -->
<button hx-get="/api/users">获取用户列表</button>

<!-- POST 请求:创建数据 -->
<form hx-post="/api/users">
  <input name="username" />
  <button type="submit">创建用户</button>
</form>

<!-- PUT 请求:完整更新 -->
<form hx-put="/api/users/1">
  <input name="username" value="新名字" />
  <button type="submit">更新</button>
</form>

<!-- PATCH 请求:部分更新 -->
<button hx-patch="/api/users/1" hx-vals='{"status": "active"}'>
  激活用户
</button>

<!-- DELETE 请求:删除 -->
<button hx-delete="/api/users/1" hx-confirm="确定删除?">
  删除用户
</button>

hx-target

指定响应内容放到哪里。默认放到发起请求的元素自身。

<!-- 放到指定 ID 的元素 -->
<button hx-get="/data" hx-target="#output">加载</button>

<!-- CSS 选择器 -->
<button hx-get="/data" hx-target=".result-area">加载</button>

<!-- 相对选择器 -->
<button hx-get="/data" hx-target="closest tr">加载到最近的表格行</button>
<button hx-get="/data" hx-target="next .sibling">加载到下一个兄弟元素</button>
<button hx-get="/data" hx-target="find .child">加载到子元素</button>

hx-swap

控制内容如何替换:

<!-- innerHTML(默认):替换目标的内部内容 -->
<div hx-get="/data" hx-swap="innerHTML">替换我的内容</div>

<!-- outerHTML:替换整个目标元素 -->
<div hx-get="/data" hx-swap="outerHTML">整个元素会被替换</div>

<!-- beforebegin:在目标元素前插入 -->
<div hx-get="/data" hx-swap="beforebegin">在我前面插入</div>

<!-- afterbegin:在目标内部最前面插入 -->
<div hx-get="/data" hx-swap="afterbegin">在我内部最前面插入</div>

<!-- beforeend:在目标内部最后面插入 -->
<div hx-get="/data" hx-swap="beforeend">在我内部最后面插入</div>

<!-- afterend:在目标元素后插入 -->
<div hx-get="/data" hx-swap="afterend">在我后面插入</div>

<!-- delete:删除目标元素 -->
<button hx-delete="/items/1" hx-target="closest tr" hx-swap="delete">删除行</button>

<!-- none:不做任何替换 -->
<button hx-post="/api/action" hx-swap="none">只执行,不更新页面</button>

swap 修饰符:

<!-- 延迟交换(过渡动画用) -->
<div hx-get="/data" hx-swap="innerHTML swap:300ms">带延迟的替换</div>

<!-- 替换后滚动到顶部 -->
<div hx-get="/data" hx-swap="innerHTML scroll:top">替换后滚动</div>

<!-- 替换后聚焦 -->
<div hx-get="/data" hx-swap="innerHTML focus-scroll:true">替换后聚焦</div>

<!-- 替换时的过渡效果 -->
<div hx-get="/data" hx-swap="innerHTML transition:true">带过渡效果</div>

hx-trigger

控制何时发起请求:

<!-- 默认触发事件(按钮是 click,表单是 submit,输入框是 change) -->
<button hx-get="/data">点击触发</button>

<!-- 自定义事件 -->
<div hx-get="/data" hx-trigger="mouseenter">鼠标悬停触发</div>

<!-- 修饰符 -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results" />
<!-- keyup:键盘抬起时 -->
<!-- changed:值确实改变了才触发 -->
<!-- delay:300ms:防抖300毫秒 -->

<!-- 多个触发条件 -->
<div hx-get="/data" hx-trigger="click, keyup[key=='Enter'] from:body">
  点击或按回车
</div>

<!-- 页面加载时触发 -->
<div hx-get="/init-data" hx-trigger="load">页面加载时获取数据</div>

<!-- 元素可见时触发(无限滚动) -->
<div hx-get="/more" hx-trigger="revealed">滚动到可见时加载更多</div>

<!-- 每隔一段时间触发(轮询) -->
<div hx-get="/updates" hx-trigger="every 5s">每5秒刷新</div>

<!-- 只触发一次 -->
<div hx-get="/data" hx-trigger="click once">只能点击一次</div>

<!-- 节流 -->
<div hx-get="/data" hx-trigger="click throttle:1s">每秒最多一次</div>

hx-vals 和 hx-include

发送额外数据:

<!-- JSON 格式传值 -->
<button hx-post="/api" hx-vals='{"key": "value", "page": 2}'>
  带参数
</button>

<!-- JavaScript 表达式(js: 前缀) -->
<button hx-post="/api" hx-vals='js:{time: new Date().toISOString()}'>
  动态参数
</button>

<!-- 包含其他表单元素的值 -->
<input id="search-input" name="q" />
<button hx-get="/search" hx-include="#search-input">搜索</button>

<!-- 包含最近的表单 -->
<button hx-post="/api" hx-include="closest form">提交表单</button>

HTMX vs React vs Vue 对比

特性HTMXReactVue 3
体积~14KB gzip~45KB gzip (react+react-dom)~33KB gzip
学习曲线极低(HTML属性)高(JSX/Hooks/生态)中(模板/组合式API)
状态管理服务端(HTML)客户端(Redux/Zustand等)客户端(Pinia等)
构建工具不需要需要(Vite/Webpack)需要(Vite/Webpack)
SEO原生友好需要SSR (Next.js)需要SSR (Nuxt)
首屏性能极快(纯HTML)需要JS加载+水合需要JS加载+水合
交互复杂度适合中低复杂度适合高复杂度适合高复杂度
离线支持不支持Service WorkerService Worker
类型安全无(HTML属性)TypeScript完整支持TypeScript完整支持
后端耦合紧耦合(返回HTML)松耦合(JSON API)松耦合(JSON API)
适用场景内容站/管理后台/CRUD复杂SPA/协作工具复杂SPA/中大型应用
生态规模小但精极大很大

超媒体驱动 vs JSON API 驱动

对比维度HTMX(超媒体驱动)SPA(JSON API 驱动)
服务端返回HTML 片段JSON 数据
渲染位置服务端客户端
路由逻辑服务端客户端
表单验证服务端为主客户端+服务端
前后端分工后端全包前后端分离
团队需求1个全栈即可前端+后端
缓存策略HTTP 缓存API 缓存 + 状态缓存

安装与配置

方法一:CDN 引入(最简单)

<!-- 最新版(生产建议锁定版本) -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- 或者用 jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js"></script>

方法二:npm 安装

npm install htmx.org

在项目中引入:

// ES Module
import 'htmx.org';

// 或在入口文件
import htmx from 'htmx.org';
window.htmx = htmx;

方法三:下载到本地

# 下载到项目静态资源目录
curl -L https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js -o static/js/htmx.min.js
<script src="/static/js/htmx.min.js"></script>

HTMX 全局配置

<!-- 在 head 中通过 meta 标签配置 -->
<meta name="htmx-config" content='{
  "defaultSwapStyle": "innerHTML",
  "defaultSwapDelay": 0,
  "defaultSettleDelay": 20,
  "includeIndicatorStyles": true,
  "historyCacheSize": 10,
  "useTemplateFragments": true,
  "selfRequestsOnly": true,
  "scrollBehavior": "instant",
  "timeout": 0
}'>
// 或通过 JavaScript 配置
htmx.config.defaultSwapStyle = "innerHTML";
htmx.config.defaultSettleDelay = 20;
htmx.config.selfRequestsOnly = true; // 只允许同源请求
htmx.config.historyCacheSize = 10;

CSRF Token 配置(Django 示例)

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
  <!-- 所有 HTMX 请求都会带上 CSRF Token -->
</body>

常用扩展安装

<!-- 官方扩展 -->
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<script src="https://unpkg.com/htmx-ext-loading-states@2.0.0/loading-states.js"></script>
<script src="https://unpkg.com/htmx-ext-response-targets@2.0.1/response-targets.js"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.0.0/sse.js"></script>

快速上手:5 分钟最小示例

使用 Python FastAPI 后端

创建项目结构:

mkdir htmx-demo && cd htmx-demo
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install fastapi uvicorn jinja2 python-multipart

main.py

from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# 模拟数据库
todos = [
    {"id": 1, "title": "学习 HTMX", "done": False},
    {"id": 2, "title": "写示例代码", "done": False},
]

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse("index.html", {
        "request": request,
        "todos": todos
    })

@app.post("/todos", response_class=HTMLResponse)
async def add_todo(request: Request, title: str = Form(...)):
    new_id = max(t["id"] for t in todos) + 1 if todos else 1
    todo = {"id": new_id, "title": title, "done": False}
    todos.append(todo)
    return templates.TemplateResponse("partials/todo_item.html", {
        "request": request,
        "todo": todo
    })

@app.patch("/todos/{todo_id}/toggle", response_class=HTMLResponse)
async def toggle_todo(request: Request, todo_id: int):
    for todo in todos:
        if todo["id"] == todo_id:
            todo["done"] = not todo["done"]
            return templates.TemplateResponse("partials/todo_item.html", {
                "request": request,
                "todo": todo
            })

@app.delete("/todos/{todo_id}", response_class=HTMLResponse)
async def delete_todo(todo_id: int):
    global todos
    todos = [t for t in todos if t["id"] != todo_id]
    return ""  # 返回空字符串,配合 hx-swap="delete"

templates/index.html

<!DOCTYPE html>
<html>
<head>
    <title>HTMX Todo</title>
    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
    <style>
        body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 0 20px; }
        .done { text-decoration: line-through; opacity: 0.6; }
        .todo-item { display: flex; align-items: center; gap: 10px; padding: 8px; border-bottom: 1px solid #eee; }
        .todo-item button { cursor: pointer; }
        form { display: flex; gap: 10px; margin-bottom: 20px; }
        input[type="text"] { flex: 1; padding: 8px; }
        .htmx-indicator { display: none; }
        .htmx-request .htmx-indicator { display: inline; }
    </style>
</head>
<body>
    <h1>HTMX Todo App</h1>

    <form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend" hx-on::after-request="this.reset()">
        <input type="text" name="title" placeholder="添加新任务..." required />
        <button type="submit">添加</button>
        <span class="htmx-indicator">加载中...</span>
    </form>

    <div id="todo-list">
        {% for todo in todos %}
            {% include "partials/todo_item.html" %}
        {% endfor %}
    </div>
</body>
</html>

templates/partials/todo_item.html

<div class="todo-item {{ 'done' if todo.done }}" id="todo-{{ todo.id }}">
    <input type="checkbox"
           hx-patch="/todos/{{ todo.id }}/toggle"
           hx-target="#todo-{{ todo.id }}"
           hx-swap="outerHTML"
           {{ 'checked' if todo.done }} />
    <span>{{ todo.title }}</span>
    <button hx-delete="/todos/{{ todo.id }}"
            hx-target="#todo-{{ todo.id }}"
            hx-swap="outerHTML"
            hx-confirm="确定删除?">
    </button>
</div>

运行:

uvicorn main:app --reload
# 打开 http://localhost:8000

进阶用法

<input type="search"
       name="q"
       hx-get="/search"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#search-results"
       hx-indicator="#search-spinner"
       placeholder="搜索用户...">

<span id="search-spinner" class="htmx-indicator">🔍 搜索中...</span>
<div id="search-results"></div>

后端(FastAPI):

@app.get("/search", response_class=HTMLResponse)
async def search(request: Request, q: str = ""):
    results = [u for u in users if q.lower() in u["name"].lower()]
    return templates.TemplateResponse("partials/search_results.html", {
        "request": request,
        "results": results,
        "query": q
    })

场景二:无限滚动

<table>
  <tbody id="data-rows">
    {% for item in items %}
    <tr>
      <td>{{ item.name }}</td>
      <td>{{ item.value }}</td>
    </tr>
    {% endfor %}

    <!-- 最后一行是触发器 -->
    <tr hx-get="/items?page={{ next_page }}"
        hx-trigger="revealed"
        hx-swap="afterend"
        hx-target="this">
      <td colspan="2">加载更多...</td>
    </tr>
  </tbody>
</table>

后端返回下一批数据行 + 新的触发器行(如果还有数据的话)。

场景三:内联编辑

<!-- 查看模式 -->
<div id="user-{{ user.id }}" class="user-card">
    <span>{{ user.name }}</span>
    <button hx-get="/users/{{ user.id }}/edit"
            hx-target="#user-{{ user.id }}"
            hx-swap="outerHTML">
        编辑
    </button>
</div>
<!-- 编辑模式(后端返回的 HTML) -->
<form id="user-{{ user.id }}" class="user-card"
      hx-put="/users/{{ user.id }}"
      hx-target="this"
      hx-swap="outerHTML">
    <input name="name" value="{{ user.name }}" />
    <button type="submit">保存</button>
    <button hx-get="/users/{{ user.id }}"
            hx-target="#user-{{ user.id }}"
            hx-swap="outerHTML"
            type="button">
        取消
    </button>
</form>

场景四:级联下拉框

<label>省份:</label>
<select name="province"
        hx-get="/cities"
        hx-trigger="change"
        hx-target="#city-select">
    <option value="">请选择</option>
    <option value="zj">浙江</option>
    <option value="js">江苏</option>
    <option value="gd">广东</option>
</select>

<label>城市:</label>
<select id="city-select" name="city"
        hx-get="/districts"
        hx-trigger="change"
        hx-target="#district-select">
    <option value="">先选择省份</option>
</select>

<label>区县:</label>
<select id="district-select" name="district">
    <option value="">先选择城市</option>
</select>

场景五:WebSocket 实时聊天

<div hx-ext="ws" ws-connect="/ws/chat">
    <div id="chat-messages">
        <!-- 消息会被追加到这里 -->
    </div>

    <form ws-send>
        <input name="message" placeholder="输入消息..." autocomplete="off" />
        <button type="submit">发送</button>
    </form>
</div>

后端(FastAPI WebSocket):

from fastapi import WebSocket
import json

@app.websocket("/ws/chat")
async def chat(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_json()
        message = data.get("message", "")
        # 返回 HTML 片段,HTMX 自动追加到 #chat-messages
        html = f"""
        <div id="chat-messages" hx-swap-oob="beforeend">
            <div class="msg">
                <strong>用户:</strong> {message}
            </div>
        </div>
        """
        await websocket.send_text(html)

场景六:Server-Sent Events(实时通知)

<div hx-ext="sse" sse-connect="/events" sse-swap="notification">
    <div id="notifications">
        <!-- SSE 消息会自动插入 -->
    </div>
</div>

后端:

from fastapi.responses import StreamingResponse
import asyncio

async def event_generator():
    while True:
        await asyncio.sleep(5)
        data = f'<div class="notification">新通知: {datetime.now()}</div>'
        yield f"event: notification\ndata: {data}\n\n"

@app.get("/events")
async def events():
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream"
    )

场景七:表单多步向导

<!-- 步骤 1 -->
<div id="wizard">
    <form hx-post="/wizard/step1" hx-target="#wizard" hx-swap="innerHTML">
        <h3>步骤 1: 基本信息</h3>
        <input name="name" placeholder="姓名" required />
        <input name="email" type="email" placeholder="邮箱" required />
        <button type="submit">下一步</button>
    </form>
</div>

后端依次返回步骤 2、3 的 HTML 表单,每一步都可以做服务端验证。

场景八:乐观 UI 更新(Optimistic Updates)

<button hx-post="/api/like"
        hx-target="this"
        hx-swap="outerHTML"
        hx-indicator="none"
        class="like-btn">
    ❤️ <span class="count">42</span>
</button>

<style>
/* 请求进行中时立即变化 */
.like-btn.htmx-request {
    color: red;
    transform: scale(1.2);
}
</style>

后端集成指南

Django 集成

# pip install django-htmx

# settings.py
MIDDLEWARE = [
    # ...
    "django_htmx.middleware.HtmxMiddleware",
]

# views.py
from django.shortcuts import render

def user_list(request):
    users = User.objects.all()
    if request.htmx:
        # HTMX 请求只返回部分模板
        return render(request, "partials/user_list.html", {"users": users})
    # 普通请求返回完整页面
    return render(request, "user_list.html", {"users": users})

Go (Gin) 集成

package main

import (
    "github.com/gin-gonic/gin"
    "net/http"
)

func main() {
    r := gin.Default()
    r.LoadHTMLGlob("templates/**/*")

    r.GET("/users", func(c *gin.Context) {
        users := getUsers()
        if c.GetHeader("HX-Request") == "true" {
            c.HTML(http.StatusOK, "partials/users.html", gin.H{"users": users})
            return
        }
        c.HTML(http.StatusOK, "pages/users.html", gin.H{"users": users})
    })

    r.Run(":8080")
}

Express.js 集成

const express = require('express');
const app = express();

app.set('view engine', 'ejs');

app.get('/items', (req, res) => {
    const items = getItems();
    if (req.headers['hx-request']) {
        res.render('partials/items', { items });
    } else {
        res.render('pages/items', { items });
    }
});

常见问题与排错

问题一:HTMX 请求没有发出

症状:点击按钮后什么都没发生。

排查步骤: 1. 打开浏览器开发工具 Network 面板查看是否有请求 2. 检查控制台是否有 HTMX 加载错误 3. 确认 htmx.org 脚本已正确加载

// 在控制台检查 HTMX 是否加载
console.log(htmx.version); // 应该输出版本号

常见原因: - 脚本路径错误或被 CSP 阻止 - 属性名拼写错误(注意是 hx-get 不是 htmx-get) - 元素在 HTMX 加载之前就已渲染(动态内容需要 htmx.process(element)

问题二:CSRF 验证失败(403 错误)

解决方案

<!-- Django -->
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

<!-- Rails -->
<meta name="csrf-token" content="<%= form_authenticity_token %>">
<script>
    document.addEventListener('htmx:configRequest', (e) => {
        e.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
    });
</script>

问题三:动态加载的内容中 HTMX 属性不生效

原因:HTMX 默认只处理初始页面中的元素。对通过 innerHTML 等方式手动插入的内容,需要手动初始化。

// 手动处理新内容
document.getElementById('container').innerHTML = newContent;
htmx.process(document.getElementById('container'));

HTMX 自己的响应插入会自动处理,不需要额外操作。

问题四:表单数据没有发送

原因:非表单元素(如 <button><div>)发请求时不会自动包含附近表单的值。

<!-- 错误:button 不在 form 内,不会发送 input 的值 -->
<input name="q" />
<button hx-get="/search">搜索</button>

<!-- 正确方法一:用 hx-include -->
<input id="q" name="q" />
<button hx-get="/search" hx-include="#q">搜索</button>

<!-- 正确方法二:放在 form 里 -->
<form hx-get="/search">
    <input name="q" />
    <button type="submit">搜索</button>
</form>

问题五:请求发了但页面没更新

排查: 1. 检查 hx-target 选择器是否正确匹配到了元素 2. 检查后端返回的 Content-Type 是否为 text/html 3. 检查 hx-swap 策略是否正确

# 确保返回 HTML
@app.get("/data")
async def data():
    return HTMLResponse("<div>新内容</div>")
    # 不要返回 JSONResponse,HTMX 期望的是 HTML

问题六:如何调试 HTMX 请求

// 启用详细日志
htmx.logAll();

// 监听特定事件
document.addEventListener('htmx:beforeRequest', (e) => {
    console.log('请求即将发送:', e.detail);
});

document.addEventListener('htmx:afterRequest', (e) => {
    console.log('请求完成:', e.detail);
});

document.addEventListener('htmx:responseError', (e) => {
    console.error('请求错误:', e.detail);
});

// 查看元素上的 HTMX 配置
console.log(htmx.closest(element, '[hx-get]'));

问题七:如何处理错误响应

<!-- 使用 response-targets 扩展 -->
<div hx-ext="response-targets">
    <form hx-post="/submit"
          hx-target="#result"
          hx-target-422="#errors"
          hx-target-5*="#server-error">
        <!-- 表单内容 -->
    </form>
    <div id="result"></div>
    <div id="errors"></div>
    <div id="server-error"></div>
</div>

问题八:页面历史和浏览器后退按钮

<!-- 启用历史记录(将请求推入浏览器历史) -->
<a hx-get="/page2" hx-push-url="true" hx-target="#main">页面2</a>

<!-- 替换当前历史记录 -->
<a hx-get="/page2" hx-replace-url="true" hx-target="#main">页面2</a>

<!-- 自定义 URL -->
<a hx-get="/api/page2" hx-push-url="/page2" hx-target="#main">页面2</a>

参考资源

  • 官方文档:https://htmx.org/docs/
  • 官方示例:https://htmx.org/examples/
  • HTMX GitHub:https://github.com/bigskysoftware/htmx
  • 《Hypermedia Systems》书籍:https://hypermedia.systems/ (免费在线阅读)
  • htmx 扩展列表:https://htmx.org/extensions/
  • django-htmx:https://github.com/adamchainz/django-htmx
  • HTMX + Go 示例:https://github.com/angelofallars/htmx-go
  • Awesome HTMX:https://github.com/rajasegar/awesome-htmx
  • Carson Gross 演讲 (HTMX 作者):在 YouTube 搜索 "htmx carson gross"
  • HTMX Discord:https://htmx.org/discord