บทเรียน 58 · Frontend · Basic HTML & CSS

ฟอร์ม — form, input, label, button, textarea, select

~20 นาที · ควรผ่าน บทเรียน 57 (แท็กที่ใช้จริงทุกวัน) มาก่อน · ถ้าจำ c.PostForm ใน บทเรียน 35 ได้ด้วยจะยิ่งสนุก

ตลอด 9 วันที่ผ่านมาคุณเขียนฝั่งที่รับข้อมูลมาตลอด — c.PostForm · c.ShouldBind · tag form:"sort"
บทนี้คือฝั่งตรงข้าม: ของที่ส่งไปให้ handler ของคุณ มันถูกประกอบขึ้นมายังไง
ฟอร์ม HTML มีแค่ 6 แท็ก และแต่ละแท็กไม่ยากเลย แต่มันมีกับดัก 3 อันที่ทุกคนต้องเจอครั้งแรกเหมือนกันหมด: คลิกที่ข้อความแล้วช่องไม่โฟกัส · กดปุ่มแล้วหน้าเด้งรีเฟรชเอง · และกรอกครบทุกช่องแต่ฝั่ง Go อ่านได้ไม่ครบ
ทั้งสามอันมีสาเหตุที่เห็นด้วยตาได้ และบทนี้จะเปิดผลรันจริงให้ดูทีละอัน

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 27 สิงหาคม 2026)

Chrome 148.0.7778.280   # macOS
python3 -m http.server   # เสิร์ฟไฟล์ .html แล้วดัก request ที่ฟอร์มส่งมา

ทุกที่ที่บทนี้เขียนว่า "ได้ผลว่า…" คือของที่กดจริงในเบราว์เซอร์แล้วอ่านค่าออกมา ไม่ได้เขียนจากความจำ

⚠️ อ่านก่อนเล่นกล่องตัวอย่างในบทนี้

กล่องตัวอย่างสีขาวในหน้านี้เป็น <iframe> ที่ถูกล็อกไว้ (sandbox) คุณพิมพ์ได้ คลิกได้ เลือกได้ ทุกอย่าง แต่ตอนกดปุ่มส่ง เบราว์เซอร์จะไม่ส่งอะไรออกไปจริง ๆ — มันฟ้องใน console ว่า Blocked form submission … the 'allow-forms' permission is not set
เพราะงั้นเรื่อง "ส่งแล้วเกิดอะไรขึ้น" บทนี้จะไม่ให้ดูในกล่อง แต่จะเอาผลรันจริงมาวางให้ดูแทน ส่วนคุณควรลองเองด้วยการเซฟเป็นไฟล์ .html แล้วเปิดในเบราว์เซอร์ตรง ๆ

1 · <form> คือ "ซอง" ไม่ใช่ "ช่องกรอก"

มือใหม่มักคิดว่า <form> คือช่องให้พิมพ์ — ไม่ใช่เลย
<form> ไม่มีหน้าตาของตัวเอง มันคือเส้นที่ลากล้อมของไว้ แล้วบอกว่า "ทุกอย่างที่อยู่ในเส้นนี้ ถ้าถูกส่ง ให้ส่งไปที่ไหน ด้วยวิธีอะไร"

<form action="/contact" method="POST">
  <!-- ช่องกรอกทั้งหมดอยู่ตรงนี้ -->
</form>

อ่านทีละคำ:

attributeแปลว่าถ้าไม่ใส่
action ส่งไปที่ URL ไหน — ก็คือ route ที่คุณเขียนไว้ฝั่ง Go ส่งกลับมาที่ URL ของหน้าตัวเอง
method ส่งด้วย HTTP method อะไร — ฟอร์ม HTML รองรับแค่ GET กับ POST ได้ GET (ค่าจะไปโผล่ต่อท้าย URL)
⚠️ ฟอร์ม HTML ล้วน ๆ ส่ง PUT / DELETE ไม่ได้

ใส่ method="PUT" ไป เบราว์เซอร์จะตกกลับไปใช้ GET เงียบ ๆ ไม่เตือนอะไรเลย
นี่คือเหตุผลที่เว็บสมัยใหม่ไม่ค่อยใช้ <form> ส่งของตรง ๆ แล้ว แต่ดักตอนกดส่งด้วย JavaScript แล้วยิง fetch() ไปเอง — ซึ่งยิง method อะไรก็ได้
บทนี้สอนของมาตรฐานให้จบก่อน เพราะ fetch() ก็ต้องไปหยิบค่ามาจากช่องพวกนี้อยู่ดี

🏦 เทียบให้เห็นภาพ — ใบฝากเงินที่เคาน์เตอร์ธนาคาร

นึกถึงใบฝากเงินหนึ่งใบ: มันไม่ใช่เงิน และไม่ใช่ช่องที่คุณเขียน มันคือกระดาษที่รวบช่องทั้งหมดไว้ในใบเดียว
· ช่องบนใบ = <input> · ชื่อกำกับหน้าช่อง = <label>
· การยื่นใบให้พนักงาน = กด <button type="submit">
· "ยื่นที่ช่องไหน" = action · "ยื่นแบบไหน" (ฝาก/ถอน) = method

