- เพิ่มปฏิสัมพันธ์แบบไดนามิกด้วย HTMX โดยยังคง การเรนเดอร์ฝั่งเซิร์ฟเวอร์ ที่อิงกับ
html/template ของ Go ไว้ และให้เรนเดอเรอร์ตัวเดียวสามารถเลือกส่งคืนได้ทั้งหน้าเต็มและชิ้นส่วน HTML
- แยกมาร์กอัปออกเป็น
base.tmpl, เทมเพลตหน้า และชิ้นส่วนที่นำกลับมาใช้ซ้ำได้ พร้อมใช้ embed.FS ของ Go 1.16 และ ชุดเทมเพลตร่วม เพื่อทำให้โครงสร้างการดีพลอยและการเรนเดอร์ง่ายขึ้น
- ส่งคืนผลการค้นหาเป็นทั้งหน้าเต็มหรือแถวของตารางตามค่า
HX-Request พร้อมใช้ Vary: HX-Request และ historyRestoreAsHxRequest: false เพื่อป้องกันปัญหาแคชและข้อผิดพลาดจากการกู้คืนเมื่อกดย้อนกลับ
- เมื่อย้ายคำขอ HTMX ไปยังหน้าอื่น ให้ใช้
HX-Redirect และการตอบกลับ 2xx แทน 3xx ปกติ และใช้ responseHandling แยกตาม 204·422·4xx·5xx เพื่อควบคุมขอบเขตการแทนที่และการแสดงข้อผิดพลาด
- ใช้การตั้งค่าที่ปิดแคช local storage และการสืบทอดแอตทริบิวต์ พร้อมกำหนดระยะเวลาหมดอายุของคำขอเป็นจุดเริ่มต้น และเมื่อระบบมีขนาดใหญ่ขึ้นก็สามารถเพิ่ม เทมเพลตเลย์เอาต์ระดับกลาง เพื่อรองรับโครงสร้างหน้าจอแยกต่างหาก เช่น พื้นที่ผู้ดูแลระบบ
เหตุผลที่ผสานการเรนเดอร์ฝั่งเซิร์ฟเวอร์ของ Go กับ HTMX
- HTMX ช่วยเพิ่มปฏิสัมพันธ์แบบไดนามิกด้วย JavaScript เพียงเล็กน้อย โดยยังคงความสม่ำเสมอและความปลอดภัยจาก การเรนเดอร์ HTML ฝั่งเซิร์ฟเวอร์ ของ Go
html/template
- แกนหลักที่พูดถึงในการใช้งานมี 3 ส่วน
- โครงสร้างเทมเพลตที่รองรับทั้งการตอบกลับเป็นทั้งหน้าเต็มและ HTML บางส่วน
- การจัดการ redirect และข้อผิดพลาดในสภาพแวดล้อมของ HTMX
- การตั้งค่าที่เปลี่ยนค่าเริ่มต้นของ HTMX และเหตุผลที่ต้องทำเช่นนั้น
- เริ่มจากฟังก์ชันพื้นฐานที่เปลี่ยนปุ่มเป็นรูปภาพ แล้วขยายไปเป็นหน้าค้นหาที่กรองรายการระหว่างพิมพ์ชื่อผู้ใช้หรืออีเมล
- แพตเทิร์นเทมเพลตเดียวกันนี้ใช้ได้ไม่เฉพาะกับ HTMX แต่ยังใช้ได้กับเครื่องมือแบบ HTML-over-the-wire อย่าง Unpoly และ Hotwire
- หากยังไม่คุ้นกับวิธีใช้งานพื้นฐานของ HTMX ควรอ่านเอกสารทางการก่อน
โครงสร้างไดเรกทอรีโปรเจกต์และไฟล์สแตติก
- โปรเจกต์แบ่งออกเป็นส่วนต่าง ๆ ตามหน้าที่ดังนี้
assets/html/base.tmpl: โครง HTML หลักที่ทุกหน้าใช้ร่วมกัน
assets/html/pages/: เนื้อหาของแต่ละหน้า
assets/html/partials/: ชิ้นส่วน HTML ที่นำกลับมาใช้ซ้ำในหลายตำแหน่ง
assets/static/css, assets/static/img, assets/static/js: แอสเซ็ตแบบสแตติก
cmd/web/: โค้ดเริ่มเซิร์ฟเวอร์, แฮนด์เลอร์ และตัวเรนเดอร์ HTML
- โครงหลักเริ่มต้นสร้างได้ด้วยคำสั่งต่อไปนี้
go mod init example.com/htmx
mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web
touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
- จะติดตั้ง HTMX ผ่าน CDN หรือ NPM ก็ได้ แต่แนวทางที่ใช้บ่อยคือดาวน์โหลดสำเนามาแล้ว ให้บริการเป็นไฟล์สแตติกของแอปพลิเคชันโดยตรง
- ตั้งค่าง่ายกว่าและหลีกเลี่ยงข้อเสียของการใช้ CDN
- ใช้
htmx.org@2.0.10 และเฟรมเวิร์ก CSS แบบไม่มีคลาส bamboo.css@1.4.0
wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css
การแยกเทมเพลตหลัก เทมเพลตหน้า และเทมเพลตบางส่วน
base.tmpl กำหนดโครงเอกสารทั้งหมด เช่น <head>, ชื่อเรื่องร่วม และ <main> แล้วแทรก page:title กับ page:content
- เรียกใช้ Bamboo CSS และสคริปต์ HTMX จากเทมเพลตหลัก และกำหนด แอตทริบิวต์
defer ให้สคริปต์ HTMX
- เบราว์เซอร์จะดึงสคริปต์แบบขนานระหว่างพาร์ส HTML
- สคริปต์จะทำงานหลังจากพาร์ส HTML และสร้าง DOM เสร็จแล้ว
- เทมเพลตทั้งหมดกำหนดชื่ออย่างชัดเจนด้วย
{{define}}...{{end}} โดยไม่พึ่งชื่อไฟล์
- โค้ด Go สามารถอ้างอิงเทมเพลตได้อย่างสม่ำเสมอด้วยชื่อที่กำหนดไว้เท่านั้น
: ใน page:title เป็นเพียงตัวคั่นตามสะดวก และจะใช้ชื่ออย่าง page_title, page-title, pageTitle, title ก็ได้
- ปุ่มบนหน้าแรกกำหนดพฤติกรรมไว้ 2 อย่าง
hx-get="/gopher": ส่งคำขอ GET /gopher เมื่อคลิก
hx-swap="outerHTML": แทนที่ตัวปุ่มเองด้วย HTML ที่ตอบกลับมา
partial:image:gopher เป็นชิ้นส่วนรูปภาพที่นำกลับมาใช้ซ้ำได้ และรับความกว้างของรูปตอนเรียกใช้ผ่าน width="{{.}}"
- ชิ้นส่วน HTML ที่ใช้เฉพาะในบางหน้า ไม่จำเป็นต้องอยู่ในไดเรกทอรี
partials กลาง แต่สามารถวางไว้ในไฟล์ของหน้านั้นได้
- กล่าวคือ ชิ้นส่วนที่ใช้หลายหน้าจะอยู่ในไดเรกทอรีกลาง ส่วนชิ้นส่วนเฉพาะหน้าจะวางไว้ข้างเนื้อหาที่เกี่ยวข้อง
ฝัง HTML และแอสเซ็ตสแตติกไว้ในไบนารีของ Go
- หลังจาก Go 1.16 เพิ่มความสามารถในการฝังไฟล์ ก็สามารถ ฝัง HTML และแอสเซ็ตสแตติกไว้ในไบนารีของ Go แทนการอ่านจากดิสก์ตอนรันไทม์ได้
- ใช้ directive
//go:embed "html" "static" เพื่อใส่สองไดเรกทอรีลงใน embed.FS แล้วใช้ fs.Sub() แยกออกเป็นระบบไฟล์ย่อย 2 ชุด
HTMLFiles: ระบบไฟล์เทมเพลตที่ใช้ html เป็นราก
StaticFiles: ระบบไฟล์สแตติกที่ใช้ static เป็นราก
- ข้อดีของการแยกเช่นนี้มี 2 อย่าง
- โค้ดส่วน HTML และโค้ดส่วนไฟล์สแตติกจะไม่เข้าถึงไฟล์ของอีกฝั่งโดยไม่จำเป็น
- เวลาเปิดไฟล์ไม่ต้องใส่คำนำหน้า
html/ หรือ static/
- หากไม่ต้องการใช้
panic() ใน sub() ก็สามารถคืนค่า error แล้วกำหนดค่าให้ตัวแปรทั้งสองใน main() ได้
- ในการใช้งานปัจจุบัน หาก
fs.Sub() จะล้มเหลวได้ ค่าของไดเรกทอรีต้องไม่ใช่พาธที่ถูกต้อง แต่สตริงคงที่ "html" และ "static" ผ่านการตรวจสอบนี้อยู่แล้ว จึงมี ความเสี่ยงต่ำมากที่จะเกิด runtime panic
ชุดเทมเพลตร่วมและ htmlRenderer
htmlRenderer จัดการลำดับการเรนเดอร์ดังนี้
- พาร์ส ชุดเทมเพลตร่วม เพียงครั้งเดียวตอนเริ่มต้น
- โคลนชุดร่วมทุกครั้งที่มีการเรนเดอร์
- พาร์สเทมเพลตหน้าที่จำเป็นสำหรับคำขอนั้นเพิ่มเติม
- รันเทมเพลตตามชื่อที่ระบุและส่งเป็นการตอบกลับ HTTP
newHTMLRenderer() ลงทะเบียน template.FuncMap ก่อน แล้วจึงพาร์สเทมเพลตร่วม
- ผูก
now เข้ากับ time.Now
- สามารถเพิ่มฟังก์ชันเทมเพลตแบบกำหนดเองอื่น ๆ ได้ที่ตำแหน่งเดียวกัน
render() สร้างชุดเทมเพลตต่อคำขอด้วย sharedTemplates.Clone() และหากมี additionalTemplateFiles ก็ขยายต่อด้วย ParseFS()
- เทมเพลตจะถูกรันลงใน
bytes.Buffer ก่อน จากนั้นจึงเขียน status code และส่งออกเป็นเนื้อหาของ response
- ตอนเริ่มต้นเซิร์ฟเวอร์ จะพาร์ส
"base.tmpl" และ "partials/*.tmpl" เป็นชุดร่วม
- ทำให้เทมเพลตหลักและชิ้นส่วนกลางทั้งหมดพร้อมใช้งานในเรนเดอเรอร์ตลอดเวลา
- ส่วนเทมเพลตเฉพาะหน้าจะเพิ่มเข้ามาเมื่อเรียกแฮนด์เลอร์เท่านั้น
- ให้บริการไฟล์สแตติกผ่าน
http.FileServerFS(assets.StaticFiles) และพาธ /static/ โดยเซิร์ฟเวอร์รันอยู่ที่ :5051
การเรนเดอร์ทั้งหน้าเต็มและชิ้นส่วน HTML
- แฮนด์เลอร์ของหน้าแรกส่งคืนเอกสารเต็มด้วยการเรียกดังนี้
app.html.render(w, 200, nil, "base", "pages/home.tmpl")
- นี่คือการเรียกที่เพิ่ม
pages/home.tmpl เข้าไปในชุดร่วม แล้วรันเทมเพลต base ด้วย 200 OK
- แฮนด์เลอร์
/gopher ไม่พาร์สไฟล์เพิ่มเติม แต่รันเฉพาะ partial:image:gopher ที่ถูกรวมอยู่ในชุดร่วมแล้ว
width := 100
app.html.render(w, http.StatusOK, width, "partial:image:gopher")
- HTMX จะสลับปุ่มกับรูปภาพที่ได้รับจาก
/gopher ตามการตั้งค่า hx-swap="outerHTML"
- โครงสร้างนี้ให้ข้อดีดังต่อไปนี้
- เทมเพลตและ static assets ถูกรวมอยู่ในไบนารี ทำให้ดีพลอยได้ง่าย
- สามารถใช้
render() เดียวกันเพื่อส่งกลับได้ทั้งหน้า HTML แบบเต็มและชิ้นส่วน HTML เฉพาะส่วน
- ลดความซ้ำซ้อนของมาร์กอัปด้วยเทมเพลตหลักและเทมเพลตย่อย
- สามารถนำเทมเพลตย่อยกลับมาใช้ซ้ำได้ทั้งในเทมเพลตหลัก เนื้อหาหน้า และเทมเพลตย่อยอื่น ๆ
ค้นหาผู้ใช้แบบอัปเดตทันทีขณะพิมพ์
- ฟังก์ชันค้นหาแบ่งออกเป็นสองเส้นทาง
GET /users: หน้า HTML แบบเต็มที่มีข้อมูลผู้ใช้ทั้งหมด
GET /users/search: ชิ้นส่วน HTML ที่ส่งกลับเฉพาะแถวตารางของผู้ใช้ที่ชื่อหรืออีเมลตรงกับคำค้น
- ช่องค้นหาใช้ชุดแอตทริบิวต์ของ HTMX ดังนี้
- ส่งคำขอค้นหาด้วย
hx-get="/users/search"
- ส่งคำขอเมื่อมีการเปลี่ยนแปลงอินพุตแล้วผ่านไป 500ms หรือเมื่อกด Enter ด้วย
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
- คำค้นจะถูกส่งเป็น query string ในรูปแบบ
GET /users/search?query=foo
- ใส่ผลลัพธ์ลงใน
<tbody id="search-results"> ด้วย hx-target="#search-results"
- อัปเดตแถบที่อยู่และเพิ่มรายการในประวัติเบราว์เซอร์ทุกครั้งที่มีคำขอด้วย
hx-push-url="true"
users:rows เป็นเทมเพลตแยกที่เรนเดอร์เฉพาะแถวของผู้ใช้
- แต่ละแถวจะแสดงชื่อและอีเมล
- หาก
IsGopher เป็นจริง จะเรนเดอร์ partial:image:gopher ด้วยความกว้าง 24
- ทั้ง
listUsers และ searchUsers ต่างก็เพิ่ม pages/users.tmpl เข้าไปใน shared set แต่ใช้เทมเพลตที่รันต่างกัน
listUsers: รัน base เพื่อส่งกลับทั้งหน้า
searchUsers: รัน users:rows เพื่อส่งกลับเฉพาะแถวที่ตรงกัน
- หากคำค้นว่างจะใช้รายชื่อผู้ใช้ทั้งหมด และถ้ามีค่าจะตรวจสอบชื่อกับอีเมลด้วย
strings.Contains()
- ในแอปพลิเคชันจริงอาจพิจารณาปรับปรุงเพิ่มเติมดังนี้
- รวม list handler และ search handler เข้าด้วยกัน
- รวมศูนย์ตัวช่วยสำหรับจัดการข้อผิดพลาดและ logging
- เพิ่ม Content Security Policy header และ panic recovery ด้วย middleware
- ตั้งค่า server timeout ให้เหมาะสม
แยกหน้าเต็มกับ partial response ด้วย HX-Request
- ถ้าผู้ใช้เปิด
/users/search?query=leo โดยตรงในเบราว์เซอร์หรือเข้าผ่านลิงก์ที่มีคนแชร์มา อาจเห็นเพียง partial HTML ของแถวตารางเท่านั้น
- คำขอจาก HTMX จะมีเฮดเดอร์
HX-Request: true ติดมาด้วยเสมอ จึงใช้ค่านี้ในการตัดสินรูปแบบของ response
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
- กำหนดให้เทมเพลตเริ่มต้นที่ search handler จะรันคือ
base และเปลี่ยนเป็น users:rows เฉพาะเมื่อเป็นคำขอจาก HTMX
- หากเข้าตรงจะได้รับทั้งหน้าที่มีผลการค้นหารวมอยู่ด้วย
- คำขอจาก HTMX จะได้รับเฉพาะชิ้นส่วนแถวสำหรับใส่ใน
<tbody>
เฮดเดอร์ Vary และการกู้คืนเมื่อกดย้อนกลับ
- เนื่องจาก URL เดียวกันอาจส่งกลับ response คนละแบบตามค่า
HX-Request จึงต้องแจ้งความแตกต่างนี้ให้แคชทราบด้วย
- เพิ่ม
Vary: HX-Request ให้กับทุก response ที่เรนเดอร์
- หากตั้งค่าเฉพาะใน handler ที่จำเป็นจริง ๆ จะประหยัดกว่าเล็กน้อยในเชิงความเข้มงวด
- แต่ถ้าตั้งครั้งเดียวใน renderer จะช่วยเลี่ยงบั๊กที่อาจเกิดจากการตกหล่น
- เมื่อ
hx-push-url หรือ hx-boost เพิ่มรายการในประวัติเบราว์เซอร์ HTMX จะเก็บแคช HTML ของหน้าที่สมบูรณ์ไว้ใน local storage ของเบราว์เซอร์
- แคชเริ่มต้นเก็บได้สูงสุด 10 หน้า
- หากย้อนกลับไปยังจุดที่ไม่มีในแคช HTMX จะร้องขอ URL นั้นจากเซิร์ฟเวอร์อีกครั้ง
- โดยปกติคำขอกู้คืนจะมี
HX-Request: true ติดมาด้วย ทำให้เซิร์ฟเวอร์อาจส่ง partial HTML แทนทั้งหน้า
- หากใช้
HX-Request ในการเลือกชนิดของ response ให้ตั้งค่า historyRestoreAsHxRequest: false
- จะตัด
HX-Request: true ออกจากคำขอกู้คืนกรณี cache miss
- ทำให้เซิร์ฟเวอร์ส่งกลับหน้า HTML แบบเต็มสำหรับคำขอนั้น
- การตั้งค่า HTMX จะถูกใส่ไว้ในเมตาแท็ก
htmx-config ของ base.tmpl
รีไดเร็กต์คำขอ HTMX ไปยังหน้าอื่น
- HTMX สามารถสลับชิ้นส่วน HTML เช่นข้อความสำเร็จได้ทันที จึงลดความจำเป็นของ flow แบบ Post/Redirect/Get ทั่วไป
- แต่เมื่อจำเป็นต้องพาไปยังอีกหน้าที่ต่างออกไปโดยสิ้นเชิง เช่นย้ายไปยังโปรไฟล์หลังล็อกอินสำเร็จ ก็ ไม่สามารถจัดการด้วย response 3xx ปกติอย่างเดียวได้
- เบราว์เซอร์จะตาม response 3xx ไปก่อนที่ HTMX จะจัดการ
- HTMX จะเห็นเพียง response สุดท้ายหลังรีไดเร็กต์ แล้วนำคอนเทนต์นั้นไปสลับแทนที่ target เดิม
- สำหรับคำขอ HTMX ให้ใช้ response 2xx ร่วมกับเฮดเดอร์
HX-Redirect
HX-Redirect: /foo/bar จะพาเบราว์เซอร์ไปยัง URL นั้นและโหลดทั้งหน้าใหม่
- คำขอไปยังปลายทางจะไม่มี
HX-Request: true ติดไปด้วย
- หากต้องการรองรับสภาพแวดล้อมที่ปิด JavaScript หรือไม่ได้โหลด HTMX ด้วย ก็ควรคง response 3xx ปกติไว้สำหรับคำขอที่ไม่ใช่ HTMX
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if isHTMXRequest(r) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, url, code)
}
- เมื่อต้องย้ายไป
/profile หลังล็อกอิน สามารถเรียกใช้ได้ดังนี้
redirect(w, r, "/profile", http.StatusSeeOther)
ความต่างระหว่าง HX-Redirect และ HX-Location
HX-Location ทำงานคล้ายการรีไดเร็กต์โดยไม่โหลดทั้งหน้าใหม่
- ดึง HTML ของ URL ปลายทาง
- สลับ response เข้าไปแทนใน
<body>
- เพิ่มรายการใหม่ในประวัติเบราว์เซอร์
- เมื่อร้องขอ URL ปลายทาง จะมี
HX-Request: true ติดไปด้วย
- หากเส้นทางปลายทางส่ง partial HTML ตามค่า
HX-Request ก็อาจเกิดปัญหาได้
- handler ไม่สามารถแยกคำขอที่ตามมาจาก
HX-Location ออกจากคำขอ HTMX ปกติได้
- แบบแรกต้องการทั้งหน้า แต่แบบหลังต้องการ partial response
- ต่างจากการกู้คืนประวัติ
HX-Location ไม่มีการตั้งค่าให้ปิด HX-Request: true ระหว่างการย้าย
- โดยมากแล้วการยอมให้มีการโหลดหน้าใหม่ทั้งหมดและ ใช้
HX-Redirect จะง่ายและปลอดภัยกว่า
- หากกำหนดให้เส้นทางปลายทางส่งกลับเฉพาะ HTML แบบเต็มเสมอ ก็สามารถใช้
HX-Location ได้เช่นกัน
การจัดการ response แบบ 204·422·4xx·5xx
- โดยปกติ HTMX จะไม่สลับ response 4xx หรือ 5xx เข้าไปใน DOM
- หน้าจอปัจจุบันจะคงเดิม
- ข้อผิดพลาดจะถูกบันทึกไว้ในคอนโซลของ developer tools
- แม้เซิร์ฟเวอร์จะส่ง
500 Internal Server Error เพราะชื่อเทมเพลตไม่มีอยู่จริง ภายใต้การตั้งค่าเริ่มต้นผู้ใช้ก็จะไม่เห็นข้อผิดพลาดบนหน้าจอ
- เพื่อแสดงข้อความผิดพลาดให้ผู้ใช้เห็น จึงตั้งค่าให้ response 4xx และ 5xx สลับแทนที่ทั้ง
<body>
- ข้อยกเว้นคือ
422 Unprocessable Content
- ใช้เมื่อส่งกลับฟอร์มที่มีข้อผิดพลาดในการตรวจสอบความถูกต้อง
- response ควรถูกสลับเข้าไปใน target เดิมตามปกติ
responseHandling ถูกตั้งค่าตามสถานะดังนี้
204 No Content: ไม่เปลี่ยน DOM
422 Unprocessable Content: สลับเข้า target เดิมตามปกติ
- 4xx และ 5xx อื่น ๆ: สลับ response เข้าไปแทนที่
<body>
- response อื่นนอกเหนือจากนี้: สลับเข้า target เดิมตามปกติ
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
]
- หากต้องใส่ข้อผิดพลาดจากการโต้ตอบบางแบบไว้ในเป้าหมายอื่นที่ไม่ใช่
<body> สามารถใช้ ส่วนขยาย response target เพื่อเขียนทับค่าเริ่มต้นได้
ตรวจสอบ URL ปัจจุบันในแถบที่อยู่ของเบราว์เซอร์
- HTMX จะไม่เปลี่ยน URL ของเบราว์เซอร์ระหว่างกระบวนการส่งคำขอ AJAX และแทนที่การตอบกลับ หากไม่ได้ใช้
hx-boost, hx-push-url, hx-replace-url
- ด้วยเหตุนี้
r.URL ใน Go handler อาจไม่ตรงกับ URL ที่ผู้ใช้เห็นอยู่ในแถบที่อยู่
- HTMX จะส่ง URL ที่เบราว์เซอร์กำลังแสดงอยู่ปัจจุบันมาในเฮดเดอร์
HX-Current-URL ของทุกคำขอ
- ฟังก์ชันช่วยจะพาร์สเฮดเดอร์นี้เป็น
url.URL และถ้าไม่มีเฮดเดอร์ก็จะคืนค่า r.URL
func browserURL(r *http.Request) (*url.URL, error) {
cu := r.Header.Get("HX-Current-URL")
if cu != "" {
return url.Parse(cu)
}
return r.URL, nil
}
การตั้งค่าเพื่อเปลี่ยนค่าเริ่มต้นของ HTMX
- ตั้ง
historyCacheSize: 0 เพื่อปิด แคชหน้าบน local storage ของ HTMX ทั้งหมด
- แคชบน local storage อาจเป็นต้นตอของบั๊กและปัญหาด้านความปลอดภัยได้
- เมื่อกดย้อนกลับ ระบบจะดึง HTML จากเซิร์ฟเวอร์ใหม่แทนการใช้แคช
- ใน HTMX เวอร์ชันอนาคต การแคชบน local storage ก็มีแผนจะถูกปิดไว้เป็นค่าเริ่มต้นด้วยเหตุผลเดียวกัน
- ตั้ง
disableInheritance: true เพื่อปิด การสืบทอดแอตทริบิวต์ของ HTMX
- การประกาศแอตทริบิวต์อย่างชัดเจนทุกครั้งให้ความชัดเจนมากกว่า
- ช่วยลดความเสี่ยงของบั๊กหรือพฤติกรรมที่ไม่ได้ตั้งใจ
- ใน HTMX เวอร์ชันอนาคต การสืบทอดแอตทริบิวต์ก็มีแผนจะถูกปิดไว้เป็นค่าเริ่มต้นเช่นกัน
- ตั้ง
includeIndicatorStyles: false เพื่อไม่ให้ HTMX inject สไตล์ของ indicator
- กำหนดสไตล์ของ indicator เองร่วมกับกฎ CSS อื่น ๆ เพื่อให้คงความสม่ำเสมอ
- โดยปกติคำขอของ HTMX ไม่มีการกำหนดเวลาหมดอายุ จึงจะรอจนกว่าเซิร์ฟเวอร์จะตอบกลับ
- สามารถกำหนด
timeout เป็นมิลลิวินาทีได้ตามแนวทางของแอปพลิเคชัน Go ในการจัดการ timeout และ deadline
- ใช้
timeout: 5000 เป็นจุดเริ่มต้น
- การตั้งค่าเริ่มต้นยังมี
historyRestoreAsHxRequest: false และ responseHandling ตามรหัสสถานะด้วย
<meta
name="htmx-config"
content='{
"includeIndicatorStyles": false,
"historyCacheSize": 0,
"historyRestoreAsHxRequest": false,
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
],
"timeout": 5000
}'
>
เลย์เอาต์แยกตามหน้าสำหรับแอปพลิเคชันขนาดใหญ่
- หากมีแค่เทมเพลตพื้นฐานและเทมเพลตแยกตามหน้าแล้วยังไม่พอ สามารถเพิ่ม เทมเพลตเลย์เอาต์ ระหว่างสองชั้นนี้ได้
- เหมาะกับส่วนที่ต้องการชื่อเรื่อง เมนูนำทาง และโครงสร้างเนื้อหาที่ต่างจากหน้าทั่วไป เช่น หน้าผู้ดูแลระบบ
base จะเรียก layout แทนการเรียก page:content โดยตรง
<body>
{{template "layout" .}}
</body>
layouts/admin.tmpl จะกำหนดโครงสร้างร่วมของส่วนผู้ดูแลระบบ และเรียก page:content ภายใน
- ชื่อเรื่องของส่วนผู้ดูแลระบบ
- เมนูนำทางไปยังผู้ใช้และคำสั่งซื้อ
<main> สำหรับใส่เนื้อหาเฉพาะหน้า
- handler จะส่งทั้งเลย์เอาต์และเทมเพลตของหน้าที่จะใช้ให้กับ
render()
app.html.render(
w,
200,
nil,
"base",
"layouts/admin.tmpl",
"pages/admin-orders.tmpl",
)
- วิธีนี้ช่วยให้คงเทมเพลตพื้นฐานที่ใช้ร่วมกันไว้ พร้อมทั้งผสมเลย์เอาต์ตามเส้นทางกับเนื้อหาเฉพาะหน้าได้ จึงขยายใช้รูปแบบการเรนเดอร์เดียวกันกับหน้าที่ต้องมีโครงสร้างแยกต่างหากอย่างส่วนผู้ดูแลระบบได้
1 ความคิดเห็น
ความเห็นจาก Hacker News
ชอบ Go + HTMX และใช้ร่วมกับ a-h/templ เพื่อเพิ่ม type safety ของเทมเพลต คอมโพเนนต์ และ HTML บางส่วน เรียกชุดเครื่องมือทั้งหมดที่ประกอบด้วย Go·Unix·SQLite ว่า GUS stack และได้เผยแพร่ไว้: https://housecat.com/blog/the-gus-stack-go-unix-sqlite ได้แรงบันดาลใจอย่างมากจาก GUTS stack ของ exe.dev แต่ใช้ HTMX แทน TypeScript: https://exe.dev/docs/guts สำหรับ stack trace errors ใช้ cockroachdb/errors, สำหรับ HTML แบบ type-safe ใช้ templ, สำหรับการสร้าง OpenAPI spec ใช้ fuego, สำหรับการสร้างโค้ด SQL ใช้ sqlc, สำหรับ SQLite แบบ pure Go ใช้ modernc.org/sqlite, สำหรับ migration ใช้ goose, สำหรับ workflow แบบ persistent ใช้ dbos, และสำหรับการทดสอบกับระบบอัตโนมัติของ Chrome/CDP ใช้ rod ชุดนี้ให้ประสิทธิผลดีทั้งตอนเขียนโค้ดเองหรือตอนให้เอเจนต์ช่วยเขียน และในกระบวนการ build·deploy เป็น single binary
go generate ./...เป็นขั้นตอนหนึ่งของการ build ก็จะได้ประโยชน์มาก และยังใช้ goverter เพื่อสร้างตัวแปลงระหว่างโมเดลของ sqlc กับอ็อบเจ็กต์เทมเพลต·ค่าที่ส่งกลับ โค้ด boilerplate ราว 50% ถูกสร้างอัตโนมัติ ทำให้ type safety ดีมากHTMX ยอดเยี่ยมมากสำหรับหลายกรณีใช้งาน แต่ถ้าเพื่อนร่วมทีมไม่เห็นด้วย จะนำมาใช้ได้ยากมาก จะมีแรงต้านว่า มันไม่ใช่เทคโนโลยีจริงจัง และบั๊กสารพัดก็มักถูกโทษว่าเป็นความผิดของ HTMX ก่อน ทั้งที่ภายหลังจะพบว่าเป็นความเข้าใจผิด แต่ความเชื่อมั่นก็เสียไปแล้ว แม้แต่ในทีมที่ดีที่สุดที่เคยร่วมงานด้วยก็ยังเป็นแบบนั้น สุดท้ายจึงได้บทเรียนว่าต้องเลือกว่าจะสู้เรื่องไหน มองว่า html/template ของ Go ก็มีอินเทอร์เฟซที่ไม่เป็นธรรมชาติเป็นพิเศษเช่นกัน ตามที่ 《A Philosophy of Software Design》 แนะนำ ความซับซ้อนควรถูกผลักเข้าไปไว้ภายใน แต่โครงสร้างที่ต้องคอยกังวลเรื่องการคัดลอกเทมเพลตทุกครั้งเวลาจะเรนเดอร์ HTML นั้นซับซ้อนเกินจำเป็น
base.tmplเป็นBASE_BEGINกับBASE_ENDเพื่อใช้ใน final template ก็ได้ ชื่อเรื่องของแต่ละหน้ายังต้องส่งตอนรันอยู่ดี แต่แนวทางในต้นฉบับก็จะพังอยู่แล้วเมื่อเริ่มรองรับ หลายภาษาในโปรเจกต์ล่าสุด ใช้ HTMX แล้วสนุกมาก ในฐานะคนที่รู้จักเว็บก่อนยุค AngularJS และ React ชอบมากตรงที่สามารถสร้างหน้าเว็บจริง ๆ และลดปริมาณ JavaScript ให้เหลือน้อยที่สุดได้ จะทำด้วย JavaScript ล้วนก็ได้ แต่ HTMX ช่วยจัดการ boilerplate ของ event handling ที่ซ้ำ ๆ ให้ ถ้าไม่ชอบแนวคิดของฟรอนต์เอนด์สมัยใหม่ ก็น่าลองใช้ดู ตัวอย่างแรก ๆ บนเว็บไซต์ทางการอาจพื้นฐานมาก แต่พอเรียนรู้เพิ่มอีกนิดจะพบว่ามันทรงพลังกว่านั้นมาก
เคยลองทำโปรเจ็กต์ขนาดค่อนข้างใหญ่ด้วย HTMX + Go แต่ก็ยังรู้สึกว่าไม่พอ และก็ไม่แน่ใจว่าต่อไปจะไปถึงระดับที่ต้องการได้หรือไม่ มันยอดเยี่ยมสำหรับแอป CRUD แบบง่ายและแอดมินแดชบอร์ด แต่พอมีคอมโพเนนต์ที่เชื่อมโยงกัน สถานะที่ใช้ร่วมกัน และปฏิสัมพันธ์ซับซ้อนจำนวนมาก การดูแลจัดการจะยากขึ้นอย่างรวดเร็ว ตอนแรกเลือก HTMX เพราะไม่ชอบ React แต่สุดท้ายก็ยังคงใช้ Go ในฝั่งแบ็กเอนด์ และเปลี่ยนฝั่งฟรอนต์เอนด์เป็น SvelteKit SPA mode ซึ่งทำให้แยกสองฝั่งออกจากกันได้อย่างสะอาด และพัฒนา/ดูแล UI ที่ซับซ้อนได้ง่ายกว่ามาก Svelte ให้ความรู้สึกเหมือนเป็นส่วนขยายตามธรรมชาติของ HTML มากกว่าจะเป็นภาษาอีกภาษาหนึ่งอย่าง JSX โมเดลคอมโพเนนต์และการจัดการสถานะก็เรียบง่าย และไวยากรณ์
$stateแบบใหม่ก็ดีเป็นพิเศษช่วงนี้โปรเจ็กต์เล่นเกือบทั้งหมดเขียนด้วย Go + HTMX ไม่ว่าจะมี LLM ช่วยหรือไม่ก็ตาม ทั้ง Opus และ GPT ก็จัดการสแต็กนี้ได้ดีมาก สร้างและเริ่มต้นได้เร็ว แถมเป็นไบนารีเดียวจึงสะดวกต่อการดีพลอยและโฮสต์ เป็นสแต็กที่ดีมากสำหรับการพัฒนาแบบวนซ้ำอย่างรวดเร็ว
ถ้าจะใช้ HTMX ขอแนะนำอย่างยิ่งให้ใช้วิธีสร้าง HTML ในแบ็กเอนด์ที่ดึงชิ้นส่วน HTML ซ้ำๆ ออกมาเป็นฟังก์ชันเพื่อ ทำให้เป็นคอมโพเนนต์ ได้ง่าย แบบเดียวกับ React HTMX ต้องการความยืดหยุ่นใน HTML ที่แบ็กเอนด์สร้าง เพราะบางกรณีต้องเพิ่มหรือลบแท็กติดกับ HTML บางส่วนโดยตรง เช่น ถ้าเจอแท็ก `` ที่ระดับบนสุดก็จะอัปเดตชื่อหน้า งานแบบนี้ทำได้ยากในเทมเพลตเอนจินแบบสตริงดั้งเดิม แต่ใน ไลบรารี HTML แบบฝังในภาษา จะง่ายมาก และยังใช้ความสามารถด้านนามธรรมของภาษาทั้งหมดได้ จึงเข้ากับ HTMX ได้ดีเป็นพิเศษ ถ้าเป็นแบ็กเอนด์ JavaScript ก็ใช้ tagged template literal function อย่าง https://github.com/WebReflection/uhtml-ssr ได้ ถ้าเป็น Go ก็มี https://www.gomponents.com/ และถ้าเป็น Scala ก็มี ScalaTags
renderToStaticMarkupอยู่แล้ว แต่การเลือกไลบรารี JSX ที่เขียนมาเพื่อแบ็กเอนด์โดยเฉพาะน่าจะดีกว่ารู้จัก Go ครั้งแรกจากหนังสือของ Alex Edwards และ Learn Go with Tests ซึ่งทุกวันนี้ก็ยังแนะนำอยู่ เลยอยากลองแปลงบางโปรเจ็กต์เก่าไปใช้ HTMX ดู
ทำเฟรมเวิร์กส่วนตัวบนพื้นฐาน Kotlin + HTMX ไว้ใช้เอง: https://github.com/reubenfirmin/zoned เป้าหมายคือดูว่าจะทำเว็บแอปให้ type-safe แบบ end-to-end ได้หรือไม่ และใช้ Kotlinx.html ซึ่งเป็น DSL สำหรับเขียน HTML คล้าย JSX 90% แรกทำเองทั้งหมด และใช้ Claude ช่วยใน 10% สุดท้ายกับ README เพื่อปิดงานให้พร้อมเผยแพร่
ชอบทั้ง Go และ Alex Edwards แต่ผลลัพธ์ที่ได้จากการใช้ HTMX ทำให้ผิดหวังเสมอ รู้สึกว่าความซับซ้อนของโค้ดเบสโตเร็วกว่าแอปจริง 2 เท่า และมักเจอกรณีขอบที่หนีไม่พ้นหากไม่มีวิธีแก้แบบแปลกๆ เข้าใจว่าทำไมหลายคนถึงไม่ชอบแพ็กเกจ Node แต่ HTMX ดูเหมือนเป็นการชดเชยมากเกินไป เวลาที่ประหยัดจากการไม่ต้องต่อสู้กับ JSON น้อยกว่ามากเมื่อเทียบกับเวลาที่ต้องใช้เพิ่ม 3 เท่าเพื่อทำให้หน้าตาและประสบการณ์ใช้งานของแอปออกมาดี Mantine template ตั้งค่าเสร็จใน 2 นาทีแล้วใช้ UI component คุณภาพสูงได้ทันที และถ้ารวม static asset ที่ build แล้วเข้าไป ก็ยังทำเป็น Go binary เดียวได้เหมือนกัน: https://github.com/mantinedev/vite-min-template
ชอบ Go + Datastar มากกว่าเยอะเพราะเรียบง่ายกว่า