บทเรียน 122 · Rails · Day 3

View layer — ทำไม Rails ไม่ต้องมีชั้น API และ ERB 4 แบบ

~20 นาที · ควรผ่าน บทเรียน 120 (เปิดโปรเจกต์กลาง + ข้อมูล 30 บทความ) · บทเรียน 103 (ERB ครั้งแรก) · และ บทเรียน 73–74 (useState · useEffect) มาก่อน

ตอนเรียน Next.js + Go คุณทำหน้ารายการด้วย สองโปรแกรม: Go ส่งข้อมูลเป็น JSON แล้ว React ในเบราว์เซอร์เอาไปวาดเป็นหน้าจอ · Rails ทำงานเดียวกันด้วย ไฟล์เดียว แล้วชั้น API ที่หายไปนั้นไปไหน?
อ่านจบบทนี้ คุณจะอธิบายได้ว่าสองแบบต่างกันตรงไหน และแต่ละแบบได้อะไร เสียอะไร · เห็นด้วยตาว่าแอปเดียวกันส่งได้ทั้ง HTML และ JSON · และใช้ ERB ทั้ง 4 แบบได้โดยไม่ตกกับดัก "หน้าว่างแต่ไม่มี error"

🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้

🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)

🗂 โค้ดเริ่มต้น: ต่อจากบท 120 — แอป wongnok-blog-all-in-one branch my-day3 (แตกจาก day3-start ของอาจารย์) + ข้อมูลตัวอย่าง 30 บทความที่ commit ไว้แล้ว · ตามไม่ทันให้ทำบท 120 ก่อน · บทนี้แก้ไฟล์เพื่อทดลองเท่านั้น จบบทสั่ง git restore คืนทุกอย่าง

ผลทุกก้อนรันจริงแล้วก็อปมา · ตัดบรรทัดที่ไม่เกี่ยวเป็น … · วันเวลา · เวลา (ms) · รหัสยาว ๆ ในฟอร์มของคุณจะไม่ตรงกับในบท

บล็อก A ของ Day 3: ทั้งบล็อกคือเรื่องของ "คนจัดจาน"

Day 3 ช่วงเช้ามี 3 บล็อก · บล็อก A คือ view (คนจัดจาน) · บล็อก B คือ CSS · บล็อก C คือทำให้หน้าเว็บขยับได้ · บล็อก A ยาว 5 บท และจบด้วยหน้า index กับ show ที่อ่านรู้เรื่อง (บท 126)

งานเดียวกัน สองเส้นทาง

โจทย์: โชว์รายการบทความทั้งหมดบนหน้าเว็บ (แอปของเรามี 30 ชิ้น) · ฝั่ง FRONT + BACK ของคอร์สนี้สอนให้ทำแบบ ① · Rails ทำแบบ ② · อ่านจากบนลงล่าง = ข้อมูลเดินทางจากฐานข้อมูลไปถึงตาคน (ภาพจากสไลด์หน้า 10)

① Next.js + Go — 7 กล่อง PostgreSQL ▼ Go handler ส่ง c.JSON(…) (บท 38) ← server ▼ ~~ JSON ผ่านเน็ต ~~ ข้อมูลล้วน ๆ ยังไม่มีหน้าตา ▼ fetch ใน browser ขอข้อมูลใน useEffect ← browser ▼ ★ state ใน client เก็บไว้ด้วย useState (บท 73) ▼ React render เอา state มาวาดเป็น HTML ▼ DOM สิ่งที่ตาเห็นบนจอ ② Rails — 5 กล่อง PostgreSQL ▼ Rails controller @posts = Post.all ← server ▼ ERB render เป็น HTML index.html.erb จัดจานเสร็จในครัว ▼ ~~ HTML ผ่านเน็ต ~~ หน้าเว็บพร้อมโชว์ ▼ DOM สิ่งที่ตาเห็นบนจอ ← browser

คำในภาพสองคำ: render = แปลงข้อมูลให้กลายเป็นหน้าตา (HTML) · DOM = หน้าเว็บที่เบราว์เซอร์อ่าน HTML แล้วสร้างเก็บไว้ในหน่วยความจำ ก่อนวาดออกจอ (ตัวที่เห็นในแท็บ Elements ของ DevTools) · บทนี้ให้คิดว่ามันคือ "จานที่วางอยู่บนโต๊ะแล้ว" พอ

ฝั่ง ① server ส่ง JSON ออกไป — ข้อมูลล้วน ๆ แบบที่ Go ส่งในบท 38 ยังไม่มีหน้าตา · แล้วโค้ด React ที่รันในเบราว์เซอร์ต้องรับมา เก็บไว้ แล้ววาดเอง

ฝั่ง ② server ทำทุกอย่างจนเสร็จ ส่ง HTML ที่พร้อมโชว์ออกไป · เบราว์เซอร์แค่เอาไปแสดง · ไม่มีใครต้อง "เรียก API" เพราะ controller กับ view อยู่ในแอปเดียวกัน ส่งของกันผ่าน @posts ได้เลย — ชั้น API ไม่ได้หายไปไหน แค่ไม่จำเป็นต้องมี

🍳 เทียบให้เห็นภาพ — จัดจานเสร็จในครัว vs ส่งวัตถุดิบไปปรุงที่โต๊ะ

แบบ ② (Rails) เหมือนร้านอาหารทั่วไป: ครัวปรุงและจัดจานเสร็จ พนักงานยกจานพร้อมกินไปวางที่โต๊ะ · ลูกค้าแค่กิน
แบบ ① เหมือนร้านหมูกระทะ: ครัวส่งวัตถุดิบดิบ (JSON) ไปที่โต๊ะ · โต๊ะต้องมีเตาของตัวเอง (React) และสมุดจดว่าบนโต๊ะมีอะไรเหลือบ้าง (state) แล้วปรุงเอง