ที่ไม่เหมือนกัน: ใบฝากเงินยื่นแล้วตัวใบยังอยู่ พนักงานถือไปทำต่อได้ แต่ฟอร์ม HTML พอกดส่ง เบราว์เซอร์จะทิ้งหน้าเดิมแล้วโหลดหน้าที่ action ชี้ไปแทน ทุกอย่างที่พิมพ์ค้างไว้หายหมด — นี่แหละคือต้นเหตุของกับดักข้อ 2 ในบทนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
form control
คำรวมของ "ของที่กรอกค่าได้" ทุกชนิด — input · textarea · select · button · ประโยคที่ใช้ได้เลย: "form control ตัวไหนที่ยังไม่มี label ครับ" — สั้นกว่าไล่พูดว่า "ช่องกรอกกับตัวเลือกกับปุ่ม"
submit
เหตุการณ์ตอนที่เบราว์เซอร์รวบค่าในฟอร์มแล้วยิงออกไป · ไม่ใช่ชื่อปุ่ม แต่เป็นชื่อเหตุการณ์ · ประโยคที่ใช้ได้เลย: "ตอนนี้มันยิงตอน submit เลย ผมอยากดักก่อนแล้วค่อยยิงเองครับ"
action / method
สองคำนี้ตรงกับ URL และ HTTP method ที่คุณเขียนใน router.POST("/contact", …) ฝั่ง Go แบบหนึ่งต่อหนึ่ง · พิมพ์บอก AI ได้เลยว่า: "ฟอร์มนี้ action=/contact method=POST ช่วยเขียน Gin handler รับให้หน่อย"

2 · <input> — ช่องกรอกบรรทัดเดียว

<input> เป็นแท็กที่ไม่มีแท็กปิด (ไม่มี </input>) เพราะมันไม่มีอะไรอยู่ข้างใน — ทุกอย่างบอกผ่าน attribute หมด
ตัวที่เปลี่ยนหน้าตาและพฤติกรรมมากที่สุดคือ type

<style>
  label { display: block; margin-top: 10px; font-weight: 600; }
  input { padding: 6px; width: 240px; }
</style>

<form>
  <label for="fullname">ชื่อ-นามสกุล</label>
  <input id="fullname" name="fullname" type="text" placeholder="สมชาย ใจดี">

  <label for="email">อีเมล</label>
  <input id="email" name="email" type="email" placeholder="you@example.com">

  <label for="password">รหัสผ่าน</label>
  <input id="password" name="password" type="password">

  <label for="age">อายุ</label>
  <input id="age" name="age" type="number">
</form>

ลองพิมพ์ในกล่องข้างบนดู — ช่อง รหัสผ่าน จะขึ้นเป็นจุด ส่วนช่อง อายุ จะมีลูกศรขึ้นลงโผล่มาให้กด นั่นคือของที่ type ให้มาฟรี ๆ

บรรทัด <style> ข้างบนมีไว้จัดหน้าให้กล่องตัวอย่างอ่านง่ายเท่านั้น ไม่เกี่ยวกับเรื่องฟอร์มเลย — เรื่อง CSS จะเริ่มจริงที่บทถัดไป ผมใส่มาด้วยเพราะโค้ดในกล่อง <pre> กับที่เรนเดอร์ข้างล่างต้องเป็นชุดเดียวกันเป๊ะ

type ที่ใช้บ่อยและได้อะไรกลับมา:

typeได้อะไรเพิ่มจาก textเหมาะกับ
textค่าเริ่มต้น ไม่มีอะไรพิเศษชื่อ · ที่อยู่ · อะไรก็ได้
emailเบราว์เซอร์ตรวจรูปแบบให้ก่อนส่ง · มือถือขึ้นคีย์บอร์ดที่มี @อีเมล
passwordปิดบังตัวอักษรเป็นจุดรหัสผ่าน
numberมีลูกศรเพิ่ม/ลด · มือถือขึ้นคีย์บอร์ดตัวเลขอายุ · จำนวน
checkboxกลายเป็นช่องติ๊ก · ส่งค่าก็ต่อเมื่อถูกติ๊กยอมรับเงื่อนไข
radioเลือกได้อันเดียวในกลุ่มที่ name เหมือนกันเพศ · ตัวเลือกไม่กี่อัน
✅ type="email" ตรวจให้จริง ไม่ใช่แค่เปลี่ยนคีย์บอร์ด

ผมใส่ค่าที่ไม่ใช่อีเมลลงไปแล้วกดส่ง — เบราว์เซอร์ไม่ยอมส่ง และคายข้อความนี้ออกมา:

Please include an '@' in the email address. 'ไม่ใช่อีเมล' is missing an '@'.

(โค้ดที่ใช้ทดสอบคือ <form><input type="email" name="email" value="ไม่ใช่อีเมล" required><button type="submit">ส่ง</button></form> · กดปุ่มแล้ว URL ไม่ขยับ และเหตุการณ์ submit ไม่ถูกยิงออกมาด้วยซ้ำ เบราว์เซอร์ตัดจบตั้งแต่ก่อนถึงขั้นตอนส่ง)
แต่ห้ามไว้ใจมันแทนการตรวจฝั่ง Go — ใครก็ยิง curl ตรงเข้า /contact ข้ามหน้าเว็บไปได้ · ของฝั่งเบราว์เซอร์มีไว้ให้คนกรอกง่ายขึ้น ส่วน binding:"required,email" ใน Go คือตัวที่กันของเสียจริง

3 · กับดักที่ 1 — <label for> ต้องตรงกับ id เป๊ะ

<label> คือข้อความกำกับหน้าช่อง แต่มันไม่ได้เป็นแค่ข้อความ — มันผูกตัวเองเข้ากับช่องหนึ่งช่อง แล้วได้สองอย่างตามมา:

