اخبار وب

HTMX و بازگشت به سادگی در توسعه وب

راهنمای htmx، قرارداد fragment و hx-target، با سرور Node.js و HTML و CSS کامل، fallback فرم و توضیح امنیت و مدیریت وضعیت.

1404/12/05 4 دقیقه مطالعه 1,648 بازدید
HTMX و بازگشت به سادگی در توسعه وب

htmx به عنصرهای HTML رفتار درخواست و جایگزینی محتوا اضافه می‌کند. سرور معمولاً HTML می‌فرستد و کتابخانه بخش مشخصی از صفحه را با همان پاسخ به‌روز می‌کند. برای فهرست، فرم یا جست‌وجویی که منطق اصلی‌اش در سرور است، این مدل می‌تواند مقدار کد اختصاصی مرورگر را کم کند. خود htmx یک کتابخانه JavaScript است؛ عبارت «بدون JavaScript» فقط در محدوده ننوشتن بعضی کدهای اختصاصی معنا دارد، نه نبودن JavaScript در برنامه.

این مدل پایان SPAها را اعلام نمی‌کند. یک ویرایشگر با حالت محلی پیچیده و تعامل پیوسته ممکن است معماری دیگری بخواهد. یک پنل CRUD هم لزوماً به نگهداری نسخه کامل حالت سرور در مرورگر نیاز ندارد. انتخاب را بر اساس محل اصلی منطق و نوع تعامل انجام دهید. مستندات رسمی htmx نقطه شروع شناخت مدل است، نه رتبه‌بندی محبوبیت ابزارها در یک سال خاص.

HTML پاسخ، قرارداد رابط است

در رویکرد JSONمحور، سرور داده می‌فرستد و کد client تصمیم می‌گیرد آن را با چه markup نمایش دهد. در رویکرد htmx، قالب سرور بخشی از همین تصمیم را می‌گیرد و HTML آماده تحویل می‌دهد. این جابه‌جایی می‌تواند تکرار منطق نمایش را کم کند، ولی سرور باید دقیق بداند پاسخ کامل صفحه لازم است یا fragment برای یک قسمت. مخلوط کردن این دو نوع پاسخ، معمولاً به ظاهر ناقص یا HTML تو در تو می‌رسد.

قرارداد fragment را با نام و محدوده مشخص تعریف کنید. اگر پاسخ باید داخل فهرست نتیجه قرار گیرد، سرور نباید دوباره کل سند شامل head و body را در آن بگذارد. اگر با outerHTML عنصر هدف را جایگزین می‌کنید، ID و attributeهای لازم برای درخواست بعدی باید در خروجی تازه حفظ شوند. نوع swap تصمیمی درباره ساختار DOM است و باید با قالب پاسخ هماهنگ باشد.

در تیمی که قالب server-side دارد، مسئولیت نمایش می‌تواند کنار همان قالب بماند. با این حال نباید تمام منطق کسب‌وکار را داخل stringهای HTML بریزید. اعتبارسنجی، مجوز و دسترسی به داده بهتر است لایه روشن خود را داشته باشند و قالب نتیجه آن‌ها را نمایش دهد. سادگی آغاز پروژه وقتی قابل نگهداری می‌شود که مرز مسئولیت‌ها حفظ شود.

Attributeهای اصلی را با مقصد واقعی بخوانید

hx-get درخواست GET را به URL مشخص می‌فرستد و hx-target عنصر مقصد را تعیین می‌کند. hx-swap شیوه جایگزینی را کنترل می‌کند. مرجع hx-get و مرجع hx-target رفتار این attributeها را توضیح می‌دهند. GET را برای خواندن به کار ببرید؛ حذف یا ثبت داده نباید به باز شدن ساده یک URL خواندنی وابسته باشد.

hx-trigger زمان آغاز درخواست را مشخص می‌کند. یک فرم می‌تواند هنگام submit درخواست بفرستد و یک ورودی جست‌وجو با فاصله کوتاهی پس از تغییر مقدار. تأخیر در trigger کمک می‌کند برای هر کلید فشرده‌شده درخواست فوری نفرستید. مستندات hx-trigger گزینه‌های رویداد را شرح می‌دهد. همین تنظیم، جای کنترل نرخ یا محدود کردن query در سرور را نمی‌گیرد.

انتخاب innerHTML در این راهنما باعث می‌شود ظرف نتیجه باقی بماند و فقط محتوای داخلی آن تغییر کند. این ظرف یک ID ثابت دارد که درخواست‌های بعدی هم به آن اشاره می‌کنند. اگر به outerHTML تغییر دهید، پاسخ باید ظرف را نیز داشته باشد. مرجع hx-swap برای تصمیم درباره این جزئیات است. نام selector بدون عنصر متناظر، مثال قابل استفاده نمی‌سازد.

نمونه کامل جست‌وجوی مقاله

