🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 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) |
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> คือข้อความกำกับหน้าช่อง แต่มันไม่ได้เป็นแค่ข้อความ —
มันผูกตัวเองเข้ากับช่องหนึ่งช่อง แล้วได้สองอย่างตามมา:
- คลิกที่ข้อความ = คลิกที่ช่อง — เคอร์เซอร์กระโดดไปอยู่ในช่องให้เลย (สำคัญมากบนมือถือ เพราะช่องติ๊กเล็กจนกดพลาด)
- โปรแกรมอ่านหน้าจอ (screen reader) อ่านออกว่าช่องนี้คือช่องอะไร — ถ้าไม่ผูก มันจะอ่านได้แค่ "edit text" เฉย ๆ
การผูกทำผ่านคู่ 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 มองไม่ออกด้วยซ้ำว่า
"เคยมีช่องนี้อยู่แล้วหาย" กับ "ไม่เคยมีช่องนี้ตั้งแต่แรก" ต่างกันยังไง เพราะมันเห็นเหมือนกันเป๊ะ
id | name | |
|---|---|---|
| ใครใช้ | <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
สามอย่างที่เห็นได้จากก้อนนี้ทันที:
Content-Typeคือapplication/x-www-form-urlencoded— ไม่ใช่ JSON · เพราะงั้นc.ShouldBindJSONใช้กับฟอร์มไม่ได้nopeหายไปจริง ๆ — ยืนยันกับดักข้อ 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
ภารกิจนี้ไม่ต้องใช้เครื่องมืออะไรเพิ่มเลย · เอาโปรเจกต์ wongnok
ที่คุณเขียนไว้แล้วมาใช้ต่อได้ทันที
- สร้างไฟล์
contact.htmlที่ไหนก็ได้บนเครื่อง แล้วก็อปฟอร์มจากหัวข้อ 8 ลงไป · เปิดด้วยเบราว์เซอร์ (ดับเบิลคลิกไฟล์ได้เลย)
เช็คก่อนไปต่อ: คลิกที่ข้อความของทุก label แล้วช่องต้องโฟกัสครบทุกช่อง — ถ้ามีช่องไหนไม่โฟกัส แปลว่าforกับidไม่ตรงกัน แก้ให้ครบก่อน - เปลี่ยน
methodเป็นGETชั่วคราว แล้วกรอกทุกช่องแล้วกดส่ง
อ่านแถบที่อยู่ แล้วจดไว้ว่าเห็นชื่อช่องอะไรบ้าง — มันต้องมีครบ 4 ตัว:fullname·email·topic·message - ลบ
nameออกจากช่องอีเมลหนึ่งช่อง แล้วกดส่งใหม่
เทียบ URL สองรอบ · เขียนลงกระดาษหนึ่งบรรทัดว่ามันหายไปกี่ตัวและตัวไหน นี่คือ "เทส" ที่ถูกที่สุดของฝั่ง frontend — ตาคุณกับแถบที่อยู่ - เอา
typeออกจากปุ่ม แล้วเพิ่มปุ่มที่สองชื่อ "ล้างช่อง" โดยไม่ใส่typeเหมือนกัน · กดปุ่มที่สอง
สังเกตว่าหน้าเด้ง แล้วกลับไปเติมtype="button"ให้มัน กดใหม่ ต้องนิ่งสนิท - ต่อเข้ากับ Go จริง: เปลี่ยน
methodกลับเป็นPOSTและตั้งactionให้ชี้ไปที่เซิร์ฟเวอร์ Gin ของคุณ (เช่นhttp://localhost:8080/api/v1/contact)
เขียน handler ที่ทำแค่c.JSON(200, gin.H{"got": c.PostForm("fullname")})แล้วกดส่งจากหน้าเว็บ - พิสูจน์ด้วยเทส ไม่ใช่ด้วยการกดดู: เขียนเทส
httptestยิงเข้า handler นี้ โดยตั้ง body เป็นfullname=somchai&topic=consultและตั้ง headerContent-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") ·
"แล้วเว็บสมัยใหม่ที่กดส่งแล้วหน้าไม่กะพริบ เขาทำยังไง"
สามข้อนี้เป็นเรื่องที่ต่อยอดจากบทนี้ตรง ๆ ทั้งหมด
อ้างอิง
- MDN —
<input>types — อ่านแค่ตารางหัวข้อ Input types ก็พอ · เป็นที่เดียวที่รวมtypeทั้ง 22 แบบพร้อมบอกว่าแต่ละอันให้ UI อะไรมา - MDN —
<label>— อ่านหัวข้อ Accessibility ซึ่งอธิบายว่าทำไมfor/idถึงสำคัญกว่าแค่ "คลิกแล้วโฟกัส" และบอกด้วยว่าทำไมไม่ควรใส่ปุ่มหรือลิงก์ไว้ข้างใน<label> - MDN —
<button>attributetype— อ่านสามบรรทัดใต้หัวข้อtypeที่ระบุตรง ๆ ว่า ค่าเริ่มต้นคือsubmit· เป็นหลักฐานของกับดักข้อ 2 ในบทนี้ - MDN — Sending form data —
อ่านหัวข้อ On the client side: defining how to send the data
ซึ่งอธิบายว่า
GETเอาค่าไปต่อท้าย URL ส่วนPOSTใส่ไว้ใน body และบอกว่าenctypeควบคุมรูปแบบของ body gin.Context.PostForm— อ่านบรรทัดเดียวใต้ชื่อฟังก์ชัน จะเห็นว่ามันอ่านจาก urlencoded form or multipart form ตรงกับที่บทนี้ดักอ่านมาได้จริง- บทเรียน 35 — query · body · header —
กลับไปอ่านหัวข้อที่เทียบ tag
uri/form/jsonแล้วจะเห็นว่าคอลัมน์formในตารางนั้น คือฝั่งปลายทางของทุกอย่างที่บทนี้สอน