การผูกทำผ่านคู่ for ↔ id · และคำว่า "ตรงกัน" หมายถึง ตรงทุกตัวอักษรรวมทั้งตัวพิมพ์เล็กใหญ่

🔑 เทียบให้เห็นภาพ — ป้ายชื่อบนตู้ล็อกเกอร์

ตู้ล็อกเกอร์ที่ฟิตเนสมีเลขตู้ติดอยู่ที่ตัวตู้ (id) แล้วมีป้ายชื่อแขวนไว้ที่เคาน์เตอร์เขียนว่า "สมชาย → ตู้ 12" (label for)
ถ้าเขียนป้ายว่า "ตู้ 21" ทั้งที่ของอยู่ตู้ 12 — ป้ายไม่ได้พัง ตู้ก็ไม่ได้พัง ทั้งสองอย่างยังอยู่ครบดูปกติทุกอย่าง
ที่พังคือความเชื่อมกัน เดินไปตามป้ายแล้วไม่เจอของ

for ที่สะกดไม่ตรงกับ id เป็นแบบนี้เป๊ะ ๆ — หน้าเว็บดูปกติ 100% ไม่มี error ไม่มีขีดแดงในเอดิเตอร์ คุณจะรู้ตัวก็ต่อเมื่อคลิกที่ข้อความแล้วไม่มีอะไรเกิดขึ้น

ที่ไม่เหมือนกัน: ป้ายล็อกเกอร์ผิดแล้วคุณยังเดาได้ว่าน่าจะสลับเลข แต่ for ผิดในโค้ด ไม่มีใครเดาให้ — เบราว์เซอร์ ไม่พยายามหาช่องที่ "ใกล้เคียง" ให้เลยแม้แต่นิดเดียว มันหาไม่เจอแล้วจบเลย

<style>
  p { margin: 0 0 14px; }
  label { font-weight: 600; }
  input { padding: 6px; }
</style>

<p>
  <label for="ok">for ตรงกับ id — คลิกที่ข้อความนี้</label><br>
  <input id="ok" name="ok" type="text">
</p>

<p>
  <label for="typo">for สะกดไม่ตรง — คลิกที่ข้อความนี้</label><br>
  <input id="notok" name="notok" type="text">
</p>

คลิกที่ข้อความบรรทัดบน แล้วคลิกที่ข้อความบรรทัดล่าง เห็นความต่างไหม?

ผมวัดด้วยโค้ดในเบราว์เซอร์แล้ว: คลิกที่ label ตัวบน document.activeElement กลายเป็น input#ok ทันที · ส่วนคลิกที่ label ตัวล่าง ค่า activeElement ไม่ขยับเลยแม้แต่นิดเดียว — ทดสอบบนหน้าเปล่าที่ยังไม่มีอะไรถูกโฟกัส มันก็ยังเป็น <body> อยู่อย่างนั้น

⚠️ ทางที่พลาดไม่ได้ (แต่คนใช้น้อยกว่า)

ถ้าเอา <input> ไปใส่ไว้ข้างใน <label> เลย ก็ผูกกันเหมือนกันโดยไม่ต้องมี for กับ id:
<label>ยอมรับเงื่อนไข <input type="checkbox" name="accept"></label>
สะกดผิดไม่ได้เพราะไม่ต้องสะกดอะไรเลย แต่ทีมส่วนใหญ่ยังใช้ for/id เพราะมันแยก label กับ input ออกจากกันได้ ทำให้จัดหน้าด้วย CSS ง่ายกว่ามาก

🗣 ศัพท์ที่เพิ่งเรียนไป
focus
สถานะ "ตอนนี้คีย์บอร์ดกำลังพิมพ์ลงตัวนี้อยู่" · มีได้ทีละหนึ่งตัวในหน้า · ประโยคที่ใช้ได้เลย: "คลิกที่ label แล้ว focus ไม่เข้าช่องครับ น่าจะ for กับ id ไม่ตรงกัน" — ประโยคเดียวจบ ไม่ต้องอธิบายอาการยาว ๆ
accessibility (a11y)
เรื่องของการทำให้คนที่ใช้เมาส์ไม่ได้หรือมองไม่เห็นยังใช้เว็บได้ · label เป็นของชิ้นแรกที่ทุกคนต้องทำให้ถูก · พิมพ์บอก AI ได้เลยว่า: "ช่วยตรวจ a11y ของฟอร์มนี้ให้หน่อย ทุก input มี label ผูกครบไหม"
id ต้องไม่ซ้ำในหน้าเดียว
ถ้าใส่ id="email" ซ้ำสองที่ในหน้าเดียว label for="email" จะผูกกับตัวแรกที่เจอ เสมอ — ทำให้คลิกแล้วโฟกัสไปผิดช่องแบบงง ๆ · ถาม senior ได้ว่า: "หน้านี้มี id ซ้ำกันไหมครับ"

4 · กับดักที่ 2 — <button> ในฟอร์มเป็น submit โดยไม่ต้องบอก

<button> ที่อยู่ในฟอร์ม ถ้าไม่ใส่ type เบราว์เซอร์ถือว่ามันคือ type="submit" ให้อัตโนมัติ
ฟังดูสะดวกดี — จนวันที่คุณอยากได้ปุ่มธรรมดา ๆ ที่ไม่เกี่ยวกับการส่งเลย

<form action="/search" method="GET">
  <label for="q">คำค้น</label>
  <input id="q" name="q" type="text" value="go">
  <button>ล้างช่อง</button>
