Cheat sheet · Rails · Day 3

Rails Day 3 สำหรับมือใหม่ — View · CSS · Hotwire แผ่นเดียวจบ

คู่กับ บทเรียน 120–135 · ทุกผลรันในหน้านี้ก็อปมาจากบทเรียน ซึ่งรันจริงบนเครื่องตรวจ ไม่ได้พิมพ์จากความจำ

หน้านี้ไม่ได้ให้อ่านรวดเดียว — เปิดไว้ข้างจอตอนลงมือทำ แล้วกวาดตาหาแถวที่ต้องการ
งงว่า “ของชิ้นนี้ในร้านคือใคร” → หัวข้อ 1 · ตรงที่ควรมีข้อมูลกลับว่างเปล่า → หัวข้อ 5 (ERB) · ใส่คลาสแล้วไม่มีสี → หัวข้อ 14
จะเลือก Turbo ตัวไหน → หัวข้อ 15 · ได้งานจาก AI มาแล้วจะตรวจยังไง → หัวข้อ 21 · จอขึ้นสีแดง / อาการแปลก ๆ → ข้ามไปหัวข้อ 22 (ตารางอาการ) ได้เลย

🧰 เวอร์ชันที่ใช้ตรวจ — Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · turbo-rails 2.0.23 · stimulus-rails 1.3.4 · tailwindcss-rails 4.7.0 (Tailwind 4.3.3) · daisyUI 5.7.50 · pagy 43.8.1 · bullet 8.3.1 (สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 — เรื่องในหน้านี้ใช้ได้เหมือนกัน)

📍 โค้ดเริ่มจากไหน — โปรเจกต์กลางของคอร์ส wongnok-blog-all-in-one branch day3-start + ข้อมูลตัวอย่างที่เราใส่ในบท 120 แล้วต่อยอดทีละบทจนจบ Lab บท 135

📦 ข้อมูลตัวอย่าง — หมวด 5 หมวด (อาหารไทย · ก๋วยเตี๋ยว · ของหวาน · อาหารริมทาง · เครื่องดื่ม) · บทความ 30 ชิ้น · ลิ้นชัก posts มีช่อง title · description · category_id (ห้ามว่างทั้งสามช่อง) · categories มี name · slug · comments (สร้างในบท 132) มี body · author_name · post_id · ยังไม่มีระบบผู้ใช้ (รอ Day 4)

ของในเครื่องคุณ เช่น เวลาในวงเล็บ (2.2ms) · เลขท้ายชื่อไฟล์ -8b441ae0 · รหัสบัตรคิว authenticity_token · วันที่ใน “1 day ago” จะไม่ตรงกับในหน้านี้ — เป็นเรื่องปกติ

สารบัญ

  1. ภาพร้านอาหาร — ส่วนที่ Day 3 เพิ่ม
  2. 3 ประโยคหลักของวัน
  3. เริ่มโปรเจกต์ day3-start
  4. prompts/ · CLAUDE.md · คำสั่ง Claude Code
  5. ERB 4 แบบ · กับดัก =
  6. layout · yield · content_for
  7. partial · render @posts · locals
  8. helper ที่ใช้บ่อย
  9. form_with — ฟอร์มใบเดียว new/edit
  10. XSS — ซองใส กับการแกะซอง
  11. N+1 ใน view · นับ query จาก log
  12. pagy — แบ่งหน้า (Lab 1)
  13. asset pipeline · fingerprint · importmap
  14. Tailwind v4 · daisyUI · ธีม · bin/dev
  15. Turbo 3 ชิ้น — เลือกตัวไหน
  16. Turbo Drive
  17. Turbo Frames
  18. Turbo Streams
  19. broadcast — เสียงตามสาย
  20. Stimulus
  21. checklist ตรวจงาน AI 3 Lab · กฎ 9–12
  22. ตารางอาการ → แปลว่า → แก้ยังไง
  23. ศัพท์ภาษาคน
  24. ถ้าเปิดสไลด์ดูประกอบ
  25. อ้างอิง

1 · ภาพร้านอาหาร — ส่วนที่ Day 3 เพิ่ม

Day 1 เดินจากหน้าร้านถึงคนจัดจาน · Day 2 ลงไปห้องเก็บของ · Day 3 กลับขึ้นมาที่หน้าร้านและการจัดจาน — จัดจานยังไงให้สวย (View) · ตกแต่งร้าน (CSS) · และเปลี่ยนจานบนโต๊ะโดยไม่ต้องรื้อโต๊ะ (Hotwire) · คอลัมน์ขวาสุดเทียบกับ React/Next.js ที่เรียนในฝั่ง FRONT (บท 69–89)

Day 3ในร้านอาหารหน้าตาในโค้ดใน React/Next.js คือบท
Rails ส่ง HTML (ไม่มีชั้น API) vs SPA ส่ง JSONครัวจัดจานเสร็จแล้วเสิร์ฟ vs ส่งวัตถุดิบดิบไปให้โต๊ะลูกค้าปรุงเอง (โต๊ะต้องมีเตาและสมุดจดของเอง)curl localhost:3000/posts ได้ HTML · /posts/1.json ได้ JSONGo ส่ง JSON → React fetch ใน useEffect แล้ววาดจอเอง (บท 73–74)122
state ใน client · บั๊ก syncสมุดจดสต็อกเล่มที่สองบนโต๊ะลูกค้า — ครัวเปลี่ยนแล้วแต่สมุดบนโต๊ะยังไม่รู้แอปเราไม่มี — server ถือข้อมูลชุดเดียวuseState ที่เก็บสำเนา posts · React Query/SWR = ผู้ช่วยคอยเช็คสมุดเล่มนั้น122 · 130
ERB <%= %> · <% %>แม่แบบจานที่มีช่องให้เติม · เติมแล้ววางบนจาน vs ทำในครัวเงียบ ๆ ไม่ขึ้นจาน<%= post.title %>{post.title} ใน JSX (JSX = JS ที่เขียน HTML ได้ · ERB = HTML ที่เขียน Ruby ได้)122
layout · yieldจานรองมาตรฐาน · ช่องกลางจานรองที่ใส่อาหารของแต่ละเมนูlayouts/application.html.erb · <%= yield %>app/layout.tsx (บท 83)123
content_forป้ายชื่อเมนูที่ส่งขึ้นไปติดหัวจานรอง<% content_for :title, @post.title %>export const metadata ใน app/layout.tsx (บท 83)123
partial · localsแม่พิมพ์ตักข้าว/ถ้วยน้ำจิ้มมาตรฐานใช้ซ้ำได้ · ยื่นของใส่มือแม่พิมพ์ชัด ๆ_post.html.erb · render "post", post: xcomponent + props <PostCard post={post} /> (บท 71)123
@ivar ใน partialแม่พิมพ์ที่ไปควานหาของบนถาดหน้าครัวเอง (ใช้ได้แค่ครัวเดียว)@post.title ใน _postตัวแปรกลางที่ทุกไฟล์มองเห็น — React บังคับส่งผ่าน props แทน123
helperเครื่องมือในลิ้นชักของคนจัดจานtruncate · time_ago_in_wordsฟังก์ชันเล็ก ๆ อย่าง formatDate() ใน lib/format.ts124
form_withใบสั่งที่พิมพ์จากระบบ ชื่อช่องตรงกับที่พนักงานต้อนรับรอเป๊ะform_with model: post → ช่อง post[title]ฟอร์มที่ต้องมี useState ทุกช่อง + onChange เอง124
escape (กัน XSS) · raw / html_safeคนจัดจานใส่ทุกอย่างที่ลูกค้าเขียนมาในซองใส ให้เห็นเป็นตัวหนังสือ ไม่ให้ “ทำงาน” · แกะซองออก<%= post.title %> (ซองใส) · raw · html_safe · <%== (แกะซอง){post.title} escape ให้เอง · dangerouslySetInnerHTML = แกะซอง125
asset · cache ของเบราว์เซอร์ของตกแต่งร้าน/เมนูเล่ม · ลูกค้าประจำจำเมนูเล่มเก่าไว้ ไม่หยิบเล่มใหม่ไฟล์ CSS · JS · รูปNext.js ซ่อนงานนี้ใน npm run build (บท 70)127
fingerprintingพิมพ์เลขรุ่นบนปกเมนูทุกครั้งที่แก้ (เมนู-9a8b7c) — เลขใหม่ = รู้ทันทีว่าต้องหยิบเล่มใหม่application-8b441ae0.css → application-ab636f88.cssทำให้ใน npm run build127
Propshaft · importmapพนักงานติดเลขรุ่นแล้ววางบนชั้น (ไม่ปรุงเพิ่ม) · สมุดรายชื่อ “ชื่อเล่น → อยู่ชั้นไหน”pin "@hotwired/turbo-rails", to: "turbo.min.js"import จาก node_modules แล้วให้ตัวมัดไฟล์รวมให้127
Tailwind สแกนคลาส · กับดักต่อ stringฝ่ายจัดซื้ออ่านรายการเมนูที่เขียนไว้บนกระดาษ ซื้อเฉพาะที่เห็น · เมนูที่สั่งด้วยปากตอนเสิร์ฟไม่ได้ซื้อมาtext-<%= color %>-700 → ไม่มีสีclassName={`text-${color}-600`} สีหาย (บท 72)128
daisyUI · ธีม · semantic colorชุดจานชามสำเร็จรูปเข้าชุดกัน · เปลี่ยนธีมทั้งร้านด้วยป้ายเดียว · “สีประจำร้าน” แทนชื่อสีตรง ๆbtn btn-primary · data-theme="dark" · bg-base-100design token ของ shadcn (บท 81) — กลไกเดียวกัน128 · 129
Hotwire · Turbo Driveครัวยังเป็นเจ้าของสมุดสต็อกเล่มเดียว · พนักงานเปลี่ยนเฉพาะจานบนโต๊ะเดิม ไม่ต้องรื้อโต๊ะจัดใหม่ (ผ้าปู ช้อนส้อม = <head> อยู่ที่เดิม)ไม่ต้องเขียนอะไร · มาจาก import "@hotwired/turbo-rails"<Link> ของ Next.js (บท 83) — แต่ Turbo ดัก <a> ธรรมดาทุกอัน130
Turbo Frame · idเปลี่ยนแค่ถ้วยใบเดียวบนโต๊ะ · ป้ายเลขถ้วยต้องตรงกันสองฝั่ง ไม่งั้นพนักงานหาไม่เจอturbo_frame_tag dom_id(post, :title)useState(false) สลับโหมดแก้ + fetch JSON + state ชุดที่สอง131
Turbo Streamใบสั่งงานหลายข้อให้พนักงานเสิร์ฟ: “เติมจานนี้ต่อท้าย · เปลี่ยนป้ายจำนวน · ล้างกระดาษสั่ง”turbo_stream.append "comments", @commentsetXxx(…) สามบรรทัดหลังได้ JSON132
broadcast · WebSocket · turbo_stream_fromเสียงตามสายประกาศถึงทุกโต๊ะที่สั่งเมนูนี้ · สายที่ต่อค้างไว้ · โต๊ะที่เปิดลำโพงรอฟังbroadcasts_to :post · <%= turbo_stream_from @post %>เปิด WebSocket เองใน useEffect + cleanup (บท 74)133
Stimulus · data-controller · connect/disconnectอุปกรณ์บนโต๊ะที่ทำงานเองไม่ต้องเรียกครัว (นาฬิกาจับเวลา) · ป้ายบนโต๊ะบอกว่าเป็นอุปกรณ์อะไร · ติดตั้งเมื่อจัดโต๊ะ เก็บกวาดเมื่อเก็บโต๊ะdata-controller="character-counter"useEffect + return () => … (cleanup) · data-action ≈ onClick134
prompts/ · 00-context.md · CLAUDE.mdแฟ้มคู่มือพนักงานใหม่ (AI) ที่แนบไปทุกกะ · กฎถาวรของร้าน · ป้ายหน้าห้องพนักงานที่ทุกคนต้องอ่านก่อนเข้างาน@prompts/00-context.md ใน CLAUDE.md— (Claude Code บท 86 · prompt 4 ส่วน บท 89)121

2 · 3 ประโยคหลักของวัน

สไลด์หน้า 8 · ชุดนี้คือ Day 3 ครึ่งเช้า (บล็อก A View · B CSS · C Hotwire) · บล็อก D rich text · E background job อยู่ใน Rails Day 4 · Part 1 (บท 136–145) · F cache ยังไม่ได้ทำเป็นบทเรียน

ประโยคภาษาคนในร้านอาหารเห็นกับมือที่
① เว็บ interactive ไม่จำเป็นต้องมี state สองชุดstate = ข้อมูลที่หน้าเว็บจำไว้ (แบบ useState) · ถ้า server เป็นเจ้าของข้อมูลชุดเดียว บั๊ก “จออีกหน้ายังเห็นของเก่า” ก็ไม่มีครัวถือสมุดสต็อกเล่มเดียว · ไม่มีสมุดเล่มที่สองบนโต๊ะลูกค้าให้ไม่ตรงกัน122 (HTML vs JSON) · 130–133 (ตัวเลขคอมเมนต์มาจาก server นับในตู้ทุกครั้ง)
② exactly-once ไม่มีอยู่จริง ออกแบบให้ทำซ้ำได้แทนไม่มีทางรับประกันว่างานจะถูกทำ “ครั้งเดียวพอดี” · ให้ทำซ้ำแล้วผลไม่เพี้ยนแทน (idempotent)สั่งซ้ำกี่รอบก็ได้จานเท่าเดิม ไม่เบิ้ล120 (seeds ที่ใช้ find_or_create_by! รันกี่รอบก็ 30 บทความ) · เต็ม ๆ ใน Part 2 (บล็อก E)
③ อย่า invalidate cache ให้ key เปลี่ยนแทนcache = สำเนาที่เก็บไว้ใช้ซ้ำ · อย่าไล่ลบสำเนาเก่า แต่เปลี่ยน “ชื่อ” ของของใหม่ คนจะหยิบของใหม่เองไม่ไล่เก็บเมนูเล่มเก่าคืน · พิมพ์เลขรุ่นใหม่บนปก127 (fingerprinting: แก้ CSS แล้วชื่อไฟล์เปลี่ยน) · เต็ม ๆ ใน Part 2 (บล็อก F)

3 · เริ่มโปรเจกต์ day3-start (บท 120)

Day 3 ไม่ใช้แอปของ Day 2 ต่อ — ทั้งห้องใช้โปรเจกต์กลางของอาจารย์ชุดเดียวกัน (ไม่มีตาราง users · บทความต้องมีหมวดทุกชิ้น · มี scaffold ดิบของ Post และ Category · ติดตั้ง Tailwind + daisyUI ไว้แล้ว) · พิมพ์ทีละบรรทัดตามตารางนี้

#พิมพ์ทำอะไร (ภาษาคน)ควรเห็น (ผลจริง)
1git clone https://github.com/chonlawit-odds/wongnok-blog-all-in-one.gitก็อป repo (โฟลเดอร์ที่ git จดประวัติ) พร้อมประวัติทั้งหมดลงเครื่องCloning into 'wongnok-blog-all-in-one'...
2cd wongnok-blog-all-in-one
git checkout day3-start
เข้าโฟลเดอร์ · สลับไฟล์ทั้งโฟลเดอร์ไปเป็นของสาย (branch) day3-startSwitched to a new branch 'day3-start'
3bundle installติดตั้ง gem ตาม Gemfile.lock (≈ npm install · go mod download)Bundle complete! 27 Gemfile dependencies, 128 gems now installed.
4docker compose up -dเปิด PostgreSQL 18 + MinIO ตาม compose.yaml (MinIO ยังไม่ได้ใช้ · ได้ใช้ใน Day 4 · Part 1)error ที่พูดถึง 5432 = มีโปรแกรมอื่นยึดประตูอยู่ → ดูหัวข้อ 22
5echo $DATABASE_URLเช็คว่าไม่มีค่าค้างจาก Day 1–2 (ถ้ามี Rails จะเชื่อค่านี้ก่อนไฟล์ตั้งค่า)บรรทัดว่าง · ไม่ว่าง → unset DATABASE_URL
6bin/rails db:prepareสร้างฐานข้อมูล + โหลดแบบลิ้นชักจาก db/schema.rbCreated database 'wongnok_blog_all_in_one_development'
Created database 'wongnok_blog_all_in_one_test'
7วางโค้ด seeds ของบท 120 ลง db/seeds.rb แล้ว bin/rails db:seedใส่ข้อมูลตัวอย่าง 5 หมวด 30 บทความ (seeds ของ day3-start มีแค่ comment)categories: 5 · posts: 30 · รันอีกรอบก็ได้เท่าเดิม
8git switch -c my-day3
git add db/seeds.rb
git commit -m "seeds: 5 หมวด 30 บทความ"
แตก branch ของเราเอง แล้วเก็บ seeds ไว้ (กันมันหายตอน git restore .)Switched to a new branch 'my-day3'
9bin/devเปิดร้าน: server + ตัวสร้าง CSS ของ Tailwind พร้อมกัน (ตาม Procfile.dev · หัวข้อ 14)* Listening on http://127.0.0.1:3000 · ≈ tailwindcss v4.3.3 · /*! 🌼 daisyUI 5.7.50 */
10เปิด http://localhost:3000/postsดูหน้ารายการ30 บทความหน้าตาดิบของ scaffold · ช่อง Category ยังเป็นเลข (1)