ที่ไม่เหมือนกัน: ในร้านหมูกระทะ ลูกค้าต้องปรุงเองจนเหนื่อย · แต่ในเว็บ "เตาบนโต๊ะ" คือโค้ด JavaScript ที่ทีม frontend เขียนไว้ให้ ลูกค้าไม่ได้ทำอะไรเพิ่ม · คนที่เหนื่อยจริงคือทีมที่ต้องดูแลสองครัว — ครัวหลัง (Go) กับเตาบนโต๊ะ (React)

Next.js รุ่นใหม่ render HTML บน server ได้ด้วย (Server Component — คือ component ที่ไม่มี 'use client' ที่เจอในบท 73) · ภาพ ① คือแบบที่ข้อมูลถูก fetch ในเบราว์เซอร์ ซึ่งเป็นแบบที่สไลด์เทียบ และเป็นแบบที่เจอบ่อยเวลา frontend กับ backend แยกทีมกัน

🗣 ศัพท์ที่เพิ่งเรียนไป
API layer · JSON API
ชั้นที่ server ส่งข้อมูลเป็น JSON ให้โปรแกรมอื่นมาเรียกใช้ (เช่น React · แอปมือถือ) · Go handler ในบท 38 คือ JSON API · ถาม senior ได้ว่า “หน้านี้ดึงข้อมูลผ่าน JSON API หรือ server render HTML มาเลย”
server-rendered HTML · client-side rendering
server-rendered = server สร้าง HTML ให้เสร็จแล้วส่ง (แบบ ②) · client-side rendering = เบราว์เซอร์รับ JSON แล้วสร้างหน้าจอเอง (แบบ ①) · พิมพ์บอก AI ได้ว่า “this is a Rails app with server-rendered ERB views, not a React SPA — don't add a JSON API for this page”

กล่องที่หายไป: "state ใน client" — ที่เกิดของบั๊ก "ทำไมหน้าจอไม่อัปเดต"

เทียบสองภาพข้างบน กล่องที่ ② ไม่มีคือ ★ state ใน client · ฟังดูเป็นแค่กล่องเดียว แต่สไลด์หน้า 10 บอกว่า บั๊กประเภท "ทำไมหน้าจอไม่อัปเดต" เกิดที่กล่องนี้เกือบทั้งหมด · มาดูว่าทำไม

นี่คือหน้ารายการแบบ ① ด้วยของที่คุณเพิ่งเรียน (ไฟล์ PostList.tsx · สมมติว่า Go รันอยู่ที่พอร์ต 8080 · แค่อ่าน ไม่ต้องพิมพ์ตาม):

'use client'
import { useEffect, useState } from 'react'

type Post = { id: number; title: string }

