🧰 เวอร์ชันที่ใช้ตรวจ — 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” จะไม่ตรงกับในหน้านี้ — เป็นเรื่องปกติ
สารบัญ
ภาพร้านอาหาร — ส่วนที่ Day 3 เพิ่ม
3 ประโยคหลักของวัน
เริ่มโปรเจกต์ day3-start
prompts/ · CLAUDE.md · คำสั่ง Claude Code
ERB 4 แบบ · กับดัก =
layout · yield · content_for
partial · render @posts · locals
helper ที่ใช้บ่อย
form_with — ฟอร์มใบเดียว new/edit
XSS — ซองใส กับการแกะซอง
N+1 ใน view · นับ query จาก log
pagy — แบ่งหน้า (Lab 1)
asset pipeline · fingerprint · importmap
Tailwind v4 · daisyUI · ธีม · bin/dev
Turbo 3 ชิ้น — เลือกตัวไหน
Turbo Drive
Turbo Frames
Turbo Streams
broadcast — เสียงตามสาย
Stimulus
checklist ตรวจงาน AI 3 Lab · กฎ 9–12
ตารางอาการ → แปลว่า → แก้ยังไง
ศัพท์ภาษาคน
ถ้าเปิดสไลด์ดูประกอบ
อ้างอิง
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.ts 124
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 build 127
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 ไว้แล้ว) · พิมพ์ทีละบรรทัดตามตารางนี้
# พิมพ์ ทำอะไร (ภาษาคน) ควรเห็น (ผลจริง)
1 git clone https://github.com/chonlawit-odds/wongnok-blog-all-in-one.gitก็อป repo (โฟลเดอร์ที่ git จดประวัติ) พร้อมประวัติทั้งหมดลงเครื่อง Cloning into 'wongnok-blog-all-in-one'...
2 cd wongnok-blog-all-in-onegit checkout day3-startเข้าโฟลเดอร์ · สลับไฟล์ทั้งโฟลเดอร์ไปเป็นของสาย (branch) day3-start Switched to a new branch 'day3-start'
3 bundle installติดตั้ง gem ตาม Gemfile.lock (≈ npm install · go mod download) Bundle complete! 27 Gemfile dependencies, 128 gems now installed.
4 docker compose up -dเปิด PostgreSQL 18 + MinIO ตาม compose.yaml (MinIO ยังไม่ได้ใช้ · ได้ใช้ใน Day 4 · Part 1) error ที่พูดถึง 5432 = มีโปรแกรมอื่นยึดประตูอยู่ → ดูหัวข้อ 22
5 echo $DATABASE_URLเช็คว่าไม่มีค่าค้างจาก Day 1–2 (ถ้ามี Rails จะเชื่อค่านี้ก่อนไฟล์ตั้งค่า) บรรทัดว่าง · ไม่ว่าง → unset DATABASE_URL
6 bin/rails db:prepareสร้างฐานข้อมูล + โหลดแบบลิ้นชักจาก db/schema.rb Created 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 · รันอีกรอบก็ได้เท่าเดิม
8 git switch -c my-day3git add db/seeds.rbgit commit -m "seeds: 5 หมวด 30 บทความ"แตก branch ของเราเอง แล้วเก็บ seeds ไว้ (กันมันหายตอน git restore .) Switched to a new branch 'my-day3'
9 bin/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 · View 126 (Lab Vibe 1)
prompts/02-styling.mdบล็อก B · CSS 129 (Lab Vibe 2)
prompts/03-hotwire.mdบล็อก C · Hotwire 135 (Lab Vibe 3)
04-content.md · 05-jobs.md · 06-caching.mdrich text · งานเบื้องหลัง · cache Rails 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
7 migration ห้ามปิดลิ้นชักนานจนแอปค้าง (expand-contract) 109
8 strong params คัดช่อง · ไม่ได้ตรวจว่าใครมีสิทธิ์ (Day 4) 117
9–12 เพิ่มในบท 135 → หัวข้อ 21 135
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 ของ Turbo 127 · 130
<%= javascript_importmap_tags %>ดึง JavaScript ผ่าน importmap 127
<%= 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 รออยู่
ใช้จานรองคนละแบบกับบาง controller layout "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 ④ รายการว่าง = ไม่วางอะไร คืน nil log: 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 nil action 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
<%= 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.submit Create 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:
ใน view HTML ที่ส่งออกไป (ผลจริงจาก curl) บนจอ script รันไหม
<%= post.title %><script>alert('XSS!')</script>ข้อความเต็ม ๆ ✅ ไม่รัน (ซองใส)
<%= 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 (คำว่า draw er)
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=2 3
/posts?page=310 หน้า 3 / 3 · ถัดไปเป็นสีเทา 3
/posts?page=0 · ?page=abc10 หน้า 1 / 3 (pagy ถือเป็นหน้า 1 ไม่ error) 3
/posts?page=990 → ไม่พบบทความ หน้า 99 / 3 · ก่อนหน้าชี้ ?page=3 2
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-rails JS เรียก esbuild · rollup · Bun · Webpack มามัด JS ต้องใช้ TypeScript หรือ library ที่ต้องมัดรวม
cssbundling-rails CSS เรียกเครื่องมือ CSS ผ่าน yarn (ต้องมี Node) framework CSS ที่ต้อง build
tailwindcss-rails ✅ CSS Tailwind ฉบับไม่ต้องมี 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) แล้ว build grep -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 ในรายการ themes grep -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.erb grep -c pink / blue สีบนจอ
1 ต่อ string (เปิด bin/dev ตอนนี้) 0 / 0 ไม่มีสี
2 เปลี่ยนเป็นชื่อเต็ม 1 / 1 มีสี
3 เปลี่ยนกลับเป็นต่อ string (เช่น AI “จัดโค้ดให้สั้นลง”) 1 / 1 ยังมีสี — watch เพิ่มคลาสได้แต่ไม่ลบจนกว่าจะเริ่มใหม่
4 เหมือนขั้น 3 · สั่ง assets:precompile (แบบขึ้นเครื่องจริง) 0 / 0 production ไม่มีสี
คำสั่ง ใช้ตอนไหน
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.json 735 403 —
เฉพาะการ์ด <article> 943 514 ~1.3 เท่า
HTML ทั้งหน้า /posts/1 6,010 2,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 แล้วฟอร์มเล็กพร้อม error Turbo หยิบเฉพาะกล่องเดียวกันจากหน้าใหม่มาวาง · 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)
ทำอะไร โค้ด / คำสั่ง ข้อควรรู้
สร้าง model bin/rails generate model Comment body:text author_name:string post:referencesgenerator ใส่ null: false ให้แค่บรรทัด references · เติมเองให้ body กับ author_name ก่อน db:migrate (validation + NOT NULL มาเป็นคู่ · บท 111)
กฎใน model belongs_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
หน้า show PostsController#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.erb No 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 · heartbeat Action Cable ทำให้
รูปแบบข้อความ · ฝั่งเบราว์เซอร์ ออกแบบ JSON เอง · เขียน JS แก้ HTML เอง ใบสั่ง Turbo Stream · Turbo ทำให้
server หลายเครื่อง ต้องมี Redis pub/sub เปลี่ยน adapter ใน config/cable.yml
ราคาของบรรทัดเดียว เรื่อง ทางออก
สร้างข้อมูลเยอะ ๆ = ประกาศเยอะ ๆ seed / import พันแถว = พันประกาศ Comment.suppressing_turbo_broadcasts { … } → log ไม่มี Enqueued
model รู้จัก view model ไปเรียก 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-controller character-counterขีดกลาง · ไม่มีคำว่า controller
HTML target data-character-counter-target="input"ชื่อ controller อยู่กลางชื่อ attribute
ใน ERB data: { character_counter_target: "input" }เขียนขีดล่างได้ Rails แปลงเป็นขีดกลางให้
ใน JS this.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 บรรทัด
หลังรอบ 2 5 บรรทัด 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)
เกณฑ์ ตรวจด้วย ผลจริงที่ผ่าน
เห็นรายการพร้อมชื่อหมวด เปิด /posts 10 ชิ้น · ชิ้นแรก เครื่องดื่ม · 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_safe grep -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]
ไม่มีไฟล์ใหม่นอกจาก .erb git 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
5 Stimulus controller มี disconnect grep -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
7 console ไม่มี error เรื่อง matching turbo-frame ว่าง (บรรทัด Failed to load resource: … 422 ตอนส่งว่าง = ปกติ)
กฎ 9–12 ที่เติมลง 00-context.md (คู่มือโตจาก 8 เป็น 12 ข้อ) + คำสั่งตรวจ
กฎ มาจากที่ AI พลาด ตรวจด้วย ผลที่ผ่าน
9 · JS ทั้งหมดอยู่ใน Stimulus controller · ห้ามมี <script> ใน view querySelector ใน <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 missing grep -rn "turbo_frame_tag" app/viewsทุกบรรทัดเป็น dom_id(…)
12 · render ใหม่หลัง validation ไม่ผ่าน ต้องมี status: :unprocessable_content ลืม status → หน้าไม่เปลี่ยน ไม่มี error grep -rn "render :new\|render :edit" app/controllersทุกบรรทัดมี status:
(เสริม) broadcast หลัง commit ใช้ after_save grep -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: เงียบ ๆ ได้หน้าละ 20 grep -rn "offset" app/controllers · grep -rn "Pagy::Backend\|items:" app/controllers (ค้นทั้ง app ไม่ได้ ไปเจอ align-items: ใน CSS)
ใส่ @post ใน partial show ใช้ได้ index พัง undefined method … for nil grep -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.erb partial มี 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
ช่องเลือกหมวดไม่มีกรอบ ไม่มีสไตล์ ไม่มี error class: ไปอยู่ถุงแรกของ 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 ชั่วโมง แอปยังใช้ UTC config.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 แล้วไม่ restart Ctrl+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 ค้างจากการลอง precompile bin/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 แล้วไม่มีผล ไม่มี error Tailwind 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 location render ฟอร์มใหม่โดยไม่ใส่ 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 ไม่มีผลอะไร ไม่มี error target 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 ยังไม่ถูกสร้างใน CSS bin/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 rendering server ทำ 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 helper method สำเร็จรูปใน view · ที่เราเขียนเองใน app/helpers เครื่องมือจัดจาน ฟังก์ชันใน lib/
i18n · locale · l (localize) ระบบหลายภาษา · ภาษาที่ใช้อยู่ · helper จัดวันที่ตามภาษา เมนูหลายภาษา —
form builder · param naming ตัวแปร form ที่วาดช่อง · ชื่อช่องแบบ post[title] ใบสั่งที่พิมพ์จากระบบ ฟอร์มที่ไม่ต้อง useState
XSS · escape · stored XSS โค้ดคนร้ายไปรันในเบราว์เซอร์คนอื่น · แปลง < เป็น < · XSS ที่เก็บลง DB แล้วโดนทุกคน ซองใส {…} escape ให้
raw · html_safe · sanitize · allowlist ไม่ต้อง escape · แปะป้ายว่าปลอดภัย · คัดแท็กตามรายชื่อที่อนุญาต · รายชื่อของที่ผ่านได้ แกะซอง · ตะแกรงคัดของ dangerouslySetInnerHTML
N+1 in a partial · eager loading · bullet N+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 descriptor class JS ผูกกับ data-controller · ชื่อในป้าย · click->x#y อุปกรณ์บนโต๊ะ · ป้ายบนโต๊ะ component · onClick
target · values · classes · outlets element ที่ JS ใช้ · ค่าจาก HTML · ชื่อคลาสจาก HTML · คุยกับ controller อื่น — —
lifecycle callback · connect · disconnect · MutationObserver method ที่ระบบเรียกเองตามจังหวะชีวิต · ตอนโผล่ · ตอนหาย · ตัวเฝ้า DOM ติดตั้งเมื่อจัดโต๊ะ · เก็บกวาดเมื่อเก็บโต๊ะ useEffect + cleanup
duplicate listener · listener leak · sprinkles of JS listener ผูกซ้ำ · ค้างทั้งที่ไม่ใช้ · “โรย JS ทีละนิด” บน HTML จาก server — ลืม cleanup
prompt file · session · context prompt ที่เก็บเป็นไฟล์ใน 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 · เป็นแค่ตัวอย่างรูปแบบ
12 layout มี render "shared/navbar" · "shared/footer" ไฟล์ยังไม่มี → ทุกหน้า Missing partial shared/_navbar อย่าเพิ่งเติม · navbar มาใน Lab 129
26 stylesheet_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)
44 render "comments/form", comment: Comment.newpartial มี strict locals → missing local: :post ส่ง post: @post ด้วย
46 · 52 broadcasts_to :post, later: trueturbo-rails 2.0.23 ไม่มี later: · ไม่ error แต่ไม่มีผล · และไม่อัปเดตตัวนับให้ ไม่ใส่ · เพิ่มบรรทัด after_create_commit ของตัวนับเอง
22 · 53 grep -rn "raw\|html_safe" app/views · grep -n "disconnect" app/javascript/**อันแรกจับ <%== ไม่ได้ · อันหลังได้ Is a directory เติม \|<%== · ใช้ grep -rn "disconnect" app/javascript
25 · อ้างอิง
← กลับไปบทเรียน 120–135 🏠 หน้าแรก