คู่มือฉบับสมบูรณ์สำหรับนักพัฒนาเว็บที่ต้องการสร้างแอปพลิเคชันแบบ Interactive โดยไม่พึ่งพา JavaScript Framework ขนาดใหญ่
HTMX คือ JavaScript Library ขนาดเล็ก (~14KB เมื่อ gzip) ที่ช่วยให้ HTML Element ธรรมดาสามารถส่ง HTTP Request และอัปเดต DOM ได้โดยตรง โดยไม่ต้องเขียน JavaScript เพิ่มเติม หลักการทำงานของ HTMX คือการ ขยายความสามารถของ HTML ให้เกินขอบเขตที่มาตรฐานดั้งเดิมกำหนดไว้
HTMX พัฒนาต่อยอดจากโปรเจกต์ intercooler.js ซึ่งสร้างโดย Carson Gross ในปี 2013 ก่อนที่จะเขียนใหม่ทั้งหมดและเปลี่ยนชื่อเป็น HTMX ในปี 2020 แนวคิดหลักมาจากการตั้งคำถามว่า "ทำไม HTML จึงจำกัดให้เฉพาะ <a> และ <form> เท่านั้นที่ส่ง HTTP Request ได้?"
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'secondaryColor': '#504945',
'tertiaryColor': '#282828',
'background': '#282828',
'mainBkg': '#3c3836',
'nodeBorder': '#a89984',
'clusterBkg': '#32302f',
'titleColor': '#ebdbb2',
'edgeLabelBackground': '#3c3836',
'fontSize': '14px'
}
}}%%
flowchart TD
subgraph era1["ยุค 2013 (Era: 2013)"]
direction LR
A["intercooler.js - Carson Gross สร้าง - First Version"]
end
subgraph era2["ยุค 2015-2019 (Era: 2015-2019)"]
direction LR
B["intercooler.js v1.x - เพิ่ม Attributes - ปรับปรุง API"]
C["ชุมชนเติบโต - Community Grows"]
end
subgraph era3["ยุค 2020 (Era: 2020)"]
direction LR
D["เขียนใหม่ทั้งหมด - Rewrite from Scratch"]
E["เปลี่ยนชื่อเป็น HTMX - Rename to HTMX"]
end
subgraph era4["ยุค 2021-ปัจจุบัน (Era: 2021-Present)"]
direction LR
F["HTMX v1.x - Stable Release"]
G["HTMX v2.0 - Breaking Changes - Modern API"]
H["GitHub Stars > 40K - Popularity Surge"]
end
era1 --> era2 --> era3 --> era4
B --> C
D --> E
F --> G --> H
style A fill:#458588,color:#ebdbb2,stroke:#83a598
style B fill:#458588,color:#ebdbb2,stroke:#83a598
style C fill:#689d6a,color:#ebdbb2,stroke:#8ec07c
style D fill:#d79921,color:#282828,stroke:#fabd2f
style E fill:#d79921,color:#282828,stroke:#fabd2f
style F fill:#b16286,color:#ebdbb2,stroke:#d3869b
style G fill:#cc241d,color:#ebdbb2,stroke:#fb4934
style H fill:#98971a,color:#282828,stroke:#b8bb26
รูปที่ 1.1: ไทม์ไลน์การพัฒนา HTMX จาก intercooler.js สู่ปัจจุบัน
HTMX ยึดถือหลักการ "HTML-First" ซึ่งมีแนวคิดสำคัญดังนี้:
<!-- ตัวอย่าง: ปรัชญา Locality of Behavior -->
<!-- ❌ วิธีเดิม: พฤติกรรมแยกออกจาก Element -->
<button id="load-btn">โหลดข้อมูล</button>
<script>
document.getElementById('load-btn').addEventListener('click', function() {
fetch('/api/data').then(r => r.json()).then(data => {
// อัปเดต DOM...
});
});
</script>
<!-- ✅ วิธี HTMX: พฤติกรรมอยู่ที่ Element โดยตรง -->
<button hx-get="/api/data" hx-target="#result" hx-swap="innerHTML">
โหลดข้อมูล
</button>
<div id="result"></div>
การพัฒนาเว็บด้วย Single Page Application (SPA) framework อย่าง React, Vue, หรือ Angular มาพร้อมกับ trade-off ที่สำคัญ:
| ปัญหา | รายละเอียด | ผลกระทบ |
|---|---|---|
| Bundle Size ขนาดใหญ่ | React + ReactDOM ~130KB gzip | โหลดช้าบน mobile/slow connection |
| JavaScript Required | ถ้า JS ล้มเหลว ทั้งหน้าพัง | Accessibility และ Resilience แย่ลง |
| State Management ซับซ้อน | Redux, Zustand, Pinia, NgRx | Learning curve สูง |
| Client-server Duplication | Logic ซ้ำกันทั้ง Frontend และ Backend | Maintenance cost เพิ่มขึ้น |
| SEO และ SSR ยาก | ต้องการ Hydration ซับซ้อน | Next.js/Nuxt เพิ่มความซับซ้อน |
| Build Process ซับซ้อน | Webpack/Vite + TypeScript + Testing | DevOps overhead สูง |
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'fontSize': '14px'
}
}}%%
pie title ต้นทุนการพัฒนา SPA (SPA Development Cost Distribution)
"Bundle Size / Network" : 20
"State Management" : 25
"Build Tooling" : 15
"Testing Complexity" : 20
"Team Learning Curve" : 20
รูปที่ 1.2: การกระจายต้นทุนในการพัฒนา SPA
HTMX เหมาะสมอย่างยิ่งสำหรับ:
HATEOAS (Hypermedia as the Engine of Application State) คือ constraint หนึ่งของ REST Architecture ที่ระบุว่า Client ไม่ควรรู้จัก URL หรือ Action ล่วงหน้า แต่ควรค้นพบผ่าน Hypermedia ที่ Server ส่งกลับมา
กล่าวง่าย ๆ คือ Server ส่ง HTML กลับมาพร้อมกับ ลิงก์ และ ฟอร์ม ที่บอกว่า Client สามารถทำอะไรได้บ้าง ณ ขณะนั้น
<!-- ตัวอย่าง HATEOAS Response จาก Server -->
<!-- เมื่อ User ยังไม่ได้ Login -->
<div class="user-actions">
<a hx-get="/login">เข้าสู่ระบบ</a>
<a hx-get="/register">สมัครสมาชิก</a>
</div>
<!-- เมื่อ User Login แล้ว -->
<div class="user-actions">
<a hx-get="/profile">โปรไฟล์</a>
<a hx-get="/orders">คำสั่งซื้อ</a>
<button hx-post="/logout">ออกจากระบบ</button>
</div>
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
sequenceDiagram
participant Browser as 🌐 Browser (Client)
participant Server as 🖥️ Server
Browser->>Server: GET /products
Server-->>Browser: HTML + hx-get="/products/1" links
Note over Browser: แสดงรายการสินค้า
(Display product list)
Browser->>Server: hx-get="/products/1"
Server-->>Browser: HTML Fragment ของสินค้า + hx-post="/cart/add"
Note over Browser: แสดงรายละเอียดสินค้า
(Display product detail)
Browser->>Server: hx-post="/cart/add" (id=1)
Server-->>Browser: HTML Fragment ตะกร้า + hx-delete="/cart/1"
Note over Browser: อัปเดตตะกร้าสินค้า
(Update shopping cart)
รูปที่ 1.3: การทำงานของ HATEOAS ใน HTMX Application
Roy Fielding ผู้บัญญัติ REST ในวิทยานิพนธ์ปี 2000 ระบุว่า REST ต้องมี HATEOAS แต่ในทางปฏิบัติ "REST API" ส่วนใหญ่ที่เราเห็นในปัจจุบันเป็นเพียง HTTP API ที่ไม่ได้เป็น REST ที่แท้จริง
| คุณสมบัติ | REST จริง (Fielding) | REST API ทั่วไป | HTMX |
|---|---|---|---|
| Stateless | ✅ | ✅ | ✅ |
| Uniform Interface | ✅ | ✅ | ✅ |
| HATEOAS | ✅ บังคับ | ❌ มักขาด | ✅ |
| Media Type | Hypermedia (HTML) | JSON | HTML (Hypermedia) |
| Self-describing | ✅ | ⚠️ บางส่วน | ✅ |
HTML มีคุณสมบัติ Hypermedia โดยธรรมชาติ:
<a href="..."> — ลิงก์ไปยังทรัพยากรอื่น<form action="..."> — การกระทำต่อทรัพยากร<img src="..."> — การฝัง Hypermediamethod attributeHTMX ขยายความสามารถนี้ให้กับ ทุก Element ใน HTML
| หัวข้อ | HTMX | React | Vue 3 | Angular |
|---|---|---|---|---|
| Bundle Size | ~14KB | ~130KB | ~90KB | ~180KB |
| Learning Curve | ต่ำมาก | ปานกลาง | ต่ำ-ปานกลาง | สูง |
| State Management | Server-side | Client-side | Client-side | Client-side |
| Rendering | Server (SSR) | Client (CSR/SSR) | Client (CSR/SSR) | Client (CSR/SSR) |
| SEO | ✅ ดีเยี่ยม | ⚠️ ต้องการ SSR | ⚠️ ต้องการ SSR | ⚠️ ต้องการ SSR |
| TypeScript Support | ❌ ไม่มี | ✅ | ✅ | ✅ บังคับ |
| Component Model | ไม่มี | ✅ | ✅ | ✅ |
| Testing | ง่าย | ปานกลาง | ปานกลาง | ซับซ้อน |
| Backend Agnostic | ✅ | ✅ | ✅ | ✅ |
| Offline Support | ❌ | ✅ | ✅ | ✅ |
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
flowchart TB
subgraph spa["SPA Architecture (React/Vue)"]
direction TB
S1["Server - (API Server)"] -->|JSON Data| C1["Client - (JavaScript Bundle)"]
C1 -->|Virtual DOM| R1["Browser - (DOM Update)"]
C1 -->|Manages| ST1["Client State - (Redux/Pinia)"]
end
subgraph htmx["HTMX Architecture"]
direction TB
S2["Server - (Template Engine)"] -->|HTML Fragment| C2["HTMX - (14KB Library)"]
C2 -->|Direct DOM Update| R2["Browser - (Real DOM)"]
S2 -->|Manages| ST2["Server State - (Session/DB)"]
end
style S1 fill:#458588,color:#ebdbb2,stroke:#83a598
style C1 fill:#cc241d,color:#ebdbb2,stroke:#fb4934
style R1 fill:#689d6a,color:#ebdbb2,stroke:#8ec07c
style ST1 fill:#d79921,color:#282828,stroke:#fabd2f
style S2 fill:#458588,color:#ebdbb2,stroke:#83a598
style C2 fill:#b16286,color:#ebdbb2,stroke:#d3869b
style R2 fill:#689d6a,color:#ebdbb2,stroke:#8ec07c
style ST2 fill:#98971a,color:#282828,stroke:#b8bb26
รูปที่ 1.4: เปรียบเทียบสถาปัตยกรรม SPA vs HTMX
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX Demo</title>
<!-- ติดตั้งผ่าน CDN - ใช้ได้ทันที -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
</head>
<body>
<h1>สวัสดี HTMX!</h1>
<button hx-get="/hello" hx-target="#result">
กดเพื่อโหลด
</button>
<div id="result"></div>
</body>
</html>
# ติดตั้งผ่าน npm
npm install htmx.org
# ติดตั้งผ่าน yarn
yarn add htmx.org
# ติดตั้งผ่าน bun
bun add htmx.org
# ติดตั้งผ่าน pnpm
pnpm add htmx.org
// นำเข้าใน JavaScript/TypeScript โปรเจกต์
import htmx from 'htmx.org';
// หรือแบบ CommonJS
const htmx = require('htmx.org');
// ใน Vite/Webpack (import เพื่อให้ global htmx object พร้อมใช้)
import 'htmx.org';
# ดาวน์โหลดไฟล์
curl -o htmx.min.js https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js
# วางในโปรเจกต์
cp htmx.min.js ./static/js/
<!-- ใช้ Local File -->
<script src="/static/js/htmx.min.js"></script>
my-htmx-app/
├── static/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── htmx.min.js
├── templates/
│ ├── base.html ← Layout หลัก
│ ├── partials/ ← HTML Fragments สำหรับ HTMX
│ │ ├── user-list.html
│ │ ├── product-card.html
│ │ └── notification.html
│ └── pages/ ← หน้าเต็ม
│ ├── index.html
│ └── about.html
├── app.py ← FastAPI/Flask Server
└── requirements.txt
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My HTMX App{% endblock %}</title>
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css">
<!-- HTMX - โหลดก่อน body content -->
<script src="/static/js/htmx.min.js"></script>
<!-- Meta tag สำหรับ CSRF Protection -->
<meta name="csrf-token" content="{{ csrf_token }}">
<!-- กำหนด Default Configuration -->
<meta name="htmx-config" content='{"defaultSwapStyle":"innerHTML", "timeout":5000}'>
</head>
<body>
<!-- Navigation -->
<nav>
<a href="/" hx-boost="true">หน้าหลัก</a>
<a href="/products" hx-boost="true">สินค้า</a>
<a href="/about" hx-boost="true">เกี่ยวกับ</a>
</nav>
<!-- Main Content -->
<main id="main-content">
{% block content %}{% endblock %}
</main>
<!-- Notification Area -->
<div id="notifications" class="notification-container"></div>
{% block scripts %}{% endblock %}
</body>
</html>
# app.py - FastAPI Server สำหรับ Demo HTMX
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import random
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# ข้อมูลตัวอย่าง (Sample Data)
sample_users = [
{"id": 1, "name": "สมชาย ใจดี", "email": "somchai@example.com", "active": True},
{"id": 2, "name": "สมหญิง รักเรียน", "email": "somying@example.com", "active": True},
{"id": 3, "name": "วิชัย แข็งแกร่ง", "email": "wichai@example.com", "active": False},
]
# หน้าหลัก (Main Page)
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("pages/index.html", {"request": request})
# Endpoint แรก: ทักทายแบบง่าย (Simple Greeting)
@app.get("/hello", response_class=HTMLResponse)
async def hello():
names = ["สมชาย", "สมหญิง", "วิชัย", "มานี", "ปิติ"]
name = random.choice(names)
return f"<p>สวัสดี, <strong>{name}</strong>! ยินดีต้อนรับสู่ HTMX 🎉</p>"
# Endpoint: รายการผู้ใช้ (User List)
@app.get("/users", response_class=HTMLResponse)
async def get_users(request: Request):
return templates.TemplateResponse(
"partials/user-list.html",
{"request": request, "users": sample_users}
)
# Endpoint: เพิ่มผู้ใช้ (Add User)
@app.post("/users", response_class=HTMLResponse)
async def add_user(request: Request):
form_data = await request.form()
new_user = {
"id": len(sample_users) + 1,
"name": form_data.get("name"),
"email": form_data.get("email"),
"active": True
}
sample_users.append(new_user)
# ส่งกลับเฉพาะ row ใหม่ (Return only the new row)
return f"""
<tr>
<td>{new_user['id']}</td>
<td>{new_user['name']}</td>
<td>{new_user['email']}</td>
<td><span class="badge active">Active</span></td>
</tr>
"""
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
<!-- templates/pages/index.html -->
{% extends "base.html" %}
{% block title %}Hello HTMX - Demo{% endblock %}
{% block content %}
<div class="container">
<h1>ยินดีต้อนรับสู่ HTMX Demo</h1>
<!-- ตัวอย่างที่ 1: Simple Click -->
<section class="demo-section">
<h2>ตัวอย่างที่ 1: การคลิกปุ่มง่าย ๆ</h2>
<!--
hx-get="/hello" → ส่ง GET Request ไปที่ /hello
hx-target="#greeting" → ใส่ Response ใน #greeting
hx-swap="innerHTML" → แทนที่เนื้อหาใน element
-->
<button
hx-get="/hello"
hx-target="#greeting"
hx-swap="innerHTML"
class="btn-primary">
👋 ทักทาย!
</button>
<div id="greeting" class="result-box">
(กดปุ่มเพื่อทักทาย)
</div>
</section>
<!-- ตัวอย่างที่ 2: โหลดรายการ -->
<section class="demo-section">
<h2>ตัวอย่างที่ 2: โหลดรายการผู้ใช้</h2>
<button
hx-get="/users"
hx-target="#user-table"
hx-swap="innerHTML"
hx-indicator="#loading-spinner">
📋 โหลดรายการผู้ใช้
</button>
<!-- Loading Indicator -->
<span id="loading-spinner" class="htmx-indicator">
⏳ กำลังโหลด...
</span>
<div id="user-table">
(รายการจะปรากฏที่นี่)
</div>
</section>
</div>
{% endblock %}
HTMX รองรับ HTTP Methods ครบทุกตัว ทำให้สามารถทำ CRUD Operations ได้อย่างสมบูรณ์:
<!-- GET: ดึงข้อมูล (Read) -->
<button hx-get="/products">ดูสินค้าทั้งหมด</button>
<!-- POST: สร้างข้อมูลใหม่ (Create) -->
<form hx-post="/products">
<input name="name" placeholder="ชื่อสินค้า">
<button type="submit">เพิ่มสินค้า</button>
</form>
<!-- PUT: อัปเดตข้อมูลทั้งหมด (Update/Replace) -->
<form hx-put="/products/1">
<input name="name" value="สินค้าอัปเดต">
<button type="submit">บันทึก</button>
</form>
<!-- PATCH: อัปเดตข้อมูลบางส่วน (Partial Update) -->
<button hx-patch="/products/1/toggle-active">
เปิด/ปิด สถานะ
</button>
<!-- DELETE: ลบข้อมูล (Delete) -->
<button hx-delete="/products/1"
hx-confirm="คุณต้องการลบสินค้านี้ใช่ไหม?">
🗑️ ลบสินค้า
</button>
# tasks_api.py - API สำหรับ Task Manager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
tasks = [
{"id": 1, "title": "เรียน HTMX", "done": False},
{"id": 2, "title": "สร้างโปรเจกต์แรก", "done": False},
{"id": 3, "title": "Deploy ขึ้น Production", "done": False},
]
next_id = 4
def render_task(task: dict) -> str:
"""Render HTML ของ task เดียว"""
done_class = "task-done" if task["done"] else ""
check_icon = "✅" if task["done"] else "⬜"
return f"""
<li id="task-{task['id']}" class="task-item {done_class}">
<button hx-patch="/tasks/{task['id']}/toggle"
hx-target="#task-{task['id']}"
hx-swap="outerHTML">
{check_icon}
</button>
<span class="task-title">{task['title']}</span>
<button hx-delete="/tasks/{task['id']}"
hx-target="#task-{task['id']}"
hx-swap="outerHTML"
hx-confirm="ลบงานนี้?">
🗑️
</button>
</li>
"""
# GET: ดึงรายการทั้งหมด
@app.get("/tasks", response_class=HTMLResponse)
async def get_tasks():
items = "".join(render_task(t) for t in tasks)
return f'<ul id="task-list" class="tasks">{items}</ul>'
# POST: เพิ่ม task ใหม่
@app.post("/tasks", response_class=HTMLResponse)
async def create_task(request: Request):
global next_id
form = await request.form()
task = {"id": next_id, "title": form.get("title"), "done": False}
tasks.append(task)
next_id += 1
return render_task(task)
# PATCH: สลับสถานะ done/undone
@app.patch("/tasks/{task_id}/toggle", response_class=HTMLResponse)
async def toggle_task(task_id: int):
for task in tasks:
if task["id"] == task_id:
task["done"] = not task["done"]
return render_task(task)
return "", 404
# DELETE: ลบ task
@app.delete("/tasks/{task_id}", response_class=HTMLResponse)
async def delete_task(task_id: int):
global tasks
tasks = [t for t in tasks if t["id"] != task_id]
return "" # ส่ง empty string เพื่อลบ element ออกจาก DOM
hx-target ใช้ CSS Selector ในการระบุ Element ที่จะรับ Response:
<!-- Target by ID (ที่นิยมใช้มากที่สุด) -->
<button hx-get="/data" hx-target="#result">โหลด</button>
<div id="result"></div>
<!-- Target: this (Element ตัวเอง) -->
<div hx-get="/updated-content" hx-target="this" hx-trigger="click">
คลิกเพื่ออัปเดต
</div>
<!-- Target: closest (หา ancestor ที่ใกล้ที่สุด) -->
<tr>
<td>ข้อมูล</td>
<td>
<button hx-delete="/items/1" hx-target="closest tr" hx-swap="outerHTML">
ลบ
</button>
</td>
</tr>
<!-- Target: find (หา descendant) -->
<div id="container">
<button hx-get="/content" hx-target="find .content-area">โหลด</button>
<div class="content-area">เนื้อหาจะปรากฏที่นี่</div>
</div>
<!-- Target: next (sibling ถัดไป) -->
<button hx-get="/help-text" hx-target="next .help">❓ ช่วยเหลือ</button>
<div class="help"></div>
<!-- Target: previous (sibling ก่อนหน้า) -->
<div class="preview"></div>
<textarea hx-post="/preview" hx-target="previous .preview"
hx-trigger="keyup delay:500ms">
</textarea>
<!-- Target: body (ทั้งหน้า) -->
<button hx-get="/full-page" hx-target="body">โหลดหน้าใหม่</button>
| hx-swap Value | คำอธิบาย | ใช้เมื่อ |
|---|---|---|
innerHTML |
แทนที่เนื้อหาภายใน (default) | ต้องการอัปเดตเนื้อหาในกล่อง |
outerHTML |
แทนที่ทั้ง Element | ต้องการเปลี่ยน Element ทั้งใบ |
beforebegin |
แทรกก่อน Element | ใส่เนื้อหาก่อนกล่อง |
afterbegin |
แทรกต้น children | ใส่ที่ต้นรายการ |
beforeend |
ต่อท้าย children | ต่อท้ายรายการ (Append) |
afterend |
แทรกหลัง Element | ใส่เนื้อหาหลังกล่อง |
delete |
ลบ Target Element | ลบ Element ออก |
none |
ไม่เปลี่ยน DOM | ต้องการ side effects เท่านั้น |
<!-- Demo: Swap Strategies ต่าง ๆ -->
<!-- 1. innerHTML: แทนที่เนื้อหาใน div -->
<div id="box">เนื้อหาเดิม</div>
<button hx-get="/new-content"
hx-target="#box"
hx-swap="innerHTML">
แทนที่เนื้อหา
</button>
<!-- 2. outerHTML: แทนที่ทั้ง element -->
<div id="card" class="card">
<button hx-get="/updated-card"
hx-target="#card"
hx-swap="outerHTML">
รีเฟรชการ์ด
</button>
</div>
<!-- 3. beforeend: ต่อท้าย List (นิยมใช้กับ Infinite Scroll) -->
<ul id="messages">
<li>ข้อความที่ 1</li>
</ul>
<button hx-get="/more-messages"
hx-target="#messages"
hx-swap="beforeend">
โหลดเพิ่ม ↓
</button>
<!-- 4. afterbegin: ใส่ตอนต้น (สำหรับ Notification Feed) -->
<ul id="notifications">
<!-- การแจ้งเตือนใหม่จะถูกใส่ที่นี่ -->
</ul>
<button hx-get="/latest-notification"
hx-target="#notifications"
hx-swap="afterbegin">
ตรวจสอบการแจ้งเตือน
</button>
<!-- 5. Swap with Transition (เพิ่ม Animation) -->
<div id="content" class="fade-in">
เนื้อหาปัจจุบัน
</div>
<button hx-get="/new"
hx-target="#content"
hx-swap="innerHTML transition:true">
สลับพร้อม Animation
</button>
<!-- 6. Swap with Delay (ชะลอก่อน Swap) -->
<button hx-delete="/item/1"
hx-target="#item-1"
hx-swap="outerHTML swap:500ms">
ลบ (พร้อม Animation)
</button>
<!-- Default Triggers (ตาม Element Type) -->
<!-- button, input[type=submit]: click -->
<!-- input, textarea, select: change -->
<!-- form: submit -->
<!-- Custom Event Triggers -->
<!-- 1. Click (กดปุ่ม) -->
<button hx-get="/data" hx-trigger="click">โหลด</button>
<!-- 2. Change (เมื่อค่าเปลี่ยน) -->
<select hx-get="/filter" hx-trigger="change" hx-target="#results">
<option value="all">ทั้งหมด</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<!-- 3. Keyup with Delay (พิมพ์แล้วหน่วง 500ms) -->
<input type="text"
name="search"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
placeholder="ค้นหา...">
<!-- 4. Load (เมื่อ Element โหลด) -->
<div hx-get="/lazy-content" hx-trigger="load">
⏳ กำลังโหลด...
</div>
<!-- 5. Revealed (เมื่อปรากฏใน Viewport - สำหรับ Lazy Load) -->
<div hx-get="/more-data" hx-trigger="revealed">
โหลดเพิ่มเมื่อเลื่อนลงมา
</div>
<!-- 6. Every N Seconds (Polling) -->
<div hx-get="/live-stats"
hx-trigger="every 5s"
hx-swap="innerHTML">
กำลังโหลดสถิติ...
</div>
<!-- 7. Custom Event -->
<div hx-get="/refresh" hx-trigger="myCustomEvent">
รอ Custom Event
</div>
<!-- เรียก Custom Event จาก JavaScript -->
<script>
htmx.trigger('#my-div', 'myCustomEvent');
</script>
<!-- 8. Intersection Observer (เมื่อมองเห็น) -->
<div hx-get="/analytics"
hx-trigger="intersect once"
hx-swap="none">
(ติดตาม Analytics เมื่อมองเห็น)
</div>
<!-- 9. Multiple Triggers -->
<input hx-get="/validate"
hx-trigger="focus, change, keyup delay:300ms changed"
hx-target="next .error-msg">
<span class="error-msg"></span>
hx-boost แปลง <a> และ <form> ทั่วไปให้กลายเป็น AJAX Request โดยอัตโนมัติ โดยที่ไม่ต้องเขียน hx-get หรือ hx-post เพิ่ม ผลลัพธ์คือ User ได้รับประสบการณ์ที่ เร็วขึ้น เนื่องจากหน้าเว็บไม่ต้อง Full Reload
<!-- hx-boost บน body หรือ nav (ครอบคลุมทุก link/form ใน element) -->
<nav hx-boost="true">
<a href="/">หน้าหลัก</a> <!-- จะเป็น AJAX GET -->
<a href="/products">สินค้า</a> <!-- จะเป็น AJAX GET -->
<a href="/about">เกี่ยวกับ</a> <!-- จะเป็น AJAX GET -->
</nav>
<!-- ปิด boost สำหรับ link บางอัน -->
<a href="/download/file.pdf" hx-boost="false">ดาวน์โหลด PDF</a>
<!-- hx-boost บน form -->
<form action="/search" method="get" hx-boost="true">
<input name="q" placeholder="ค้นหา...">
<button type="submit">ค้นหา</button>
</form>
<!-- อัปเดต URL เมื่อโหลดข้อมูล (เหมือน SPA Navigation) -->
<a href="/products/1"
hx-get="/products/1/content"
hx-target="#main"
hx-push-url="true">
สินค้าชิ้นที่ 1
</a>
<!-- กำหนด URL ที่ต้องการ (ต่างจาก hx-get path) -->
<button hx-get="/products?category=electronics"
hx-target="#products-list"
hx-push-url="/products?category=electronics">
อิเล็กทรอนิกส์
</button>
<!-- ไม่ push URL (default สำหรับ hx-get ปกติ) -->
<button hx-get="/modal-content"
hx-target="#modal"
hx-push-url="false">
เปิด Modal
</button>
<!-- hx-replace-url: แทนที่ URL ใน History แทนที่จะ Push -->
<button hx-get="/search?q=htmx"
hx-replace-url="/search?q=htmx">
ค้นหา
</button>
<!-- รวม input จาก element อื่นที่ไม่ได้อยู่ใน form เดียวกัน -->
<div>
<input id="user-id" type="hidden" name="user_id" value="42">
<input id="filter-date" type="date" name="date" value="2024-01-15">
</div>
<!-- ปุ่มนี้จะรวมค่าจาก #user-id และ #filter-date ด้วย -->
<button hx-get="/report"
hx-include="#user-id, #filter-date"
hx-target="#report-area">
📊 ดูรายงาน
</button>
<!-- รวมทุก input ใน Form (ใช้ CSS Selector) -->
<button hx-post="/submit-all"
hx-include="closest form">
ส่งฟอร์มทั้งหมด
</button>
<!-- hx-params: กรองพารามิเตอร์ที่ส่งไป -->
<form hx-post="/update-profile">
<input name="username" value="john">
<input name="email" value="john@example.com">
<input name="password" value="secret">
<!-- ส่งเฉพาะ username และ email (ไม่ส่ง password) -->
<button hx-post="/update-profile"
hx-params="username, email">
อัปเดตโปรไฟล์
</button>
<!-- ส่งทุกอย่างยกเว้น password -->
<button hx-post="/update-profile"
hx-params="not password">
อัปเดต (ไม่เปลี่ยนรหัสผ่าน)
</button>
</form>
<!-- hx-vals: เพิ่มค่าพิเศษ (JSON format) -->
<button hx-post="/purchase"
hx-vals='{"product_id": 42, "quantity": 1, "currency": "THB"}'>
🛒 เพิ่มลงตะกร้า
</button>
<!-- hx-vals แบบ Dynamic (ใช้ JavaScript) -->
<button hx-post="/add-to-cart"
hx-vals='js:{product_id: getSelectedProductId(), timestamp: Date.now()}'>
เพิ่มลงตะกร้า
</button>
<!-- ตัวอย่างจริง: ส่ง Context พิเศษ -->
<div class="product-card" data-category="electronics">
<h3>โทรศัพท์มือถือ</h3>
<button hx-post="/analytics/view"
hx-vals='js:{
category: this.closest(".product-card").dataset.category,
page: window.location.pathname,
userId: document.querySelector("meta[name=user-id]")?.content
}'
hx-swap="none">
ดูรายละเอียด
</button>
</div>
<!-- กำหนด Custom Header สำหรับทุก Request ใน section นี้ -->
<div hx-headers='{"X-Custom-Header": "my-value", "X-App-Version": "2.0"}'>
<button hx-get="/api/data">โหลดข้อมูล</button>
<button hx-post="/api/submit">ส่งข้อมูล</button>
</div>
<!-- CSRF Token ผ่าน Header -->
<body hx-headers='js:{"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content}'>
<!-- ทุก HTMX Request ใน body จะส่ง CSRF Token -->
<form hx-post="/update">
<input name="data" value="ข้อมูล">
<button type="submit">บันทึก</button>
</form>
</body>
<!-- Authorization Header สำหรับ API -->
<div hx-headers='js:{"Authorization": "Bearer " + localStorage.getItem("token")}'>
<button hx-get="/api/protected">เข้าถึง API ที่ต้องการ Auth</button>
</div>
# server_responses.py - ตัวอย่าง Response ประเภทต่าง ๆ
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
app = FastAPI()
# ✅ HTML Partial Response (แนะนำ)
@app.get("/products/{product_id}", response_class=HTMLResponse)
async def get_product_html(product_id: int):
# ส่ง HTML Fragment กลับไปโดยตรง
return f"""
<div class="product-card" id="product-{product_id}">
<h3>สินค้า #{product_id}</h3>
<p class="price">฿1,299</p>
<button hx-post="/cart/add"
hx-vals='{{"product_id": {product_id}}}'
hx-target="#cart-count"
hx-swap="innerHTML">
เพิ่มลงตะกร้า
</button>
</div>
"""
# ⚠️ JSON Response (ต้องใช้ JS เพิ่มเติม - ไม่แนะนำสำหรับ HTMX)
@app.get("/products/{product_id}/json")
async def get_product_json(product_id: int):
return {"id": product_id, "name": "สินค้า", "price": 1299}
# HTTP Status Codes ที่ HTMX จัดการ
@app.post("/form-submit", response_class=HTMLResponse)
async def handle_form(request: Request):
form = await request.form()
if not form.get("name"):
# 422: ข้อมูลไม่ถูกต้อง (HTMX จะยัง Swap ปกติ)
response = HTMLResponse(
content='<p class="error">❌ กรุณากรอกชื่อ</p>',
status_code=422
)
return response
# 200: สำเร็จ
return f'<p class="success">✅ บันทึก "{form.get("name")}" สำเร็จ!</p>'
| Status Code | ความหมาย | HTMX พฤติกรรม |
|---|---|---|
| 200 OK | สำเร็จ | Swap เนื้อหาตามปกติ |
| 201 Created | สร้างสำเร็จ | Swap เนื้อหา |
| 204 No Content | สำเร็จ ไม่มีเนื้อหา | ไม่ Swap |
| 3xx Redirect | Redirect | ทำ Full Page Redirect |
| 4xx Client Error | ข้อผิดพลาดฝั่ง Client | ไม่ Swap (default), ใช้ htmx:responseError จัดการ |
| 422 Unprocessable | Validation Error | Swap ตามปกติ (พิเศษ) |
| 5xx Server Error | ข้อผิดพลาดฝั่ง Server | ไม่ Swap |
# hx_trigger_examples.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import json
app = FastAPI()
# ส่ง Event เดียว
@app.post("/save", response_class=HTMLResponse)
async def save_data(request: Request):
form = await request.form()
# บันทึกข้อมูล...
response = HTMLResponse(content='<p>✅ บันทึกสำเร็จ!</p>')
# บอกให้ HTMX fire event "dataSaved" บน Client
response.headers["HX-Trigger"] = "dataSaved"
return response
# ส่งหลาย Events พร้อมข้อมูล
@app.delete("/products/{product_id}", response_class=HTMLResponse)
async def delete_product(product_id: int):
# ลบสินค้า...
response = HTMLResponse(content="") # ลบ element ออก
response.headers["HX-Trigger"] = json.dumps({
"productDeleted": {"id": product_id},
"showNotification": {
"message": f"ลบสินค้า #{product_id} สำเร็จ",
"type": "success"
}
})
return response
# HX-Trigger-After-Settle (fire หลัง DOM settle)
@app.post("/create-order")
async def create_order(request: Request):
response = HTMLResponse(content='<p>สร้างคำสั่งซื้อแล้ว</p>')
response.headers["HX-Trigger-After-Settle"] = "orderCreated"
# HX-Trigger-After-Swap: fire หลัง Swap เสร็จ
response.headers["HX-Trigger-After-Swap"] = "pageUpdated"
return response
<!-- Client: รับฟัง Events จาก Server -->
<div id="notification-area"></div>
<!-- Component ที่รับฟัง dataSaved event -->
<div hx-on:dataSaved="this.classList.add('saved')">
เนื้อหา
</div>
<!-- ใช้ JavaScript สำหรับ Event ซับซ้อน -->
<script>
// รับฟัง showNotification event
document.body.addEventListener("showNotification", function(evt) {
const { message, type } = evt.detail;
const area = document.getElementById("notification-area");
area.innerHTML = `<div class="alert alert-${type}">${message}</div>`;
// ซ่อนหลัง 3 วินาที
setTimeout(() => area.innerHTML = "", 3000);
});
// รับฟัง productDeleted event
document.body.addEventListener("productDeleted", function(evt) {
console.log("ลบสินค้า ID:", evt.detail.id);
// อัปเดต Cart count หรืออื่น ๆ
});
</script>
<!-- style.css ที่จำเป็น -->
<style>
/* htmx-indicator ซ่อนอยู่โดย default */
.htmx-indicator {
opacity: 0;
transition: opacity 500ms ease-in;
}
/* เมื่อ Request กำลังทำงาน: แสดง indicator */
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity: 1;
}
/* Spinner Animation */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0,0,0,.1);
border-top-color: #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<!-- ตัวอย่าง 1: Spinner บน Button -->
<button hx-get="/slow-data"
hx-target="#output"
hx-indicator="#btn-spinner">
โหลดข้อมูล
<span id="btn-spinner" class="htmx-indicator spinner"></span>
</button>
<!-- ตัวอย่าง 2: Loading Overlay บน Section -->
<div id="data-section" style="position: relative;">
<!-- Overlay ปรากฏระหว่างโหลด -->
<div class="htmx-indicator" style="
position: absolute; inset: 0;
background: rgba(255,255,255,0.8);
display: flex; align-items: center; justify-content: center;
">
<span class="spinner"></span> กำลังโหลด...
</div>
<div id="data-content">เนื้อหา</div>
</div>
<button hx-get="/refresh"
hx-target="#data-content"
hx-indicator="#data-section">
รีเฟรช
</button>
<!-- ตัวอย่าง 3: Global Loading Bar (Progress Bar ด้านบน) -->
<div id="global-loading" class="htmx-indicator" style="
position: fixed; top: 0; left: 0; right: 0;
height: 3px; background: #3498db;
animation: loading-bar 1s ease-in-out infinite;
">
</div>
<!-- Confirm Dialog ปกติ (Browser native) -->
<button hx-delete="/products/1"
hx-target="#product-1"
hx-swap="outerHTML"
hx-confirm="คุณต้องการลบสินค้านี้ใช่ไหม? การดำเนินการนี้ไม่สามารถย้อนกลับได้">
🗑️ ลบสินค้า
</button>
<!-- Custom Confirm ด้วย JavaScript -->
<script>
// Override confirm dialog ด้วย Custom Modal
document.body.addEventListener('htmx:confirm', function(evt) {
evt.preventDefault(); // หยุด default confirm
const question = evt.detail.question;
// แสดง Custom Modal
showCustomConfirm(question, function(confirmed) {
if (confirmed) {
evt.detail.issueRequest(true); // ดำเนินการต่อ
}
});
});
function showCustomConfirm(message, callback) {
// ใช้ SweetAlert2 หรือ Modal Library อื่น ๆ
if (window.Swal) {
Swal.fire({
title: 'ยืนยันการดำเนินการ',
text: message,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'ยืนยัน',
cancelButtonText: 'ยกเลิก',
confirmButtonColor: '#d33',
}).then(result => callback(result.isConfirmed));
}
}
</script>
<!-- hx-disable: ปิด HTMX บน element -->
<button hx-post="/submit"
hx-disable>
ปุ่มนี้ถูกปิด HTMX
</button>
<!-- ป้องกัน Double Submit ด้วย hx-disabled-elt -->
<form hx-post="/checkout"
hx-disabled-elt="find button[type=submit]">
<input name="address" placeholder="ที่อยู่จัดส่ง">
<button type="submit">
ชำระเงิน
<span class="htmx-indicator">⏳</span>
</button>
</form>
<!-- ปิด Form ทั้งหมดระหว่างส่ง -->
<form hx-post="/important-action"
hx-disabled-elt="this">
<input name="data">
<button>ส่ง</button>
</form>
/* htmx-animation.css */
/* Fade In เมื่อ Element ใหม่ปรากฏ */
.htmx-added {
opacity: 0;
}
.htmx-added:not(.htmx-settling) {
opacity: 1;
transition: opacity 0.5s ease-in;
}
/* Fade Out เมื่อ Element เก่าหายไป */
.htmx-swapping {
opacity: 1;
transition: opacity 0.3s ease-out;
}
.htmx-swapping {
opacity: 0;
}
/* Slide In from Top */
@keyframes slideInFromTop {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.slide-in {
animation: slideInFromTop 0.3s ease-out;
}
/* View Transitions API (Modern Browsers) */
@view-transition {
navigation: auto;
}
<!-- ใช้ Class-based Animation กับ HTMX -->
<ul id="item-list">
<li class="slide-in">รายการที่ 1</li>
</ul>
<button hx-post="/items"
hx-target="#item-list"
hx-swap="beforeend">
เพิ่มรายการ
</button>
<!-- Server ส่งกลับ HTML พร้อม class animation -->
<!-- <li class="slide-in">รายการใหม่</li> -->
<!-- Server ส่ง Full Page กลับ แต่เราต้องการเฉพาะ #content -->
<button hx-get="/full-page"
hx-select="#content"
hx-target="#main">
โหลดเฉพาะเนื้อหาหลัก
</button>
<!-- hx-select-oob: ดึงหลาย Element พร้อมกัน -->
<button hx-get="/page-with-sidebar"
hx-select="#main-content"
hx-target="#content-area"
hx-select-oob="#sidebar:innerHTML,#breadcrumb:innerHTML">
โหลดหน้าพร้อม Sidebar
</button>
hx-swap-oob (Out-of-Band) ช่วยให้ Server สามารถอัปเดต หลาย Element ในหน้าเว็บพร้อมกันใน Response เดียว โดยที่ Response หลักอัปเดต Target ปกติ ส่วน OOB Elements จะถูกอัปเดตตาม ID ของตัวเอง
# oob_example.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
cart_count = 0
@app.post("/cart/add", response_class=HTMLResponse)
async def add_to_cart(request: Request):
global cart_count
form = await request.form()
product_id = form.get("product_id")
cart_count += 1
# Main Response: ยืนยันการเพิ่มสินค้า
# OOB Response: อัปเดต Cart Badge และ Notification พร้อมกัน
return f"""
<div class="add-confirmation">
✅ เพิ่มสินค้า #{product_id} ลงตะกร้าแล้ว
</div>
<!-- OOB: อัปเดต Cart Count ที่ Navbar -->
<span id="cart-count" hx-swap-oob="innerHTML">
{cart_count}
</span>
<!-- OOB: แสดง Notification -->
<div id="notification" hx-swap-oob="innerHTML">
<div class="toast success">
🛒 เพิ่มลงตะกร้าสำเร็จ! (รวม {cart_count} ชิ้น)
</div>
</div>
"""
<!-- index.html -->
<nav>
<a href="/cart">
🛒 ตะกร้า
<span id="cart-count" class="badge">0</span>
</a>
</nav>
<div id="notification"></div>
<div class="products">
<div class="product-card">
<h3>สินค้า A</h3>
<form hx-post="/cart/add" hx-target="#add-feedback">
<input type="hidden" name="product_id" value="101">
<button type="submit">🛒 เพิ่มลงตะกร้า</button>
</form>
</div>
<div id="add-feedback"></div>
</div>
<!-- Polling ทุก 5 วินาที -->
<div hx-get="/live-stats"
hx-trigger="every 5s"
hx-swap="innerHTML"
class="stats-panel">
กำลังโหลดสถิติ...
</div>
<!-- Conditional Polling (หยุดเมื่อ Server บอก) -->
<div id="job-status"
hx-get="/jobs/123/status"
hx-trigger="every 2s"
hx-swap="outerHTML">
⏳ กำลังประมวลผล...
</div>
<!-- Server ส่งกลับ: ถ้าเสร็จแล้ว ไม่ใส่ hx-trigger ใน Response -->
<!-- <div id="job-status">✅ ประมวลผลเสร็จแล้ว!</div> -->
<!-- Dashboard ที่ Refresh ข้อมูลหลายส่วนพร้อมกัน -->
<div class="dashboard">
<div hx-get="/stats/users" hx-trigger="load, every 30s" hx-swap="innerHTML">
<div class="spinner"></div>
</div>
<div hx-get="/stats/sales" hx-trigger="load, every 30s" hx-swap="innerHTML">
<div class="spinner"></div>
</div>
<div hx-get="/stats/inventory" hx-trigger="load, every 60s" hx-swap="innerHTML">
<div class="spinner"></div>
</div>
</div>
# sse_server.py
import asyncio
import random
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def generate_stock_updates():
"""Generator สำหรับ SSE Stream"""
stocks = {"KBANK": 152.0, "PTT": 35.5, "SCB": 112.0}
while True:
# สร้างข้อมูลหุ้นสุ่ม
for ticker, price in stocks.items():
change = random.uniform(-2, 2)
stocks[ticker] = round(price + change, 2)
direction = "up" if change > 0 else "down"
html_content = f"""
<div id="stock-{ticker}" hx-swap-oob="innerHTML">
<span class="price {direction}">{stocks[ticker]:.2f}</span>
<span class="change">{'▲' if change > 0 else '▼'} {abs(change):.2f}</span>
</div>
"""
# SSE Format: data: <content> - -
yield f"data: {html_content} - - "
await asyncio.sleep(2) # อัปเดตทุก 2 วินาที
@app.get("/stocks/stream")
async def stock_stream():
return StreamingResponse(
generate_stock_updates(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"
}
)
<!-- client: ใช้ HTMX SSE Extension -->
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<!-- ติดต่อ SSE Stream -->
<div hx-ext="sse" sse-connect="/stocks/stream">
<h2>ราคาหุ้น Real-time</h2>
<!-- Placeholder สำหรับแต่ละหุ้น (OOB จะอัปเดต) -->
<div id="stock-KBANK" class="stock-row">
<span class="ticker">KBANK</span>
<span class="price">--</span>
</div>
<div id="stock-PTT" class="stock-row">
<span class="ticker">PTT</span>
<span class="price">--</span>
</div>
<div id="stock-SCB" class="stock-row">
<span class="ticker">SCB</span>
<span class="price">--</span>
</div>
</div>
# websocket_chat.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import List
import json
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.connections: List[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.connections.append(ws)
def disconnect(self, ws: WebSocket):
self.connections.remove(ws)
async def broadcast(self, html: str):
"""ส่ง HTML ไปยังทุก Connection"""
for ws in self.connections:
try:
await ws.send_text(html)
except:
pass
manager = ConnectionManager()
message_count = 0
@app.websocket("/chat/ws")
async def chat_ws(websocket: WebSocket):
global message_count
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
msg_data = json.loads(data)
message_count += 1
# Broadcast HTML Fragment ไปทุก Client
html = f"""
<li id="msg-{message_count}" class="message animate-in">
<strong>{msg_data['username']}:</strong> {msg_data['message']}
<small>{msg_data.get('time', 'ตอนนี้')}</small>
</li>
"""
await manager.broadcast(html)
except WebSocketDisconnect:
manager.disconnect(websocket)
<!-- chat.html -->
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
<div id="chat-container" hx-ext="ws" ws-connect="/chat/ws">
<!-- ข้อความจะถูกต่อท้ายที่นี่ -->
<ul id="messages" hx-swap-oob="beforeend:#messages">
</ul>
<!-- ฟอร์มส่งข้อความ -->
<form ws-send id="chat-form">
<input type="hidden" name="username" value="ผู้ใช้">
<input type="text" name="message"
placeholder="พิมพ์ข้อความ..."
autocomplete="off">
<button type="submit">ส่ง</button>
</form>
</div>
<script>
// เพิ่ม timestamp และล้าง input หลังส่ง
document.getElementById('chat-form').addEventListener('htmx:wsAfterSend', function() {
this.querySelector('input[name=message]').value = '';
});
</script>
# infinite_scroll.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
# ข้อมูลสินค้าตัวอย่าง 100 ชิ้น
all_products = [{"id": i, "name": f"สินค้า #{i}", "price": i * 100} for i in range(1, 101)]
PAGE_SIZE = 10
@app.get("/products", response_class=HTMLResponse)
async def get_products(page: int = 1):
start = (page - 1) * PAGE_SIZE
end = start + PAGE_SIZE
products = all_products[start:end]
has_more = end < len(all_products)
items_html = "".join(f"""
<div class="product-card">
<h3>{p['name']}</h3>
<p>฿{p['price']}</p>
</div>
""" for p in products)
# ปุ่ม "โหลดเพิ่ม" จะปรากฏเฉพาะเมื่อยังมีข้อมูล
# ใช้ hx-trigger="revealed" เพื่อ auto-load เมื่อเลื่อนลงมาถึง
load_more = f"""
<div id="load-trigger"
hx-get="/products?page={page + 1}"
hx-trigger="revealed"
hx-target="#load-trigger"
hx-swap="outerHTML"
class="load-more-trigger">
⏳ กำลังโหลด...
</div>
""" if has_more else '<p class="end-msg">✅ แสดงสินค้าทั้งหมดแล้ว</p>'
return items_html + load_more
<!-- infinite-scroll.html -->
<div id="products-container">
<!-- โหลดหน้าแรกทันที -->
<div hx-get="/products?page=1"
hx-trigger="load"
hx-target="this"
hx-swap="outerHTML">
⏳ กำลังโหลดสินค้า...
</div>
</div>
<!-- hx-on: จัดการ HTMX Events โดยตรงใน HTML -->
<!-- Log ก่อน Request ส่ง -->
<button hx-get="/data"
hx-on:htmx:before-request="console.log('กำลังส่ง Request...')">
โหลด
</button>
<!-- Reset Form หลัง Submit สำเร็จ -->
<form hx-post="/messages"
hx-target="#messages"
hx-swap="beforeend"
hx-on:htmx:after-request="this.reset()">
<input name="text" placeholder="ข้อความ">
<button type="submit">ส่ง</button>
</form>
<!-- ดักจับ Error -->
<div hx-get="/might-fail"
hx-on:htmx:response-error="
alert('เกิดข้อผิดพลาด: ' + event.detail.xhr.status)
">
ข้อมูล
</div>
<!-- Lifecycle Events ที่สำคัญ -->
<div hx-get="/data"
hx-on:htmx:before-send="this.classList.add('loading')"
hx-on:htmx:after-settle="this.classList.remove('loading')"
hx-on:htmx:swap="console.log('DOM อัปเดตแล้ว')">
เนื้อหา
</div>
# fastapi_htmx_complete.py
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from typing import Optional
import sqlite3
import contextlib
app = FastAPI()
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
# Database Setup (SQLite สำหรับ Demo)
def get_db():
conn = sqlite3.connect("demo.db")
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def init_db():
with sqlite3.connect("demo.db") as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ใส่ข้อมูลตัวอย่าง
conn.execute("INSERT OR IGNORE INTO todos (id, title) VALUES (1, 'เรียน HTMX')")
conn.execute("INSERT OR IGNORE INTO todos (id, title) VALUES (2, 'สร้าง Project')")
conn.commit()
init_db()
# Middleware: ตรวจสอบว่าเป็น HTMX Request หรือไม่
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
# Main Page
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, db=Depends(get_db)):
todos = db.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
return templates.TemplateResponse("todos.html", {
"request": request,
"todos": todos
})
# Get all todos (HTML Fragment)
@app.get("/todos", response_class=HTMLResponse)
async def get_todos(request: Request, db=Depends(get_db)):
todos = db.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
return templates.TemplateResponse("partials/todo-list.html", {
"request": request,
"todos": todos
})
# Create todo
@app.post("/todos", response_class=HTMLResponse)
async def create_todo(request: Request, db=Depends(get_db)):
form = await request.form()
title = form.get("title", "").strip()
if not title:
return HTMLResponse('<p class="error">กรุณากรอกชื่องาน</p>', status_code=422)
cursor = db.execute("INSERT INTO todos (title) VALUES (?)", (title,))
db.commit()
todo_id = cursor.lastrowid
todo = db.execute("SELECT * FROM todos WHERE id=?", (todo_id,)).fetchone()
return templates.TemplateResponse("partials/todo-item.html", {
"request": request, "todo": todo
})
# Toggle complete
@app.patch("/todos/{todo_id}/toggle", response_class=HTMLResponse)
async def toggle_todo(todo_id: int, request: Request, db=Depends(get_db)):
db.execute("UPDATE todos SET completed = NOT completed WHERE id=?", (todo_id,))
db.commit()
todo = db.execute("SELECT * FROM todos WHERE id=?", (todo_id,)).fetchone()
return templates.TemplateResponse("partials/todo-item.html", {
"request": request, "todo": todo
})
# Delete todo
@app.delete("/todos/{todo_id}", response_class=HTMLResponse)
async def delete_todo(todo_id: int, db=Depends(get_db)):
db.execute("DELETE FROM todos WHERE id=?", (todo_id,))
db.commit()
return HTMLResponse("") # ลบ Element ออก
# django_views.py
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from django.views.decorators.http import require_http_methods
from .models import Product
def is_htmx(request):
return request.headers.get("HX-Request") == "true"
def product_list(request):
products = Product.objects.all()
# ถ้าเป็น HTMX Request → ส่งเฉพาะ Fragment
if is_htmx(request):
return render(request, "partials/product-list.html", {"products": products})
# ถ้าเป็น Normal Request → ส่ง Full Page
return render(request, "products.html", {"products": products})
@require_http_methods(["DELETE"])
def delete_product(request, pk):
product = get_object_or_404(Product, pk=pk)
product.delete()
return HttpResponse("") # ลบ Element
// main.go - Go + Gin + HTMX
package main
import (
"html/template"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type Task struct {
ID int
Title string
Done bool
}
var tasks = []Task{
{1, "เรียน Go", false},
{2, "สร้าง HTMX App", false},
{3, "Deploy ขึ้น Server", false},
}
var nextID = 4
// Render Task Item HTML
func renderTaskItem(t Task) string {
checkIcon := "⬜"
doneClass := ""
if t.Done {
checkIcon = "✅"
doneClass = "task-done"
}
return `<li id="task-` + strconv.Itoa(t.ID) + `" class="task-item ` + doneClass + `">
<button hx-patch="/tasks/` + strconv.Itoa(t.ID) + `/toggle"
hx-target="#task-` + strconv.Itoa(t.ID) + `"
hx-swap="outerHTML">` + checkIcon + `</button>
<span>` + t.Title + `</span>
<button hx-delete="/tasks/` + strconv.Itoa(t.ID) + `"
hx-target="#task-` + strconv.Itoa(t.ID) + `"
hx-swap="outerHTML"
hx-confirm="ลบงานนี้?">🗑️</button>
</li>`
}
func main() {
r := gin.Default()
r.LoadHTMLGlob("templates/*")
// GET /tasks
r.GET("/tasks", func(c *gin.Context) {
html := `<ul id="task-list">`
for _, t := range tasks {
html += renderTaskItem(t)
}
html += `</ul>`
c.Data(http.StatusOK, "text/html", []byte(html))
})
// POST /tasks
r.POST("/tasks", func(c *gin.Context) {
title := c.PostForm("title")
if title == "" {
c.Data(http.StatusUnprocessableEntity, "text/html",
[]byte(`<p class="error">กรุณากรอกชื่องาน</p>`))
return
}
task := Task{ID: nextID, Title: title, Done: false}
tasks = append(tasks, task)
nextID++
c.Data(http.StatusOK, "text/html", []byte(renderTaskItem(task)))
})
// PATCH /tasks/:id/toggle
r.PATCH("/tasks/:id/toggle", func(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = !tasks[i].Done
c.Data(http.StatusOK, "text/html", []byte(renderTaskItem(tasks[i])))
return
}
}
c.Status(http.StatusNotFound)
})
// DELETE /tasks/:id
r.DELETE("/tasks/:id", func(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
for i, t := range tasks {
if t.ID == id {
tasks = append(tasks[:i], tasks[i+1:]...)
break
}
}
c.Data(http.StatusOK, "text/html", []byte(""))
})
r.Run(":8080")
}
// server.ts - Hono + HTMX (รันด้วย Bun)
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
const app = new Hono();
interface Todo {
id: number;
title: string;
done: boolean;
}
let todos: Todo[] = [
{ id: 1, title: "เรียน Hono", done: false },
{ id: 2, title: "ทดลอง HTMX + Bun", done: false },
];
let nextId = 3;
// Helper: render todo item
const renderTodo = (t: Todo) => `
<li id="todo-${t.id}" class="${t.done ? "done" : ""}">
<button hx-patch="/todos/${t.id}/toggle"
hx-target="#todo-${t.id}"
hx-swap="outerHTML">
${t.done ? "✅" : "⬜"}
</button>
${t.title}
<button hx-delete="/todos/${t.id}"
hx-target="#todo-${t.id}"
hx-swap="outerHTML"
hx-confirm="ลบ?">🗑️</button>
</li>`;
// Static files
app.use("/static/*", serveStatic({ root: "./" }));
// GET /
app.get("/", (c) => {
const todosHtml = todos.map(renderTodo).join("");
return c.html(`
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Todo App (Hono + HTMX)</h1>
<form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend"
hx-on:htmx:after-request="this.reset()">
<input name="title" placeholder="งานใหม่...">
<button>เพิ่ม</button>
</form>
<ul id="todo-list">${todosHtml}</ul>
</body>
</html>
`);
});
// POST /todos
app.post("/todos", async (c) => {
const form = await c.req.formData();
const title = (form.get("title") as string)?.trim();
if (!title) return c.html('<p class="error">กรุณากรอกชื่องาน</p>', 422);
const todo: Todo = { id: nextId++, title, done: false };
todos.push(todo);
return c.html(renderTodo(todo));
});
// PATCH /todos/:id/toggle
app.patch("/todos/:id/toggle", (c) => {
const id = parseInt(c.req.param("id"));
const todo = todos.find((t) => t.id === id);
if (!todo) return c.text("Not found", 404);
todo.done = !todo.done;
return c.html(renderTodo(todo));
});
// DELETE /todos/:id
app.delete("/todos/:id", (c) => {
const id = parseInt(c.req.param("id"));
todos = todos.filter((t) => t.id !== id);
return c.html("");
});
export default { port: 3000, fetch: app.fetch };
// รัน: bun run server.ts
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
flowchart TB
subgraph full["Full Page Request"]
direction LR
FP1["base.html"] --> FP2["page.html"]
FP2 --> FP3["partials/component.html"]
end
subgraph htmxreq["HTMX Partial Request"]
direction LR
HR1["partials/component.html - เท่านั้น"]
end
Client1["🌐 Browser - (Full Request)"] --> full
Client2["🌐 Browser - (HTMX Request - hx-get)"] --> htmxreq
full --> Resp1["🖥️ Full HTML Page"]
htmxreq --> Resp2["📄 HTML Fragment"]
style FP1 fill:#458588,color:#ebdbb2,stroke:#83a598
style FP2 fill:#458588,color:#ebdbb2,stroke:#83a598
style FP3 fill:#689d6a,color:#ebdbb2,stroke:#8ec07c
style HR1 fill:#d79921,color:#282828,stroke:#fabd2f
รูปที่ 7.1: การแยก Full Page vs Partial Template
# template_pattern.py - Pattern ที่ดีสำหรับ HTMX + Jinja2
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
def render(request: Request, full_template: str,
partial_template: str, context: dict):
"""
ส่ง Full Page สำหรับ Normal Request
ส่ง Partial สำหรับ HTMX Request
"""
is_htmx = request.headers.get("HX-Request") == "true"
template = partial_template if is_htmx else full_template
return templates.TemplateResponse(template, {"request": request, **context})
@app.get("/products", response_class=HTMLResponse)
async def products(request: Request):
products_data = [{"id": 1, "name": "สินค้า A"}, {"id": 2, "name": "สินค้า B"}]
return render(
request,
full_template="pages/products.html",
partial_template="partials/product-list.html",
context={"products": products_data}
)
# search_api.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
# ข้อมูลสินค้า (ตัวอย่าง)
PRODUCTS = [
{"id": 1, "name": "iPhone 15 Pro", "category": "โทรศัพท์", "price": 45900},
{"id": 2, "name": "Samsung Galaxy S24", "category": "โทรศัพท์", "price": 39900},
{"id": 3, "name": "MacBook Air M3", "category": "แล็บท็อป", "price": 49900},
{"id": 4, "name": "iPad Pro 2024", "category": "แท็บเล็ต", "price": 32900},
{"id": 5, "name": "AirPods Pro", "category": "หูฟัง", "price": 8990},
{"id": 6, "name": "Apple Watch Ultra 2", "category": "สมาร์ทวอทช์", "price": 29900},
{"id": 7, "name": "Dell XPS 15", "category": "แล็บท็อป", "price": 55000},
{"id": 8, "name": "Sony WH-1000XM5", "category": "หูฟัง", "price": 12990},
]
@app.get("/search", response_class=HTMLResponse)
async def search(q: str = "", category: str = ""):
results = PRODUCTS
# กรองตาม keyword
if q:
results = [p for p in results if q.lower() in p["name"].lower()]
# กรองตาม category
if category:
results = [p for p in results if p["category"] == category]
if not results:
return '<p class="no-results">🔍 ไม่พบสินค้าที่ค้นหา</p>'
items = "".join(f"""
<div class="search-result-item">
<div class="product-info">
<strong>{p['name']}</strong>
<span class="category-badge">{p['category']}</span>
</div>
<span class="price">฿{p['price']:,}</span>
<button hx-post="/cart/add"
hx-vals='{{"product_id": {p["id"]}}}'
hx-swap="none">
🛒 เพิ่ม
</button>
</div>
""" for p in results)
count_html = f'<p class="result-count">พบ {len(results)} รายการ</p>'
return count_html + items
<!-- live-search.html -->
<div class="search-container">
<div class="search-controls">
<!-- Search Input พร้อม Live Update -->
<input type="text"
name="q"
id="search-input"
placeholder="ค้นหาสินค้า..."
hx-get="/search"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#search-results"
hx-include="[name='q'], [name='category']"
hx-indicator="#search-spinner"
autocomplete="off">
<span id="search-spinner" class="htmx-indicator">🔍</span>
<!-- Filter by Category -->
<select name="category"
hx-get="/search"
hx-trigger="change"
hx-target="#search-results"
hx-include="#search-input">
<option value="">ทุกหมวดหมู่</option>
<option value="โทรศัพท์">📱 โทรศัพท์</option>
<option value="แล็บท็อป">💻 แล็บท็อป</option>
<option value="หูฟัง">🎧 หูฟัง</option>
<option value="แท็บเล็ต">📟 แท็บเล็ต</option>
</select>
</div>
<!-- Results Area -->
<div id="search-results" class="search-results">
<p class="hint">พิมพ์เพื่อค้นหาสินค้า...</p>
</div>
</div>
# inline_edit.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
users_db = {
1: {"id": 1, "name": "สมชาย ใจดี", "email": "somchai@example.com", "role": "Admin"},
2: {"id": 2, "name": "สมหญิง รักเรียน", "email": "somying@example.com", "role": "User"},
}
def render_user_row(user: dict) -> str:
"""แสดงแถวข้อมูลแบบปกติ"""
return f"""
<tr id="user-row-{user['id']}">
<td>{user['id']}</td>
<td class="editable">{user['name']}</td>
<td class="editable">{user['email']}</td>
<td>{user['role']}</td>
<td>
<button hx-get="/users/{user['id']}/edit"
hx-target="#user-row-{user['id']}"
hx-swap="outerHTML">
✏️ แก้ไข
</button>
</td>
</tr>
"""
def render_edit_row(user: dict) -> str:
"""แสดงแถวข้อมูลแบบ Editable"""
return f"""
<tr id="user-row-{user['id']}">
<td>{user['id']}</td>
<td><input name="name" value="{user['name']}" class="inline-input"></td>
<td><input name="email" value="{user['email']}" class="inline-input"></td>
<td>{user['role']}</td>
<td>
<button hx-put="/users/{user['id']}"
hx-include="closest tr"
hx-target="#user-row-{user['id']}"
hx-swap="outerHTML">
💾 บันทึก
</button>
<button hx-get="/users/{user['id']}"
hx-target="#user-row-{user['id']}"
hx-swap="outerHTML">
❌ ยกเลิก
</button>
</td>
</tr>
"""
@app.get("/users/{user_id}/edit", response_class=HTMLResponse)
async def get_edit_form(user_id: int):
user = users_db.get(user_id)
if not user:
return "ไม่พบผู้ใช้", 404
return render_edit_row(user)
@app.get("/users/{user_id}", response_class=HTMLResponse)
async def get_user_row(user_id: int):
user = users_db.get(user_id)
if not user:
return "ไม่พบผู้ใช้", 404
return render_user_row(user)
@app.put("/users/{user_id}", response_class=HTMLResponse)
async def update_user(user_id: int, request: Request):
form = await request.form()
user = users_db.get(user_id)
if not user:
return "ไม่พบผู้ใช้", 404
user["name"] = form.get("name", user["name"])
user["email"] = form.get("email", user["email"])
return render_user_row(user)
# validation.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import re
app = FastAPI()
existing_emails = {"admin@example.com", "user@example.com"}
@app.get("/validate/email", response_class=HTMLResponse)
async def validate_email(email: str = ""):
if not email:
return '<span class="hint">กรุณากรอกอีเมล</span>'
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_regex, email):
return '<span class="error">❌ รูปแบบอีเมลไม่ถูกต้อง</span>'
if email in existing_emails:
return '<span class="error">❌ อีเมลนี้ถูกใช้งานแล้ว</span>'
return '<span class="success">✅ อีเมลนี้ใช้งานได้</span>'
@app.get("/validate/username", response_class=HTMLResponse)
async def validate_username(username: str = ""):
if len(username) < 3:
return '<span class="error">❌ ต้องมีอย่างน้อย 3 ตัวอักษร</span>'
if not re.match(r'^[a-zA-Z0-9_]+$', username):
return '<span class="error">❌ ใช้ได้เฉพาะตัวอักษร ตัวเลข และ _</span>'
return '<span class="success">✅ Username นี้ใช้ได้</span>'
<!-- register-form.html -->
<form hx-post="/register" hx-target="#form-result">
<div class="field-group">
<label>Username</label>
<input type="text"
name="username"
hx-get="/validate/username"
hx-trigger="keyup changed delay:400ms"
hx-target="next .validation-msg"
placeholder="username">
<span class="validation-msg"></span>
</div>
<div class="field-group">
<label>อีเมล</label>
<input type="email"
name="email"
hx-get="/validate/email"
hx-trigger="keyup changed delay:500ms, blur"
hx-target="next .validation-msg"
placeholder="email@example.com">
<span class="validation-msg"></span>
</div>
<button type="submit">สมัครสมาชิก</button>
<div id="form-result"></div>
</form>
# pagination.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import math
app = FastAPI()
# สร้างข้อมูลตัวอย่าง 50 รายการ
ITEMS = [{"id": i, "title": f"บทความที่ {i}", "author": f"ผู้เขียน {i%5+1}"}
for i in range(1, 51)]
PER_PAGE = 5
@app.get("/articles", response_class=HTMLResponse)
async def get_articles(page: int = 1):
total = len(ITEMS)
total_pages = math.ceil(total / PER_PAGE)
start = (page - 1) * PER_PAGE
items = ITEMS[start:start + PER_PAGE]
items_html = "".join(f"""
<article class="article-card">
<h3>{item['title']}</h3>
<p>โดย: {item['author']}</p>
</article>
""" for item in items)
# Pagination Controls
prev_btn = f"""
<button hx-get="/articles?page={page-1}"
hx-target="#articles-container"
hx-swap="innerHTML"
hx-push-url="/articles?page={page-1}">
← ก่อนหน้า
</button>
""" if page > 1 else ""
next_btn = f"""
<button hx-get="/articles?page={page+1}"
hx-target="#articles-container"
hx-swap="innerHTML"
hx-push-url="/articles?page={page+1}">
ถัดไป →
</button>
""" if page < total_pages else ""
pagination = f"""
<div class="pagination">
{prev_btn}
<span>หน้า {page} จาก {total_pages}</span>
{next_btn}
</div>
"""
return items_html + pagination
# modal_api.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/modal/product/{product_id}", response_class=HTMLResponse)
async def get_product_modal(product_id: int):
# ดึงข้อมูลสินค้า (ตัวอย่าง)
product = {
"id": product_id,
"name": f"สินค้า #{product_id}",
"price": product_id * 500,
"description": "รายละเอียดสินค้าที่น่าสนใจ...",
"stock": 10
}
return f"""
<div id="modal-overlay"
class="modal-overlay"
hx-on:click="htmx.remove(this)">
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close"
hx-on:click="htmx.remove(document.getElementById('modal-overlay'))">
✕
</button>
<h2>{product['name']}</h2>
<p class="price">฿{product['price']:,}</p>
<p>{product['description']}</p>
<p>สินค้าคงเหลือ: {product['stock']} ชิ้น</p>
<div class="modal-actions">
<button hx-post="/cart/add"
hx-vals='{{"product_id": {product["id"]}}}'
hx-target="#cart-count"
hx-on:htmx:after-request="htmx.remove(document.getElementById('modal-overlay'))">
🛒 เพิ่มลงตะกร้า
</button>
</div>
</div>
</div>
"""
<!-- product-list.html -->
<div class="product-grid">
<div class="product-card">
<h3>สินค้า #1</h3>
<button hx-get="/modal/product/1"
hx-target="body"
hx-swap="beforeend">
🔍 ดูรายละเอียด
</button>
</div>
</div>
<style>
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal-content {
background: white; padding: 2rem;
border-radius: 8px; max-width: 500px; width: 90%;
position: relative;
}
.modal-close {
position: absolute; top: 1rem; right: 1rem;
background: none; border: none; font-size: 1.5rem; cursor: pointer;
}
</style>
CSRF (Cross-Site Request Forgery) เป็นการโจมตีที่ให้เว็บไซต์ที่เป็นอันตรายส่ง Request แทนผู้ใช้โดยไม่ได้รับอนุญาต HTMX ต้องการการป้องกัน CSRF เช่นเดียวกับ Form ธรรมดา
# csrf_protection.py
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
import secrets
import hashlib
import hmac
app = FastAPI()
SECRET_KEY = "your-secret-key-change-this-in-production"
def generate_csrf_token(session_id: str) -> str:
"""สร้าง CSRF Token จาก Session ID"""
return hmac.new(
SECRET_KEY.encode(),
session_id.encode(),
hashlib.sha256
).hexdigest()
def verify_csrf_token(session_id: str, token: str) -> bool:
"""ตรวจสอบ CSRF Token"""
expected = generate_csrf_token(session_id)
return hmac.compare_digest(expected, token)
# Middleware สำหรับ CSRF
@app.middleware("http")
async def csrf_middleware(request: Request, call_next):
# ตรวจสอบเฉพาะ Mutating Methods
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
# ดึง CSRF Token จาก Header (HTMX ส่งผ่าน Custom Header)
csrf_token = request.headers.get("X-CSRF-Token")
session_id = request.cookies.get("session_id", "")
if not csrf_token or not verify_csrf_token(session_id, csrf_token):
return HTMLResponse(
content='<p class="error">❌ CSRF Token ไม่ถูกต้อง</p>',
status_code=403
)
return await call_next(request)
<!-- ใส่ CSRF Token ใน Meta Tag -->
<meta name="csrf-token" content="{{ csrf_token }}">
<!-- ส่ง CSRF Token ผ่าน Header ทุก HTMX Request -->
<body hx-headers='js:{"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content}'>
<!-- ทุก HTMX Request จะส่ง CSRF Token โดยอัตโนมัติ -->
<form hx-post="/update-profile">
<input name="email" type="email">
<button type="submit">บันทึก</button>
</form>
</body>
XSS (Cross-Site Scripting) เป็นความเสี่ยงสำคัญสำหรับ HTMX เนื่องจาก Server ส่ง HTML กลับมาโดยตรง ต้องระวัง HTML Injection จากข้อมูลที่ผู้ใช้ป้อนเข้ามา
# xss_prevention.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import html
app = FastAPI()
def safe_html(value: str) -> str:
"""Escape HTML Special Characters เพื่อป้องกัน XSS"""
return html.escape(str(value), quote=True)
# ❌ ไม่ปลอดภัย: Direct String Interpolation
@app.get("/unsafe/search")
async def unsafe_search(q: str = ""):
# ถ้า q = "<script>alert('XSS')</script>" จะทำงานได้!
return HTMLResponse(f"<p>ค้นหา: {q}</p>")
# ✅ ปลอดภัย: Escape HTML
@app.get("/safe/search")
async def safe_search(q: str = ""):
safe_q = safe_html(q)
return HTMLResponse(f"<p>ค้นหา: {safe_q}</p>")
# ✅ ปลอดภัย: ใช้ Template Engine (Jinja2 auto-escape)
# Jinja2 จะ escape HTML โดยอัตโนมัติเมื่อใช้ {{ variable }}
# ต้องใช้ {{ variable | safe }} เท่านั้นเมื่อต้องการ HTML จริง ๆ
# Content Security Policy Header
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' https://unpkg.com; "
"style-src 'self' 'unsafe-inline';"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
return response
# rate_limiting.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from collections import defaultdict
from datetime import datetime, timedelta
import asyncio
app = FastAPI()
# Simple In-memory Rate Limiter
class RateLimiter:
def __init__(self, max_requests: int = 30, window_seconds: int = 60):
self.max_requests = max_requests
self.window = timedelta(seconds=window_seconds)
self.requests: dict = defaultdict(list)
def is_allowed(self, client_ip: str) -> bool:
now = datetime.now()
# ลบ Requests เก่าออก
self.requests[client_ip] = [
t for t in self.requests[client_ip]
if now - t < self.window
]
if len(self.requests[client_ip]) >= self.max_requests:
return False
self.requests[client_ip].append(now)
return True
limiter = RateLimiter(max_requests=30, window_seconds=60)
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host
is_htmx = request.headers.get("HX-Request") == "true"
if not limiter.is_allowed(client_ip):
if is_htmx:
# ส่ง Error เป็น HTML Fragment
return HTMLResponse(
content="""
<div class="rate-limit-error">
⚠️ คุณส่งคำขอมากเกินไป กรุณารอสักครู่แล้วลองใหม่
</div>
""",
status_code=429,
headers={"Retry-After": "60"}
)
else:
return HTMLResponse("Too Many Requests", status_code=429)
return await call_next(request)
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
flowchart LR
subgraph client["Client Metrics"]
C1["Time to First Byte - (TTFB)"]
C2["First Contentful Paint - (FCP)"]
C3["Largest Contentful Paint - (LCP)"]
C4["HTMX Request Time - (Partial Load)"]
end
subgraph server["Server Metrics"]
S1["Response Time - (Server Processing)"]
S2["Template Render Time"]
S3["DB Query Time"]
S4["Memory Usage"]
end
Client["🌐 User"] --> C1 --> C2 --> C3
C3 --> C4
C4 --> S1 --> S2 --> S3
รูปที่ 10.1: Performance Metrics ใน HTMX Application
สมการคำนวณ Time Saved เมื่อใช้ HTMX แทน Full Page Reload:
โดยที่:
ตัวอย่างการคำนวณ:
สมมติว่า:
ประสิทธิภาพที่เพิ่มขึ้น (Performance Gain):
# caching.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
import hashlib
import time
from functools import lru_cache
app = FastAPI()
# Simple In-memory Cache
cache: dict = {}
CACHE_TTL = 300 # 5 นาที
def get_cache_key(path: str, params: dict) -> str:
"""สร้าง Cache Key จาก Path และ Parameters"""
data = f"{path}:{sorted(params.items())}"
return hashlib.md5(data.encode()).hexdigest()
def get_cached(key: str):
if key in cache:
data, timestamp = cache[key]
if time.time() - timestamp < CACHE_TTL:
return data
del cache[key]
return None
def set_cache(key: str, value: str):
cache[key] = (value, time.time())
@app.get("/products/featured", response_class=HTMLResponse)
async def get_featured_products(request: Request, response: Response):
cache_key = get_cache_key("/products/featured", {})
# ตรวจสอบ Cache
cached = get_cached(cache_key)
if cached:
response.headers["X-Cache"] = "HIT"
return cached
# สร้าง Response ใหม่
html = """
<div class="featured-products">
<div class="product-card">สินค้าแนะนำ 1</div>
<div class="product-card">สินค้าแนะนำ 2</div>
</div>
"""
set_cache(cache_key, html)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={CACHE_TTL}"
return html
# test_htmx_app.py
import pytest
from fastapi.testclient import TestClient
from app import app # import จาก main app
client = TestClient(app)
# Helper: Header สำหรับ HTMX Request
HTMX_HEADERS = {
"HX-Request": "true",
"HX-Current-URL": "http://localhost/",
}
class TestProductAPI:
"""Test Cases สำหรับ Product Endpoints"""
def test_get_products_full_page(self):
"""ทดสอบ Full Page Request"""
response = client.get("/products")
assert response.status_code == 200
assert "<!DOCTYPE html>" in response.text
def test_get_products_htmx_request(self):
"""ทดสอบ HTMX Partial Request"""
response = client.get("/products", headers=HTMX_HEADERS)
assert response.status_code == 200
# HTMX Request ไม่ควรได้ Full HTML
assert "<!DOCTYPE html>" not in response.text
assert "product-card" in response.text
def test_create_product_success(self):
"""ทดสอบสร้างสินค้าสำเร็จ"""
response = client.post(
"/products",
data={"name": "สินค้าทดสอบ", "price": "999"},
headers=HTMX_HEADERS
)
assert response.status_code == 200
assert "สินค้าทดสอบ" in response.text
def test_create_product_validation_error(self):
"""ทดสอบ Validation Error"""
response = client.post(
"/products",
data={"name": ""}, # ชื่อว่าง
headers=HTMX_HEADERS
)
assert response.status_code == 422
assert "error" in response.text.lower()
def test_delete_product_returns_empty(self):
"""ทดสอบลบสินค้า (ควรได้ empty response)"""
response = client.delete("/products/1", headers=HTMX_HEADERS)
assert response.status_code == 200
assert response.text.strip() == ""
def test_oob_swap_headers(self):
"""ทดสอบว่า Response มี OOB Elements"""
response = client.post(
"/cart/add",
data={"product_id": "1"},
headers=HTMX_HEADERS
)
assert response.status_code == 200
assert 'hx-swap-oob' in response.text
| Extension | วัตถุประสงค์ | การใช้งาน |
|---|---|---|
| preload | Preload ลิงก์ล่วงหน้า | hx-ext="preload" |
| class-tools | จัดการ CSS Classes | hx-ext="class-tools" |
| alpine-morph | Morph DOM กับ Alpine.js | hx-ext="alpine-morph" |
| ws | WebSocket Support | hx-ext="ws" |
| sse | Server-Sent Events | hx-ext="sse" |
| json-enc | ส่งข้อมูลเป็น JSON | hx-ext="json-enc" |
| response-targets | Target ตาม Status Code | hx-ext="response-targets" |
<!-- Extension: response-targets (จัดการ Error Response) -->
<script src="https://unpkg.com/htmx-ext-response-targets@2.0.0/response-targets.js"></script>
<form hx-post="/submit"
hx-ext="response-targets"
hx-target="#success-msg"
hx-target-422="#validation-errors"
hx-target-500="#server-error">
<input name="data">
<button type="submit">ส่ง</button>
</form>
<div id="success-msg"></div>
<div id="validation-errors" class="errors"></div>
<div id="server-error" class="server-error"></div>
<!-- Extension: preload -->
<script src="https://unpkg.com/htmx-ext-preload@2.0.1/preload.js"></script>
<div hx-ext="preload">
<!-- Preload เมื่อ hover -->
<a href="/about" preload="mouseover">เกี่ยวกับเรา</a>
<!-- Preload ทันที -->
<a href="/popular" preload>บทความยอดนิยม</a>
</div>
Alpine.js เป็น JavaScript Framework ขนาดเล็ก (~15KB) ที่เหมาะกับการทำ Client-side Interactivity ที่ HTMX ไม่ถนัด เช่น Dropdown, Toggle, Local State
<!-- ใช้ Alpine.js กับ HTMX ร่วมกัน -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- ตัวอย่าง: Accordion กับ Server-loaded Content -->
<div x-data="{ open: false }">
<button @click="open = !open"
:aria-expanded="open">
<span x-text="open ? '▼' : '▶'"></span>
รายละเอียดเพิ่มเติม
</button>
<!-- Alpine ควบคุมการแสดง, HTMX โหลดเนื้อหา -->
<div x-show="open"
x-transition
hx-get="/details/1"
hx-trigger="intersect once"
hx-target="this">
<div class="htmx-indicator">⏳ กำลังโหลด...</div>
</div>
</div>
<!-- ตัวอย่าง: Shopping Cart พร้อม Local State -->
<div x-data="{ cart: [], total: 0 }">
<!-- เพิ่มสินค้า (HTMX อัปเดต Server, Alpine อัปเดต UI ทันที) -->
<button hx-post="/cart/add"
hx-vals='{"product_id": 1, "price": 999}'
hx-on:htmx:after-request="cart.push({id: 1, price: 999}); total += 999">
🛒 เพิ่ม (฿999)
</button>
<!-- แสดง Local State ด้วย Alpine -->
<div>
รวม: ฿<span x-text="total.toLocaleString()">0</span>
(<span x-text="cart.length">0</span> ชิ้น)
</div>
</div>
<!-- เปิด Debug Mode -->
<script>
// Log ทุก HTMX Event ใน Console
htmx.logAll();
// ดู HTMX Version
console.log("HTMX Version:", htmx.version);
// Debug Specific Element
htmx.on("#my-button", "htmx:beforeRequest", function(evt) {
console.log("Request Details:", evt.detail);
});
// ดู Configuration ปัจจุบัน
console.log("HTMX Config:", htmx.config);
// หยุด Debug Mode
// htmx.logNone();
</script>
<!-- HTMX Meta Configuration -->
<meta name="htmx-config" content='{
"defaultSwapStyle": "innerHTML",
"defaultSwapDelay": 0,
"defaultSettleDelay": 20,
"historyCacheSize": 10,
"refreshOnHistoryMiss": false,
"timeout": 0,
"wsReconnectDelay": "full-jitter",
"allowScriptTags": false,
"inlineScriptNonce": "",
"includeIndicatorStyles": true,
"selfRequestsOnly": true
}'>
# migration_example.py
# กรณีศึกษา: แปลง React Product Catalog เป็น HTMX
# BEFORE: React API Endpoint (JSON)
# @app.get("/api/products")
# def get_products_json():
# return {"products": [...], "total": 100} # JSON
# AFTER: HTMX HTML Endpoint
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
PRODUCTS = [
{"id": i, "name": f"สินค้า #{i}", "price": i*100, "category": "อิเล็กทรอนิกส์"}
for i in range(1, 21)
]
@app.get("/products", response_class=HTMLResponse)
async def get_products(request: Request, category: str = "", q: str = ""):
products = PRODUCTS
if category:
products = [p for p in products if p["category"] == category]
if q:
products = [p for p in products if q.lower() in p["name"].lower()]
is_htmx = request.headers.get("HX-Request") == "true"
grid_html = f"""
<div id="products-grid" class="grid">
{"".join(f'''
<div class="product-card" id="product-{p["id"]}">
<h3>{p["name"]}</h3>
<p class="price">฿{p["price"]}</p>
<button hx-post="/cart/add"
hx-vals=\'{{"product_id": {p["id"]}}}\'>
🛒 เพิ่ม
</button>
</div>
''' for p in products)}
</div>
<p>{len(products)} สินค้า</p>
"""
if is_htmx:
return grid_html
# Full Page
return f"""
<!DOCTYPE html><html><head>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head><body>
<h1>สินค้าของเรา</h1>
<input hx-get="/products" hx-trigger="keyup delay:300ms"
hx-target="#products-grid" name="q" placeholder="ค้นหา...">
{grid_html}
</body></html>
"""
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
flowchart TD
Start([🤔 เริ่มต้น: ต้องการ Interactivity?]) --> Q1{ต้องการ Offline Support?}
Q1 -->|ใช่| SPA[✅ ใช้ SPA Framework - React / Vue / Angular]
Q1 -->|ไม่| Q2{ต้องการ Complex - Client-side State?}
Q2 -->|ใช่ มาก| SPA
Q2 -->|ไม่ หรือน้อย| Q3{ต้องการ SEO ดี?}
Q3 -->|ใช่| HTMX[✅ ใช้ HTMX - เหมาะมาก!]
Q3 -->|ไม่สำคัญ| Q4{ทีมถนัด JS มาก?}
Q4 -->|ใช่| SPA
Q4 -->|ไม่| HTMX
style HTMX fill:#98971a,color:#282828,stroke:#b8bb26
style SPA fill:#458588,color:#ebdbb2,stroke:#83a598
style Start fill:#d79921,color:#282828,stroke:#fabd2f
รูปที่ 12.1: Decision Tree สำหรับการเลือกใช้ HTMX
| Scenario | HTMX | SPA |
|---|---|---|
| เว็บสินค้า / E-commerce | ✅ ดีเยี่ยม | ⚠️ Overkill |
| Admin Dashboard | ✅ ดีเยี่ยม | ✅ ดี |
| Web App ซับซ้อน | ⚠️ จำกัด | ✅ ดีเยี่ยม |
| Mobile App PWA | ❌ ไม่เหมาะ | ✅ ดีเยี่ยม |
| Blog / CMS | ✅ ดีเยี่ยม | ⚠️ Overkill |
| Real-time Chat | ✅ ได้ (SSE/WS) | ✅ ดี |
| Data Visualization | ⚠️ จำกัด | ✅ ดีเยี่ยม |
| Form-heavy App | ✅ ดีเยี่ยม | ✅ ดี |
HTMX กำลังเติบโตอย่างต่อเนื่อง ด้วยเหตุผลหลายประการ:
<!-- ตัวอย่าง: View Transitions API + HTMX (อนาคต) -->
<head>
<meta name="htmx-config" content='{"useViewTransitionsIfAvailable": true}'>
</head>
<!-- Navigation จะมี Smooth Transition -->
<nav hx-boost="true">
<a href="/home">หน้าหลัก</a>
<a href="/about">เกี่ยวกับ</a>
</nav>
<!-- CSS View Transitions -->
<style>
@view-transition { navigation: auto; }
::view-transition-old(root) {
animation: fade-out 0.3s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-out;
}
</style>
| ประเภท | แหล่งข้อมูล | URL |
|---|---|---|
| Official Docs | htmx.org | https://htmx.org |
| Reference | HTMX Attributes | https://htmx.org/reference |
| Examples | HTMX Examples | https://htmx.org/examples |
| Discord | HTMX Community | https://htmx.org/discord |
| GitHub | Source Code | https://github.com/bigskysoftware/htmx |
| Book | Hypermedia Systems | https://hypermedia.systems (ฟรี) |
| YouTube | Fireship HTMX Video | — |
| Extension | htmx-ext | https://extensions.htmx.org |
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#3c3836',
'primaryTextColor': '#ebdbb2',
'primaryBorderColor': '#a89984',
'lineColor': '#d79921',
'background': '#282828',
'mainBkg': '#3c3836',
'clusterBkg': '#32302f',
'fontSize': '14px'
}
}}%%
mindmap
root((HTMX))
Core Attributes
hx-get/post/put/patch/delete
hx-target
hx-swap
hx-trigger
Advanced Features
SSE / WebSocket
Out-of-Band Swaps
Polling
Infinite Scroll
Ecosystem
Alpine.js
Hyperscript
Extensions
Backend Support
Python FastAPI
Go Gin/Echo
Node.js Hono
PHP Laravel
Security
CSRF Protection
XSS Prevention
Rate Limiting
รูปที่ 12.2: Mind Map สรุป HTMX Ecosystem
สรุปท้ายบท: HTMX ไม่ใช่คำตอบสำหรับทุกปัญหา แต่สำหรับเว็บแอปพลิเคชันที่ต้องการ ความเรียบง่าย, ประสิทธิภาพสูง, SEO ดี และ ทีมพัฒนาขนาดเล็ก — HTMX คือตัวเลือกที่คุ้มค่าอย่างยิ่ง ด้วยปรัชญา Hypermedia-first ที่นำพาเราย้อนกลับสู่รากเหง้าของ Web ในแบบที่ทันสมัยและมีประสิทธิภาพกว่าเดิม