seeds ที่รันซ้ำได้ — ประโยค ② ฉบับเล็ก

ใน seeds เขียนรัน db:seed รอบสองภาษาคน
Post.find_or_create_by!(title: title) do |post| … endcategories: 5 · posts: 30 (เท่าเดิม)“หาตามชื่อเรื่อง เจอใช้ของเดิม ไม่เจอค่อยสร้าง” = idempotent
Post.create!(title: title) do |post| … end (แบบ Day 2)categories: 5 · posts: 60สร้างใหม่ทุกรอบ ข้อมูลเบิ้ล
หมวดก็ใช้ create!Validation failed: Slug has already been taken (ActiveRecord::RecordInvalid)slug ห้ามซ้ำ · กฎของ model กันไว้

คำสั่งที่ใช้บ่อยตลอดวัน

พิมพ์ได้ (ผลจริง)ใช้ตอนไหน
bin/rails console แล้ว Post.count=> 30 (prompt หน้าตา wongnok-blog-all-in-one(dev):001>)เช็คว่าข้อมูลมาครบ
Category.find_by(slug: "desserts").posts.count=> 6หมวดละ 6 ชิ้น
bin/rails db:resetDropped database … → Created database … → categories: 5 · posts: 30ตู้รก/ข้อมูลทดลองค้าง → ล้างแล้วเริ่มใหม่
git status --shortไม่มีอะไรขึ้น = ไม่มีของค้างต้นทุก Lab
git restore ชื่อไฟล์ · git restore appคืนไฟล์ที่ทดลองแก้ เป็นแบบ commit ล่าสุดจบการทดลอง · อย่าติดนิสัย git restore . — มันทิ้งทุกไฟล์ที่ยังไม่ commit โดยไม่ถาม

ตามไม่ทัน? branch สำเร็จรูปของอาจารย์ (day3-a-done · day3-b-done · day3-c-done …) ใช้ระบบแปลภาษา (i18n) และ seeds คนละชุด ไฟล์จะหน้าตาไม่ตรงกับบทเรียน · ทางที่ดีกว่า: กลับไปทำ Lab ก่อนหน้า (บท 126 · 129 · 131–134) ตามลำดับ · ทุกบทบอกไว้ต้นบทว่าเริ่มจากโค้ดสถานะไหน

4 · prompts/ · CLAUDE.md · คำสั่ง Claude Code (บท 121)

AI คือพนักงานใหม่ที่จำบทสนทนาเมื่อวานไม่ได้ · ของที่อยากให้มันรู้ทุกครั้งจึงต้องอยู่ในไฟล์ ไม่ใช่ในแชท · โฟลเดอร์ prompts/ = แฟ้มคู่มือที่โตขึ้นทีละบล็อก

ไฟล์เรื่องเกิดในบท
prompts/00-context.mdstack ของโปรเจกต์ + กฎถาวร (8 ข้อจาก Day 2 → 12 ข้อหลังบท 135)มีมากับ day3-start · อ่านในบท 121
prompts/01-views.mdบล็อก A · View126 (Lab Vibe 1)
prompts/02-styling.mdบล็อก B · CSS129 (Lab Vibe 2)
prompts/03-hotwire.mdบล็อก C · Hotwire135 (Lab Vibe 3)
04-content.md · 05-jobs.md · 06-caching.mdrich text · งานเบื้องหลัง · cacheRails Day 4 · Part 1 (04 · 05 = บท 136–145 · 06 ยังไม่ได้ทำ)
07-auth.md · 08-testing.md · FINAL.mdlogin · test · รวมทุกไฟล์Day 4 · ท้ายคอร์ส

ใบสั่งงานรายบล็อก = 4 ส่วนเสมอ (ต่อจาก prompt 4 ส่วน บท 89)

ส่วนภาษาคนตัวอย่างจากภารกิจบท 121ศัพท์อังกฤษ
## เป้าหมายอยากได้อะไร เล่าแบบที่ลูกค้าเล่า ไม่ต้องมีศัพท์หน้า /posts บอกชื่อหมวด เช่น “ของหวาน” แทนตัวเลขgoal
## ข้อกำหนดต้องทำด้วยวิธีไหน · ใช้ศัพท์ที่เพิ่งเรียนแก้ _post.html.erb ให้แสดง post.category.name · index ต้อง includes(:category)requirements
## ห้ามกับดักที่เราเพิ่งตกมาเอง เขียนไว้ให้ AI ไม่ตกซ้ำแก้ไฟล์อื่น · เปลี่ยนโครงตารางconstraints
## เกณฑ์ตรวจรับรายการ - [ ] ที่ตรวจได้จริงทีละข้อ ไม่ใช่ความรู้สึกlog ไม่มี SELECT ตาราง categories ซ้ำทีละบทความacceptance criteria

00-context.md — กฎ 8 ข้อที่ย้ายมาจาก Day 2

ครึ่งบนบอก stack: Propshaft + importmap (ห้าม Sprockets · ห้ามแนะนำ npm) · tailwindcss-rails (Tailwind v4) + daisyUI 5 ผ่าน @plugin · ห้ามสร้าง tailwind.config.js · ใช้ params.expect · ห้ามมี <script> ใน view · Minitest (ไม่ใช่ RSpec)

ข้อภาษาคนบท
1–2กฎใน model (validation) ต้องมีกฎที่ตัวตู้ (unique index · NOT NULL) คู่กันเสมอ เพราะกันคนละรู110 · 111
3ช่องที่ใช้ค้นหา และช่อง …_id ต้องมีป้ายแท็บ (index)109
4ลบแม่แล้วลูกไปไหน (dependent:) ต้องเลือกจากกฎธุรกิจ116
5วน each แล้วแตะของที่ผูกกัน ต้อง includes (N+1)114 · 125
6หน้ารายการห้ามดึงทุกแถว ต้อง limit หรือแบ่งหน้า113 · 126
7migration ห้ามปิดลิ้นชักนานจนแอปค้าง (expand-contract)109
8strong params คัดช่อง · ไม่ได้ตรวจว่าใครมีสิทธิ์ (Day 4)117
9–12เพิ่มในบท 135 → หัวข้อ 21135

CLAUDE.md · AGENTS.md · กฎ 3 ชั้น

ของภาษาคนผลจริง / ต้องรู้
CLAUDE.mdป้ายหน้าห้องพนักงาน — Claude Code อ่านเองตอนเริ่มทุก sessionใน repo เป็น symlink (ป้ายชี้ไฟล์อื่น): CLAUDE.md -> AGENTS.md · แก้กฎให้แก้ AGENTS.md
@prompts/00-context.md ใน CLAUDE.mdimport = ดึงไฟล์นั้นมาโหลดทุก session ด้วยอย่า import ไฟล์รายบล็อก (01–03) เข้า CLAUDE.md — โหลดทุก session ทั้งที่ทำเรื่องอื่น
~/.claude/CLAUDE.mdนิสัยของเราเองที่ใช้ทุกโปรเจกต์ไม่อยู่ใน repo ไหน · ไม่เข้า git
./CLAUDE.mdกฎของโปรเจกต์ที่ทั้งทีมทำตามcommit ไปพร้อมโค้ด
./CLAUDE.local.mdของส่วนตัวในโปรเจกต์นี้ (เช่น URL ของ DB ทดลอง)ต้องอยู่ใน .gitignore · เช็ค git check-ignore -v CLAUDE.local.md → .gitignore:40:CLAUDE.local.md

4 อย่างที่ใช้ทั้งวันใน Claude Code

พิมพ์ที่ไหนทำอะไร
@prompts/03-hotwire.md ทำตามไฟล์นี้ช่องแชทยื่นไฟล์ใส่บทสนทนาครั้งนี้ (ต่างจาก @ ใน CLAUDE.md ที่โหลดทุก session) · พิมพ์ @ แล้วกด Tab เลือกไฟล์
/context (รายการถูกพับ → /context all)ช่องแชทดูว่ามีอะไรวางบนโต๊ะของ AI บ้าง · หาหัวข้อ Memory files ว่ามี CLAUDE.md + 00-context.md ไหม · ไม่เห็น = เปิด claude ผิดโฟลเดอร์
เพิ่มกฎนี้ลง prompts/00-context.md: …ช่องแชทพิธีท้ายทุกบล็อก · ระบุชื่อไฟล์เสมอ — พูดแค่ “จำไว้นะ” อาจไปลง auto memory ที่เพื่อนในทีมไม่เห็น
/memoryช่องแชทเปิดรายการไฟล์คำสั่ง เลือกแล้วแก้ในเอดิเตอร์ · เปิด/ปิด auto memory
/compactช่องแชทย่อบทสนทนาเพื่อคืนพื้นที่ · CLAUDE.md โหลดกลับ แต่คำสั่งที่พิมพ์ในแชทอาจหาย
/initช่องแชทให้มันสแกน repo แล้วร่าง CLAUDE.md (ใช้กับโปรเจกต์ที่ทำงาน · กฎธุรกิจยังต้องเติมเอง)
claude --versionterminalดูรุ่น Claude Code (เครื่องตรวจได้ 2.1.318 (Claude Code))

คำแนะนำ ไม่ใช่การบังคับ: 00-context.md มีกฎข้อ 6 “index ต้องมี limit” แต่ PostsController#index ของ day3-start ยังเป็น @posts = Post.all — กฎในไฟล์ไม่ได้แก้โค้ดให้เอง · CLAUDE.md = ป้าย “กรุณาล้างมือ” · test/CI/gem ที่ดัก (strong_migrations · bullet) = ประตูที่สแกนมือก่อนเปิด · เทียบ TypeScript: comment “ห้ามส่ง null” คือคำแนะนำ · type ที่ tsc ฟ้องคือการบังคับ

5 · ERB 4 แบบ · กับดัก = (บท 122)

ERB = แม่แบบจาน: HTML ธรรมดาที่เจาะช่องให้ Ruby เติม · Rails เติมให้เสร็จบน server แล้วส่ง HTML ล้วน ๆ ไปเบราว์เซอร์ · ชื่อไฟล์อ่านจากขวาไปซ้าย: index.html.erb = “ใช้ erb แปลง ได้ html” · index.json.jbuilder = “ใช้ jbuilder แปลง ได้ json” (scaffold สร้างคู่กันไว้ — /posts ได้ 53,937 ไบต์ · /posts.json ได้ 13,566 ไบต์)

แบบหน้าตาRuby ทำงานไหมผลไปถึงเบราว์เซอร์ไหมในร้านอาหารใน JSX
① พิมพ์ผล<%= @post.title %>✅✅ ผลถูกวางลงตรงนั้น (escape ให้ด้วย · หัวข้อ 10)เติมแล้ววางบนจาน{post.title}
② รันอย่างเดียว<% if … %> … <% end %>✅❌ ตัวมันไม่ไป (HTML ที่มันคุมไปได้)ทำในครัวเงียบ ๆ{cond && …}
③ วนลูป<% @posts.each do |post| %> … <% end %>✅❌ ตัวมันไม่ไป · ของข้างในถูกพิมพ์ซ้ำทุกรอบตักทีละจานจนครบ{posts.map(p => …)}
④ คอมเมนต์<%# โน้ต %>❌❌ หายตั้งแต่ในครัวโน้ตของเชฟ ลูกค้าไม่เห็น{/* … */}

กับดัก 3 แบบ (ลองจริงในบท 122)