</form>

หน้านี้เปิดอยู่ที่ /btn.html · กดปุ่ม "ล้างช่อง" หนึ่งครั้ง
URL บนแถบที่อยู่จะกลายเป็นอะไร?

ผลรันจริง (Chrome 148, กดปุ่มแล้วอ่าน location.href):

ก่อนกด : http://127.0.0.1:8932/btn.html
หลังกด : http://127.0.0.1:8932/search?q=go

หน้าเปลี่ยนไปเลย — ปุ่มที่ตั้งใจให้ "ล้างช่อง" กลับส่งฟอร์มออกไปแทน
ผมอ่านค่า button.type จาก DOM ด้วย ได้ค่าว่า "submit" ทั้งที่ในโค้ดไม่ได้เขียนคำนี้ไว้เลยสักตัว

ผมก๊อปไฟล์เดิมเป็น /btn2.html แล้วเติม type="button" ให้ปุ่ม แล้วรันซ้ำ: button.type เป็น "button" และหลังกดปุ่ม URL ยังเป็น /btn2.html เท่าเดิม — ไม่ขยับ ซึ่งคือสิ่งที่ต้องการ

อาการที่คุณจะเจอเวลาเขียนเว็บจริงคือ "กดปุ่มแล้วหน้ากะพริบแล้วทุกอย่างที่พิมพ์หายหมด"
นั่นไม่ใช่บั๊กของ JavaScript ที่คุณเขียน — มันคือเบราว์เซอร์ส่งฟอร์มแล้วโหลดหน้าใหม่ ตามที่มันควรทำเป๊ะ ๆ

type ของ <button>กดแล้วเกิดอะไรใช้ตอนไหน
submit (ค่าเริ่มต้น)รวบค่าทั้งฟอร์มส่งไปที่ action แล้วโหลดหน้าใหม่ปุ่มส่งจริง ๆ ปุ่มเดียวของฟอร์ม
buttonไม่ทำอะไรเลย รอให้ JavaScript มาผูกงานให้ปุ่มอื่นทุกปุ่ม เช่น ล้างช่อง เพิ่มแถว เปิด popup
resetคืนทุกช่องกลับเป็นค่าตั้งต้น (ไม่ใช่ค่าว่าง)แทบไม่ใช้แล้ว เพราะคนกดพลาดบ่อย
✅ กฎง่าย ๆ ที่ทำให้ไม่เจอกับดักนี้อีก

เขียน type ให้ทุกปุ่มเสมอ แม้แต่ปุ่มที่ตั้งใจให้เป็น submit จริง ๆ
ไม่ใช่เพราะเบราว์เซอร์ต้องการ แต่เพราะคนที่มาอ่านโค้ดต่อจากคุณต้องการ — เห็น type="submit" ปุ๊บก็รู้ทันทีว่าปุ่มนี้ตั้งใจให้ส่ง ไม่ใช่ลืมใส่

5 · <textarea> และ <select> / <option>

สองตัวนี้ทำสิ่งที่ <input> ทำไม่ได้:
· <textarea> — พิมพ์ได้หลายบรรทัด และลากมุมขยายได้
· <select> — เลือกจากรายการที่กำหนดไว้แล้ว พิมพ์เองไม่ได้

<style>
  label { display: block; margin-top: 10px; font-weight: 600; }
  textarea, select { padding: 6px; width: 260px; }
</style>

<label for="topic">เรื่องที่ติดต่อ</label>
<select id="topic" name="topic">
  <option value="" disabled selected>เลือกหัวข้อ</option>
  <option value="consult">ต้องการรับคำปรึกษา (Mentor)</option>
  <option value="freelance">ต้องการจ้างงาน (Freelance)</option>
</select>

<label for="message">รายละเอียดการติดต่อ</label>
<textarea id="message" name="message" rows="4" placeholder="กรอกรายละเอียดการติดต่อ"></textarea>

สามจุดในโค้ดชุดนี้ที่ควรอ่านซ้ำ:

สิ่งที่เห็นทำไมถึงเขียนแบบนั้น
<textarea …></textarea> ปิดติดกัน textarea ไม่มี value — ข้อความเริ่มต้นคือของที่อยู่ระหว่างแท็ก · เบราว์เซอร์ตัดการขึ้นบรรทัดใหม่ตัวแรกสุดหลังแท็กเปิดทิ้งให้หนึ่งตัวพอดี เขียนแท็กปิดไว้บรรทัดถัดไปเฉย ๆ จึงยังได้ค่าว่างและ placeholder โผล่ปกติ · แต่เกินจากหนึ่งตัวนั้นเมื่อไหร่ ทุกตัวอักษรกลายเป็นค่าจริงทันที — ผมอ่าน .value ออกมาวัดแล้ว: ขึ้นบรรทัดแล้วเคาะวรรคสองที ได้ค่ายาว 2 ตัว · ขึ้นบรรทัดสองครั้ง ได้ค่ายาว 1 ตัว (เหลือ \n หนึ่งตัว) · ทั้งสองแบบช่องไม่ว่างแล้ว placeholder จึงไม่โผล่
สรุปคือเขียนปิดติดกันไปเลยปลอดภัยที่สุด ไม่ต้องมานั่งนับว่าเว้นไปกี่ตัว
rows="4" ความสูงเริ่มต้นเป็นจำนวนบรรทัด · ผู้ใช้ยังลากมุมขวาล่างขยายเองได้อยู่ดี
<option value="" disabled selected> ทำ "หัวข้อหลอก" ให้ dropdown · selected = โชว์อันนี้ตอนเปิดหน้า · disabled = เลือกกลับมาไม่ได้อีก · value="" = ถ้าผู้ใช้ไม่เลือกอะไร ฝั่ง Go จะได้ค่าว่าง ซึ่ง binding:"required" จับได้
⚠️ ค่าที่ส่งไปคือ value ไม่ใช่ข้อความที่คนเห็น