نمونه آموزشی پایین یک سرور Node.js و صفحه جست‌وجو دارد. داده‌ها سه عنوان ثابت آموزشی‌اند و هیچ پایگاه داده یا API بیرونی وجود ندارد. htmx از بسته npm نصب‌شده در همان پروژه سرو می‌شود؛ CDN ناشناخته یا فایل معرفی‌نشده لازم نیست. در پوشه‌ای تازه package.json را ذخیره کنید، npm install را اجرا کنید و Lockfile حاصل را برای نصب بعدی نگه دارید.

{
  "name": "htmx-search-example",
  "private": true,
  "type": "module",
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "htmx.org": "2.0.11" }
}

فایل server.mjs زیر تمام markup، style و endpointهای نمونه را دارد. نسخه پشتیبانی‌شده Node.js با پشتیبانی ES Modules لازم است. npm start سرور را روی 127.0.0.1 و پورت 3000 راه‌اندازی می‌کند. صفحه ریشه فرم را نشان می‌دهد؛ action و method معمول HTML هم ثبت شده‌اند تا اگر JavaScript اجرا نشد، submit فرم یک صفحه کامل نتیجه تحویل بگیرد.

import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const htmxPath = new URL("./node_modules/htmx.org/dist/htmx.min.js", import.meta.url);
const articles = ["راهنمای HTML", "یادگیری CSS", "شروع JavaScript"];
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({
  "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[char]);

function results(query) {
  const matches = articles.filter((title) => title.toLowerCase().includes(query.toLowerCase()));
  return `<p>نتیجه جست‌وجوی «${escapeHtml(query)}»</p>` + (matches.length
    ? `<ul>${matches.map((title) => `<li>${escapeHtml(title)}</li>`).join("")}</ul>`
    : "<p>عنوانی با این عبارت پیدا نشد.</p>");
}

function page(query) {
  return `<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>جست‌وجوی آموزشی</title>
<style>
body { font-family: sans-serif; margin: 0; line-height: 1.8; color: #18212b; }
main { max-width: 44rem; margin: 2rem auto; padding: 1rem; }
form { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
input, button { font: inherit; padding: .5rem; }
input { min-width: 0; }
:focus-visible { outline: 3px solid #1262bc; outline-offset: 2px; }
#results { margin-top: 1rem; min-height: 8rem; }
.htmx-indicator { opacity: 0; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
</style>
<script src="/htmx.js" defer></script>
</head>
<body><main>
<h1>جست‌وجوی عنوان‌های آموزشی</h1>
<form action="/search" method="get" hx-get="/search" hx-target="#results"
 hx-swap="innerHTML" hx-trigger="submit, input changed delay:300ms"
 hx-indicator="#search-status" hx-sync="this:replace">
<label for="query">عبارت جست‌وجو</label>
<input id="query" name="q" type="search" maxlength="100" value="${escapeHtml(query)}">
<button type="submit">جست‌وجو</button>
<span id="search-status" class="htmx-indicator" role="status">نتیجه آماده می‌شود.</span>
</form>
<section id="results" aria-label="نتایج جست‌وجو" aria-live="polite">${results(query)}</section>
</main></body></html>`;
}

createServer(async (request, response) => {
  try {
    const url = new URL(request.url, "http://127.0.0.1:3000");
    if (request.method !== "GET") {
      response.writeHead(405, { Allow: "GET" }); response.end("Method not allowed"); return;
    }
    if (url.pathname === "/htmx.js") {
      response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
      response.end(await readFile(htmxPath)); return;
    }
    if (!["/", "/search"].includes(url.pathname)) {
      response.writeHead(404); response.end("Not found"); return;
    }
    const query = (url.searchParams.get("q") ?? "").slice(0, 100);
    const fragment = url.pathname === "/search" && request.headers["hx-request"] === "true";
    response.writeHead(200, {
      "Content-Type": "text/html; charset=utf-8",
      "Cache-Control": "no-store",
      Vary: "HX-Request",
    });
    response.end(fragment ? results(query) : page(query));
  } catch {
    response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
    response.end("Server error");
  }
}).listen(3000, "127.0.0.1");

در حالت htmx، header درخواست به سرور نشان می‌دهد fragment خواسته شده است. این header ابزار احراز هویت نیست؛ هر درخواست‌کننده‌ای می‌تواند آن را ارسال کند. در مثال، هم صفحه کامل و هم fragment از همان تابع results استفاده می‌کنند تا فیلتر و نمایش نتیجه دو پیاده‌سازی متفاوت نداشته باشد. برای داده محرمانه، بررسی مجوز باید قبل از ساخت هر دو پاسخ انجام شود.

input جست‌وجو name برابر q دارد، بنابراین مقدار آن در درخواست فرم ارسال می‌شود. متن query پیش از قرار گرفتن در HTML escape می‌شود. شناسه results همان هدف hx-target است. hx-sync با replace برای درخواست‌های همین فرم کمک می‌کند درخواست جدید جای درخواست قبلی را بگیرد؛ قرارداد مرتب بودن نتایج را در interactionهای پیچیده‌تر جدا بررسی کنید.

در این مثال، درخواست‌های GET فقط داده ثابت را می‌خوانند. اضافه کردن قابلیت ویرایش، عملیات جدید و کنترل‌های جدید می‌خواهد. نمونه را با تعویض GET به POST و بدون اعتبارسنجی به فرم Production تبدیل نکنید. روش اجرای آن برای شناخت چرخه درخواست و fragment ارائه شده است و نتیجه آزمون مرورگر یا سرویس منتشرشده نیست.

انتظار، نتیجه خالی و خطا را جدا طراحی کنید

هنگام درخواست، indicator وضعیت کوتاهی نمایش می‌دهد. این عنصر با CSS مربوط به htmx-request کنترل می‌شود و spinner تصویری لازم ندارد. راهنمای hx-indicator رفتار را توضیح می‌دهد. نتیجه خالی در این نمونه پیام مشخصی دارد؛ خالی بودن فهرست به معنی شکست سرور نیست. متن رابط باید این دو وضعیت را از هم جدا کند.

در برنامه واقعی، اگر درخواست شکست بخورد، محتوای قبلی شاید همچنان روی صفحه باقی بماند. کاربر باید بداند نتیجه تازه نشده است. برای پاسخ ناموفق، رفتار نمایش خطا و امکان تکرار را تعریف کنید و به حالت پیش‌فرضی که اتفاقاً دیده می‌شود اتکا نکنید. مقاله طراحی Error Handling به متن و مسیر ادامه این وضعیت مربوط است.

تغییر یک بخش صفحه، Focus را هم تحت تأثیر قرار می‌دهد. اگر عنصری که کاربر روی آن کار می‌کند حذف شود، باید معلوم باشد کار را کجا ادامه می‌دهد. در جست‌وجوی نمونه، ورودی بیرون target است و با نتیجه جایگزین نمی‌شود. برای فرم‌های چندمرحله‌ای یا حذف یک ردیف، تصمیم Focus باید بخشی از طراحی باشد. aria-live نیز برای هر تغییر بزرگ و پرتکرار راه‌حل مناسب نیست؛ حجم اعلان را با وظیفه کاربر هماهنگ کنید.

امنیت و تاریخچه را به سرور بسپارید

HTML تولیدشده از ورودی کاربر باید با قواعد درست escape یا sanitize شود. htmx پاسخ HTML را وارد صفحه می‌کند، بنابراین اعتماد بی‌بررسی به داده خطر دارد. راهنمای Security در مستندات htmx به XSS و مسئولیت سازنده برنامه اشاره می‌کند. در این نمونه فقط متن ثابت و query محدود وجود دارد؛ محتوای غنی کاربر نیاز به سیاست جدا دارد.

برای POST و دیگر عملیات تغییردهنده، قرارداد CSRF framework را حفظ کنید. وجود attribute در HTML باعث نمی‌شود سرور به درخواست اعتماد کند. هر endpoint باید اعتبارسنجی و مجوز را خودش انجام دهد. پنهان کردن دکمه حذف یا تغییر target در client، کنترل دسترسی محسوب نمی‌شود. اگر یک عملیات باید idempotent باشد، این ویژگی هم در لایه سرور طراحی می‌شود.

تاریخچه و URL به انتظار کاربر مربوط‌اند. جست‌وجوی قابل اشتراک‌گذاری باید query را در نشانی قابل فهم حفظ کند. در نمونه ساده، submit معمولی این رفتار را دارد ولی درخواست htmx نشانی را تغییر نمی‌دهد؛ برای افزودن History، تنظیم مناسب و پاسخ کامل هنگام باز کردن مستقیم URL لازم است. قبل از فعال کردن push URL مشخص کنید صفحه برگشتی با Back و Refresh باید چه شکلی باشد.

محل مناسب استفاده را با یک جریان انتخاب کنید

htmx برای یک جریان server-driven که پاسخ آن fragment مشخص است، گزینه‌ای قابل بررسی است. برای قابلیت‌هایی مانند ویرایش طولانی محلی، کار آفلاین یا تعامل بسیار پرتکرار، رفت‌وبرگشت به سرور ممکن است مدل مناسب نباشد. از یک صفحه واقعی و محدود شروع کنید و قرارداد آن را بنویسید. انتخاب ابزار باید هزینه client، server و نگهداری را با هم ببیند.

اگر ساختار معنایی و راه ادامه بدون JavaScript را از ابتدا نگه دارید، تغییر رفتار صفحه قابل توضیح‌تر می‌شود. مقاله دسترسی‌پذیری از روز اول به همین طراحی مربوط است. قدم بعدی، انتخاب یک فرم یا فهرست و تعیین پاسخ کامل، fragment، target و حالت خطای آن است؛ پس از روشن شدن این چهار مورد، اضافه کردن attributeها کار کوچکی خواهد بود.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!