เขียนได้ (ผลจริง)แก้
<p>บทความแรก: <% @posts.first.title %></p> (ลืม =)<p>บทความแรก: </p> — หน้าว่าง ไม่มี error · log ยังมี Post Load (คำนวณแล้วแต่ไม่วาง)ไล่ดูทุกแท็กที่ควรโชว์ของว่าขึ้นต้น <%= ครบไหม
<%= @posts.first(3).each do |post| %> (= เกินที่บรรทัด each)รายการขึ้นครบ แต่มีข้อความแปลก [#<Post id: 1, title: "ต้มยำกุ้งน้ำข้น", …>, …] โผล่ท้ายบรรทัด each · if · end ใช้ <% · (<%= if ได้หน้า error ActionView::SyntaxErrorInTemplate)
<!-- <p>บทความแรก: <%= @posts.first.title %></p> --><!-- <p>บทความแรก: ต้มยำกุ้งน้ำข้น</p> --> — Ruby ยังรัน · ใครเปิดซอร์สก็อ่านได้ปิด Ruby ด้วย <%#= … %>

ข้อยกเว้นเดียวของ “บรรทัดที่มี do ใช้ <%”: helper ที่สร้าง HTML ครอบของข้างใน เช่น <%= form_with … do |form| %> · <%= turbo_frame_tag … do %> — ตัวพวกนี้ต้องมี =

6 · layout · yield · content_for (บท 123)

layout = จานรองมาตรฐานที่ทุกหน้าวางลงไป (app/views/layouts/application.html.erb) · บรรทัดที่ต้องรู้จัก:

บรรทัดใน layoutภาษาคนบท
<title><%= content_for(:title) || "Wongnok Blog All In One" %></title>ชื่อแท็บ: หน้าไหนฝากชื่อมาก็ใช้ ไม่ฝากใช้ชื่อแอป123
<%= csrf_meta_tags %>บัตรคิวกันใบสั่งปลอม (CSRF)ทวน 103
<%= yield :head %>ช่องใน <head> ที่แต่ละหน้าฝากของมาใส่ได้123
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>ดึง CSS ทุกไฟล์ของแอป (application + tailwind) · ป้าย reload ของ Turbo127 · 130
<%= javascript_importmap_tags %>ดึง JavaScript ผ่าน importmap127
<%= yield %>ช่องกลางจานรอง — เนื้อหาของแต่ละหน้าวางตรงนี้ทวน 103
<%= render "shared/navbar" %>แถบเมนูทุกหน้า — เพิ่มใน Lab บท 129 · ก่อนหน้านั้นไฟล์ยังไม่มี129
อยากทำเขียน (ในไฟล์ไหน)ได้ (ผลจริง)
ให้แท็บเบราว์เซอร์ขึ้นชื่อบทความ<% content_for :title, @post.title %> (บรรทัดแรกของ show.html.erb)<title>ต้มยำกุ้งน้ำข้น</title>
(พลาด) เผลอมีสองบรรทัดcontent_for :title, "Showing post" + content_for :title, @post.title<title>Showing postต้มยำกุ้งน้ำข้น</title> — มันต่อท้าย ไม่เขียนทับ
ฝากของยาวหลายบรรทัดไปใส่ <head><% content_for :head do %> <meta name="description" content="<%= @post.description %>"> <% end %>แท็ก meta ขึ้นใน <head> ตรงที่ yield :head รออยู่
ใช้จานรองคนละแบบกับบาง controllerlayout "admin" ใน controller · ไฟล์ app/views/layouts/admin.html.erb(ตัวอย่างจากสไลด์ · โปรเจกต์ยังไม่มี)
รู้ว่า HTML ก้อนไหนมาจากไฟล์ไหนcurl -s localhost:3000/posts/1 | grep -E "BEGIN|END"<!-- BEGIN app/views/posts/_post.html.erb … ป้ายที่ Rails แปะให้ตอน development

7 · partial · render @posts · locals · strict locals (บท 123)

partial = แม่พิมพ์ใช้ซ้ำ · ไฟล์ชื่อขึ้นต้น _ (_post.html.erb) แต่ตอนเรียกไม่ต้องใส่ _ · เทียบ React: partial ≈ component · locals ≈ props · strict locals ≈ type ของ props

เรียกแบบนี้Rails ทำอะไรผลจริง / ข้อควรรู้
<%= render "post", post: post %>แบบเต็ม: ใช้ _post.html.erb ยื่น post ใส่มือlog: Rendered posts/_post.html.erb ทีละบรรทัด 30 ครั้ง
<%= render @post %> · <%= render post %>ของชิ้นเดียว · เดาชื่อไฟล์จาก class (Post → posts/_post)≈ <PostCard post={post} />
<%= render @posts %>เดาให้ 4 อย่าง: ① ใช้ posts/_post ② วนเอง ③ ยื่นเข้าชื่อ post ④ รายการว่าง = ไม่วางอะไร คืน nillog: Rendered collection of posts/_post.html.erb [30 times] · ≈ {posts.map(post => <PostCard key={post.id} post={post} />)}
<%= render(@posts) || "ไม่มีบทความ" %>รายการว่าง → ใช้ข้อความแทนไม่มีบทความ ✅
<%= render @posts || "ไม่มีบทความ" %> (ไม่มีวงเล็บ)Ruby อ่านเป็น render(@posts || "…") · รายการว่างไม่ใช่ nil จึงไม่ได้ข้อความว่างเปล่า ❌ — ใส่วงเล็บเสมอ
<%= post_counter %> ใน partialเลขลำดับในรายการ เริ่มที่ 0ได้ 0 … 29 เฉพาะตอน render เป็นรายการ · หน้า show (render @post) ได้ 500 undefined local variable or method 'post_counter'

locals กับ @ตัวแปร — ยื่นใส่มือ หรือให้ไปควานหาบนถาดเอง

ใน _post.html.erb เขียนหน้า show /posts/1หน้า index /postsเพราะ
<%= post.title %> (local)✅ 200✅ 200ใครเรียกก็ยื่นของใส่มือมา
<%= @post.title %> (@ivar)✅ 200❌ 500 undefined method 'title' for nilaction index วางบนถาดแค่ @posts (มี s) · @post ไม่มีใครวาง = nil

กฎ (สไลด์หน้า 14): ไฟล์หน้าหลัก (index · show · new · edit) ใช้ @ ได้ · partial ห้ามแตะ @ รับของผ่าน local เท่านั้น · ตรวจด้วย grep -n "@" app/views/posts/_*.html.erb ต้องไม่ขึ้นอะไร · scaffold ทำถูกอยู่แล้ว: <%= render "form", post: @post %>

strict locals — ป้ายติดแม่พิมพ์ “ต้องยื่นอะไรบ้าง”

บรรทัดแรกของ partial: <%# locals: (post:, show_category: false) %> = ต้องยื่น post · show_category ไม่ยื่นก็ได้ ใช้ false · (หน้าตาเหมือนคอมเมนต์ แต่ Rails อ่าน · มีตั้งแต่ Rails 7.1)

เรียกแบบนี้ไม่มีบรรทัด locals:มี <%# locals: (post:) %>
render "post" (ลืมยื่น post)500 undefined local variable or method 'post' — ต้องเดาเองว่าลืมส่ง500 missing local: :post for app/views/posts/_post.html.erb — บอกตรง ๆ
render "post", post: @post, color: "red" (ยื่นเกิน)200 · color ถูกทิ้งเงียบ ๆ500 unknown local: :color for …
render @posts แล้วใช้ post_counterได้ 0, 1, 2…undefined local variable or method 'post_counter' → ประกาศเพิ่ม (post:, post_counter: nil)

8 · helper ที่ใช้บ่อย (บท 124)

helper = เครื่องมือในลิ้นชักของคนจัดจาน · method สำเร็จรูปที่เรียกใน view ได้เลย · ลองก่อนใน console ต้องมี helper. นำหน้า: truncate(…) เฉย ๆ ได้ undefined method 'truncate' for main (NoMethodError) · helper.truncate("ต้มยำกุ้ง", length: 5) ได้ "ต้..."

กลุ่มhelperทำอะไรผลจริง
ลิงก์ / ฟอร์มlink_to "แก้", "/posts/1/edit"ลิงก์ (บท 103) · ส่ง record ได้: link_to post.title, post<a href="/posts/1/edit">แก้</a>
button_to "ลบ", post, method: :deleteฟอร์มเล็กปุ่มเดียว สำหรับงานที่เปลี่ยนข้อมูลปุ่ม Destroy ของ scaffold
form_with model: postฟอร์มทั้งใบหัวข้อ 9
ข้อความtruncate(text, length: 40, separator: " ")ตัดให้สั้น + ... · separator: " " = ตัดที่ช่องว่าง"ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด..."
pluralize(3, "comment")ตัวเลข + คำนามอังกฤษเติม s ให้ถูก"3 comments" · 1 ได้ "1 comment"
simple_format("บรรทัดแรก\nบรรทัดสอง")ขึ้นบรรทัดใหม่ → HTML<p>บรรทัดแรก
<br />บรรทัดสอง</p>
highlight("ต้มยำกุ้งน้ำข้น", "กุ้ง")ไฮไลต์คำค้นต้มยำ<mark>กุ้ง</mark>น้ำข้น
ตัวเลข / เวลาtime_ago_in_words(post.created_at)ห่างจากตอนนี้นานแค่ไหน (อังกฤษ)"about 1 month"
l(post.created_at, format: :long)จัดวันที่ตามภาษาของแอป (l = localize) · ยังอังกฤษเพราะไม่มีคำแปลไทย"October 08, 2026 03:35"
number_to_human(12500)ย่อเลขใหญ่"12.5 Thousand"
number_to_currency(99, unit: "฿")หน่วยเงิน + ทศนิยม 2 ตำแหน่ง"฿99.00" · ไม่ใส่ unit ได้ "$99.00"
อื่น ๆtag.div("อร่อย", class: "card")สร้างแท็ก HTML จาก Ruby<div class="card">อร่อย</div>
image_tag("/icon.png", alt: "โลโก้")แท็กรูป<img alt="โลโก้" src="/icon.png" />
dom_id(post) · dom_id(post, :title)สร้าง id ของ HTML จาก record"post_30" · "title_post_30" (หัวข้อ 17)
เรื่องผลจริงทำยังไง
ตัดข้อความไทยด้วย description[0..19]"ต้มยำกุ้งแบบน้ำข้นใส" — ตัดกลางคำ ไม่มี ... · วรรณยุกต์หลุดได้ใช้ truncate(…, separator: " ") · ไม่ใส่ separator ก็ยังตัดกลางคำ (…ของหวานที...)
เขียน helper เอง reading_time ใน app/helpers/posts_helper.rb (module PostsHelper)ภาษาไทยได้ "1 นาที" แทบทุกบทความ เพราะ split นับคำจากช่องว่าง ("อร่อยมาก" * 1000 → 1 คำ)ใช้ฝึกเขียน helper ได้ อย่าเชื่อตัวเลข · ส่ง nil ได้ "0 นาที" เพราะ .to_s
เวลาในวงเล็บช้ากว่านาฬิกาไทย 7 ชั่วโมงTime.zone.name = "UTC"Lab 126 ตั้ง config.time_zone = "Asia/Bangkok" ใน config/application.rb → +07:00

9 · form_with — ฟอร์มใบเดียว ใช้ทั้ง new และ edit (บท 124)

<%= form_with(model: post) do |form| %> ถามแฟ้มว่า persisted? (อยู่ในตู้แล้วหรือยัง) แล้วเลือกให้เอง · form คือผู้ช่วยวาดช่อง (form builder) ที่ตั้งชื่อช่องตาม model

ส่วนของ HTML ที่ได้หน้า new (Post.new)หน้า edit (Post.find(1))มาจากไหน
action/posts/posts/1ยังไม่อยู่ในตู้ = สร้างใหม่ · อยู่แล้ว = แก้แผ่นเลข 1
ช่องซ่อน _methodไม่มี (= POST)<input type="hidden" name="_method" value="patch" />ฟอร์ม HTML ส่งได้แค่ GET/POST · ป้ายนี้บอก Rails ว่า “ถือเป็น PATCH”
value="…" ในช่องว่างvalue="ต้มยำกุ้งน้ำข้น"form builder อ่าน post.title มาเติมเอง
ปุ่ม form.submitCreate PostUpdate Postตั้งชื่อตามสถานะของแฟ้ม

ของฟรี 3 อย่าง

ของฟรีใน HTMLถ้าไม่มี / เขียนช่องเอง (ผลจริง)
① บัตรคิวกัน CSRF<input type="hidden" name="authenticity_token" value="…" /> (ค่าเปลี่ยนทุกครั้ง)ฟอร์มที่ไม่แนบบัตร Rails ตอบ 422 ไม่รับ
② ชื่อช่องตรงกับที่ controller รอname="post[title]" คู่กับ params.expect(post: [ :title, :description, :category_id ])ทุกช่องชื่อเปล่า name="title" → 400 Bad Request ParameterMissing (param is missing … : post) · ลืมแค่ช่องเดียว → 422 Title can't be blank ทั้งที่พิมพ์แล้ว
③ ค่าเดิมเติมกลับเมื่อบันทึกไม่ผ่านหลัง 422 ช่องหัวข้อยังมี value="ลาบหมู" · กล่องแดง Category must exist · Description can't be blank— (controller render :new ด้วย @post ตัวเดิมที่ถือค่าและ errors อยู่)

ช่องหมวด: เลือกจากรายการแทนพิมพ์เลข

<%= form.collection_select :category_id, Category.all, :id, :name, { prompt: "เลือกหมวด" }, class: "…" %>

ชิ้นแปลว่า
:category_idเก็บค่าที่เลือกในช่องไหน (ได้ชื่อ post[category_id])
Category.all · :id · :nameรายการตัวเลือก · ค่าที่ส่งจริง · ข้อความที่คนเห็น → <option value="3">ของหวาน
{ prompt: "เลือกหมวด" }ตัวเลือกแรกที่ไม่มีค่า · ไม่มี = เบราว์เซอร์เลือก “อาหารไทย” ให้เอง ลืมเลือกได้หมวดผิดเงียบ ๆ · หน้า edit จะไม่มีตัวนี้ และติด selected ที่หมวดเดิม
class: "…"⚠️ ต้องมีปีกกา { } ก่อน class: เสมอ (ไม่ใช้ prompt ก็ใส่ {} ว่าง) · ไม่มีถุงแรก Rails เอา class ไปผิดถุง ได้ <select> ไม่มี class และไม่มี error

เทียบ React: ฟอร์มแก้ไขต้องมี useState หนึ่งตัวต่อช่อง ดึงค่าเดิมมาใส่ ผูก onChange และวน categories.map(c => …) สร้าง <option> เอง · form_with ทำให้หมดจาก model ตัวเดียว

10 · XSS — ซองใส กับการแกะซอง (บท 125)

XSS = คนร้ายพิมพ์โค้ด JavaScript ลงช่องข้อมูล แล้วโค้ดไปรันในเบราว์เซอร์ของทุกคนที่เปิดหน้านั้น (ทวนบท 103) · ลองด้วยบทความชื่อ <script>alert('XSS!')</script> ที่สร้างใน console:

ใน viewHTML ที่ส่งออกไป (ผลจริงจาก curl)บนจอscript รันไหม
<%= post.title %>&lt;script&gt;alert(&#39;XSS!&#39;)&lt;/script&gt;ข้อความเต็ม ๆ✅ ไม่รัน (ซองใส)
<%= raw post.title %><script>alert('XSS!')</script>ว่าง + กล่องเด้ง❌ รัน
<%= post.title.html_safe %><script>alert('XSS!')</script>ว่าง + กล่องเด้ง❌ รัน — ชื่อหลอกตา มันแค่แปะป้าย “ปลอดภัยแล้ว” Rails เชื่อทันที
<%= sanitize post.title %>alert('XSS!')ข้อความที่ถอดแท็กแล้ว✅ ไม่รัน
<%== … %>(= raw แบบย่อ)—❌ แกะซอง — คู่มือของ pagy ใช้ท่านี้ (หัวข้อ 12)
sanitize ใน consoleได้ (ผลจริง)ภาษาคน
helper.sanitize('<b>อร่อยมาก</b>')"<b>อร่อยมาก</b>"แท็กที่อยู่ในรายชื่ออนุญาต (allowlist) ผ่าน
helper.sanitize('<script>alert(1)</script>สวัสดี')"alert(1)สวัสดี"ถอดแท็กที่ไม่อนุญาตทิ้ง
helper.sanitize('<a href="/posts" onclick="alert(1)">ร้านอื่น</a>')"<a href=\"/posts\">ร้านอื่น</a>"คงลิงก์ ตัด attribute อันตราย

กฎหนึ่งบรรทัด: ห้าม raw / html_safe / <%== กับข้อมูลที่ผู้ใช้พิมพ์ · จำเป็นต้องให้มี HTML → sanitize (อย่าแก้ allowlist เอง — เติม script ลงไปแล้ว script ก็ผ่าน) · เทียบ React: {post.title} escape ให้เองเหมือน <%= %> · อยากวาง HTML ดิบต้องพิมพ์ dangerouslySetInnerHTML ที่ตั้งชื่อให้ดูอันตรายโดยตั้งใจ

ค้นคำอันตรายด้วยจับได้หลุด
grep -rn "raw\|html_safe" app/views (สไลด์หน้า 22)app/views/posts/_post.html.erb:4: <%= raw post.title %><%== · และ html_safe ที่ซ่อนใน app/helpers
grep -rn "raw\|html_safe" appทั้ง appเกินมา 9 บรรทัดจาก daisyui.mjs (คำว่า drawer)
grep -rnw "raw\|html_safe" app (-w = คำเต็ม)เฉพาะบรรทัดที่ใช่—
grep -rn "raw\|html_safe\|<%==" app/viewsรวม <%==— (ใช้ใน checklist Lab 1)

11 · N+1 ใน view · นับ query จาก log (บท 125)

N+1 ใน view หายากกว่าบท 114 เพราะลูปอยู่ไฟล์หนึ่ง บรรทัดที่ไปตู้อยู่ในแม่พิมพ์อีกไฟล์ · diff ที่แก้แค่ post.category_id → post.category.name ใน _post.html.erb ดูไม่ออกเลยว่าช้า · ต้องอ่าน log

ใน log เห็นอ่านว่า
Completed 200 OK in 57ms (Views: 49.3ms | ActiveRecord: 6.2ms (32 queries, 25 cached) | GC: 17.5ms)บรรทัดที่ต้องหาเสมอ · ไปตู้ 32 ครั้ง · 25 ครั้งตอบจากความจำ
Category Load … WHERE "categories"."id" = 1 LIMIT 1 ตามด้วย ↳ app/views/posts/_post.html.erb:13หยิบหมวดทีละแผ่น · บรรทัด ↳ บอกว่าโค้ดบรรทัดไหนสั่ง
CACHE Category Load …ถามซ้ำของเดิมใน request เดียวกัน Rails ตอบจากความจำ ไม่ได้ไปตู้ · แต่ยังเป็นสัญญาณ N+1
Category Load … WHERE "categories"."id" IN (1, 2, 3, 4, 5)ใบเบิกทีเดียวทั้งตะกร้า (ของ includes)
Post Exists? … SELECT 1 AS one … LIMIT 1มาจาก if @posts.any? ของ scaffold (“มีสักชิ้นไหม”)
Post Count … SELECT COUNT(*)pagy นับว่ามีกี่ชิ้น จะได้รู้ว่ามีกี่หน้า (หัวข้อ 12)
หน้าก่อนแก้แก้ที่ controllerหลังแก้
/posts (scaffold 30 ชิ้น)(32 queries, 25 cached)@posts = Post.includes(:category)(3 queries, 0 cached)
/categories (ภารกิจ: โชว์ชื่อบทความในหมวด)(7 queries, 0 cached)@categories = Category.includes(:posts)(3 queries, 0 cached)
/posts หลัง Lab 1 (หน้าละ 10)(12 queries, 5 cached)pagy(Post.includes(:category).recent, limit: 10)(3 queries, 0 cached) — ไม่โตตามจำนวนข้อมูล

bullet — ผู้ช่วยที่ตะโกนบอกเอง (เปิดไว้แล้วใน config/environments/development.rb)

bullet บอกแปลว่าทำยังไง
USE eager loading detected Post => [:category] Add to your query: .includes([:category])หยิบหมวดทีละแผ่น = N+1เติม includes(:category) ที่ controller (ไม่ใช่ที่ view) · อ่าน Call stack บรรทัดบนสุดที่เป็นไฟล์ของเรา
AVOID eager loading detected Post => [:category] Remove from your query: .includes([:category])หยิบหมวดมาล่วงหน้าแต่ไม่ได้ใช้ใช้ชื่อหมวดใน view แล้วคำเตือนหายเอง · หรือเอา includes ออก

ดู log สด ๆ: tail -f log/development.log (กด Ctrl+C เพื่อหยุด) · bullet ยังแปะกล่อง Bullet Warnings มุมหน้าเว็บด้วย (Bullet.add_footer = true)

12 · pagy — แบ่งหน้า (Lab Vibe 1 · บท 126)

ขั้นเขียน / พิมพ์ภาษาคน
ติดตั้งbundle add pagy --version "~> 43.5" → Gemfile ได้ gem "pagy", "~> 43.5"⚠️ แล้วปิด bin/dev (Ctrl+C) เปิดใหม่ — Rails โหลด gem แค่ตอนเปิดร้าน
app/controllers/application_controller.rbinclude Pagy::Methodให้ทุก controller เรียก pagy(…) ได้
PostsController#index@pagy, @posts = pagy(Post.includes(:category).recent, limit: 10)หยิบทีละ 10 · ใหม่ไปเก่า (recent = scope เรียงตาม created_at) · พร้อมหมวด · ได้ของกลับมาสองชิ้น
index.html.erb<%= render(@posts) || tag.p("ไม่พบบทความ", …) %>
<%= render "shared/pagination", pagy: @pagy %>
รายการ + แถบเปลี่ยนหน้า (partial ที่เราเขียนเอง)
shared/_pagination.html.erb<%# locals: (pagy:) -%> · pagy.previous · pagy.page_url(:previous) · pagy.page / pagy.last · pagy.next · pagy.page_url(:next)มีหน้าก่อนไหม · URL ของหน้านั้น · หน้าปัจจุบัน / หน้าสุดท้าย → หน้า 1 / 3 · ลิงก์ /posts?page=2
กับดักผลจริง
ไม่ restart หลัง bundle addหน้าแดง NameError (uninitialized constant ApplicationController::Pagy)
ก็อป tutorial เก่า include Pagy::Backendหน้าแดง NameError (uninitialized constant Pagy::Backend) — รุ่น 43 ไม่มีแล้ว
ใช้ items: 10 (ชื่อเก่าของ limit:)ไม่ error · เงียบ ๆ ได้หน้าละ 20 ชิ้น “หน้า 1 / 2”
<%= @pagy.series_nav %> (แถบสำเร็จรูปของ pagy)โชว์เป็นตัวหนังสือ <nav class="pagy series-nav"… บนหน้าเว็บ · คู่มือ pagy ใช้ <%== = แกะซอง → ขัดข้อห้าม · เราจึงเขียนแถบเองด้วย link_to
URLบทความแถบเปลี่ยนหน้าquery
/posts10หน้า 1 / 3 · ถัดไป → ?page=23
/posts?page=310หน้า 3 / 3 · ถัดไปเป็นสีเทา3
/posts?page=0 · ?page=abc10หน้า 1 / 3 (pagy ถือเป็นหน้า 1 ไม่ error)3
/posts?page=990 → ไม่พบบทความหน้า 99 / 3 · ก่อนหน้าชี้ ?page=32

Lab 1 ได้อีก 3 อย่าง: _post.html.erb สองโหมดด้วย <%# locals: (post:, full: false) -%> (render @post, full: true ในหน้า show) · breadcrumb บทความทั้งหมด › ชื่อหมวด › ชื่อบทความ ด้วย link_to + route helper (ห้ามเขียน path เป็น string "/posts/#{id}") · ข้อความ notice ไทยใน controller (notice: "สร้างบทความแล้ว") · checklist ตรวจรับอยู่หัวข้อ 21

13 · asset pipeline · fingerprint · importmap (บท 127)

asset = ไฟล์ที่ไม่ใช่ HTML ที่หน้าเว็บต้องใช้ (CSS · JavaScript · รูป · ฟอนต์) — ของตกแต่งร้านที่ไม่ได้ทำใหม่ทุกจาน · เบราว์เซอร์เก็บสำเนาไว้ (cache) เปิดหน้าที่สองจึงโหลด CSS 0 ไบต์ · ปัญหาคือแก้ CSS แล้วลูกค้าประจำยังถือเมนูเล่มเก่า

ไฟล์ / โฟลเดอร์คืออะไร
app/assets/stylesheets/application.cssCSS ที่เราเขียนเอง (ตอนแรกมีแต่คอมเมนต์)
app/assets/tailwind/application.cssจุดตั้งค่า Tailwind + daisyUI (หัวข้อ 14)
app/assets/builds/tailwind.cssCSS ที่ Tailwind สร้างให้ · ไม่เก็บใน git · ห้ามแก้มือ (ถูกสร้างทับทุกรอบ)
app/javascript/application.js + controllers/ ของ Stimulus (หัวข้อ 20)
config/importmap.rbสมุดรายชื่อ JavaScript (pin)
public/assets/ของที่ assets:precompile เตรียมไว้ (แบบขึ้นเครื่องจริง) · ไม่เก็บใน git
ปัญหาภาษาคนวิธีแก้ตอนนี้ยังสำคัญไหม
1 · cache ตามชื่อแก้ CSS แล้ว deploy แต่ผู้ใช้ยังเห็นของเก่าเป็นวันfingerprintingสำคัญเสมอ
2 · ไฟล์เยอะ = ช้าสมัยก่อนโหลดพร้อมกันได้ไม่กี่ไฟล์bundling (มัดรวม)น้อยลงมาก
3 · ไฟล์ใหญ่เกินจำเป็นช่องว่าง คอมเมนต์ที่เบราว์เซอร์ไม่ใช้minify · compressน้อยลงมาก

fingerprinting — เปลี่ยนเนื้อใน ชื่อเปลี่ยนตาม (ผลจริง)

ทำอะไรชื่อไฟล์ใน <link>ภาษาคน
ก่อนแก้/assets/application-8b441ae0.cssเลข 8 ตัวท้ายชื่อ = digest คำนวณจากเนื้อไฟล์
เพิ่ม body { background: #fffbeb; }/assets/application-ab636f88.cssเนื้อเปลี่ยน → ชื่อเปลี่ยน → เบราว์เซอร์ต้องโหลดใหม่ (822 ไบต์) · ไฟล์ tailwind ชื่อเดิม โหลด 0
curl -sI …/application-8b441ae0.cssHTTP/1.1 404 Not Foundชื่อเก่าไม่มีใครเรียกถึงอีก
curl -sI …/application-ab636f88.csscache-control: public, max-age=31536000, immutableเก็บได้ 1 ปี · ชื่อนี้ไม่มีวันเปลี่ยนเนื้อ ไม่ต้องถามซ้ำ
git restore กลับเป็นสีเดิมapplication-8b441ae0.css อีกครั้งเนื้อเดิม = ชื่อเดิมเป๊ะ
Sprockets (ของเดิม)Propshaft (Rails 8 ค่าเริ่มต้น)
ทำอะไรfingerprint + มัดรวม + บีบ + แปลงภาษา (เช่น Sass)fingerprint + เสิร์ฟไฟล์ เท่านั้น
ภาษาของตัวเองมี เช่น //= requireไม่มี · CSS/JS ธรรมดา
ข้อเสียทำเยอะ จึงช้าและหาบั๊กยากอยากมัดรวม/แปลงภาษา ต้องใช้เครื่องมืออื่น

importmap — JavaScript โดยไม่มี node_modules

ls node_modules package.json → No such file or directory ทั้งคู่ · application.js มีแค่ import "@hotwired/turbo-rails" + import "controllers" · เบราว์เซอร์รู้ว่าชื่อพวกนี้คือไฟล์ไหนจากตาราง <script type="importmap"> ที่ Rails เขียนลงหน้าให้

ใน config/importmap.rbภาษาคนใน bin/importmap json (ผลจริง)
pin "application"ชื่อ application = ไฟล์ของเรา"application": "/assets/application-bfcdf840.js"
pin "@hotwired/turbo-rails", to: "turbo.min.js"ชื่อเล่นนี้ → ไฟล์ที่มากับ gem turbo-rails"/assets/turbo.min-9fd88cd5.js"
pin_all_from "app/javascript/controllers", under: "controllers"ทุกไฟล์ในโฟลเดอร์ controllers ลงสมุดเอง"controllers/hello_controller": … · ไฟล์ใหม่ของเราโผล่เองหลังสร้าง
importmap ได้อะไรเสียอะไร
ไม่มี node_modules · package.json · ขั้น build ของ JSไม่มี tree-shaking (ตัดส่วนที่ไม่ใช้ทิ้ง) — ได้ library ทั้งก้อน
แก้ไฟล์ JS แล้ว refresh เห็นเลยเขียน TypeScript / JSX ตรง ๆ ไม่ได้
ไม่ต้องกังวล library ที่ npm ลากมาเป็นร้อยตัวlibrary ที่ไม่ได้เขียนแบบ import/export ใช้ไม่ได้

ทางเลือกทั้งหมด

gemดูแลทำอะไรเหมาะกับ
importmap-rails ✅JSไม่มีขั้น build · เบราว์เซอร์ import เองแอปที่ JS ไม่เยอะ · ค่าเริ่มต้น Rails 8
jsbundling-railsJSเรียก esbuild · rollup · Bun · Webpack มามัด JSต้องใช้ TypeScript หรือ library ที่ต้องมัดรวม
cssbundling-railsCSSเรียกเครื่องมือ CSS ผ่าน yarn (ต้องมี Node)framework CSS ที่ต้อง build
tailwindcss-rails ✅CSSTailwind ฉบับไม่ต้องมี Nodeใช้ Tailwind อย่างเดียว
propshaft ✅ทั้งคู่fingerprint + เสิร์ฟฐานของทุกตัว
vite_railsทั้งคู่ยก Vite มาทั้งชุดทีมที่คุ้น Vite และ JS เยอะมาก

✅ = ที่โปรเจกต์ของคอร์สใช้ · อยากเพิ่ม library: bin/importmap pin <ชื่อ> จะดาวน์โหลดไฟล์จากเน็ตมาไว้ vendor/javascript/ — ใช้เมื่อตั้งใจเพิ่มจริง และตรวจไฟล์ก่อน commit

14 · Tailwind v4 · daisyUI · ธีม · bin/dev (บท 128 · 129)

ไฟล์ตั้งค่าอยู่ที่ app/assets/tailwind/application.css (ไม่ใช่ tailwind.config.js) · โปรแกรม Tailwind อ่านไฟล์โปรเจกต์หาชื่อคลาส แล้วสร้าง app/assets/builds/tailwind.css

บรรทัดภาษาคน
@import "tailwindcss";เอา Tailwind ทั้งชุดมาใช้ (บรรทัดเดียวกับ globals.css ของ Next.js บท 72)
@source not "./daisyui{,*}.mjs";อย่าสแกนไฟล์ daisyUI หาชื่อคลาส
@plugin "./daisyui.mjs" { themes: light --default, dark --prefersdark; }เสียบ daisyUI (ไฟล์ที่ก็อปมาเก็บในโปรเจกต์ = vendor) · เปิดธีม light เป็นค่าเริ่มต้น · dark เมื่อเครื่องตั้งโหมดมืด
@theme { --font-sans: "Noto Sans Thai", …; --color-brand: #ff3d7f; }ตั้งค่าของร้าน · --color-brand → ใช้ text-brand · bg-brand ได้ (ลงไฟล์เฉพาะตอนมีคนใช้คลาสนั้น)
ลองผลจริงเพราะ
สร้าง tailwind.config.js แบบ v3 (สี mint) แล้ว buildgrep -c 'text-mint' → 0 · ไม่มี errorv4 ไม่อ่านไฟล์นั้นเลย · ไวยากรณ์ v3 ในโปรเจกต์ v4 มักไม่ error แค่ไม่มีผล
เขียน @plugin "daisyui"; ตามสไลด์Error: Can't resolve 'daisyui' in '…/app/assets/tailwind' · bin/rails aborted!repo ไม่มี Node ไม่มี package ชื่อ daisyui · ต้องเป็น "./daisyui.mjs"
เพิ่ม synthwave ในรายการ themesgrep -o '\[data-theme=[a-z]*\]' … | sort | uniq -c → dark 1 · light 2 · synthwave 1ธีมที่ไม่เปิด ใส่ data-theme ไปก็ได้หน้าตาธีมเริ่มต้น

daisyUI ชิ้นที่ใช้ในแอป (ชุดจานชามสำเร็จรูป)

<button class="btn btn-primary"> แทนคลาส Tailwind ยาวทั้งแถว (px-4 py-2 rounded-lg bg-blue-600 text-white …) · ผสมกับ Tailwind ได้ (btn btn-primary w-full mt-4) · modifier (btn-sm · -primary) ต้องใช้คู่กับตัวหลักเสมอ

คลาสได้อะไรใช้ที่ (Lab 2 · 3)
btn · btn-primary · btn-ghost · btn-error · btn-sm · btn-disabledปุ่ม · สีหลักของธีม · ปุ่มโปร่ง · สีอันตราย · เล็ก · ปิดใช้ทุกหน้า · ปุ่มลบ · แถบเปลี่ยนหน้า
card · card-body · card-title · card-actionsการ์ดมุมโค้ง · ไส้การ์ดที่มีระยะขอบ · หัวข้อในการ์ด · แถวปุ่ม_post · _comment · ฟอร์มคอมเมนต์
badge badge-primaryป้ายเล็กมุมมนชื่อหมวด
navbarแถบเมนูบนสุดshared/_navbar.html.erb
alert alert-success · alert-errorกล่องแจ้งเตือนnotice · กล่อง error ของฟอร์ม
breadcrumbs (ครอบ <ul><li>)แถบ “หน้าแรก › หมวด › หน้านี้” ใส่ลูกศรให้เองหน้า show
join · join-itemต่อปุ่มหลายอันเป็นแถบเดียวแถบเปลี่ยนหน้า
input · textarea · select + input-error ฯลฯช่องฟอร์ม · ขอบแดงเมื่อมี error_form
link link-hoverลิงก์ขีดเส้นใต้ตอนชี้หัวข้อบทความ
text-base-content/70สีตัวหนังสือของธีม จาง 70% (/70 = ความทึบ)บรรทัดเวลา · คำโปรย

สีตามหน้าที่ (semantic color) กับธีม — วัดจริงใน Chrome

oklch(…) = วิธีเขียนสีอีกแบบ เหมือน #ff3d7f · ไม่ต้องอ่านออก ดูแค่ว่าตัวเลขเปลี่ยนหรือไม่เปลี่ยนเมื่อสลับธีม

data-theme บน <html>พื้นปุ่ม btn-primaryพื้นปุ่ม bg-blue-600พื้นหลังหน้า
ไม่ใส่ / "light"oklch(0.45 0.24 277.023)oklch(0.546 0.245 262.881)oklch(1 0 0) ขาว
"dark"oklch(0.58 0.233 277.117) ← เปลี่ยนตามoklch(0.546 0.245 262.881) ← ค้างoklch(0.2533 0.016 252.42) เกือบดำ

primary · base-100 · base-content · error = “สีประจำร้าน” ค่าจริงเปลี่ยนตามธีม (กลไกเดียวกับ design token บท 81) · blue-600 · gray-700 = ชื่อสีตายตัว · ก่อนแก้ Lab 2 หน้า /posts มี 35 จุดที่สลับธีมแล้วค้าง · ปุ่ม btn-error สีเท่ากันทั้ง light/dark (oklch(0.71 0.194 13.428)) — ไม่ใช่บั๊ก สองธีมนี้ตั้งสีอันตรายเท่ากัน

★ กับดักชื่อคลาสที่ต่อ string

เขียนgrep -c 'text-pink-700' ใน tailwind.cssสีบนจอ
❌ <span class="font-medium text-<%= color %>-700">0 (blue ก็ 0)ไม่มีสี — Tailwind อ่านไฟล์ (static analysis) เห็นแค่ text-<%= color %>-700 ไม่ใช่ชื่อคลาส
❌ "bg-#{tone}-100"0ไม่มีสี — แบบเดียวกัน
✅ color_class = slug == "desserts" ? "text-pink-700" : "text-blue-700"
<span class="font-medium <%= color_class %>">
1 (blue 1)มีสี — ชื่อเต็มอยู่บนกระดาษให้ฝ่ายจัดซื้อเห็น

หาตัวต่อ string ทั้งโปรเจกต์: git grep -n -E '[a-z]-(<%=|#\{)' app/views (⚠️ git grep ค้นเฉพาะไฟล์ที่ git รู้จัก — ไฟล์ใหม่ที่ AI เพิ่งสร้างแต่ยังไม่ git add จะไม่ถูกค้นเลย · เติม --untracked เป็น git grep --untracked -n -E … ถึงจะเจอ · ลองแล้วกับ partial ใหม่ที่มี text-<%= color %>-700: แบบไม่เติมไม่มี output แบบเติมเจอ) · “บางทีดูเหมือนใช้ได้” = มีไฟล์อื่นเขียนชื่อเต็มนั้นไว้บังเอิญ (สไลด์ใช้ red/green ซึ่ง scaffold มี text-red-500 text-green-500 อยู่แล้ว) หรือ watch จำคลาสเก่า (ตารางถัดไป) · เทียบ React: className={`text-${color}-600`} สีหาย (บท 72) — เรื่องเดียวกันทุกอย่าง

bin/dev — เปิดร้านพร้อมฝ่ายจัดซื้อประจำกะ

Procfile.dev มีสองบรรทัด: web: bin/rails server · css: bin/rails tailwindcss:watch · bin/dev เปิดทั้งสองพร้อมกัน (log ขึ้นต้น web.1 | · css.1 |) · เปิดแค่ bin/rails server → คลาสใหม่ที่เพิ่งพิมพ์ (text-orange-600 · card · input-lg) ไม่มีใน CSS (grep -c = 0) หน้าไม่เปลี่ยน

ขั้นโค้ดใน _post.html.erbgrep -c pink / blueสีบนจอ
1ต่อ string (เปิด bin/dev ตอนนี้)0 / 0ไม่มีสี
2เปลี่ยนเป็นชื่อเต็ม1 / 1มีสี
3เปลี่ยนกลับเป็นต่อ string (เช่น AI “จัดโค้ดให้สั้นลง”)1 / 1ยังมีสี — watch เพิ่มคลาสได้แต่ไม่ลบจนกว่าจะเริ่มใหม่
4เหมือนขั้น 3 · สั่ง assets:precompile (แบบขึ้นเครื่องจริง)0 / 0production ไม่มีสี
คำสั่งใช้ตอนไหน
bin/devทำงานปกติทุกวัน — server + Tailwind watch
bin/rails tailwindcss:buildสร้าง CSS ใหม่หนึ่งรอบแล้วจบ · ใช้ตอนพิสูจน์ด้วย grep หรือหลัง clobber
bin/rails assets:precompileซ้อมเตรียมไฟล์แบบขึ้นเครื่องจริง → public/assets/ · ทิ้งท้าย Warning: You are precompiling assets in development… · ลองแล้วต้อง clobber ทุกครั้ง
bin/rails assets:clobberลบ public/assets (ทำงานเงียบ ๆ) · ลบ app/assets/builds/tailwind.css ไปด้วย → ต้อง build ใหม่หรือเปิด bin/dev ใหม่
bin/importmap jsonดูว่า JS ชื่ออะไรชี้ไปไฟล์ไหน (อ่านอย่างเดียว)

ปุ่มสลับธีม (Lab 2 · บท 129)

ไฟล์บรรทัดสำคัญภาษาคน
app/views/shared/_navbar.html.erb (ใหม่ · <%# locals: () -%>)<header class="navbar bg-base-100 shadow-sm"> · ลิงก์ บทความ / หมวด · ปุ่ม data-controller="theme" data-action="theme#toggle" 🌓แถบบนทุกหน้า · ปุ่มประกาศเองว่าใช้ JS ชื่อ theme ถ้าโดนคลิกให้เรียก toggle
app/views/layouts/application.html.erb<body class="min-h-screen bg-base-200"> · <%= render "shared/navbar" %>พื้นหลังสีตามธีม + วางแถบเมนู
app/javascript/controllers/theme_controller.js (ใหม่)connect(): อ่าน localStorage.getItem("theme") แล้วใส่กลับ · toggle(): สลับ light ↔ dark ที่ document.documentElement.dataset.theme แล้ว localStorage.setItem("theme", next)localStorage = ที่เก็บข้อความเล็ก ๆ ในเบราว์เซอร์ อยู่ข้าม refresh · ไม่เคยเลือก → ดู prefers-color-scheme ของเครื่อง

15 · Turbo 3 ชิ้น — เลือกตัวไหน (บท 130–134)

Hotwire = “ส่ง HTML ที่จัดเสร็จแล้วผ่านเน็ต แทนการส่ง JSON” (HTML over the wire) · ชุดเครื่องมือคือ Turbo + Stimulus · คำถามเดียวเบื้องหลัง: อยากให้จอเปลี่ยน โดยไม่มีสมุดเล่มที่สอง ได้ไหม

ฝั่ง SPA (React ที่ fetch JSON)ฝั่ง Hotwire
สำเนาข้อมูลในเบราว์เซอร์ยอมให้มี แล้วหาเครื่องมือช่วยดูแล (Redux · Zustand · React Query · SWR)ไม่มี — ข้อมูลอยู่ที่ server ที่เดียว
อยากให้จอเปลี่ยนแก้สำเนาใน state → React วาดจอใหม่ขอ HTML ใหม่จาก server → แทนเฉพาะส่วนที่ต้องเปลี่ยน
ของที่วิ่งผ่านเน็ตJSON (ข้อมูลดิบ)HTML (จัดหน้าเสร็จแล้ว)
โค้ดจัดหน้าตาในเบราว์เซอร์ (JSX)ใน view ของ Rails (ERB) ที่เดียว
วัดจริง (บทความชิ้นที่ 1)ไม่บีบบีบด้วย gzipเทียบกับ JSON
JSON /posts/1.json735403—
เฉพาะการ์ด <article>943514~1.3 เท่า
HTML ทั้งหน้า /posts/16,0102,279~8 เท่า (บีบแล้ว ~5.7 เท่า)
ชิ้นเปลี่ยนแค่ไหนในร้านอาหารเราต้องเขียนในแอปเรา
Turbo Drive (130)ทั้ง <body> เมื่อกดลิงก์/ส่งฟอร์มเปลี่ยนจานบนโต๊ะเดิม ไม่รื้อโต๊ะไม่ต้องเขียนเลยทุกลิงก์ ไม่กระพริบขาว
Turbo Frames (131)เฉพาะกล่องที่ครอบไว้เปลี่ยนแค่ถ้วยใบเดียวturbo_frame_tag สองฝั่ง id ตรงกันแก้หัวข้อบทความในหน้าเดิม
Turbo Streams (132)หลายจุดพร้อมกันใบสั่งงานหลายข้อไฟล์ .turbo_stream.erbส่งคอมเมนต์ → รายการ + ตัวนับ + ฟอร์ม
broadcast (133)Stream ที่ server ส่งไปทุกแท็บเองเสียงตามสายbroadcasts_to + turbo_stream_fromคอมเมนต์เด้งทุกแท็บ
Stimulus (134)ไม่ถาม server เลยอุปกรณ์บนโต๊ะไฟล์ *_controller.js + ป้าย data-ตัวนับอักษร · ปุ่มสลับธีม

เลือกเครื่องมือจากงาน

อยากได้ต้องถาม server?ใช้
กดลิงก์แล้วไม่กระพริบใช่Drive (มีอยู่แล้ว)
แก้ของในที่ของมัน (inline edit) · แท็บ · modal ที่เนื้อหามาจาก server · ส่วนช้าให้โหลดทีหลัง · ฟอร์มค้นหาที่อัปเดตผลในกล่องใช่Frame
ส่งฟอร์มแล้วต้องเปลี่ยนหลายจุด · เพิ่ม/ลบของในรายการ · เปลี่ยนของนอกกล่อง (ตัวเลขบน navbar)ใช่Stream
ของใหม่เด้งขึ้นทุกแท็บที่เปิดอยู่ (แชท · คอมเมนต์ · แจ้งเตือน · dashboard ทุกไม่กี่วินาที)ใช่ (server ส่งเอง)broadcast
นับอักษร · เปิด/ปิดเมนู · คัดลอกข้อความ · สลับธีมไม่Stimulus
ลากวาง · canvas · แผนที่/กราฟที่ซูมลาก · cursor คนอื่นแบบ Figma · ใช้ตอนไม่มีเน็ตถี่เกิน / ไม่มีครัวTurbo ไม่เหมาะ — ถ้าทั้งแอปเป็นแบบนี้ React เหมาะกว่า

16 · Turbo Drive (บท 130)

ทำงานอยู่แล้วตั้งแต่ Lab แรก จากบรรทัด import "@hotwired/turbo-rails" ใน app/javascript/application.js · ไม่ต้องเขียนอะไร · ต่างจาก <Link> ของ Next.js ตรงที่ Turbo ดัก <a> ธรรมดาทุกอัน (link_to ที่เขียนมาตลอดได้ผลทันที)

ขั้น (กดลิงก์ /posts → /posts/30)เห็นจริงใน Chrome
① ดักการกด ไม่ให้เบราว์เซอร์เปลี่ยนหน้าเองตัวแปร JS window.marker ยังอยู่
② fetch "/posts/30" เบื้องหลังrequest ใหม่มีแค่ /posts/30 · header x-turbo-request-id · server ตอบ HTML ทั้งหน้า (log เหมือนปกติ Processing by PostsController#show as HTML)
③ เอา <body> ใหม่มาแทน <body> เดิมnavbar · main · ทุกอย่างใน body เป็นชิ้นใหม่
④ เทียบ <head> ใส่เพิ่มเฉพาะที่ต่างtitle เปลี่ยน · ไม่โหลด CSS/JS ซ้ำ · ยกเว้นไฟล์ที่ติด data-turbo-track="reload" เปลี่ยนชื่อ → โหลดเต็มหน้าหนึ่งครั้ง
⑤ เปลี่ยน URL ด้วย History API (คำสั่งของเบราว์เซอร์ที่เปลี่ยน URL + เพิ่มประวัติให้ปุ่ม back โดยไม่โหลดหน้า)แถบที่อยู่เป็น /posts/30 · history.length +1
⑥ เก็บภาพหน้าเดิมไว้ใน cache ของแท็บกด back: หน้าเก่าโผล่ทันที ไม่มี request ใหม่ใน log
ป้าย (ใส่ผ่าน link_to …, data: { … })HTML ที่ได้ผลจริง
data: { turbo: false }data-turbo="false"ลิงก์นี้โหลดหน้าใหม่ทั้งใบ (window.marker หาย) · เหมาะกับลิงก์ดาวน์โหลดไฟล์ (เช่น PDF)
data: { turbo_action: "replace" }data-turbo-action="replace"เปลี่ยนหน้าแต่ไม่เพิ่มประวัติ (history.length ไม่เพิ่ม) · กด back ข้ามหน้า 1–3 ไปเลย
data: { turbo_confirm: "ลบบทความนี้จริงไหม?" } บน button_todata-turbo-confirmถามยืนยันก่อนส่ง (ใช้กับปุ่มลบ · ไม่ต้องเขียน JS เอง)

★ ผลข้างเคียง: DOMContentLoaded ยิงครั้งเดียว

JS แบบนี้ผลจริงเพราะ
document.addEventListener("DOMContentLoaded", () => { … })ยิงครั้งแรกครั้งเดียว · กดลิงก์ไปหน้าอื่นแล้วกลับมา ไม่ยิงอีก · listener ที่ผูกกับ main เงียบตั้งแต่หน้าที่สองTurbo ไม่เคยทิ้งหน้าเดิม หน้าเว็บ “เกิด” ครั้งเดียว
document.addEventListener("turbo:load", () => { … })ยิงตอนเปิดครั้งแรกและทุกครั้งที่ Turbo เปลี่ยนหน้า (รวมกด back)event ของ Turbo เอง
ใน turbo:load ผูก document.addEventListener("keydown", …)เปลี่ยน 2 หน้าแล้วกด k ครั้งเดียว → listener ตัวที่ 1 · 2 · 3 ขึ้นสามบรรทัดdocument อยู่ถาวร listener สะสม (duplicate listener) · ผูกกับ element ใน body ไม่สะสม เพราะ body เป็นชิ้นใหม่ทุกหน้า

ทางออกจริง: กลับทิศ — แทนที่ JS เดินไปหา element ตอนหน้าโหลด ให้ element ประกาศเองด้วยป้ายใน HTML แล้วมีผู้ดูแลติดตั้ง/เก็บกวาดให้เมื่อ element โผล่/หาย = Stimulus (หัวข้อ 20)

17 · Turbo Frames — เปลี่ยนแค่ถ้วยใบเดียว (บท 131)

กล่อง <turbo-frame id="…"> = ลิงก์และฟอร์มข้างในโหลดผลมาใส่แค่ในกล่อง · ใช้ทำ “แก้หัวข้อบทความในหน้าเดิม” (inline edit) · กฎข้อเดียว: id ต้องตรงกันสองฝั่ง (หน้าที่มีกล่อง กับคำตอบจาก server)

ชิ้นไฟล์ · โค้ดภาษาคน
1 · ครอบหัวข้อapp/views/posts/_post.html.erb (โหมด full):
<%= turbo_frame_tag dom_id(post, :title) do %> หัวข้อ + link_to "แก้ไขหัวข้อ", edit_post_path(post) <% end %>
ได้ <turbo-frame id="title_post_30"> · ใช้ dom_id(post, :title) เพราะ post_30 ถูก <article> ใช้แล้ว
2 · ป้ายที่เบราว์เซอร์แนบมาheader turbo-frame: title_post_30บอก server ว่า “ขอแค่กล่องนี้”
3 · controller เลือกสูตรจานเล็กbefore_action :set_turbo_frame_variant, only: %i[ edit update ]
request.variant = :turbo_frame if turbo_frame_request?
มีป้าย → ใช้ไฟล์ที่ชื่อมี +turbo_frame · ไม่มีป้าย → หน้าเต็มเดิม
4 · ฟอร์มเล็กในกล่องapp/views/posts/edit.html+turbo_frame.erb (ใหม่):
<%= turbo_frame_tag dom_id(@post, :title) do %> form_with model: @post ช่อง title + ปุ่มบันทึก/ยกเลิก
log: Rendering posts/edit.html+turbo_frame.erb within layouts/turbo_rails/frame (layout บาง ๆ ของ turbo-rails)
5 · บันทึกPATCH → Redirected to http://localhost:3000/posts/30 · 303 See Other · ว่าง → 422 แล้วฟอร์มเล็กพร้อม errorTurbo หยิบเฉพาะกล่องเดียวกันจากหน้าใหม่มาวาง · URL ไม่เปลี่ยน
อยากได้เขียนภาษาคน
ลิงก์ในกล่องแต่ต้องไปทั้งหน้าlink_to "เปิดฟอร์มเต็ม", edit_post_path(@post), data: { turbo_frame: "_top" }_top = ทั้งหน้า · ไม่ใส่ = โดนขังในกล่อง
ลิงก์ไปเปลี่ยนกล่องอื่นdata: { turbo_frame: "sidebar" }ใส่ id ของกล่องปลายทาง (ต้องมีทั้งในหน้าและในคำตอบ)
ส่วนที่ช้า ให้โหลดทีหลัง<turbo-frame id="comments" src="…" loading="lazy"> + ข้อความ “กำลังโหลด…”lazy frame · กล่องไปขอ src เองตอนเลื่อนมาเห็น ไม่ต้องเขียน JS
เปิด /posts/30/edit ตรง ๆ(ไม่ต้องเขียนอะไร)ได้หน้าเต็ม edit.html.erb เหมือนเดิม = progressive enhancement
id ไม่ตรง (เช่นฝั่งหนึ่ง "post_#{@post.id}_title")อาการจริง
บนจอหัวข้อหาย กลายเป็นคำว่า Content missing
Console (DevTools)The response (200) did not contain the expected <turbo-frame id="title_post_30"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.
ตรวจด้วยcurl -s -H "Turbo-Frame: title_post_30" localhost:3000/posts/30/edit | grep turbo-frame ต้องได้ <turbo-frame id="title_post_30"> · และ grep -rn "turbo_frame_tag" app/views ทุกบรรทัดต้องเป็น dom_id(…)

18 · Turbo Streams — ใบสั่งงานหลายข้อ (บท 132)

ส่งคอมเมนต์ครั้งเดียว ต้องเปลี่ยนสามกล่อง: เติมคอมเมนต์ต่อท้ายรายการ · ตัวเลข “ความคิดเห็น (2)” · ล้างฟอร์ม — Frame ทำได้กล่องเดียว จึงใช้ Stream

เตรียม Comment (ทวน Day 2)

ทำอะไรโค้ด / คำสั่งข้อควรรู้
สร้าง modelbin/rails generate model Comment body:text author_name:string post:referencesgenerator ใส่ null: false ให้แค่บรรทัด references · เติมเองให้ body กับ author_name ก่อน db:migrate (validation + NOT NULL มาเป็นคู่ · บท 111)
กฎใน modelbelongs_to :post · validates :author_name, presence: true, length: { maximum: 50 } · validates :body, presence: true, length: { maximum: 1000 } · ใน Post: has_many :comments, dependent: :destroy—
route ซ้อนresources :posts do resources :comments, only: :create endpost_comments POST /posts/:post_id/comments(.:format) comments#create
หน้า showPostsController#show: @comments = @post.comments.order(:created_at) · @comment = Comment.new
view: <span id="comment_count"> · <div id="comments"><%= render @comments %></div> · <%= render "comments/form", post: @post, comment: @comment %>
สามกล่องมี id ให้ใบสั่งอ้างถึง · ฟอร์มมี id: "comment_form" · _comment มี id="<%= dom_id comment %>"

controller + ไฟล์ใบสั่งงาน

ไฟล์บรรทัดสำคัญภาษาคน
app/controllers/comments_controller.rb@comment = @post.comments.build(comment_params) · respond_to do |format|
สำเร็จ: format.turbo_stream · format.html { redirect_to @post, notice: "…" }
ไม่ผ่าน: render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { post: @post, comment: @comment }), status: :unprocessable_content
เบราว์เซอร์ขอแบบ stream ได้ (header Accept: text/vnd.turbo-stream.html, text/html, …) → log Processing by CommentsController#create as TURBO_STREAM · html = ทางสำรอง
app/views/comments/create.turbo_stream.erb<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_count", @post.comments.count %>
<%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { post: @post, comment: Comment.new } %>
ใบสั่งสามข้อ: ต่อท้ายรายการ · เปลี่ยนตัวเลข (นับในตู้ใหม่) · แทนด้วยฟอร์มว่าง

ที่ server ส่งกลับจริง: <turbo-stream action="append" target="comments"><template><article id="comment_2" …>…</template></turbo-stream> · <turbo-stream action="update" target="comment_count"><template>2</template></turbo-stream> · … (<template> = ห่อของที่จะเอาไปวาง เบราว์เซอร์ไม่โชว์จนกว่า Turbo หยิบไปใช้) · เทียบ React: ได้ JSON แล้วต้อง setXxx(…) สามบรรทัดเอง และเบราว์เซอร์ต้องจำข้อมูลชุดที่สอง

คำสั่งทั้ง 8

คำสั่งทำอะไรกับกล่อง targetในร้านอาหารใช้ตอนไหน
appendเติมของใหม่ต่อท้ายข้างในกล่อง · ถ้า id ซ้ำของที่มีอยู่ เอาอันเก่าออกแล้วใส่ใหม่วางจานใหม่ปลายแถวคอมเมนต์ใหม่ (เก่า→ใหม่)
prependเติมไว้บนสุดข้างในกล่องวางจานใหม่หัวแถวแจ้งเตือน
replaceเอากล่องทั้งกล่องออก (รวมตัวมันเองและ id) วางของใหม่แทนยกถ้วยทั้งใบออก วางใบใหม่ฟอร์ม · การ์ดที่ถูกแก้ทั้งใบ
updateเปลี่ยนเฉพาะของข้างใน กล่องเดิมอยู่ที่เดิมเทของในถ้วยออก เติมใหม่ ถ้วยเดิมตัวเลข · ข้อความสั้น ๆ
removeลบกล่องทิ้ง (ไม่ต้องมี <template>)เก็บจานออกจากโต๊ะลบคอมเมนต์
before · afterแทรกของใหม่ข้างนอกกล่อง ก่อน/หลังมันวางจานข้าง ๆ ถ้วยข้อความแจ้งเหนือฟอร์ม
refreshให้หน้าไปขอหน้าเดิมจาก server ใหม่ (ไม่ต้องมี target)จัดโต๊ะใหม่ตามสมุดของครัวเปลี่ยนหลายจุดจนเขียนทีละข้อไม่ไหว
พลาดแบบนี้อาการจริงเพราะ
turbo_stream.replace "comment_count", … (แทน update)ส่งครั้งแรกเลขขึ้น 3 · ครั้งที่สองรายการ 4 ชิ้นแต่ตัวเลขค้าง 3 · Console ว่างreplace เอา <span id="comment_count"> ทิ้งไปทั้งตัว · รอบถัดไปหา target ไม่เจอ Turbo ข้ามเงียบ ๆ
ลืมไฟล์ create.turbo_stream.erbNo template found for CommentsController#create, rendering head :no_content · Completed 204 No Content · หน้าไม่ขยับ (บันทึกแล้วจริง)204 = สำเร็จแต่ไม่มีเนื้อหา
ส่งฟอร์มว่างฟอร์มถูกแทนด้วยฟอร์มที่มี ส่งไม่ได้ มี 2 จุดที่ต้องแก้ · Author name can't be blank · Body can't be blank ที่เดิมทางไม่ผ่านของ controller · ต้องมี status: :unprocessable_content (422) เพื่อบอกความจริงให้ log/test/Lab
turbo_stream.prepend "flash" (ตามสไลด์)ไม่มีอะไรขึ้น ไม่มี errorหน้าเราไม่มี id="flash" · target ไม่มี = ข้ามเงียบ
Turbo Frame (131)Turbo Stream ตอบฟอร์ม (132)
เปลี่ยนได้กี่กล่อง1 กรอบ — กรอบที่ลิงก์/ฟอร์มอยู่ข้างในกี่กล่องก็ได้ อยู่ตรงไหนของหน้าก็ได้
ใครเลือกกล่องเบราว์เซอร์ — หา <turbo-frame id> ที่ตรงกันในคำตอบserver — เขียน target ไว้ในใบสั่งทุกข้อ
server ตอบอะไรหน้า HTML ธรรมดาที่มีกรอบ id เดียวกันใบสั่ง <turbo-stream> (text/vnd.turbo-stream.html)
เหมาะกับแก้ในที่เดิม · แท็บ · โหลดทีหลังส่งฟอร์มแล้วเปลี่ยนหลายจุด · เพิ่ม/ลบของในรายการ

19 · broadcast — เสียงตามสายถึงทุกโต๊ะ (บท 133)

ใบสั่งของบท 132 ไปถึงแค่แท็บที่กดส่ง · แท็บอื่นที่เปิดหน้าเดียวกันไม่รู้เรื่อง · broadcast = ให้ server ประกาศผ่าน WebSocket (สายที่เปิดค้างไว้ คุยได้สองทาง ต่างจาก HTTP ที่ถามหนึ่งครั้งตอบหนึ่งครั้ง) · Action Cable ของ Rails ดูแลสายที่ /cable ให้ · ต้องมี 2 ชิ้น:

ชิ้นเขียนที่ในร้านอาหารขาดแล้วเป็นยังไง (ผลจริง)
คนประกาศbroadcasts_to :post ใน app/models/comment.rbเสียงตามสายประกาศในช่องของบทความนี้—
โต๊ะที่เปิดลำโพง<%= turbo_stream_from @post %> ในหน้า show → ได้แท็ก <turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="…">ลำโพงที่ฟังช่องของบทความนี้ (ชื่อช่องมีลายเซ็นกันปลอม)ขาดอันนี้: log ยังเขียน Broadcasting to … แต่แท็บขวานิ่ง · Console ว่าง · ไม่มีใครฟ้อง
ตัวนับในแท็บอื่นafter_create_commit -> { broadcast_update_later_to post, target: "comment_count", html: post.comments.count }ประกาศเพิ่มอีกข้อ: เปลี่ยนป้ายจำนวนไม่มีบรรทัดนี้: คอมเมนต์เด้ง แต่ตัวนับแท็บขวาค้าง 0 (broadcasts_to ไม่ทำให้)

⚠️ html: ใส่ของลงใบสั่งตรง ๆ ไม่ใส่ซองใส (ลอง html: "<b>ตัวหนา</b>" ได้แท็กจริง) · ตัวเลขจาก count ปลอดภัย · ข้อความที่ผู้ใช้พิมพ์ให้ใช้ partial ซึ่ง escape ให้ · เทียบ React: ต้องเปิด WebSocket เองใน useEffect พร้อม cleanup · ที่นี่เขียน JavaScript 0 บรรทัด

คำถามคำตอบ (ผลจริง)
แท็บที่กดส่งเองได้ใบสั่ง append สองใบ (ตอบฟอร์ม + เสียงตามสาย) ทำไมคอมเมนต์ไม่ซ้ำcomment_3 ถูกใส่สองรอบจริง แต่ในกล่องเหลือ 1 · Turbo มีกฎว่า id ซ้ำ = เอาอันเก่าออกใส่อันใหม่ · ลบ id="<%= dom_id comment %>" ออกจาก partial → แท็บซ้ายเห็นซ้ำ 2 อัน · dom_id ไม่ใช่ของประดับ
สร้างคอมเมนต์จาก bin/rails console แล้วทำไมเบราว์เซอร์ไม่เด้งdevelopment ใช้ adapter: async (เก็บในหน่วยความจำของ process เดียว) · console เป็นอีก process · กด refresh ถึงเห็น · production ใช้ solid_cable (ตารางในฐานข้อมูล)
log บอกอะไรEnqueued Turbo::Streams::ActionBroadcastJob … to Async(default) (ฝากเป็นงานเบื้องหลัง) → [ActionCable] Broadcasting to Z2lk… → Turbo::StreamsChannel transmitting … หนึ่งบรรทัดต่อหนึ่งแท็บที่ฟังอยู่
ทำไมต้องหลัง commit (after_*_commit ไม่ใช่ after_save)after_save ประกาศก่อนตู้เซ็นรับ · ถ้า transaction ถูกยกเลิก ทุกแท็บเห็นคอมเมนต์ที่ไม่มีจริง · ลอง raise ActiveRecord::Rollback: log มี BEGIN → ROLLBACK ไม่มี Enqueued สักบรรทัด ✅
broadcasts_to :post, later: true (ตามสไลด์)turbo-rails 2.0.23 ไม่มีตัวเลือก later: · ใส่แล้วไม่ error แต่ไม่มีผล · มันส่งต่อให้ job เองอยู่แล้ว
งานใน Go (gorilla/websocket)ใน Rails
เปิดทางให้เบราว์เซอร์ต่อสายเขียน endpoint เองAction Cable เปิด /cable ให้
จำว่าใครต่อสายอยู่เขียน hub · register/unregister · heartbeatAction Cable ทำให้
รูปแบบข้อความ · ฝั่งเบราว์เซอร์ออกแบบ JSON เอง · เขียน JS แก้ HTML เองใบสั่ง Turbo Stream · Turbo ทำให้
server หลายเครื่องต้องมี Redis pub/subเปลี่ยน adapter ใน config/cable.yml
ราคาของบรรทัดเดียวเรื่องทางออก
สร้างข้อมูลเยอะ ๆ = ประกาศเยอะ ๆseed / import พันแถว = พันประกาศComment.suppressing_turbo_broadcasts { … } → log ไม่มี Enqueued
model รู้จัก viewmodel ไปเรียก partial comments/_comment เอง · เปลี่ยนชื่อ partial แล้ว model พังให้ controller สั่งเอง: Turbo::StreamsChannel.broadcast_append_later_to(@post, target: "comments", partial: "comments/comment", locals: { comment: @comment }) (ข้อเสีย: ทุกที่ที่สร้างคอมเมนต์ต้องจำใส่)
refresh ทั้งหน้าแบบข้อความที่พิมพ์ค้างเคอร์เซอร์ในช่องตำแหน่งเลื่อนจอ
ปกติ❌ หาย❌ หลุด❌ กลับบนสุด
<meta name="turbo-refresh-method" content="morph"> + turbo-refresh-scroll preserve❌ หาย✅ อยู่✅ คงที่
morph + ติด data-turbo-permanent ที่ช่องพิมพ์ (ต้องมี id)✅ อยู่✅ อยู่✅ คงที่

morphing = เทียบทีละชิ้นแล้วแก้เฉพาะที่ต่าง แทนเปลี่ยนทั้ง body · data-turbo-permanent = ห้ามแตะชิ้นนี้

20 · Stimulus — อุปกรณ์บนโต๊ะที่ไม่ต้องเรียกครัว (บท 134)

Stimulus = ให้ HTML แปะป้ายบอกเองว่าต้องการ JS ตัวไหน แล้ว Stimulus คอยดูว่า element ที่มีป้ายโผล่เมื่อไหร่ (ติดตั้ง) หายเมื่อไหร่ (เก็บกวาด) · ใช้กับงานที่ไม่ต้องถาม server · ไฟล์อยู่ app/javascript/controllers/ (คนละอย่างกับ controller ของ Rails แม้ชื่อเหมือน)

ชื่อไฟล์ ↔ ชื่อใน HTML — ผิดตัวเดียว เงียบทั้งหน้า

ที่ไหนเขียนว่าสังเกต
ชื่อไฟล์character_counter_controller.jsขีดล่าง · ลงท้าย _controller.js
HTML data-controllercharacter-counterขีดกลาง · ไม่มีคำว่า controller
HTML targetdata-character-counter-target="input"ชื่อ controller อยู่กลางชื่อ attribute
ใน ERBdata: { character_counter_target: "input" }เขียนขีดล่างได้ Rails แปลงเป็นขีดกลางให้
ใน JSthis.inputTargetชื่อ target + คำว่า Target

ส่วนประกอบ

ป้ายใน HTMLประกาศใน JSใช้ใน JSภาษาคนใน React
data-controller="character-counter"ไฟล์ character_counter_controller.js—“กล่องนี้ใช้ JS ตัวนี้” (ป้ายบนโต๊ะบอกว่าเป็นอุปกรณ์อะไร)component หนึ่งตัว
data-action="click->theme#toggle" (บนปุ่มละได้ = theme#toggle)method toggle()—“เกิด event นี้ ให้เรียก method นี้” (action descriptor)onClick={toggle}
data-character-counter-target="input"static targets = ["input", "output"]this.inputTargetelement ข้างในที่ JS ต้องใช้ · ไม่ต้อง querySelector—
data-character-counter-warn-at-value="10"static values = { warnAt: Number }this.warnAtValue → 10 (ตัวเลขแล้ว)ส่งค่าจาก server เข้า JS แบบมีชนิด—
data-character-counter-warning-class="text-error"static classes = ["warning"]this.warningClass → "text-error"ส่งชื่อคลาส CSS เข้า JS (ชื่อเต็มอยู่ใน ERB ให้ Tailwind เห็น)—
outlets—this.modalOutlet.open()แค่รู้จักชื่อ: controller หนึ่งสั่ง controller อีกตัว—

ตัวนับอักษรใต้ช่องคอมเมนต์ — โค้ดเต็ม

ไฟล์ app/javascript/controllers/character_counter_controller.js:

บรรทัดภาษาคน
import { Controller } from "@hotwired/stimulus"หยิบ Controller ของ Stimulus มาใช้ (ชื่อนี้อยู่ในสมุด importmap)
export default class extends Controller {ไฟล์นี้ส่งออก class หนึ่งตัว ที่สืบทอดจาก Controller (≈ Ruby class X < Controller)
static targets = ["input", "output"]ใช้ target สองตัว → ได้ this.inputTarget · this.outputTarget
connect() {
this.onInput = this.update.bind(this)
this.inputTarget.addEventListener("input", this.onInput)
this.update()
}
ฟอร์มโผล่ → เริ่มฟังการพิมพ์ · this.onInput ≈ @on_input ของ Ruby เก็บไว้ถอดทีหลัง · bind(this) = มัดไว้ว่า this คือ controller · นับทันทีหนึ่งรอบ
disconnect() {
this.inputTarget.removeEventListener("input", this.onInput)
}
ฟอร์มหายไป → ถอด listener ตัวเดียวกันออก
update() {
this.outputTarget.textContent = `${this.inputTarget.value.length} / ${this.inputTarget.maxLength}`
}
เขียน “5 / 1000” ลงช่องแสดงผล · `…${…}` = แทรกค่าลงข้อความ (≈ "#{}" ของ Ruby)

ใน comments/_form.html.erb: ครอบด้วย <div data-controller="character-counter"> · form.textarea :body, rows: 3, maxlength: 1000, data: { character_counter_target: "input" } · <p … data-character-counter-target="output"></p> · ผลจริง: เปิดหน้า 0 / 1000 → พิมพ์ “อร่อย” 5 / 1000 → ส่งคอมเมนต์ 0 / 1000 (ฟอร์มใบใหม่ connect แล้วนับเอง)

connect / disconnect ถูกเรียกตอนไหน (log จริง)

ทำอะไรConsole
เปิด /posts (ไม่มีฟอร์มคอมเมนต์)(ไม่มีอะไร)
คลิกบทความ (Turbo Drive)ตัวนับ: connect
ส่งคอมเมนต์ (Turbo Stream แทนฟอร์ม) · หรือส่งว่างได้ 422ตัวนับ: disconnect → ตัวนับ: connect
กลับไปหน้ารวมตัวนับ: disconnect
คลิกบทความที่เคยเปิดแล้วconnect → disconnect → connect (ภาพจาก cache ก่อน แล้วหน้าจริง)

Stimulus รู้ได้ด้วย MutationObserver (ตัวเฝ้าของเบราว์เซอร์ที่แจ้งเมื่อ element ถูกเพิ่ม/ลบ) · เทียบ React: connect() ≈ โค้ดใน useEffect · disconnect() ≈ ฟังก์ชันที่ return ออกมา (cleanup) · Go ≈ defer

ใน connect ผูก document.addEventListener("keydown", this.onKey) แล้วกด aลืมเก็บกวาดเก็บกวาดใน disconnect แล้ว
เปิดหน้าแรก1 บรรทัด1 บรรทัด
หลังไปหน้ารวมแล้วกลับมา 1 รอบ3 บรรทัด1 บรรทัด
หลังรอบ 25 บรรทัด1 บรรทัด
อยู่หน้ารวม (ไม่มีฟอร์มแล้ว)5 บรรทัด0 บรรทัด
ตัวนับแบบเดิม (DOMContentLoaded + querySelector)ผลจริงStimulus
เปิด /posts/30 ตรง ๆ แล้วพิมพ์✅ ขึ้น 5✅
ส่งคอมเมนต์ แล้วพิมพ์ต่อในฟอร์มใบใหม่❌ ไม่ขยับ — listener ผูกกับช่องใบเก่าที่หายไปแล้ว✅ ฟอร์มใหม่ connect เอง
กดลิงก์ไปบทความอื่นแล้วพิมพ์❌ ไม่ขยับ — DOMContentLoaded ไม่ยิงซ้ำ✅
เปิด /posts ตรง ๆ (ไม่มีฟอร์ม)💥 TypeError: Cannot read properties of null (reading 'addEventListener')✅ ไม่มีป้าย = ไม่มีอะไรรัน

21 · checklist ตรวจงาน AI 3 Lab · กฎ 9–12 (บท 126 · 129 · 135)

ทุก Lab เดินแบบเดียวกัน: ทำเองเป็นก่อน → เขียนใบสั่ง prompts/0N-….md 4 ส่วน → ตรวจรับทีละข้อด้วยคำสั่งจริง → เติมกฎที่เจอลง 00-context.md → commit · ใส่บั๊กกลับก่อนเชื่อว่า “ไม่เจอ = ผ่าน” (ตัวตรวจที่ไม่เคยจับอะไรได้ อาจพังเอง = false negative)

Lab Vibe 1 — View (prompts/01-views.md)

เกณฑ์ตรวจด้วยผลจริงที่ผ่าน
เห็นรายการพร้อมชื่อหมวดเปิด /posts10 ชิ้น · ชิ้นแรก เครื่องดื่ม · 1 day ago / นมเย็น
ไม่เกิน 3 query ต่อหน้าtail -f log/development.log ดูบรรทัด Completed/posts · ?page=2 · ?page=3 = 3 · /posts/30 = 2 · (ใส่บั๊กกลับ: ลบ includes → (12 queries, 5 cached) + bullet USE)
partial ไม่มี @grep -n "@" app/views/posts/_*.html.erb app/views/shared/_*.html.erbไม่มี output
ไม่มี raw / html_safegrep -rn "raw\|html_safe\|<%==" app/viewsไม่มี output (ของสไลด์ไม่มี <%== → จับ series_nav ไม่ได้)
pagination ใช้ได้จริงกด “ถัดไป →” · ลอง ?page=0 abc 99ตารางหัวข้อ 12
seed รันซ้ำไม่พังbin/rails db:seed สองรอบสองรอบ categories: 5 · posts: 30

Lab Vibe 2 — Styling (prompts/02-styling.md)

เกณฑ์ตรวจด้วยผลจริงที่ผ่าน
สลับธีมแล้วเปลี่ยนทั้งเว็บgrep -rnE "(bg|text|border|divide)-(gray|blue|red|green)-" app/viewsไม่มี output · (ปุ่ม btn-error สีเท่ากันสองธีม — ปกติ)
refresh แล้วธีมยังอยู่กด 🌓 แล้ว F5<html data-theme="dark"> · พื้นยังมืด
assets:precompile แล้วสียังอยู่ครบbin/rails assets:precompile แล้ว grep -o 'navbar\|card-body\|badge-primary\|…' public/assets/tailwind-*.css | sort | uniq -c · จบด้วย bin/rails assets:clobberทุกคลาสนับได้ ≥ 1 · มี [data-theme=dark] · [data-theme=light]
ไม่มีไฟล์ใหม่นอกจาก .erbgit status --short · ls tailwind.config.js package.json node_modulesไฟล์ใหม่มีแค่ _navbar.html.erb · theme_controller.js (ต้องมี เพราะห้าม <script>) · prompts/02-styling.md · สามชื่อหลัง No such file
ย่อหน้าต่างเหลือความกว้างมือถือแล้วยังอ่านได้DevTools โหมดมือถือ 375pxทุกหน้ากว้าง 375/375 ไม่มีเลื่อนข้าง

Lab Vibe 3 — Hotwire (prompts/03-hotwire.md) · เปิดสองหน้าต่างวางข้างกัน

#เกณฑ์ควรเห็น (ผลจริง)
1กดแก้ไขแล้ว URL ไม่เปลี่ยน และส่วนอื่นไม่ขยับหัวข้อกลายเป็นช่องพิมพ์ที่เดิม · บันทึกแล้วหัวข้อใหม่ขึ้นที่เดิม · URL ยังเป็น /posts/1
2เปิด 2 แท็บ พิมพ์แท็บหนึ่ง อีกแท็บขึ้นเองคอมเมนต์โผล่หน้าต่างขวาเอง (เครื่องตรวจ 104 ms)
3ตัวนับจำนวน comment อัปเดตพร้อมกัน“ความคิดเห็น (0)” → “(1)” ทั้งสองหน้าต่าง (ตัวนับจำนวนคอมเมนต์ ไม่ใช่ตัวนับอักษร)
4ฟอร์มว่างเองหลัง submitช่องชื่อ/ความคิดเห็นว่าง · ตัวนับอักษรกลับเป็น 0 / 1000
5Stimulus controller มี disconnectgrep -rn "disconnect" app/javascript → character_counter_controller.js:15: disconnect() {
6เปิด /posts/1/edit ตรง ๆ ยังได้หน้าเต็มหน้า “แก้ไขบทความ” มี navbar · ฟอร์ม 3 ช่อง · log Rendering posts/edit.html.erb within layouts/application
7console ไม่มี error เรื่อง matching turbo-frameว่าง (บรรทัด Failed to load resource: … 422 ตอนส่งว่าง = ปกติ)

กฎ 9–12 ที่เติมลง 00-context.md (คู่มือโตจาก 8 เป็น 12 ข้อ) + คำสั่งตรวจ

กฎมาจากที่ AI พลาดตรวจด้วยผลที่ผ่าน
9 · JS ทั้งหมดอยู่ใน Stimulus controller · ห้ามมี <script> ใน viewquerySelector ใน <script> — ใช้ได้จนส่งคอมเมนต์ครั้งแรกgrep -rn "<script" app/viewsว่าง
10 · ทุก controller ที่สร้างของใน connect ต้องเก็บกวาดใน disconnectไม่มี disconnect — listener ซ้อนgrep -rn "disconnect" app/javascript (ไม่ใช่ app/javascript/** ที่ได้ Is a directory)เจอในทุกไฟล์ที่มี addEventListener ใน connect
11 · turbo-frame id ใช้ dom_id() ทั้งสองฝั่งฝั่งหนึ่งพิมพ์ id เอง → Content missinggrep -rn "turbo_frame_tag" app/viewsทุกบรรทัดเป็น dom_id(…)
12 · render ใหม่หลัง validation ไม่ผ่าน ต้องมี status: :unprocessable_contentลืม status → หน้าไม่เปลี่ยน ไม่มี errorgrep -rn "render :new\|render :edit" app/controllersทุกบรรทัดมี status:
(เสริม) broadcast หลัง commitใช้ after_savegrep -rn "after_" app/modelsลงท้าย _commit ทุกตัว

ที่ AI พลาดประจำ — รวม 3 บล็อก

AI ทำแบบนี้อาการตรวจเจอด้วย
ลืม includes(:category)หน้าเปิดได้ แค่ช้าลงเมื่อข้อมูลเยอะlog (12 queries, 5 cached) · bullet USE eager loading
ใช้ description[0..80] แทน truncateตัดกลางคำ ไม่มี ...grep -rn "\[0\.\." app/views
ทำ pagination เองด้วย offset · ใช้ API pagy รุ่นเก่า?page แปลก ๆ พัง · Pagy::Backend NameError · items: เงียบ ๆ ได้หน้าละ 20grep -rn "offset" app/controllers · grep -rn "Pagy::Backend\|items:" app/controllers (ค้นทั้ง app ไม่ได้ ไปเจอ align-items: ใน CSS)
ใส่ @post ใน partialshow ใช้ได้ index พัง undefined method … for nilgrep -n "@" app/views/posts/_*.html.erb
seed ใช้ create!รันรอบสอง 60 ชิ้นbin/rails db:seed สองรอบ
สั่งสร้าง tailwind.config.js · npm install daisyui · @plugin "daisyui"ถูกเมินเงียบ ๆ · build พัง Can't resolve 'daisyui'ls tailwind.config.js package.json ต้องไม่มี · bin/rails tailwindcss:build ต้องผ่าน
ใช้ bg-blue-600 · text-gray-700สลับธีมแล้วค้างgrep สีตายตัว (Lab 2 ข้อ 1)
ต่อ string เป็นชื่อคลาสไม่มีสี (หรือมีบนเครื่องเพราะ watch จำ แต่ production ไม่มี)git grep --untracked -n -E '[a-z]-(<%=|#\{)' app/views (ไม่มี --untracked = ข้ามไฟล์ใหม่ที่ยังไม่ git add)
frame id ไม่ตรง · ลืม status · <script> · ไม่มี disconnect · after_saveดูกฎ 9–12 ข้างบนgrep 5 บรรทัดข้างบน
ลืม turbo_stream_from ในหน้า showหน้าต่างที่ส่งเองเห็น อีกหน้าต่างนิ่ง · Console ว่าง · log ยังเขียน Broadcasting — ตรวจหน้าต่างเดียวจะ “ผ่าน” ทุกข้อทดสอบสองหน้าต่าง · grep -rn "turbo_stream_from" app/views

22 · ตารางอาการ → แปลว่า → แก้ยังไง

อ่านอาการ 3 ที่: ① หน้าเว็บ (หน้าแดง / ว่าง / ไม่ขยับ) → ② log ของ server (หาบรรทัด Completed … และข้อความ error) → ③ Console ใน DevTools (คลิกขวา → Inspect → แท็บ Console) สำหรับเรื่อง Turbo/Stimulus · Day 3 มีอาการเงียบ ๆ ไม่มี error เยอะ — ตารางนี้รวมไว้ด้วย

ERB · partial · ฟอร์ม

อาการ / ข้อความที่เห็นแปลว่าแก้ยังไง
ตรงที่ควรมีข้อมูลว่างเปล่า ไม่มี errorใช้ <% %> ลืม = — คำนวณแล้วไม่วางบนจานเปลี่ยนเป็น <%= %>
ข้อความ [#<Post id: 1, …>] โผล่บนหน้ามี <%= หน้า eachบรรทัด each/if/end ใช้ <%
ActionView::SyntaxErrorInTemplateเขียน <%= if … %>ใช้ <% if %>
500 undefined method 'title' for nil (หน้า index) แต่หน้า show ใช้ได้partial แอบอ่าน @post ที่หน้านั้นไม่มีใครวางใช้ local post
500 missing local: :post for app/views/…/_x.html.erbpartial มี strict locals แต่คนเรียกไม่ได้ยื่นของที่บังคับ (เช่น render "comments/form", comment: … ลืม post:)ยื่นให้ครบ หรือใส่ค่าเริ่มต้น (show_category: false)
500 unknown local: :show_categroy for …ยื่นของที่ partial ไม่ได้ประกาศ (มักสะกดผิด)เทียบชื่อหลัง unknown local: กับบรรทัด locals:
undefined local variable or method 'post_counter'เรียก partial แบบชิ้นเดียว หรือใช้ strict locals โดยไม่ประกาศใช้กับ render @posts เท่านั้น · ประกาศ post_counter: nil
Missing partial shared/_navbar with {locale: [:en], formats: [:html, …เรียก partial ที่ยังไม่มีไฟล์ (เช่นพิมพ์ตามสไลด์หน้า 12 ก่อน Lab 129)สร้างไฟล์ app/views/shared/_navbar.html.erb ก่อน หรือเอาบรรทัดออก
<title>Showing postต้มยำกุ้งน้ำข้น</title>มี content_for :title สองบรรทัด — มันต่อท้ายหนึ่งหน้า หนึ่งบรรทัด
หน้า error undefined method 'reading_tme' for an instance of #<Class:0x…>สะกดชื่อ helper ผิด หรือยังไม่ save ไฟล์ helperเทียบชื่อกับ app/helpers/*.rb
undefined method 'published?' · undefined method 'body' for an instance of Postก็อปตัวอย่างสไลด์ที่ใช้ช่องที่แอปเราไม่มีตารางเรามีแค่ title · description · category_id
ช่องเลือกหมวดไม่มีกรอบ ไม่มีสไตล์ ไม่มี errorclass: ไปอยู่ถุงแรกของ collection_selectใส่ { prompt: … } หรือ {} ก่อน class:
400 Bad Request ParameterMissing (param is missing … : post)ชื่อช่องในฟอร์มไม่อยู่ในกลุ่ม post[…]ใช้ form builder (form.text_field) ไม่เขียน <input> เอง
Console Failed to load resource: … status of 422 (Unprocessable Content) หลังส่งฟอร์มที่ข้อมูลไม่ครบปกติ — validation ไม่ผ่าน server ตอบ 422 ตามที่ควรไม่ต้องแก้
เวลาในหน้าเว็บช้ากว่านาฬิกาไทย 7 ชั่วโมงแอปยังใช้ UTCconfig.time_zone = "Asia/Bangkok" (Lab 126)

setup · gem · asset · CSS

อาการ / ข้อความที่เห็นแปลว่าแก้ยังไง
docker compose up -d ฟ้อง error ที่พูดถึง 5432มี PostgreSQL ตัวเก่ายึดประตูอยู่ (เช่น container ของฝั่ง Go)docker ps ดูคอลัมน์ PORTS → docker stop ชื่อกล่อง (ข้อมูลไม่หาย)
แอปไปใช้ฐานข้อมูลของแอปเก่าDATABASE_URL ค้างจาก Day 1–2unset DATABASE_URL
Validation failed: Slug has already been taken ตอน db:seedseeds ใช้ create! ซึ่งไม่รันซ้ำได้find_or_create_by!
NameError (uninitialized constant ApplicationController::Pagy)เพิ่ม gem แล้วไม่ restartCtrl+C แล้ว bin/dev ใหม่
NameError (uninitialized constant Pagy::Backend)ก็อป API pagy รุ่นเก่าinclude Pagy::Method + limit:
หน้าไม่มี style เลย / คลาสใหม่ไม่มีผล (grep -c ได้ 0)เปิดแค่ bin/rails server ไม่มีใครสร้าง CSS ใหม่ใช้ bin/dev หรือ bin/rails tailwindcss:build
คลาสสีที่ต่อ string ไม่มีสีTailwind ไม่เห็นชื่อเต็มในไฟล์เขียนชื่อเต็มทุกตัวแล้วเลือกด้วยเงื่อนไข
บนเครื่องมีสี แต่หลัง precompile ไม่มีwatch จำคลาสเก่าไว้ ทั้งที่โค้ดตอนนี้ไม่มีชื่อเต็มแล้วปิด bin/dev → tailwindcss:build ใหม่ → grep พิสูจน์
แก้ CSS แล้วหน้าเว็บยังชี้ชื่อไฟล์เดิม สีเก่ามี public/assets/.manifest.json ค้างจากการลอง precompilebin/rails assets:clobber → bin/rails tailwindcss:build
หลัง clobber หน้าไม่มี style · บรรทัด <link … tailwind-….css> หายไปทั้งบรรทัดclobber ลบ app/assets/builds/tailwind.css ไปด้วยbin/rails tailwindcss:build หรือเปิด bin/dev ใหม่
Error: Can't resolve 'daisyui' in '…/app/assets/tailwind'เขียน @plugin "daisyui"@plugin "./daisyui.mjs"
เพิ่มสีใน tailwind.config.js แล้วไม่มีผล ไม่มี errorTailwind v4 ไม่อ่านไฟล์นั้นตั้งใน @theme { --color-…: … } แล้วลบไฟล์ทิ้ง
สลับธีมแล้วบางจุดสีค้างใช้สีตายตัว (bg-blue-600)เปลี่ยนเป็น semantic color / ชิ้นของ daisyUI
grep … app เจอผลเกินมาจาก daisyui.mjs หรือ align-items:grep หาตัวอักษร ไม่ได้อ่านโค้ดเป็นใช้ -w หรือค้นเฉพาะโฟลเดอร์ (app/views · app/controllers)

Turbo · Stimulus · JavaScript

อาการ / ข้อความที่เห็นแปลว่าแก้ยังไง
JS ทำงานแค่หน้าแรก กดลิงก์แล้วเงียบใช้ DOMContentLoaded ซึ่งยิงครั้งเดียว (Turbo Drive ไม่โหลดหน้าใหม่)เขียนเป็น Stimulus controller
กดปุ่มครั้งเดียว ทำงาน 2–3 รอบ (log ซ้ำหลายบรรทัด)duplicate listener — ผูกกับ document ใน turbo:load หรือใน connect โดยไม่เก็บกวาดย้ายเข้า Stimulus + removeEventListener ใน disconnect()
กล่องขึ้นคำว่า Content missing · Console The response (200) did not contain the expected <turbo-frame id="…"> and will be ignored…id ของ frame ไม่ตรงสองฝั่งdom_id(post, :title) ทั้งสองฝั่ง · ตรวจด้วย curl + header Turbo-Frame:
ในกล่องได้ฟอร์ม 3 ช่อง / หน้า edit เต็มบันทึกแล้ว URL ค้าง /editครอบหน้า edit เต็มด้วย turbo-frame ตามสไลด์หน้า 41ใช้ variant + edit.html+turbo_frame.erb
ลิงก์ในกล่องเปิดหน้าใหม่ไม่ได้ ติดอยู่ในกล่องลิงก์ใน frame ถูกขังโดยค่าเริ่มต้นdata: { turbo_frame: "_top" }
กดบันทึกฟอร์มที่ข้อมูลผิด หน้าไม่เปลี่ยน ไม่มีข้อความ error · log Completed 200 OK · Console Error: Form responses must redirect to another locationrender ฟอร์มใหม่โดยไม่ใส่ status (กฎ 12)status: :unprocessable_content
ส่งฟอร์มแล้วหน้าไม่ขยับ · log No template found for CommentsController#create, rendering head :no_content · 204 No Contentลืมไฟล์ create.turbo_stream.erbสร้างไฟล์ใบสั่งงาน
ตัวเลขขึ้นครั้งแรก แล้วค้าง · Console ว่างใช้ replace กับกล่องตัวเลข id หายไปกับกล่องใช้ update
ใบสั่ง stream ไม่มีผลอะไร ไม่มี errortarget id ไม่มีในหน้า (เช่น "flash")เพิ่มกล่องที่มี id นั้น · ถามตัวเองว่า “หลังคำสั่งรอบก่อน id นี้ยังอยู่ไหม”
แท็บที่ส่งเองเห็นคอมเมนต์ซ้ำ 2 อันpartial _comment ไม่มี id="<%= dom_id comment %>"ใส่ dom_id กลับ
อีกแท็บไม่เด้ง · log ยังมี Broadcasting to …ลืม <%= turbo_stream_from @post %>เติมในหน้า show
คอมเมนต์เด้งแต่ตัวนับแท็บอื่นค้างbroadcasts_to ไม่อัปเดตตัวนับให้after_create_commit -> { broadcast_update_later_to … }
สร้างคอมเมนต์จาก console แล้วเบราว์เซอร์ไม่เด้งdevelopment ใช้ adapter async (คนละ process)ปกติ · ลองผ่านหน้าเว็บ หรือกด refresh
ตัวนับอักษรว่าง · Console ไม่มีอะไรเลยสะกด data-controller ผิด เช่น character_counter (ขีดล่าง)ใช้ขีดกลาง character-counter
Console Error connecting controller · Missing target element "output" for "character-counter" controllerสะกดชื่อ target ผิด (เช่น ouput)เทียบกับ static targets
TypeError: Cannot read properties of null (reading 'addEventListener')JS แบบเดิมหา element ไม่เจอในหน้านั้นStimulus — ไม่มีป้ายก็ไม่รัน
ช่องพิมพ์หัวข้อไม่ใหญ่ (สูง 40px แทน 48px)คลาสใหม่ input-lg ยังไม่ถูกสร้างใน CSSbin/dev / tailwindcss:build แล้ว refresh
grep: app/javascript/controllers: Is a directory** ไม่ได้ค้นลงโฟลเดอร์ใน shell ของเราgrep -rn "disconnect" app/javascript
PG::DuplicateTable: ERROR: relation "comments" already existsตู้ยังมีตารางจากบท 132 แต่สลับ branch ให้ AI สร้างใหม่bin/rails db:reset · อย่าให้ AI ลบ migration หรือแก้ schema.rb

23 · ศัพท์ภาษาคน

ศัพท์ภาษาคนในร้านอาหารใน React/Next.js
server-rendered HTML · client-side renderingserver ทำ HTML เสร็จแล้วส่ง · เบราว์เซอร์รับ JSON แล้ววาดเองจัดจานในครัว · ส่งวัตถุดิบไปปรุงที่โต๊ะServer Component · client component ที่ fetch
client state · single source of truthสำเนาข้อมูลในเบราว์เซอร์ · ข้อมูลมีเจ้าของที่เดียวสมุดเล่มที่สองบนโต๊ะ · สมุดของครัวเล่มเดียวuseState ที่เก็บข้อมูลจาก server
stale data · sync bugข้อมูลค้าง · บั๊กจากสองชุดไม่ตรงกันสมุดบนโต๊ะยังเขียนของเก่าลืม fetch ใหม่
idempotentทำซ้ำกี่รอบผลเท่ากับรอบเดียวสั่งซ้ำไม่ได้จานเบิ้ล—
output tag · execution tag<%= %> รันแล้ววาง · <% %> รันอย่างเดียวเติมแล้ววางบนจาน · ทำในครัวเงียบ ๆ{ } ใน JSX
template handler · jbuilderท่อนท้ายของชื่อไฟล์บอกเครื่องมือแปลง · ตัวสร้าง JSONแบบจาน · แบบใส่กล่องกลับบ้าน—
layout · yield · content_forกรอบทุกหน้า · ช่องวางเนื้อหา · ฝากของไปวางในกรอบจานรอง · ช่องกลาง · ป้ายชื่อเมนูlayout.tsx · metadata
partial · collection renderingไฟล์ view ชิ้นส่วน (ขึ้นต้น _) · render ทั้งรายการทีเดียวแม่พิมพ์ตักข้าวcomponent · map
locals · instance variable (@ivar)ของที่ยื่นเข้า partial · ของที่ controller วางไว้ให้ทุก view เห็นยื่นใส่มือ · ของบนถาดหน้าครัวprops · ตัวแปรกลาง
strict localsบรรทัดแรกของ partial ที่ประกาศว่ารับอะไร · ขาด = missing · เกิน = unknownป้ายติดแม่พิมพ์type ของ props
view helper · custom helpermethod สำเร็จรูปใน view · ที่เราเขียนเองใน app/helpersเครื่องมือจัดจานฟังก์ชันใน lib/
i18n · locale · l (localize)ระบบหลายภาษา · ภาษาที่ใช้อยู่ · helper จัดวันที่ตามภาษาเมนูหลายภาษา—
form builder · param namingตัวแปร form ที่วาดช่อง · ชื่อช่องแบบ post[title]ใบสั่งที่พิมพ์จากระบบฟอร์มที่ไม่ต้อง useState
XSS · escape · stored XSSโค้ดคนร้ายไปรันในเบราว์เซอร์คนอื่น · แปลง < เป็น &lt; · XSS ที่เก็บลง DB แล้วโดนทุกคนซองใส{…} escape ให้
raw · html_safe · sanitize · allowlistไม่ต้อง escape · แปะป้ายว่าปลอดภัย · คัดแท็กตามรายชื่อที่อนุญาต · รายชื่อของที่ผ่านได้แกะซอง · ตะแกรงคัดของdangerouslySetInnerHTML
N+1 in a partial · eager loading · bulletN+1 ที่ลูปกับจุดไปตู้อยู่คนละไฟล์ · หยิบล่วงหน้า · gem ที่เตือนเองเดินไปตู้ทีละแผ่น · ใบเบิกทั้งตะกร้าcomponent ลูกแต่ละตัว fetch เอง
pagination · limit · offsetแบ่งรายการเป็นหน้า · จำนวนต่อหน้า · ข้ามกี่แถวเมนูทีละหน้า—
acceptance criteria · false negative · sanity checkเกณฑ์ว่าเสร็จ · ตัวตรวจบอกผ่านทั้งที่มีปัญหา · ลองให้ตัวตรวจจับของที่รู้คำตอบก่อนใบตรวจรับของ—
asset · browser cache · Cache-Controlไฟล์ CSS/JS/รูป · สำเนาในเบราว์เซอร์ · หัวจดหมายบอกว่าเก็บได้นานแค่ไหนเมนูเล่ม · ลูกค้าประจำจำเล่มเก่า—
fingerprinting · digest · cache busting · immutableต่อเลขจากเนื้อไฟล์ท้ายชื่อ · เลขนั้น · บังคับทิ้งสำเนาเก่า · “ชื่อนี้ไม่มีวันเปลี่ยนเนื้อ”เลขรุ่นบนปกเมนูทำใน npm run build
Propshaft · Sprockets · manifestตัวจัดการ asset ของ Rails 8 (ทำน้อย) · รุ่นเก่า (ทำทุกอย่าง) · ตาราง “ชื่อเดิม → ชื่อมี digest”พนักงานติดเลขรุ่นวางบนชั้น—
import map · pin · ES moduleตารางบอกเบราว์เซอร์ว่าชื่อไหนคือไฟล์ไหน · เพิ่มหนึ่งบรรทัด · มาตรฐาน import/exportสมุดรายชื่อชื่อเล่น → ชั้นimport … from
bundler · tree-shakingเครื่องมือมัด JS รวมก้อน · ตัดส่วนที่ไม่ใช้ทิ้ง—ที่ Next.js ใช้ตอน build
theme variable · @theme · @plugin · @sourceตัวแปร CSS ที่ Tailwind สร้างคลาสให้ · ที่ตั้งค่า · เสียบส่วนเสริม · บอกไฟล์ที่ต้อง/ไม่ต้องสแกนสมุดตั้งค่าร้านglobals.css
static analysis · dynamic class nameวิเคราะห์โค้ดด้วยการอ่านไฟล์ ไม่ได้รัน · ชื่อคลาสที่ประกอบตอนรันฝ่ายจัดซื้ออ่านกระดาษ · เมนูสั่งด้วยปาก`text-${color}-600`
component class · modifier · semantic colorคลาส daisyUI หนึ่งคำแทนชุดคลาส · คลาสปรับชิ้นนั้น · สีตั้งชื่อตามหน้าที่ชุดจานชาม · สีประจำร้านdesign token (บท 81)
data-theme · --default · --prefersdark · localStorageป้ายธีมบน <html> · ธีมเริ่มต้น · ธีมเมื่อเครื่องตั้งมืด · ที่เก็บข้อความเล็ก ๆ ในเบราว์เซอร์ป้ายธีมร้าน—
watch mode · Procfile.dev · bin/devเฝ้าไฟล์แล้วทำใหม่ (เพิ่มคลาสได้ ไม่ลบ) · รายการโปรแกรม · คำสั่งเปิดทั้งหมดฝ่ายจัดซื้อประจำกะnpm run dev
Hotwire · HTML over the wire · SPAส่ง HTML แทน JSON (Turbo + Stimulus) · เว็บที่ JS วาดทั้งหน้าครัวยังถือสมุดเล่มเดียวReact app
Turbo Drive · visit · full page loadดักลิงก์/ฟอร์มแล้วสลับ body · การเปลี่ยนหน้าหนึ่งครั้ง · โหลดใหม่ทั้งใบเปลี่ยนจานบนโต๊ะเดิม<Link>
event · listener · DOM · DOMContentLoaded · turbo:loadเหตุการณ์ · ฟังก์ชันที่รอฟัง · ต้นไม้ของ element · “อ่าน HTML เสร็จ” (ครั้งเดียว) · “Turbo เปลี่ยนหน้าแล้ว” (ทุกครั้ง)—onClick · useEffect(…, [])
Turbo Frame · inline edit · lazy frame · _topกล่องที่โหลดผลมาใส่แค่ในกล่อง · แก้ในที่เดิม · กล่องที่โหลดทีหลัง · ทั้งหน้าเปลี่ยนถ้วยใบเดียว · ป้ายเลขถ้วยuseState(editing)
request variant · turbo_frame_request?“รุ่น” ของคำขอที่เลือกไฟล์ view +turbo_frame · มีป้าย Turbo-Frame มาไหมสูตรจานเล็ก—
progressive enhancementใช้ได้ด้วย HTML ธรรมดาก่อน แล้วเสริม JS · JS พังชั้นล่างยังทำงาน——
Turbo Stream · action · target · <template>คำตอบที่เป็นรายการคำสั่งเปลี่ยน HTML · คำสั่ง · id กล่อง · ห่อของที่จะวางใบสั่งงานหลายข้อsetXxx หลายตัว
replace (outerHTML) · update (innerHTML)แทนทั้งกล่องรวมแท็ก · แทนเฉพาะข้างในยกถ้วยทั้งใบ · เทของในถ้วย—
Accept header · MIME type · respond_to · 204 · 422บอกว่ารับคำตอบชนิดไหน · ชื่อชนิด · ตอบตามชนิด · สำเร็จไม่มีเนื้อหา · ข้อมูลไม่ผ่านกฎ——
real-time · WebSocket · Action Cableของใหม่โผล่จอคนอื่นทันที · สายเปิดค้างคุยสองทาง · ส่วนของ Rails ที่ดูแลสายสายที่ต่อค้างไว้WebSocket ใน useEffect
broadcast · stream · subscribe · pub/subส่งครั้งเดียวถึงทุกคนที่ฟัง · ชื่อช่อง · ขอฟังช่อง · ประกาศ/ฟังโดยไม่ต้องรู้จักกันเสียงตามสาย · โต๊ะที่เปิดลำโพง—
cable adapter · async · Solid Cableสถานีกลางส่งข่าว · ในหน่วยความจำ process เดียว (dev) · ตารางในฐานข้อมูล (production)—hub ของ Go
after_commit · jobหลังตู้เซ็นรับจริง · งานที่ server เก็บไว้ทำเบื้องหลัง (ละเอียด Part 2)ส่งไลน์หลังตู้เซ็นรับ—
morphing · data-turbo-permanentเทียบแล้วแก้เฉพาะที่ต่าง · ห้ามแตะชิ้นนี้——
Stimulus controller · identifier · action descriptorclass JS ผูกกับ data-controller · ชื่อในป้าย · click->x#yอุปกรณ์บนโต๊ะ · ป้ายบนโต๊ะcomponent · onClick
target · values · classes · outletselement ที่ JS ใช้ · ค่าจาก HTML · ชื่อคลาสจาก HTML · คุยกับ controller อื่น——
lifecycle callback · connect · disconnect · MutationObservermethod ที่ระบบเรียกเองตามจังหวะชีวิต · ตอนโผล่ · ตอนหาย · ตัวเฝ้า DOMติดตั้งเมื่อจัดโต๊ะ · เก็บกวาดเมื่อเก็บโต๊ะuseEffect + cleanup
duplicate listener · listener leak · sprinkles of JSlistener ผูกซ้ำ · ค้างทั้งที่ไม่ใช้ · “โรย JS ทีละนิด” บน HTML จาก server—ลืม cleanup
prompt file · session · contextprompt ที่เก็บเป็นไฟล์ใน repo · บทสนทนาหนึ่งรอบ · ทุกอย่างที่ AI เห็นใน sessionแฟ้มคู่มือ · กะทำงาน · โต๊ะทำงานของพนักงาน—
project instructions · symlink · scopeไฟล์คำสั่งที่โหลดเอง (CLAUDE.md / AGENTS.md) · ป้ายชี้ไฟล์อื่น · user/project/localป้ายหน้าห้องพนักงาน—
guidance vs enforcement · guardrail · CIคำแนะนำ vs กลไกที่ไม่ผ่านก็ไปต่อไม่ได้ · กฎ/การตรวจที่กันพลาดซ้ำ · ระบบรันตรวจอัตโนมัติทุก pushป้ายล้างมือ · ประตูสแกนมือcomment vs type ของ tsc

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

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

หน้าสไลด์เขียนของจริงทำยังไง
11 · 17<% if @post.published? %> · sanitize post.bodyแอปเราไม่มีช่อง published / body → undefined methodใช้ description · เป็นแค่ตัวอย่างรูปแบบ
12layout มี render "shared/navbar" · "shared/footer"ไฟล์ยังไม่มี → ทุกหน้า Missing partial shared/_navbarอย่าเพิ่งเติม · navbar มาใน Lab 129
26stylesheet_link_tag "application"layout จริงใช้ :app = ได้ทั้ง application และ tailwindอย่าแก้ ไม่งั้น Tailwind หายทั้งเว็บ
29–30@plugin "daisyui"; · ไฟล์ application.tailwind.cssCan't resolve 'daisyui' · ไฟล์จริงคือ app/assets/tailwind/application.css@plugin "./daisyui.mjs"
41ครอบฟอร์มในหน้า edit ด้วย turbo-frame · “controller ไม่ต้องแก้”กล่องได้ฟอร์ม 3 ช่อง · หน้า edit เต็มพัง (บันทึกแล้ว URL ค้าง /edit)variant + edit.html+turbo_frame.erb (หัวข้อ 17)
44render "comments/form", comment: Comment.newpartial มี strict locals → missing local: :postส่ง post: @post ด้วย
46 · 52broadcasts_to :post, later: trueturbo-rails 2.0.23 ไม่มี later: · ไม่ error แต่ไม่มีผล · และไม่อัปเดตตัวนับให้ไม่ใส่ · เพิ่มบรรทัด after_create_commit ของตัวนับเอง
22 · 53grep -rn "raw\|html_safe" app/views · grep -n "disconnect" app/javascript/**อันแรกจับ <%== ไม่ได้ · อันหลังได้ Is a directoryเติม \|<%== · ใช้ grep -rn "disconnect" app/javascript

25 · อ้างอิง