ผู้ใช้เห็นคำว่า "ต้องการรับคำปรึกษา (Mentor)" แต่ฝั่ง Go ได้คำว่า consult — เพราะนั่นคือ value
ถ้า <option> ไม่มี value เลย เบราว์เซอร์จะส่งข้อความข้างในไปแทน · แปลว่าวันไหนมีคนแก้คำภาษาไทยให้สวยขึ้น ค่าที่ส่งเข้า database ก็เปลี่ยนตามไปด้วยโดยไม่มีใครรู้ตัว — เพราะงั้นใส่ value ให้ทุก <option> เสมอ

6 · กับดักที่ 3 — ช่องที่ไม่มี name จะไม่ถูกส่ง

นี่คือกับดักที่เจ็บที่สุดในสามอัน เพราะมันไม่มีอาการอะไรให้เห็นบนหน้าเว็บเลย
ช่องแสดงผลปกติ · พิมพ์ได้ปกติ · กดส่งได้ปกติ · แต่ค่าหายไปกลางทาง

<form action="/echo" method="GET">
  <label for="nickname">ชื่อเล่น</label>
  <input id="nickname" name="nickname" type="text" value="somchai">
  <label for="secret">ข้อความลับ</label>
  <input id="secret" type="text" value="hello">
  <button type="submit">ส่ง</button>
</form>

ทั้งสองช่องมี id · ทั้งสองช่องมีค่าอยู่แล้ว · กดปุ่ม "ส่ง"
URL หลังกดจะเป็นอะไร?

ผลรันจริง (Chrome 148):

http://127.0.0.1:8932/echo?nickname=somchai

secret=hello ไม่ได้ไปด้วยเลย ทั้งที่มี id มีค่า และอยู่ในฟอร์มเดียวกันแท้ ๆ

เหตุผลตรงไปตรงมามาก: เวลาเบราว์เซอร์รวบค่า มันต้องเขียนออกมาเป็นคู่ ชื่อ=ค่า — ช่องที่ไม่มี name ก็ไม่มี "ชื่อ" ให้เขียน มันเลยถูกข้ามไปเงียบ ๆ ไม่มี warning ไม่มี error

📦 เทียบให้เห็นภาพ — สติกเกอร์ในคลังของ กับ ชื่อช่องบนใบส่งของ

คลังสินค้าติดสติกเกอร์เลขไว้บนกล่องทุกใบ เพื่อให้คนในคลังเดินหาเจอ — นั่นคือ id · มันมีประโยชน์ในบ้านตัวเอง
ส่วนใบส่งของที่แปะไปกับพัสดุเขียนว่า "ผู้รับ: …" "เบอร์โทร: …" — นั่นคือ name · มันคือชื่อช่องที่คนปลายทางใช้อ่าน

กล่องที่มีสติกเกอร์แต่ไม่ได้เขียนลงใบส่งของ = ของที่อยู่ในคลังจริง แต่ปลายทางไม่มีวันรู้ว่ามี
นั่นคือ <input id="secret"> ที่ไม่มี name เป๊ะ ๆ

ที่ไม่เหมือนกัน: ในคลังจริง ของที่ตกหล่นจะมีของเหลืออยู่ให้นับเจอทีหลัง
แต่ฟอร์ม HTML ไม่มีร่องรอยอะไรเหลือเลย — ฝั่ง Go มองไม่ออกด้วยซ้ำว่า "เคยมีช่องนี้อยู่แล้วหาย" กับ "ไม่เคยมีช่องนี้ตั้งแต่แรก" ต่างกันยังไง เพราะมันเห็นเหมือนกันเป๊ะ

idname
ใครใช้<label for> · CSS · JavaScriptตัวเบราว์เซอร์ตอนส่ง และ handler ฝั่ง Go
ต้องไม่ซ้ำไหมต้องไม่ซ้ำทั้งหน้าซ้ำได้ — radio กลุ่มเดียวกันต้องซ้ำด้วยซ้ำ
ไม่ใส่แล้วเป็นไงคลิก label ไม่โฟกัส · JS หาไม่เจอค่าไม่ถูกส่งไปเลย
ตั้งชื่อยังไงดีตั้งให้อ่านง่ายในหน้านี้ตั้งให้ตรงกับ tag form:"…" ฝั่ง Go
🗣 ศัพท์ที่เพิ่งเรียนไป
query string
ท่อนหลัง ? ใน URL เช่น ?nickname=somchai&topic=consult · คือที่ที่ฟอร์ม method="GET" เอาค่าไปวาง และคือของที่ c.Query อ่าน · ประโยคที่ใช้ได้เลย: "ค่ามันไปอยู่ใน query string ครับ ผมเลยอ่านด้วย c.Query ไม่ใช่ c.PostForm"
application/x-www-form-urlencoded
ชื่อรูปแบบข้อมูลที่ฟอร์ม method="POST" ส่งใน body · หน้าตาเหมือน query string เป๊ะ แค่ย้ายจาก URL มาอยู่ใน body · ถาม senior ได้ว่า: "endpoint นี้รับ urlencoded หรือรับ JSON ครับ" — เพราะสองอย่างนี้ Gin อ่านด้วยคนละฟังก์ชัน
URL-encode (percent-encoding)
การแปลงตัวอักษรที่ใส่ตรง ๆ ใน URL ไม่ได้ ให้กลายเป็น % ตามด้วยเลขฐาน 16 · ภาษาไทยหนึ่งตัวกลายเป็นสามชุด และช่องว่างกลายเป็น + · พิมพ์บอก AI ได้เลยว่า: "ช่วย decode urlencoded ก้อนนี้ให้อ่านออกหน่อย"