export default function PostList() {
  const [posts, setPosts] = useState<Post[]>([])

  useEffect(() => {
    fetch('http://localhost:8080/posts')
      .then((res) => res.json())
      .then((data: Post[]) => setPosts(data))
  }, [])

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

ตอนนี้ข้อมูลบทความมี สองชุด: ชุดจริงในฐานข้อมูล กับชุดที่ก็อปมาไว้ในหน่วยความจำของแท็บนี้ · ถ้าเพื่อนเปิดอีกแท็บแล้วแก้ชื่อบทความ — ฐานข้อมูลเปลี่ยนแล้ว แต่ posts ในแท็บของคุณไม่มีใครบอกมัน · useEffect ก็สั่งไว้ให้ fetch แค่ครั้งเดียว จอจึงโชว์ชื่อเก่าต่อไป

ทีมที่ใช้แบบ ① จึงต้องเขียนโค้ดเพิ่มเพื่อตามล้างชุดที่สอง: แก้เสร็จแล้ว fetch ใหม่ · ทุกกี่วินาทีไปถามใหม่ · หน้าไหนใช้ข้อมูลเดียวกันต้องบอกกัน — ลืมจุดเดียว = บั๊ก

📒 เทียบให้เห็นภาพ — สมุดจดสต็อกเล่มที่สองบนโต๊ะลูกค้า

ร้านหมูกระทะจดของบนโต๊ะไว้ในสมุดเล่มเล็ก "หมูสามชั้นเหลือ 2 จาน" · แต่ครัวเพิ่งประกาศว่าหมูสามชั้นหมดแล้ว · สมุดบนโต๊ะไม่รู้ ยังเขียนว่า 2 จาน ลูกค้าสั่งไปก็ได้คำตอบว่าไม่มี · ปัญหาไม่ได้อยู่ที่ครัวหรือโต๊ะ แต่อยู่ที่มีสมุดสองเล่มที่ต้องตรงกัน
ร้านแบบ ② (Rails) ไม่มีสมุดบนโต๊ะ · ทุกครั้งที่ลูกค้าขออะไร ครัวเปิดสมุดเล่มเดียวของครัวแล้วจัดจานใหม่

ที่ไม่เหมือนกัน: อย่าเข้าใจว่าหน้า Rails อัปเดตเองได้ · ผมเปิดหน้า /posts/1 สองแท็บใน Chrome จริง แก้ชื่อจากแท็บที่สอง — แท็บแรกก็ยังโชว์ชื่อเก่าเหมือนกัน จนกดรีเฟรชถึงเห็นชื่อใหม่ · ที่ต่างคือแค่คลิกหรือรีเฟรชก็ได้ของล่าสุดจากครัวทันที ไม่มีสมุดเล่มที่สองให้ลืมล้าง · ทำให้แท็บแรกเด้งเองได้โดยไม่ต้องมีสมุดเล่มที่สอง คือเรื่องของบล็อก C (บท 133)

นี่คือประโยคหลักข้อแรกของ Day 3 (สไลด์หน้า 8): เว็บที่โต้ตอบได้ไม่จำเป็นต้องมี state สองชุด — ถ้า server เป็นเจ้าของข้อมูลชุดเดียว บั๊ก sync ก็ไม่มี · บล็อก C (บท 130) จะพากลับมาที่ประโยคนี้อีกครั้ง

🗣 ศัพท์ที่เพิ่งเรียนไป
client state
ข้อมูลที่ก็อปมาเก็บไว้ในเบราว์เซอร์ (เช่น ใน useState) · สะดวก แต่ต้องคอยทำให้ตรงกับของจริงบน server · ถาม senior ได้ว่า “ข้อมูลหน้านี้เก็บเป็น client state ไหม แล้วอะไรเป็นตัวสั่งให้ fetch ใหม่”
single source of truth
ข้อมูลมีเจ้าของเพียงที่เดียว ที่อื่นแค่ขอดู ไม่เก็บสำเนาของตัวเอง · ใน Rails เจ้าของคือฐานข้อมูลบน server · พิมพ์บอก AI ได้ว่า “keep the server as the single source of truth; don't keep a separate copy of posts in client state”
stale data · sync bug
stale = ข้อมูลค้าง (เก่าแล้วแต่ยังโชว์) · sync bug = บั๊กที่เกิดเพราะสองชุดไม่ตรงกัน · อาการคลาสสิก "แก้แล้วแต่จออีกหน้ายังเห็นของเก่า" · ถาม senior ได้ว่า “บั๊กนี้เป็น stale data ฝั่ง client หรือ server ส่งข้อมูลผิดมาจริง ๆ”

แฟร์ ๆ: ฝั่ง ① ไม่ได้ผิด มันแลกมาเพื่อของบางอย่าง

สไลด์หน้า 10 ย้ำไว้เองว่า "ไม่ใช่ว่าฝั่งซ้ายผิด" · ฝั่ง ① ยอมรับความยุ่งของสมุดเล่มที่สอง เพื่อแลกกับสองเรื่องนี้:

① Next.js + Go (ส่ง JSON)② Rails (ส่ง HTML)
ได้ ทีม frontend กับ backend ทำงานแยกกันได้ ตกลงกันแค่หน้าตา JSON · JSON ชุดเดียวใช้ได้หลายแบบ — เว็บ · แอปมือถือ · ระบบของพาร์ตเนอร์ โค้ดน้อยกว่า ทีมเดียวทำได้ทั้งหน้า · ข้อมูลชุดเดียว ไม่มีบั๊ก sync ระหว่างสองชุด
เสีย ต้องดูแลสองโปรแกรม · ต้องคอยให้ state ในเบราว์เซอร์ตรงกับของจริง แอปมือถือหรือพาร์ตเนอร์อ่าน HTML ไม่สะดวก ต้องทำ JSON เพิ่ม · หน้าที่โต้ตอบซับซ้อนมาก ๆ ยังต้องพึ่ง JavaScript (เรื่องของบล็อก C)

ช่อง "เสีย" ของ Rails มีทางแก้ที่ซ่อนอยู่ในแอปของคุณแล้ว — scaffold ทำ JSON ไว้ให้ด้วย · มาดูของจริงกัน

ดูของจริง: แอปเดียวกัน เสิร์ฟได้ทั้ง HTML และ JSON

เปิด server ในโฟลเดอร์แอป (bin/dev ตามบท 120) แล้วเปิดเทอร์มินัลอีกหน้าต่าง ลองขอหน้า /posts แบบที่เบราว์เซอร์ขอ · (-s = เงียบ ไม่ต้องโชว์แถบความคืบหน้า · บท 102) · ด้านล่างตัดมาเฉพาะต้นไฟล์กับบทความชิ้นแรก (ผลจริง · ทั้งหน้ายาวมาก):

$ curl -s localhost:3000/posts
<!-- BEGIN app/views/layouts/application.html.erb
--><!DOCTYPE html>
<html>
  <head>
    <title>Posts</title>
…
  <div id="posts" class="min-w-full divide-y divide-gray-200 space-y-5">
        <div class="flex flex-col sm:flex-row justify-between items-center pb-5 sm:pb-0">
          <!-- BEGIN app/views/posts/_post.html.erb
--><div id="post_1" class="w-full sm:w-auto my-5 space-y-5">
  <div>
    <strong class="block font-medium mb-1">Title:</strong>
    ต้มยำกุ้งน้ำข้น
  </div>
…

ทีนี้ขอ URL เดิม แต่เติม .json ท้าย · ขอบทความชิ้นเดียว (/posts/1.json) จะได้อ่านง่าย · ท่อน | python3 -m json.tool --no-ensure-ascii แค่จัดบรรทัดให้สวยและโชว์ภาษาไทยตรง ๆ (ไม่ต้องจำ):

$ curl -s localhost:3000/posts/1.json | python3 -m json.tool --no-ensure-ascii
{
    "id": 1,
    "title": "ต้มยำกุ้งน้ำข้น",
    "description": "ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม กุ้งแม่น้ำตัวใหญ่ หอมตะไคร้ ข่า ใบมะกรูด กินกับข้าวสวยร้อน ๆ ได้ทั้งถ้วย ร้านนี้เปิดมาสามสิบปีแล้ว คิวยาวทุกวันเสาร์อาทิตย์",
    "category_id": 1,
    "created_at": "2026-10-08T03:38:59.900Z",
    "updated_at": "2026-11-07T03:38:59.900Z",
    "url": "http://localhost:3000/posts/1.json"
}

บทความชิ้นเดียวกัน แต่คราวนี้เป็นวัตถุดิบดิบ — ไม่มีกล่อง ไม่มีป้าย ไม่มีสี · เหมือน JSON ที่ Go ส่งในบท 38 ทุกอย่าง · (วันที่ created_at ของคุณจะต่าง เพราะข้อมูลตัวอย่างนับย้อนจากวันที่คุณรัน seed) · ช่อง url ไม่ได้อยู่ในตาราง — แม่แบบ JSON ของ scaffold เติมที่อยู่ของบทความชิ้นนั้นให้

ทีนี้สั่ง curl -s localhost:3000/posts แล้วตามด้วย curl -s localhost:3000/posts.json (รายการทั้งหมดแบบ JSON) · ใน log ของ server (log/development.log) เห็นชัดว่าเป็น action เดียวกัน แค่เลือกแม่แบบคนละไฟล์ (ผลจริง · ตัดบรรทัดที่ไม่เกี่ยวออก):

Started GET "/posts" for ::1 at 2026-11-07 11:22:43 +0700
Processing by PostsController#index as */*
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
…
Started GET "/posts.json" for ::1 at 2026-11-07 11:22:44 +0700
Processing by PostsController#index as JSON
  Rendering posts/index.json.jbuilder
…

ลองวัดขนาดหน้ารายการ 30 บทความ (-o /dev/null = ไม่ต้องโชว์เนื้อ · -w '%{size_download}' = บอกขนาดที่ได้มาเป็นไบต์) — ผลจริงในเครื่องที่ใช้ตรวจ:

$ curl -s -o /dev/null -w '%{size_download}\n' localhost:3000/posts
53937
$ curl -s -o /dev/null -w '%{size_download}\n' localhost:3000/posts.json
13566

HTML ใหญ่กว่าราว 4 เท่า เพราะมีของหน้าตาติดมาด้วย (ชื่อ class ยาว ๆ · ปุ่ม Show/Edit/Destroy · โน้ต BEGIN/END ของโหมดพัฒนา) · ตัวเลข HTML ของคุณจะต่างเล็กน้อย และโตขึ้นนิดหน่อยทุกครั้งที่สั่งซ้ำ (เครื่องมือวัดความเร็ว rack-mini-profiler ที่ติดมากับแอปแปะรหัสเพิ่มท้ายหน้า) · ตัวเลขนี้คือหน้า scaffold ในเครื่องพัฒนา ไม่ใช่กฎตายตัว · ประเด็นคือ Rails ไม่ได้ "ทำ API ไม่ได้" — ถ้าวันหนึ่งต้องมีแอปมือถือ JSON ก็อยู่ตรงนี้แล้ว แค่ไม่บังคับให้หน้าเว็บต้องผ่านมัน

ชื่อไฟล์บอกทุกอย่าง: index.html.erb กับ index.json.jbuilder

ดูโฟลเดอร์ view ของบทความ (ผลจริง · ในเทอร์มินัลของคุณชื่ออาจเรียงเป็นหลายคอลัมน์ แต่เป็นไฟล์ชุดเดียวกัน):

$ ls app/views/posts
_form.html.erb
_post.html.erb
_post.json.jbuilder
edit.html.erb
index.html.erb
index.json.jbuilder
new.html.erb
show.html.erb
show.json.jbuilder

ไฟล์มาเป็นคู่ — index มีทั้ง .html.erb และ .json.jbuilder · show ก็เหมือนกัน · ชื่อไฟล์แบ่งเป็น 3 ท่อน อ่านจากขวาไปซ้าย (สไลด์หน้า 11):

index . html . erb │ │ └── ประมวลผลด้วยอะไร → erb (HTML ที่แทรก Ruby ได้) │ └───────── ส่งออกเป็นอะไร → html └──────────────── ของ action ไหน → index index . json . jbuilder │ │ └── ประมวลผลด้วยอะไร → jbuilder (Ruby ที่เขียนสร้าง JSON) │ └───────── ส่งออกเป็นอะไร → json └──────────────── ของ action ไหน → index

ไฟล์ index.json.jbuilder สั้นมาก — มีบรรทัดเดียว:

# app/views/posts/index.json.jbuilder
json.array! @posts, partial: "posts/post", as: :post

บทนี้แค่ให้รู้ว่ามันอยู่ตรงนี้ · Day 3 ทั้งวันเราทำงานกับไฟล์ .html.erb

🗣 ศัพท์ที่เพิ่งเรียนไป
format · respond_to
"ลูกค้าขอผลแบบไหน" — HTML · JSON ฯลฯ · ดูจากท้าย URL (.json) หรือหัวใบสั่ง (header — ข้อมูลกำกับที่เบราว์เซอร์แนบมากับ request บอกว่า "รับ HTML นะ") · action ไหนต้องทำต่างกันตามแบบจะเขียน respond_to do |format| (เปิดดูได้ในเมธอด create ของ app/controllers/posts_controller.rb) · ถาม senior ได้ว่า “action นี้ต้องตอบ JSON ด้วยไหม หรือลบ .json.jbuilder ทิ้งได้”
template handler · jbuilder
ท่อนท้ายสุดของชื่อไฟล์ บอกว่าใช้เครื่องมืออะไรแปลงแม่แบบ · erb สำหรับ HTML · jbuilder (gem ที่มากับ Rails) สำหรับสร้าง JSON · พิมพ์บอก AI ได้ว่า “add the category name to the JSON in app/views/posts/_post.json.jbuilder”

ERB 4 แบบ — ทวนบท 103 แล้วลองบนหน้าจริง

บท 103 รู้จักไปแล้ว 3 แท็ก · สไลด์หน้า 11 นับเป็น 4 แบบ เพราะแยก "วนลูป" ออกมาเป็นอีกแบบ (จริง ๆ คือสองแท็กแรกทำงานร่วมกัน):

แบบหน้าตาRuby ทำงานไหมผลไปถึงเบราว์เซอร์ไหม
① พิมพ์ผล<%= @post.title %>✅✅ ผลลัพธ์ถูกวางลงตรงนั้น
② รันอย่างเดียว<% if … %> … <% end %>✅❌ ตัวมันไม่ไป (แต่ HTML ที่มันคุมไปได้)
③ วนลูป<% @posts.each do |post| %> … <% end %>✅❌ ตัวมันไม่ไป · ของข้างในถูกพิมพ์ซ้ำทุกรอบ
④ คอมเมนต์<%# โน้ต %>❌❌ หายตั้งแต่ในครัว

มาลองบนหน้า /posts ของแอปจริง · เปิดไฟล์ app/views/posts/index.html.erb หาบรรทัดที่ 3 <div class="w-full"> · แล้วแทรก 9 บรรทัดนี้ต่อจากมัน (โค้ดทดลอง · จบบทจะลบทิ้ง):

  <%# ทดลองบท 122: โน้ตของเชฟ ลูกค้าไม่เห็น %>
  <!-- ทดลองบท 122: โน้ต HTML ลูกค้าเปิดดูได้ -->
  <p>มีบทความทั้งหมด <%= @posts.size %> ชิ้น</p>
  <p>บทความแรก: <%= @posts.first.title %></p>
  <ul>
    <% @posts.first(3).each do |post| %>
      <li><%= post.title %></li>
    <% end %>
  </ul>

เซฟแล้วไม่ต้องรีสตาร์ต server (โหมดพัฒนาอ่านไฟล์ view ใหม่ทุกครั้งเอง) · curl ดูเฉพาะช่วงที่แทรก (ผลจริง · ตัดส่วนอื่นออก):

$ curl -s localhost:3000/posts
…
      <!-- BEGIN app/views/posts/index.html.erb
-->
<div class="w-full">
  <!-- ทดลองบท 122: โน้ต HTML ลูกค้าเปิดดูได้ -->
  <p>มีบทความทั้งหมด 30 ชิ้น</p>
  <p>บทความแรก: ต้มยำกุ้งน้ำข้น</p>
  <ul>
      <li>ต้มยำกุ้งน้ำข้น</li>
      <li>ก๋วยเตี๋ยวเรืออยุธยา</li>
      <li>ข้าวเหนียวมะม่วง</li>
  </ul>
…
🍱 เทียบให้เห็นภาพ — แม่แบบจานที่มีช่องให้เติม

ไฟล์ .html.erb คือแม่แบบจานที่ติดไว้ในครัว · <%= %> = "เติมของในช่องนี้แล้ววางลงจาน" · <% %> = งานที่คนจัดจานทำในครัวเงียบ ๆ เช่น "ทำซ้ำ 3 ที่" — ตัวคำสั่งไม่ขึ้นจาน · <%# %> = โน้ตดินสอบนแม่แบบ ไม่ออกจากครัว
ส่วน <!-- --> คือกระดาษโน้ตที่แปะติดใต้จาน — ลูกค้ามองจากด้านบนไม่เห็น แต่ใครพลิกจานดูก็อ่านได้

ที่ไม่เหมือนกัน: คนจัดจานจริงเห็นช่องว่างก็ทักว่า "ช่องนี้ลืมใส่อะไรหรือเปล่า" · แต่ ERB ไม่ทักอะไรเลย — ใช้แท็กผิดแบบ มันทำตามตัวอักษรเป๊ะ ๆ แล้วเสิร์ฟจานนั้นออกไปเงียบ ๆ ไม่มี error · สามหัวข้อถัดไปคือกับดักจากเรื่องนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
output tag <%= %> · execution tag <% %>
แท็กที่รันแล้วพิมพ์ผล กับแท็กที่รันอย่างเดียว · พิมพ์บอก AI ได้ว่า “the title is blank in the HTML — check whether I used an execution tag instead of an output tag”
ERB comment <%# %> · HTML comment <!-- -->
คอมเมนต์ของ ERB หายตั้งแต่บน server · คอมเมนต์ของ HTML ถูกส่งไปถึงเบราว์เซอร์และใครก็เปิดซอร์สอ่านได้ · ถาม senior ได้ว่า “ทีมเราห้ามใส่ HTML comment ใน view ไหม หรือให้ใช้ ERB comment อย่างเดียว”

กับดัก ①: ลืม = ตัวเดียว — หน้าว่าง ไม่มี error

สไลด์หน้า 11 บอกว่านี่คือบั๊กที่มือใหม่เจอบ่อยที่สุด · บท 103 เห็นมาแล้วกับข้อมูลจำลอง · คราวนี้ลองกับฐานข้อมูลจริง แล้วดูให้ลึกกว่าเดิม

<%# แก้บรรทัดเดียวในโค้ดทดลอง: ลบ = ออกจาก "บทความแรก" %>
  <p>บทความแรก: <% @posts.first.title %></p>

เซฟแล้ว curl ใหม่ · บรรทัดนี้ได้ HTML หน้าตาแบบไหน? แล้ว Rails ยังไปหยิบบทความแรกจากฐานข้อมูลไหม ทั้งที่ไม่ได้โชว์?

ผลจริง — ชื่อหายไป เหลือแค่ป้าย · หน้าเว็บตอบ 200 OK ปกติ ไม่มี error:

  <p>มีบทความทั้งหมด 30 ชิ้น</p>
  <p>บทความแรก: </p>

และ log บอกว่ายังไปหยิบจริง (ผลจริง · ตัดมาสองบรรทัด):

  Post Load (0.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:8
  • ORDER BY "posts"."id" ASC LIMIT 1 = "เรียงตามเลขที่ เอาแผ่นแรกแผ่นเดียว" — นี่คือ @posts.first
  • /*action='index',…*/ ท้าย SQL = ป้ายที่ Rails แปะบอกว่า query นี้มาจาก controller/action ไหน · (0.8ms) = เวลาที่ใช้ ของคุณจะต่าง
  • ↳ …index.html.erb:8 = บรรทัดที่สั่ง · ในไฟล์คือบรรทัด 7 แต่ log นับเกิน 1 เพราะโน้ต BEGIN (เรื่องเดียวกับบท 114)

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

ถ้าหน้าเว็บ "ว่างตรงที่ควรมีข้อมูล" แต่ไม่มี error — เช็ค = ก่อนอย่างอื่น · เปิดไฟล์ view แล้วไล่ดูทุกแท็กที่ควรโชว์ของ ว่าขึ้นต้นด้วย <%= ครบไหม

กับดัก ②: ใส่ = เกินที่บรรทัด each

ทิศกลับกันก็พัง · ลองคืน = ให้บรรทัด "บทความแรก" แล้วเผลอใส่ = ที่บรรทัด each แทน:

    <%= @posts.first(3).each do |post| %>

ผลจริงจาก curl (ตัดมาเฉพาะ <ul> · บรรทัดยาวมากจนต้องเลื่อนดู):

  <ul>

      <li>ต้มยำกุ้งน้ำข้น</li>

      <li>ก๋วยเตี๋ยวเรืออยุธยา</li>

      <li>ข้าวเหนียวมะม่วง</li>
[#&lt;Post id: 1, title: &quot;ต้มยำกุ้งน้ำข้น&quot;, description: &quot;ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อ...&quot;, category_id: 1, created_at: &quot;2026-10-08 03:38:59.900313000 +0000&quot;, updated_at: &quot;2026-11-07 03:38:59.900694000 +0000&quot;&gt;, #&lt;Post id: 2, … &gt;]  </ul>

ผมเปิดหน้านี้ใน Chrome จริง: ใต้รายการมีข้อความ [#<Post id: 1, title: "ต้มยำกุ้งน้ำข้น", … โชว์อยู่บนจอให้ทุกคนเห็น · (วันที่ในข้อความของคุณจะต่าง)

เห็นข้อความแปลก ๆ หน้าตา [#<Post id: …>] โผล่บนหน้าเว็บ = มี <%= อยู่หน้า each · บรรทัด each · if · end ใช้ <% (ไม่มี =) — ผมลอง <%= if … %> แล้วได้หน้า error ActionView::SyntaxErrorInTemplate เลย · ข้อยกเว้นเดียวที่จะเจอ: helper ที่สร้าง HTML ครอบเนื้อหาข้างใน เช่น <%= form_with … do |f| %> (บท 124) ตัวนั้นต้องมี =

กับดัก ③: <!-- --> ไม่ได้หยุด Ruby

มือใหม่ชอบ "ปิดบรรทัดชั่วคราว" ด้วยคอมเมนต์ HTML เพราะคุ้นจากตอนเขียน HTML ล้วน ๆ · ลองครอบบรรทัด "บทความแรก" (ที่มี = ครบแล้ว) ด้วย <!-- -->:

  <!-- <p>บทความแรก: <%= @posts.first.title %></p> -->

ผลจริง:

  <!-- <p>บทความแรก: ต้มยำกุ้งน้ำข้น</p> -->

ชื่อบทความยังถูกเติมเข้าไปในคอมเมนต์ · และ log ยังมีบรรทัด Post Load … LIMIT 1 ครบ · ERB ไม่รู้จัก <!-- --> — สำหรับมันนั่นคือ HTML ธรรมดา ก้อนหนึ่ง มันเติมช่อง <%= %> ข้างในตามปกติ แล้วเบราว์เซอร์ค่อยซ่อน · ถ้าในนั้นเป็นข้อมูลที่ไม่ควรให้ใครเห็น ก็หลุดไปถึงเบราว์เซอร์แล้ว

วิธีที่ถูก: เติม # หลัง <% ให้กลายเป็นคอมเมนต์ของ ERB:

  <p>บทความแรก: <%#= @posts.first.title %></p>

ผลจริง: ได้ <p>บทความแรก: </p> และใน log ไม่มีบรรทัด LIMIT 1 แล้ว — Ruby ไม่ได้ทำงานเลย · แอปของคุณใช้ท่านี้อยู่แล้ว: เปิด app/views/layouts/application.html.erb จะเจอบรรทัด <%#= tag.link rel: "manifest", … %> ที่ Rails ปิดไว้ให้

เทียบกับ JSX: ทิศกลับกัน

สไลด์หน้า 11 สรุปไว้ประโยคเดียว: JSX คือ JavaScript ที่เขียน HTML ได้ · ERB คือ HTML ที่เขียน Ruby ได้ · วางงานเดียวกันข้างกันจะเห็นชัด:

JSX (ไฟล์ .tsx) — markup อยู่ในภาษา

<ul>
  {posts.map((p) => (
    <li key={p.id}>{p.title}</li>
  ))}
</ul>

ERB (ไฟล์ .html.erb) — ภาษาอยู่ใน markup

<ul>
  <% @posts.each do |post| %>
    <li><%= post.title %></li>
  <% end %>
</ul>

ภารกิจ

🎯 ภารกิจ — กล่องสรุปบนหน้า /posts แล้วจงใจทำพังสองแบบ (~10 นาที)

ทำในแอป wongnok-blog-all-in-one (สถานะจากบท 120) · ถ้ายังมีโค้ดทดลองของบทนี้ค้างอยู่ สั่ง git restore app/views/posts/index.html.erb ก่อน · เปิด server ด้วย bin/dev

  1. ใน app/views/posts/index.html.erb แทรกต่อจาก <div class="w-full">:
      <%# กล่องสรุป (ภารกิจบท 122) %>
      <!-- กล่องสรุป เริ่มตรงนี้ -->
      <p>มีบทความทั้งหมด <%= @posts.size %> ชิ้น</p>
      <ol>
        <% @posts.first(5).each do |post| %>
          <li><%= post.title %></li>
        <% end %>
      </ol>
  2. เปิด http://localhost:3000/posts ในเบราว์เซอร์ · แล้วดูซอร์สของหน้า (Mac: ⌥ Option+⌘ Cmd+U · Windows: Ctrl+U) หรือ curl -s localhost:3000/posts · จดว่าคอมเมนต์อันไหนอยู่ในซอร์ส อันไหนไม่อยู่
  3. พังแบบที่ 1: ลบ = ออกจาก <%= post.title %> → รีเฟรช → ดูทั้งบนจอและในซอร์ส
  4. คืน = · พังแบบที่ 2: ลบบรรทัด <% end %> ทิ้ง → รีเฟรช
  5. เก็บกวาด: git restore app/views/posts/index.html.erb แล้ว git status ต้องไม่เหลือไฟล์ที่แก้

ก่อนกดเฉลย: ขั้น 3 จะได้ error ไหม? และขั้น 4 ต่างจากขั้น 3 ยังไง?

ขั้น 2 — ในซอร์สมี <!-- กล่องสรุป เริ่มตรงนี้ --> แต่ไม่มีบรรทัด "ภารกิจบท 122" · บนจอเห็นประโยค "มีบทความทั้งหมด 30 ชิ้น" กับชื่อบทความ 5 บรรทัด (ไม่มีเลขนำหน้า — อธิบายในขั้น 3) · ในซอร์สส่วนนี้หน้าตาแบบนี้ (ผลจริง):

  <p>มีบทความทั้งหมด 30 ชิ้น</p>
  <ol>
      <li>ต้มยำกุ้งน้ำข้น</li>
      <li>ก๋วยเตี๋ยวเรืออยุธยา</li>
      <li>ข้าวเหนียวมะม่วง</li>
      <li>หมูปิ้งข้าวเหนียว</li>
      <li>ชาไทยเย็น</li>
  </ol>

ขั้น 3 — ไม่มี error หน้าโหลดปกติ · บนจอรายการหายไปเฉย ๆ เหลือแค่บรรทัด "มีบทความทั้งหมด 30 ชิ้น" · แต่ในซอร์สมี <li></li> ว่าง ๆ ห้าอัน (ผลจริง) — กับดัก ① แบบในลูป · (ที่บนจอไม่เห็นแม้แต่เลข 1–5 เพราะ Tailwind ของแอปปิดเลขหน้ารายการไว้ และ <li> ที่ว่างก็ไม่มีความสูง — ผมเช็คใน Chrome จริง: รายการสูง 0 พิกเซล)

ขั้น 4 — พังทั้งหน้า ได้หน้า error สีแดงของ Rails หัวข้อ ActionView::SyntaxErrorInTemplate in PostsController#index · ต่างจากขั้น 3 เพราะลืม end = Ruby อ่านโค้ดไม่ออก (เปิดบล็อกแล้วไม่ปิด) · ส่วนลืม = = Ruby อ่านออกและรันได้ แค่ไม่พิมพ์

ถ้าเจออาการนี้ แปลว่า…

  • log เขียนว่า ActionView::SyntaxErrorInTemplate (Encountered a syntax error while rendering template located at: app/views/posts/index.html.erb) — มี do หรือ if ที่ไม่มี <% end %> คู่กัน · นับคู่ให้ครบ (เลขบรรทัดใน error มักชี้ไปท้ายไฟล์ ไม่ใช่จุดที่ลืม)
  • มีข้อความ [#<Post id: 1, … โผล่ใต้รายการ — คุณพิมพ์ <%= @posts.first(5).each … (มี = เกิน) · กลับไปดูกับดัก ②
  • ไม่เห็นกล่องสรุปเลย ทั้งที่เซฟแล้ว — แก้ผิดไฟล์ (เช่น show.html.erb) หรือเปิดผิดหน้า (/posts/1) · กล่องอยู่ที่ /posts เท่านั้น
  • ชื่อบทความไม่ตรงกับเฉลย — ไม่เป็นไร ถ้าข้อมูลของคุณไม่ได้มาจาก seed ของบท 120 · ที่ต้องตรงคือจำนวนและอาการ

ทำแล้วผลไม่ตรง ลองถาม Claude Code (บท 86) โดยแปะโค้ดที่แทรกกับผล curl ช่วงนั้นไปด้วย — ได้คำตอบตรงกว่าเล่าอาการเฉย ๆ

ทดสอบความเข้าใจ

หน้ารายการแบบ ① (React fetch ใน useEffect แล้ว setPosts) เปิดค้างไว้ · เพื่อนแก้ชื่อบทความจากอีกแท็บสำเร็จ แต่จอนี้ยังโชว์ชื่อเก่า · สาเหตุหลักคืออะไร?

ตัวเลือกที่ 2 ถูก — ข้อมูลมีสองชุด: ในฐานข้อมูล (เปลี่ยนแล้ว) กับใน useState ของแท็บนี้ (ยังเป็นของเก่า) · useEffect ที่มี [] fetch แค่ครั้งเดียว ไม่มีอะไรไปบอกให้ fetch ใหม่

ตัวเลือกที่ 1 ผิด เพราะเพื่อนแก้สำเร็จแล้ว ชื่อใหม่อยู่ในฐานข้อมูลแล้ว · ตัวเลือกที่ 3 ผิด เพราะไม่มีข้อมูลใหม่กำลัง "เดินทาง" มาเลย แท็บนี้ไม่ได้ขอใหม่ · ตัวเลือกที่ 4 ผิด เพราะ CSS เป็นเรื่องหน้าตา ไม่เกี่ยวกับข้อความชื่อบทความ

จุดที่ต้องจำ: มีสำเนาข้อมูลสองชุด = ต้องคอยทำให้ตรงกัน · ลืมจุดเดียว = บั๊ก "ทำไมหน้าจอไม่อัปเดต"

ใน index.html.erb เขียนว่า <p>บทความแรก: <% @posts.first.title %></p> · เปิดหน้าแล้วเกิดอะไรขึ้น?

ตัวเลือกที่ 3 ถูก — ผลจริงในกับดัก ①: ป้ายยังอยู่ ชื่อหาย · log มี Post Load … LIMIT 1 แปลว่า Ruby รันจริง แค่ไม่พิมพ์

ตัวเลือกที่ 1 ผิด เพราะโค้ดถูกไวยากรณ์ทุกตัว ไม่มีอะไรให้ error · ตัวเลือกที่ 2 ผิด เพราะ ERB ไม่เดาใจ ทำตามแท็กเป๊ะ ๆ · ตัวเลือกที่ 4 ผิด เพราะสิ่งที่หายคือแค่ผลของแท็ก ส่วน <p>บทความแรก: เป็น HTML ธรรมดานอกแท็ก จึงถูกส่งออกไป

จุดที่ต้องจำ: จะโชว์ค่า ต้องมี = · ไม่มี = = รันแล้วทิ้ง เงียบ ๆ ไม่มี error

แอป scaffold มีทั้ง index.html.erb และ index.json.jbuilder · เปิด /posts.json แล้ว Rails ใช้อะไรสร้างคำตอบ?

ตัวเลือกที่ 4 ถูก — log จริง: Processing by PostsController#index as JSON แล้ว Rendering posts/index.json.jbuilder · action เดียวกัน เลือกแม่แบบตาม format

ตัวเลือกที่ 1 ผิด เพราะ Rails ไม่แปลง HTML เป็น JSON · ไฟล์ .html.erb ใช้เฉพาะเมื่อขอ HTML · ตัวเลือกที่ 2 ผิด เพราะ _post.html.erb สร้าง HTML · ฝั่ง JSON ใช้ _post.json.jbuilder · ตัวเลือกที่ 3 ผิด เพราะ index ของ scaffold มีแค่ @posts = Post.all ไม่ได้เขียน JSON เอง

จุดที่ต้องจำ: ชื่อไฟล์ = action . ส่งออกเป็นอะไร . ประมวลผลด้วยอะไร · Rails เลือกไฟล์จาก format ที่ลูกค้าขอ

ข้อไหนอธิบายความต่างของ <%# โน้ต %> กับ <!-- โน้ต --> ในไฟล์ .html.erb ได้ถูกต้อง?

ตัวเลือกที่ 1 ถูก — ผลจริงจาก curl: บรรทัด <%# ไม่มีในซอร์สเลย · บรรทัด <!-- อยู่ในซอร์ส และ <%= %> ที่อยู่ข้างในถูกเติมค่าตามปกติ (กับดัก ③)

ตัวเลือกที่ 2 ผิด เพราะ ERB ไม่รู้จัก <!-- มันส่งออกไปเหมือน HTML ทั่วไป · ตัวเลือกที่ 3 สลับสองแบบกัน · ตัวเลือกที่ 4 ผิดครึ่งหนึ่ง: <!-- ถูกส่งและซ่อนจริง แต่ <%# ไม่เคยออกจาก server

จุดที่ต้องจำ: จะปิดโค้ด Ruby ใน view ชั่วคราว ใช้ <%# (เช่น <%#=) ไม่ใช่ <!-- -->

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของ Day 3)ต้องสนใจไหม
11<% if @post.published? %>บทความของเราไม่มีช่อง published · พิมพ์ตามใน show.html.erb ได้หน้า error undefined method 'published?' for an instance of Postสนใจ ถ้าจะพิมพ์ตาม — เป็นแค่ตัวอย่างรูปแบบ if
10"โชว์รายการโพสต์ 20 อัน"แอปของเรามี 30 บทความไม่ต้อง — ภาพเดียวกัน
10Next.js + Go = fetch ใน browser → state ใน clientNext.js render บน server ได้ด้วย (Server Component) · ภาพนี้คือแบบ client fetchไม่ต้อง — แค่รู้ว่าไม่ใช่ Next.js ทุกแบบ
11"ชื่อไฟล์บอกทุกอย่าง" ยกตัวอย่างแค่ index.html.erbscaffold สร้าง index.json.jbuilder ไว้ข้างกันด้วย · /posts.json ใช้ได้ทันทีไม่ต้อง — แต่ดีที่รู้ว่ามันอยู่ตรงนั้น

อ้างอิง