7 · ของพวกนี้ไปโผล่ตรงไหนในโค้ด Go ที่คุณเขียนไปแล้ว

ผมทำฟอร์ม method="POST" จริงหนึ่งใบ แล้วเอา server เล็ก ๆ ไปดักอ่าน request ที่เบราว์เซอร์ส่งมา · โค้ดฟอร์มคือ:

<form action="/contact" method="POST">
  <input id="fullname" name="fullname" type="text" value="สมชาย ใจดี">
  <input id="nope" type="text" value="hello">
  <select id="topic" name="topic"><option value="consult" selected>รับคำปรึกษา</option></select>
  <textarea id="message" name="message" rows="2">อยากคุยเรื่อง Go</textarea>
  <button type="submit">ส่งข้อมูล</button>
</form>

กดปุ่มแล้ว server พิมพ์ของที่ได้รับออกมาแบบนี้ (ก็อปมาทั้งดุ้น):

CT=application/x-www-form-urlencoded
BODY=fullname=%E0%B8%AA%E0%B8%A1%E0%B8%8A%E0%B8%B2%E0%B8%A2+%E0%B9%83%E0%B8%88%E0%B8%94%E0%B8%B5&topic=consult&message=%E0%B8%AD%E0%B8%A2%E0%B8%B2%E0%B8%81%E0%B8%84%E0%B8%B8%E0%B8%A2%E0%B9%80%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87+Go

ถอด % ออกแล้วอ่านเป็นภาษาคนได้ว่า:

fullname=สมชาย+ใจดี&topic=consult&message=อยากคุยเรื่อง+Go

สามอย่างที่เห็นได้จากก้อนนี้ทันที:

  1. Content-Type คือ application/x-www-form-urlencoded — ไม่ใช่ JSON · เพราะงั้น c.ShouldBindJSON ใช้กับฟอร์มไม่ได้
  2. nope หายไปจริง ๆ — ยืนยันกับดักข้อ 3 อีกรอบด้วยของจริง
  3. ช่องว่างในชื่อ "สมชาย ใจดี" กลายเป็น + และภาษาไทยกลายเป็น %E0%B8… — คุณไม่ต้องถอดเอง Gin ถอดให้แล้วก่อนถึงมือคุณ

ทีนี้เอาไปเทียบกับสิ่งที่คุณเขียนไว้แล้วใน บทเรียน 35:

ในฟอร์ม HTMLฝั่ง Gin หยิบด้วยหรือผูกเข้า struct ด้วย tag
<input name="fullname"> ในฟอร์ม POST c.PostForm("fullname") form:"fullname" + c.ShouldBind(&req)
<input name="q"> ในฟอร์ม GET c.Query("q") form:"q" + c.ShouldBindQuery(&req)
<select name="topic"> c.PostForm("topic") form:"topic" — ได้ value ของ option ที่เลือก
<textarea name="message"> c.PostForm("message") form:"message" — เหมือน input ทุกอย่าง

สังเกตว่า tag ที่ใช้คือ form:"…" ทั้งกรณี query และกรณี body แบบฟอร์ม ซึ่งเป็นเรื่องเดียวกับที่บทเรียน 35 บอกไว้ว่า path param ใช้ uri · body JSON ใช้ json
แปลว่าคำว่า name ที่คุณพิมพ์ใน HTML กับสตริงในเครื่องหมายคำพูดของ tag ฝั่ง Go ต้องสะกดตรงกันเป๊ะ — สองไฟล์คนละภาษาคนละโฟลเดอร์ แต่ผูกกันด้วยสตริงที่ไม่มีคอมไพเลอร์ตัวไหนตรวจให้

8 · ประกอบร่าง — ฟอร์มติดต่อหนึ่งใบที่ใช้ครบทุกแท็ก

รวมทุกอย่างในบทนี้เป็นฟอร์มเดียว · ลองกรอกในกล่องข้างล่างได้เลย

<style>
  form { max-width: 320px; }
  label { display: block; margin-top: 12px; font-weight: 600; }
  input, textarea, select { padding: 6px; width: 100%; box-sizing: border-box; }
  button { margin-top: 14px; padding: 8px 16px; }
</style>

<form action="/contact" method="POST">
  <label for="fullname">ชื่อ-นามสกุล</label>
  <input id="fullname" name="fullname" type="text" required>

  <label for="email">อีเมล</label>
  <input id="email" name="email" type="email" required>

  <label for="topic">เรื่องที่ติดต่อ</label>
  <select id="topic" name="topic">
    <option value="" disabled selected>เลือกหัวข้อ</option>
    <option value="consult">ต้องการรับคำปรึกษา (Mentor)</option>
    <option value="freelance">ต้องการจ้างงาน (Freelance)</option>
  </select>

  <label for="message">รายละเอียดการติดต่อ</label>
  <textarea id="message" name="message" rows="4"></textarea>

  <button type="submit">ส่งข้อมูล</button>
</form>

ลองคลิกที่ข้อความ "เรื่องที่ติดต่อ" ดู — ตัว dropdown จะขึ้นกรอบโฟกัสให้ทันที โดยที่คุณไม่ได้แตะตัวช่องเลย (ผมวัดแล้วได้ activeElement เป็น select#topic) นั่นคือ label for ทำงานถูกต้อง
ส่วนปุ่ม "ส่งข้อมูล" กดแล้วจะไม่มีอะไรเกิดขึ้น เพราะกล่องนี้ถูกล็อกไว้ตามที่บอกไว้ตอนต้นบท

9 · วัดความเข้าใจ

1. <label for="username">ชื่อผู้ใช้</label> คู่กับ <input id="user_name" name="username"> — คลิกที่คำว่า "ชื่อผู้ใช้" แล้วเกิดอะไรขึ้น?

for จับคู่กับ id เท่านั้น และต้องตรงทุกตัวอักษร · ในโค้ดนี้ id คือ user_name (มีขีดล่าง) ส่วน for คือ username จึงหาไม่เจอ · ผมกดจริงในเบราว์เซอร์แล้ว document.activeElement ยังเป็น <body> อยู่เหมือนเดิม

ตัวเลือกที่ 1 ผิดเพราะ for ไม่เคยดู name เลย สองคำนี้คนละหน้าที่กันคนละเรื่อง · ตัวเลือกที่ 3 ผิดเพราะเงียบสนิท ไม่มี error ไม่มี warning ซึ่งคือสิ่งที่ทำให้กับดักนี้หายาก · ตัวเลือกที่ 4 ผิดเพราะเสียทั้งสองอย่างพร้อมกัน ไม่ใช่เสียแค่ screen reader — การคลิกก็ไม่ทำงานด้วย

จุดที่ต้องจำ: for คู่กับ id เท่านั้น และผิดแล้วเงียบสนิท ต้องทดสอบด้วยการคลิกที่ข้อความเสมอ

2. ในฟอร์มมี <button>ล้างช่อง</button> ที่ไม่ได้ใส่ type และยังไม่ได้ผูก JavaScript อะไรเลย — กดปุ่มนี้แล้วเกิดอะไร?

ผลรันจริง: อ่าน button.type จาก DOM ได้ค่า "submit" ทั้งที่ไม่ได้เขียนไว้ · กดแล้ว URL เปลี่ยนจาก /btn.html เป็น /search?q=go จริง ๆ · พอเติม type="button" เข้าไป URL ไม่ขยับเลย

ตัวเลือกที่ 1 ผิดเพราะ "ไม่มี JS ผูก" ไม่ได้แปลว่าปุ่มไม่ทำอะไร — พฤติกรรม submit เป็นของเบราว์เซอร์เอง ไม่ต้องมีใครเขียน · ตัวเลือกที่ 2 ผิดเพราะเบราว์เซอร์ไม่ได้อ่านข้อความบนปุ่ม (และถึงจะใส่ type="reset" ก็คืนค่าตั้งต้น ไม่ใช่ล้างเป็นค่าว่าง) · ตัวเลือกที่ 4 ผิดเพราะไม่มีคำเตือนอะไรเลย มันถือว่าเป็นพฤติกรรมปกติตามสเปก

จุดที่ต้องจำ: ปุ่มในฟอร์มที่ไม่ใส่ type คือปุ่มส่ง — ปุ่มอื่นทุกปุ่มต้องเขียน type="button" กำกับ

3. ฟอร์ม method="GET" มีสองช่อง: <input id="a" name="a" value="1"> และ <input id="b" value="2"> — กดส่งแล้ว query string เป็นอะไร?

ผลรันจริงกับโค้ดหน้าตาเดียวกัน: URL หลังกดคือ http://127.0.0.1:8932/echo?nickname=somchai — ช่องที่ไม่มี name (ค่า hello) หายไปเฉย ๆ

ตัวเลือกที่ 2 ผิดเพราะเบราว์เซอร์ไม่เคยใช้ id แทน name สองคำนี้ไม่มีทางแทนกันได้ · ตัวเลือกที่ 3 ผิดเพราะช่องนั้นไม่ได้ถูกนับเข้าไปในชุดข้อมูลตั้งแต่แรก ไม่ใช่ถูกนับแล้วชื่อว่าง · ตัวเลือกที่ 4 ผิดเพราะฟอร์มส่งได้ปกติทุกอย่าง — มันส่งได้นี่แหละคือปัญหา ถ้ามันพังโครม ๆ คุณคงเจอตั้งแต่วันแรก

จุดที่ต้องจำ: name คือใบเบิกทางของค่า — ไม่มี name ก็ไม่มีค่าไปถึงฝั่ง Go

4. ฟอร์ม <form action="/contact" method="POST"> ที่มี <input name="fullname"> — ฝั่ง Gin ควรอ่านค่านี้ด้วยอะไร?

ผมดักอ่าน request จริงที่เบราว์เซอร์ส่ง ได้ Content-Type: application/x-www-form-urlencoded และ body คือ fullname=%E0%B8%AA…&topic=consult&message=… · นั่นคือรูปแบบที่ c.PostForm และ tag form:"…" อ่านออกโดยตรง

ตัวเลือกที่ 1 ผิดเพราะ c.Param อ่าน path param อย่าง /users/:id ซึ่งฟอร์มไม่ได้ยุ่งด้วยเลย · ตัวเลือกที่ 2 ผิดเพราะค่าอยู่ใน body ไม่ใช่ header · ตัวเลือกที่ 3 ผิดเพราะ body ที่ส่งมาไม่ใช่ JSON — ShouldBindJSON บังคับอ่านเป็น JSON เสมอ (ตามที่บทเรียน 35 บอกไว้) มันจึงพังทันทีกับ urlencoded

จุดที่ต้องจำ: ฟอร์ม HTML แบบ POST ส่ง urlencoded ไม่ใช่ JSON — อ่านด้วย c.PostForm / tag form

🛠️ ภารกิจ — ทำฟอร์มจริงหนึ่งใบ แล้วพิสูจน์ว่ามันคุยกับ Go ของคุณรู้เรื่อง

ภารกิจนี้ไม่ต้องใช้เครื่องมืออะไรเพิ่มเลย · เอาโปรเจกต์ wongnok ที่คุณเขียนไว้แล้วมาใช้ต่อได้ทันที

  1. สร้างไฟล์ contact.html ที่ไหนก็ได้บนเครื่อง แล้วก็อปฟอร์มจากหัวข้อ 8 ลงไป · เปิดด้วยเบราว์เซอร์ (ดับเบิลคลิกไฟล์ได้เลย)
    เช็คก่อนไปต่อ: คลิกที่ข้อความของทุก label แล้วช่องต้องโฟกัสครบทุกช่อง — ถ้ามีช่องไหนไม่โฟกัส แปลว่า for กับ id ไม่ตรงกัน แก้ให้ครบก่อน
  2. เปลี่ยน method เป็น GET ชั่วคราว แล้วกรอกทุกช่องแล้วกดส่ง
    อ่านแถบที่อยู่ แล้วจดไว้ว่าเห็นชื่อช่องอะไรบ้าง — มันต้องมีครบ 4 ตัว: fullname · email · topic · message
  3. ลบ name ออกจากช่องอีเมลหนึ่งช่อง แล้วกดส่งใหม่
    เทียบ URL สองรอบ · เขียนลงกระดาษหนึ่งบรรทัดว่ามันหายไปกี่ตัวและตัวไหน นี่คือ "เทส" ที่ถูกที่สุดของฝั่ง frontend — ตาคุณกับแถบที่อยู่
  4. เอา type ออกจากปุ่ม แล้วเพิ่มปุ่มที่สองชื่อ "ล้างช่อง" โดยไม่ใส่ type เหมือนกัน · กดปุ่มที่สอง
    สังเกตว่าหน้าเด้ง แล้วกลับไปเติม type="button" ให้มัน กดใหม่ ต้องนิ่งสนิท
  5. ต่อเข้ากับ Go จริง: เปลี่ยน method กลับเป็น POST และตั้ง action ให้ชี้ไปที่เซิร์ฟเวอร์ Gin ของคุณ (เช่น http://localhost:8080/api/v1/contact)
    เขียน handler ที่ทำแค่ c.JSON(200, gin.H{"got": c.PostForm("fullname")}) แล้วกดส่งจากหน้าเว็บ
  6. พิสูจน์ด้วยเทส ไม่ใช่ด้วยการกดดู: เขียนเทส httptest ยิงเข้า handler นี้ โดยตั้ง body เป็น fullname=somchai&topic=consult และตั้ง header Content-Type: application/x-www-form-urlencoded
    ให้เทส assert ว่า status เป็น 200 และ body มีคำว่า somchai
    แล้วลองลบ header ทิ้งแล้วรันเทสซ้ำ — ดูว่ามันยังผ่านไหม นี่คือคำถามที่ทำให้คุณเข้าใจว่า Content-Type มีผลจริงแค่ไหน

ข้อ 3: ต้องหายไปหนึ่งตัว คือ email · fullname · topic · message ยังอยู่ครบ · และหน้าเว็บไม่แสดงอาการผิดปกติอะไรเลย ช่องอีเมลยังกรอกได้เหมือนเดิมทุกอย่าง

ข้อ 6: พอลบ header ออก Gin จะไม่รู้ว่า body เป็นฟอร์ม c.PostForm จึงหยิบค่าไม่ได้ · ถ้าเทสของคุณยังผ่านอยู่ ให้กลับไปดูว่า assert แน่นพอหรือยัง — เทสที่ผ่านทั้งที่ของพัง อันตรายกว่าไม่มีเทสเลย · ถ้าอยากได้คำอธิบายลึกกว่านี้ ถามผมได้ว่า "Gin ตัดสินใจยังไงว่าจะ bind body แบบไหน" แล้วเราไล่โค้ดของ Gin ด้วยกัน

ติดตรงไหนถามได้เลย — โดยเฉพาะสามคำถามที่คนถามบ่อยที่สุดของบทนี้: "ทำไม checkbox ที่ไม่ติ๊กถึงไม่ส่งค่ามาเลยแทนที่จะส่ง false" · "ถ้าอยากส่งไฟล์ด้วยต้องทำยังไง" (คำใบ้: enctype="multipart/form-data") · "แล้วเว็บสมัยใหม่ที่กดส่งแล้วหน้าไม่กะพริบ เขาทำยังไง"
สามข้อนี้เป็นเรื่องที่ต่อยอดจากบทนี้ตรง ๆ ทั้งหมด

อ้างอิง