🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- Turbo Stream (บท 132) — "ใบสั่งงานให้พนักงานเสิร์ฟ" หน้าตาแบบ
<turbo-stream action="append" target="comments">= "เอาของในซองไปต่อท้ายกล่องที่ id เป็นcomments" · ในบท 132 ใบสั่งนี้มาจากcreate.turbo_stream.erbและส่งกลับไปให้แท็บที่กดส่งเท่านั้น dom_id(บท 131) — ตั้งป้ายเลขถ้วยให้ HTML เช่นคอมเมนต์เลข 8 ได้id="comment_8"- callback (บท 112) — กฎ "ทำทุกครั้ง" ที่ติดไว้ข้างตู้ ·
after_commit= ทำหลังตู้เซ็นรับแฟ้มแล้วเท่านั้น - transaction (บท 111) — กล่อง "ทำทั้งหมด หรือไม่ทำเลย" · log เห็นเป็น
BEGIN…COMMIT(ยืนยัน) หรือROLLBACK(ยกเลิกทั้งกล่อง) - หน้า show ของบทความ (บท 132) — มีจุดที่มี id อยู่ 3 จุด: ตัวนับ
#comment_count· รายการ#comments· ฟอร์ม#comment_form
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · turbo-rails 2.0.23 (Turbo 8.0.23) · PostgreSQL 18 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน)
🗂 โค้ดเริ่มจาก: แอปหลังจบบท 132 (คอมเมนต์ + ฟอร์มที่ตอบด้วย Turbo Stream · ตามไม่ทันดูบท 132) ·
บทนี้เพิ่มโค้ดจริง 3 บรรทัดแล้วเก็บไว้ (commit ท้ายภารกิจ) · ส่วนการทดลองอื่น ๆ ในบทให้ git restore ทิ้ง ·
branch day3-c-done ของอาจารย์มีไว้ตามทันได้ แต่หน้าตาไฟล์ต่างจากบทเรียน (ใช้ i18n และข้อมูลคนละชุด)
ผลทุกก้อนรันจริงแล้วก็อปมา · ตัดบรรทัดที่ไม่เกี่ยวเป็น … และตัดคอมเมนต์ SQL /*application=…*/ ท้ายบรรทัดออก ·
เลขบทความ · เลขคอมเมนต์ · Job ID · เวลา (ms) · ชื่อช่องยาว ๆ ที่ขึ้นต้น Z2lk · ลายเซ็นหลัง -- ของคุณจะไม่ตรงกับในบท
ปัญหา: แท็บอื่นไม่รู้ว่ามีคอมเมนต์ใหม่
ลองนึกภาพ: คุณเปิดบทความ "ราดหน้าหมูหมัก" ไว้สองแท็บ · ส่งคอมเมนต์จากแท็บซ้าย แท็บซ้ายเปลี่ยนทันที (บท 132) · แต่แท็บขวานิ่งสนิท ต้องกด refresh เองถึงจะเห็น
เหตุผลอยู่ที่วิธีคุยของเว็บปกติ (HTTP): เบราว์เซอร์ต้องถามก่อน server ถึงจะตอบ · ถามหนึ่งครั้งได้คำตอบหนึ่งครั้ง แล้วจบ · server ไม่มีทางวิ่งไปบอกแท็บขวาเองได้ เพราะแท็บขวาไม่ได้ถามอะไรอยู่
สิ่งที่เราอยากได้เรียกว่า real-time — ข้อมูลใหม่วิ่งมาหาเราเองทันทีที่เกิด โดยเราไม่ต้องกดอะไร · ทางแก้คือให้เบราว์เซอร์ต่อสายค้างไว้กับ server ตั้งแต่เปิดหน้า · สายแบบนี้ชื่อ WebSocket · ต่อครั้งเดียวแล้วคุยได้สองทาง ใครมีเรื่องก็พูดได้เลย ไม่ต้องรออีกฝั่งถาม
HTTP ปกติเหมือนใบสั่งอาหาร: โต๊ะยื่นใบสั่ง → ครัวทำ → เสิร์ฟจาน → จบ · ครัวไม่เดินไปหาโต๊ะไหนเองถ้าโต๊ะนั้นไม่ได้สั่ง · โต๊ะขวาจะรู้ว่ามีเมนูใหม่ก็ต่อเมื่อยื่นใบสั่งไปถามเอง (กด refresh)
WebSocket เหมือนร้านที่ติดลำโพงเสียงตามสายไว้ทุกโต๊ะ · สายต่อค้างไว้ตลอดที่ลูกค้านั่งอยู่ · พอครัวมีข่าว ประกาศครั้งเดียว ทุกโต๊ะที่เปิดลำโพงได้ยินพร้อมกัน
ที่ไม่เหมือนกัน: เสียงตามสายจริงเป็นทางเดียว (ครัวพูด โต๊ะฟัง) แต่ WebSocket คุยได้สองทาง — แค่ในบทนี้เราใช้ทางเดียว · และโต๊ะที่เพิ่งมานั่งไม่ได้ยินประกาศที่ผ่านไปแล้ว — เขาเห็นคอมเมนต์เก่าได้เพราะตอนเปิดหน้า Rails ดึงจากฐานข้อมูลมาให้ ไม่ใช่เพราะลำโพงเล่นย้อนหลัง
เทียบกับฝั่ง React ที่เพิ่งเรียน: ใน บท 74 การ "เปิด WebSocket" คือตัวอย่างงานที่ต้องเขียนใน useEffect เอง
พร้อม cleanup ตอนปิด · ใน Rails บทนี้เราไม่เขียน JavaScript สักบรรทัด — Turbo เปิดและปิดสายให้เอง
- real-time
- ข้อมูลใหม่ไปโผล่ที่หน้าจอคนอื่นทันทีโดยเขาไม่ต้องกดอะไร · พิมพ์บอก AI ได้ว่า “make new comments appear in real time for everyone viewing the post”
- WebSocket
- การเชื่อมต่อที่เปิดค้างไว้ระหว่างเบราว์เซอร์กับ server คุยได้สองทาง · ต่างจาก HTTP ที่ถามหนึ่งครั้งตอบหนึ่งครั้ง · ถาม senior ได้ว่า “ตอนขึ้น server จริง WebSocket ต้องตั้งค่าอะไรเพิ่มไหม”
- Action Cable
- ส่วนของ Rails ที่จัดการ WebSocket ให้ (รับสาย · จำว่าใครฟังช่องไหน · ส่งข้อความ) · เปิดอยู่ที่ path
/cableโดยไม่ต้องแก้ routes · ถาม AI ได้ว่า “how does Action Cable know which browser is listening to which stream?”
สองชิ้นที่ต้องมี — คนประกาศ กับ โต๊ะที่เปิดลำโพง
real-time ใน Rails ต้องมีสองฝั่งเสมอ: ฝั่งที่ประกาศ (เกิดคอมเมนต์ใหม่ → ส่งข่าว) กับฝั่งที่ฟัง (หน้าเว็บที่รอรับข่าว) · ขาดฝั่งไหนก็ไม่ทำงาน · เราจะใส่ทีละฝั่ง แล้วดูผลจริงแต่ละขั้น
ชิ้นที่ 1 — คนประกาศ: broadcasts_to :post ใน model
เปิดไฟล์ app/models/comment.rb แล้วเพิ่ม 2 บรรทัดใต้ belongs_to :post พร้อมบรรทัดว่างคั่นก่อน validates ให้หน้าตาเป็นแบบนี้ (บรรทัดคอมเมนต์ # พิมพ์ตามไว้ด้วย ไฟล์จะได้ตรงกับบทเรียน):
class Comment < ApplicationRecord
belongs_to :post
# คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าโพสต์นี้ (ส่งต่อให้ job · หลัง commit)
broadcasts_to :post
validates :author_name, presence: true, length: { maximum: 50 }
validates :body, presence: true, length: { maximum: 1000 }
end
broadcasts_to— แปลว่า "ทุกครั้งที่คอมเมนต์ถูกสร้าง · แก้ · ลบ ให้ประกาศข่าว" · broadcast = ประกาศถึงทุกคนที่ฟังอยู่:post— "ประกาศในช่องของบทความที่คอมเมนต์นี้อยู่" · Rails เรียกcomment.postแล้วใช้บทความนั้นเป็นชื่อช่อง · คอมเมนต์ของบทความ 23 จึงประกาศในช่องของบทความ 23 เท่านั้น คนเปิดบทความ 24 ไม่ได้ยิน- ข่าวที่ประกาศตอนสร้างคือใบสั่ง Turbo Stream แบบเดียวกับบท 132: "append คอมเมนต์นี้ ต่อท้ายกล่อง
comments" · Rails เดาให้หมด: ใช้ partialcomments/_comment(ชื่อตาม model) และ targetcomments(ชื่อ model แบบพหูพจน์) - บรรทัดคอมเมนต์
(ส่งต่อให้ job · หลัง commit)— job คืองานที่ฝากไว้ให้ทำทีหลัง (ละเอียดใน Day 3 Part 2 ซึ่งยังไม่มา) · commit คือจังหวะCOMMITที่ตู้เซ็นรับแฟ้ม (บท 111) · ทั้งสองอย่างจะเห็นด้วยตาในหัวข้อ "อ่าน log" ข้างล่าง
ไม่ต้อง restart server — แก้ไฟล์ใน app/ ตอน development Rails โหลดใหม่ให้เอง
ตอนนี้มีแค่คนประกาศ ยังไม่ได้แตะหน้า show เลย · เปิดบทความเดียวกันสองแท็บ ส่งคอมเมนต์จากแท็บซ้าย — แท็บขวาจะเห็นคอมเมนต์ใหม่ไหม?
ไม่เห็น — ผลจริงจาก Chrome สองแท็บ (บทความเลข 30 · สถานะก่อนและหลังกดส่ง 2.5 วินาที):
before A {"marker":"A","count":"0","n":0,"ids":[],"source":false,"connected":null}
before B {"marker":"B","count":"0","n":0,"ids":[],"source":false,"connected":null}
after A {"marker":"A","count":"1","n":1,"ids":["comment_1"],"source":false,"connected":null}
after B {"marker":"B","count":"0","n":0,"ids":[],"source":false,"connected":null}
วิธีอ่าน: count = เลขในตัวนับ · n = จำนวนคอมเมนต์ในกล่อง · ids = id ของคอมเมนต์ในกล่อง ·
source / connected = หน้านี้มี "ลำโพง" หรือยัง และต่อสายติดไหม (เดี๋ยวรู้จัก) ·
marker = ป้ายที่เราแปะไว้ในหน้าด้วย JavaScript ก่อนกดส่ง ถ้าหน้าโหลดใหม่ป้ายจะหาย — ยังอยู่ทุกรอบ = ไม่มีแท็บไหนโหลดหน้าใหม่
แท็บซ้าย (A) ได้คอมเมนต์จากคำตอบของฟอร์มตามบท 132 · แท็บขวา (B) นิ่ง · (การทดลองแต่ละรอบในบทนี้ใช้บทความคนละเลข ตัวนับจะได้เริ่มที่ 0 ทุกรอบ)
แต่ใน log ของ server มีการประกาศจริง (ตัดท้ายบรรทัด):
Started POST "/posts/30/comments" for ::1 at …
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] […] [ActionCable] Broadcasting to Z2lkOi8vd29uZ25vay1ibG9nLWFsbC1pbi1vbmUvUG9zdC8zMA: "<turbo-stream action=\"append\" target=\"comments\"…
Z2lkOi8v… ที่อ่านไม่ออกคือชื่อช่องของบทความ 30 ที่ถูกแปลงเป็นตัวอักษรชุดหนึ่ง (เดี๋ยวเจออีกในหัวข้อถัดไป) ·
ครัวประกาศแล้ว แต่ไม่มีโต๊ะไหนเปิดลำโพง · และไม่มี error ทั้งในเบราว์เซอร์และใน log — พังแบบเงียบ ๆ (สไลด์หน้า 53 บอกว่าเป็นจุดที่ AI ลืมบ่อย)
ชิ้นที่ 2 — เปิดลำโพง: turbo_stream_from @post ในหน้า show
เปิด app/views/posts/show.html.erb · หา <section class="mt-10 space-y-4"> (ส่วนความคิดเห็นจากบท 132) แล้วเพิ่ม 2 บรรทัดนี้ (บรรทัดที่สองคือบรรทัดว่าง) เป็นบรรทัดแรกในนั้น:
<section class="mt-10 space-y-4">
<%= turbo_stream_from @post %>
<h2 class="text-xl font-bold">
ความคิดเห็น (<span id="comment_count"><%= @post.comments.count %></span>)
</h2>
turbo_stream_from @post— "หน้านี้ขอฟังช่องของบทความนี้" · ต้องเป็นของชิ้นเดียวกับที่ model ประกาศ (:post↔@post) ไม่งั้นต่างคนต่างอยู่คนละช่อง- ใช้
<%= %>(มีเครื่องหมาย=) เพราะมันต้องวางแท็กลงบนจาน — ลืม=แล้วไม่มีลำโพง (บท 122) - วางไว้ตรงไหนในหน้าก็ได้ · ที่วางใน
<section>เพราะอยู่ใกล้ส่วนที่มันจะเปลี่ยน อ่านแล้วเข้าใจ · แท็กนี้ไม่มีหน้าตาให้เห็นบนจอ
ดูว่าเบราว์เซอร์ได้อะไรจริง (บทความเลข 23 ของเรา · ของคุณใช้เลขไหนก็ได้):
$ curl -s localhost:3000/posts/23 | grep -B1 -A4 turbo-cable-stream-source
<section class="mt-10 space-y-4">
<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="IloybGtPaTh2ZDI5dVoyNXZheTFpYkc5bkxXRnNiQzFwYmkxdmJtVXZVRzl6ZEM4eU13Ig==--5bd6c5ff5bee9fefc0a058276b908435619e744c593415866dc65f0536ff2a9c"></turbo-cable-stream-source>
<h2 class="text-xl font-bold">
ความคิดเห็น (<span id="comment_count">1</span>)
</h2>
- ตัวนับเป็น
1เพราะบทความ 23 ของเรามีคอมเมนต์จากการทดลองอยู่แล้ว 1 อัน · บรรทัด<%= turbo_stream_from @post %>กลายเป็นแท็กยาว ๆ บรรทัดที่สอง <turbo-cable-stream-source>— นี่แหละลำโพง · เป็นแท็กพิเศษของ Turbo พอเบราว์เซอร์เจอแท็กนี้ JavaScript ของ Turbo จะต่อ WebSocket ไปที่/cableให้เองchannel="Turbo::StreamsChannel"— ใช้ "สายประเภท Turbo Stream" ของ Action Cablesigned-stream-name="…--5bd6…"— ชื่อช่องแบบมีลายเซ็น · ข้างในคือ "บทความเลข 23 ของแอปนี้" (gid://wongnok-blog-all-in-one/Post/23) ที่ถูกแปลงเป็นตัวอักษรชุดหนึ่ง (ไม่ใช่ความลับ ใครก็ถอดกลับได้ — ส่วนที่ขึ้นต้นZ2lkใน log ก็คือชื่อนี้) · ส่วนหลัง--คือลายเซ็นจาก server — ใครแอบแก้ชื่อในเบราว์เซอร์ให้ไปฟังช่องอื่น ลายเซ็นจะไม่ตรง server ไม่ยอม · ลายเซ็นของคุณจะต่างจากในบทแน่นอน เพราะแต่ละเครื่องมีกุญแจลับสำหรับเซ็นของตัวเอง- เปิด Chrome DevTools แท็บ Elements จะเห็นแท็กนี้มีคำว่า
connectedต่อท้ายเพิ่มมา = ต่อสายติดแล้ว (เราตรวจด้วยสคริปต์ว่า attribute นี้มีจริงหลังโหลดหน้า)
ส่งคอมเมนต์จากแท็บซ้ายอีกครั้ง (บทความเลข 29) — ผลจริง:
before A {"marker":"A","count":"0","n":0,"ids":[],"source":true,"connected":true}
before B {"marker":"B","count":"0","n":0,"ids":[],"source":true,"connected":true}
after A {"marker":"A","count":"1","n":1,"ids":["comment_2"],"source":true,"connected":true}
after B {"marker":"B","count":"0","n":1,"ids":["comment_2"],"source":true,"connected":true}
แท็บขวา (after B) ได้คอมเมนต์ comment_2 โดยไม่ต้องกดอะไร และ marker ยังเป็น "B" = มาโดยไม่โหลดหน้าใหม่ 🎉 · แต่สังเกต "count":"0" — หัวข้อยังเขียนว่า "ความคิดเห็น (0)" ทั้งที่มีคอมเมนต์ 1 อัน ·
เพราะ broadcasts_to ประกาศแค่ "append คอมเมนต์" ไม่ได้ประกาศเรื่องตัวนับ · ต้องเพิ่มเอง (หัวข้อถัดไป)
ร้านมีเสียงตามสายหลายช่อง ช่องละหนึ่งบทความ · broadcasts_to :post คือกฎของครัว: "มีคอมเมนต์ใหม่เมื่อไหร่ ประกาศในช่องของบทความนั้น" ·
turbo_stream_from @post คือโต๊ะที่หมุนลำโพงไปที่ช่องของบทความที่กำลังดู ·
ข่าวที่ประกาศเป็นใบสั่งงานแบบเดียวกับบท 132 พนักงานเสิร์ฟ (Turbo) ที่โต๊ะได้ยินแล้วเอาไปจัดจานตามนั้นทันที
ที่ไม่เหมือนกัน: ลำโพงจริงหมุนผิดช่องก็ยังได้ยินเสียงอะไรสักอย่าง · ในเว็บ ถ้าชื่อช่องสองฝั่งไม่ตรงกัน หรือลืมลำโพง ไม่มีเสียงอะไรเลย และไม่มีใครแจ้ง error — ต้องเปิดสองแท็บทดสอบเองถึงจะรู้
- broadcast
- ส่งข้อความครั้งเดียวไปถึงทุกคนที่ฟังช่องนั้นอยู่ ·
broadcasts_to= ให้ model ประกาศเองทุกครั้งที่สร้าง/แก้/ลบ · พิมพ์บอก AI ได้ว่า “Comment should broadcast to its post's stream with broadcasts_to :post” - stream (ช่อง) · subscribe
- stream คือชื่อช่องที่ใช้ประกาศ (ในที่นี้ = บทความหนึ่งชิ้น) · subscribe คือ "ขอฟังช่องนี้" ซึ่ง
turbo_stream_fromทำให้ · ถาม senior ได้ว่า “stream ควรผูกกับ record ไหน ถ้าอยากให้คนละหน้าไม่ได้ยินกัน” <turbo-cable-stream-source>· signed stream name- แท็กที่
turbo_stream_fromสร้าง = ลำโพงในหน้าเว็บ · ชื่อช่องในแท็กมีลายเซ็นกันการปลอม · ใช้ตอนดีบัก: “the page has turbo-cable-stream-source but no connected attribute — why?”
ให้ตัวนับในแท็บอื่นขยับด้วย — บรรทัดที่สาม
กลับไปที่ app/models/comment.rb เพิ่มอีก 2 บรรทัดต่อจาก broadcasts_to :post (ก่อนบรรทัดว่าง) · ไฟล์ทั้งไฟล์จะเป็นแบบนี้ (ตรงกับโค้ดของบทเรียนทุกบรรทัด):
class Comment < ApplicationRecord
belongs_to :post
# คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าโพสต์นี้ (ส่งต่อให้ job · หลัง commit)
broadcasts_to :post
# ตัวนับในแท็บอื่นต้องเปลี่ยนตามด้วย
after_create_commit -> { broadcast_update_later_to post, target: "comment_count", html: post.comments.count }
validates :author_name, presence: true, length: { maximum: 50 }
validates :body, presence: true, length: { maximum: 1000 }
end
แกะบรรทัดยาวทีละท่อน:
after_create_commit— callback (บท 112) "หลังตู้เซ็นรับคอมเมนต์ใหม่แล้ว" · เฉพาะตอนสร้าง ไม่รวมแก้/ลบ-> { … }— ก้อนโค้ดที่เก็บไว้รันทีหลัง (Ruby เรียกว่า lambda) · คล้าย block{ |x| … }ที่ใช้กับeachแต่ตั้งไว้ก่อน รอ Rails เรียกตอนถึงจังหวะbroadcast_update_later_to post— "ประกาศในช่องของบทความนี้ ให้เปลี่ยนเนื้อใน (update) ของ…" · คำว่าlater= ฝากเป็นงานไว้ให้ทำต่อ ไม่ทำตรงนี้ (เดี๋ยวเห็นใน log)post(ไม่มี@) — โค้ดนี้อยู่ใน model ของคอมเมนต์ ·postเฉย ๆ จึงแปลว่า "บทความของคอมเมนต์ตัวนี้" (เหมือนเขียนcomment.postใน console) ได้มาจากbelongs_to :post·@postเป็นของ controller/view ใช้ใน model ไม่ได้target: "comment_count"— ของที่จะเปลี่ยนคือ<span id="comment_count">ที่มีอยู่แล้วตั้งแต่บท 132html: post.comments.count— เนื้อใหม่ = นับคอมเมนต์ของบทความนี้ใหม่จากฐานข้อมูล
⚠️ html: ใส่ของลงใบสั่งตรง ๆ ไม่ใส่ซองใส (ไม่ escape — ทวน XSS บท 125) · เราลองส่ง html: "<b>ตัวหนา</b>" แล้ว log ได้
<template><b>ตัวหนา</b></template> เป็นแท็กจริง ·
ตัวเลขจาก count ปลอดภัย แต่ห้ามส่งข้อความที่ผู้ใช้พิมพ์ผ่าน html: — ของแบบนั้นให้ใช้ partial ซึ่ง escape ให้เอง
ทดลองรอบที่สาม (บทความเลข 28) — ผลจริง:
after A {"marker":"A","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}
after B {"marker":"B","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}
แท็บขวา "count":"1" แล้ว — ตัวนับขยับตาม
ไม่ต้องรัน tailwindcss:build ใหม่ (ไม่มีคลาส CSS ใหม่) และไม่มี gem หรือ migration ใหม่
แท็บที่กดส่งเอง ได้ใบสั่งสองใบ — ทำไมคอมเมนต์ไม่ซ้ำ
มีเรื่องแปลกที่ต้องสังเกต: แท็บซ้ายก็เปิดลำโพงอยู่เหมือนกัน · ตอนกดส่ง มันจึงได้ใบสั่ง "append คอมเมนต์" สองใบ —
ใบแรกจากคำตอบของฟอร์ม (create.turbo_stream.erb บท 132) ใบที่สองจากเสียงตามสาย
แท็บซ้ายได้ append คอมเมนต์ใบเดียวกันสองรอบ — ในกล่องจะมีคอมเมนต์กี่อัน? แล้วถ้าลบ id="<%= dom_id comment %>" ออกจาก _comment.html.erb ล่ะ?
ผลจริง — เราดักดูว่ามีอะไรถูกใส่เข้ากล่อง #comments บ้าง (appended) เทียบกับของที่เหลือจริง:
after A {"marker":"A","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}
A appended ids: ["comment_3","comment_3"]
บรรทัดล่าง: comment_3 ถูกใส่เข้ากล่องสองรอบจริง · บรรทัดบน: แต่ในกล่องเหลืออันเดียว ("n":1)
พอลบ id ออกจาก partial ชั่วคราว (ใส่บั๊กกลับเพื่อพิสูจน์):
after A {"marker":"A","count":"1","n":2,"ids":["",""],"source":true,"connected":true}
after B {"marker":"B","count":"1","n":1,"ids":[""],"source":true,"connected":true}
แท็บซ้าย "n":2 = เห็นคอมเมนต์ซ้ำ 2 อัน (id ว่างทั้งคู่) · แท็บขวาปกติเพราะได้ใบสั่งใบเดียว
เพราะ Turbo มีกฎว่า ถ้าของใหม่มี id ซ้ำกับของที่อยู่ในกล่องแล้ว ให้เอาอันเก่าออกแล้วใส่อันใหม่แทน (เอกสาร Turbo หน้า Streams เขียนไว้ใต้หัวข้อ Append) ·
ป้ายเลขถ้วย comment_3 จึงช่วยให้ไม่ซ้ำ · ไม่มีป้าย Turbo เทียบไม่ได้ ก็ใส่ทั้งสองอัน
บทเรียน: dom_id ใน partial ไม่ใช่ของประดับ — มันคือสิ่งที่ทำให้ broadcast กับคำตอบฟอร์มอยู่ด้วยกันได้ ·
อย่าลืม git restore app/views/comments/_comment.html.erb ถ้าลองตาม
อ่าน log ของ server — ข่าววิ่งไปถึงใครบ้าง
ภาพรวมของสิ่งที่เกิดขึ้นตอนกดส่งหนึ่งครั้ง:
log จริงของการกดส่งหนึ่งครั้ง (มีสองแท็บเปิดบทความ 23 อยู่ · ส่วนที่ตัดออกแทนด้วย …: บรรทัดที่ไม่เกี่ยว · บรรทัด ↳ · ท้ายบรรทัดที่ยาวเกิน · ชื่อช่องย่อเหลือ Z2lk…UG9zdC8yMw):
Started POST "/posts/23/comments" for ::1 at 2026-11-07 11:01:29 +0700
Processing by CommentsController#create as TURBO_STREAM
…
TRANSACTION (1.1ms) BEGIN
Comment Create (3.0ms) INSERT INTO "comments" (…) VALUES ('สวัสดีจากแท็บซ้าย', 'ต้นกล้า', 23, …) RETURNING "id"
TRANSACTION (1.2ms) COMMIT
[ActiveJob] Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 1e8b4d1c-…) to Async(default) with arguments: "Z2lk…UG9zdC8yMw", {action: :append, target: "comments", …
Comment Count (1.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 23
[ActiveJob] Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 09095142-…) to Async(default) with arguments: "Z2lk…UG9zdC8yMw", {action: :update, target: "comment_count", …
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [1e8b4d1c-…] Rendered comments/_comment.html.erb (Duration: 0.3ms | GC: 0.0ms)
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [1e8b4d1c-…] [ActionCable] Broadcasting to Z2lk…UG9zdC8yMw: "<turbo-stream action=\"append\" target=\"comments\">…
…
Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"comments\"><template>…<article id=\"comment_8\" …
Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"comments\"><template>…<article id=\"comment_8\" …
Rendering comments/create.turbo_stream.erb
…
Completed 200 OK in 37ms (Views: 7.1ms | ActiveRecord: 5.9ms (4 queries, 1 cached) | GC: 0.0ms)
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09095142-…] [ActionCable] Broadcasting to Z2lk…UG9zdC8yMw: "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>"
…
Turbo::StreamsChannel transmitting "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>" (via streamed from Z2lk…UG9zdC8yMw)
Turbo::StreamsChannel transmitting "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>" (via streamed from Z2lk…UG9zdC8yMw)
อ่านทีละกลุ่ม:
Enqueued … ActionBroadcastJobอยู่หลังCOMMIT— Enqueued = "ฝากงาน (job) ประกาศไว้ในคิว" · เกิดหลังตู้เซ็นรับแล้วเท่านั้น (เพราะเป็นafter_*_commit) · มี 2 บรรทัด = งานรายการคอมเมนต์ (append) กับงานตัวนับ (update)Async(default)— ในเครื่องเรา job ทำใน server ตัวเดิมเลย ไม่ต้องเปิดโปรแกรมอื่น · บรรทัดที่ขึ้นต้น[ActiveJob] [Turbo::Streams::ActionBroadcastJob]คือตัว job กำลังทำงานRendered comments/_comment.html.erb— job ทำ HTML ของคอมเมนต์จาก partial ตัวเดียวกับที่หน้า show ใช้ → หน้าตาตรงกันทุกที่Broadcasting to Z2lk…= ครัวประกาศ 1 ครั้ง ในช่องของบทความ 23 (ต่องาน — งานตัวนับก็มีบรรทัดนี้ของตัวเองด้านล่าง)transmitting2 บรรทัด = ส่งถึงลำโพง 2 ตัว (แท็บ A และ B) · ยิ่งเปิดหลายแท็บ บรรทัดนี้ยิ่งมากตาม- ลำดับบรรทัดอาจสลับกันได้ในเครื่องคุณ (job ทำงานคู่ขนานกับคำขอ) ไม่ใช่ปัญหา
✅ วิธีดีบักที่ใช้ได้ตลอด: เห็น Broadcasting to แต่ไม่มี transmitting ตามมา = ประกาศแล้วแต่ไม่มีใครฟัง →
ไปดูว่าหน้าเว็บมี turbo_stream_from ไหม และชื่อช่องตรงกันไหม ·
ตอนเปิดหน้า log ต้องมี Started GET "/cable" [WebSocket] และ Turbo::StreamsChannel is streaming from Z2lk… = ลำโพงต่อสายสำเร็จ
เบื้องหลังที่ Rails ทำให้ — เทียบกับการเขียนเองใน Go
สไลด์หน้า 45 เทียบว่าถ้าทำเรื่องเดียวกันใน Go ต้องเขียนเองหลายชิ้น (คอร์ส Go ของเราไม่ได้ทำ WebSocket — เล่าเป็นภาษาคนพอให้เห็นขนาดงาน):
| งาน | ใน Go (เช่นใช้ gorilla/websocket) | ใน Rails บทนี้ |
|---|---|---|
| เปิดทางให้เบราว์เซอร์ต่อสาย | เขียน endpoint รับ WebSocket เอง | Action Cable เปิด /cable ให้ |
| จำว่าใครต่อสายอยู่บ้าง | เขียน hub (สมุดรายชื่อโต๊ะที่ต่อสาย) · register ตอนต่อ · unregister ตอนหลุด · heartbeat เช็คว่ายังอยู่ | Action Cable ทำให้ (log: is streaming from / stopped streaming from) |
| รูปแบบข้อความ | ออกแบบเอง (เช่น JSON) | ใบสั่ง Turbo Stream (HTML) |
| ฝั่งเบราว์เซอร์ | เขียน JS รับข้อความแล้วไปแก้ HTML ในหน้าเอง | Turbo ทำให้ · เราเขียน JS 0 บรรทัด |
| มี server หลายเครื่อง | ต้องมี Redis pub/sub ให้ทุกเครื่องได้ยินกัน | เปลี่ยน adapter ใน config/cable.yml (ด้านล่าง) |
แถวสุดท้ายสำคัญ: ถ้าแอปรันบน server สองเครื่อง แท็บ A ต่อสายกับเครื่อง 1 แท็บ B ต่อกับเครื่อง 2 · ประกาศที่เกิดในเครื่อง 1 ต้องมีสถานีกลางส่งต่อให้เครื่อง 2 ด้วย · สถานีกลางนี้เรียกว่า pub/sub (publish = ประกาศ · subscribe = ขอฟัง) · ใน Go นิยมใช้ Redis (บท 32 ใช้ Redis ทำ cache — คนละงานแต่ตัวเดียวกัน)
Rails เรียกสถานีกลางนี้ว่า adapter และตั้งไว้ใน config/cable.yml · เปิดไฟล์จริงในโปรเจกต์ (ไม่ต้องแก้):
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
…
development: adapter: async— ในเครื่องเรา สถานีกลางคือหน่วยความจำของ server ตัวที่รันอยู่ · ไม่มี Redis ไม่ใช้ฐานข้อมูล · พอ เพราะมี server ตัวเดียวproduction: adapter: solid_cable— ตอนขึ้นเครื่องจริง ใช้ Solid Cable: ประกาศถูกเขียนลงตารางในฐานข้อมูล แล้วทุก server คอยเปิดดูตารางนั้นถี่ ๆ (polling_interval: 0.1.seconds= ดูทุก 0.1 วินาที) · ได้สถานีกลางโดยไม่ต้องดูแล Redis เพิ่มอีกตัว- คอมเมนต์ภาษาอังกฤษบนสุดเตือนเรื่องสำคัญ: async ใช้ได้เฉพาะใน process เดียวกัน ·
process = โปรแกรมหนึ่งตัวที่กำลังรันอยู่ · server Rails (ที่
bin/devเปิดให้) คือหนึ่งตัว ·bin/rails consoleที่เปิดอีกหน้าต่างคืออีกตัว ความจำไม่ได้แชร์กัน
ผลจริง: เปิดแท็บดูบทความ 26 ไว้ แล้วสร้างคอมเมนต์จาก terminal อีกหน้าต่างด้วย bin/rails runner '…'
(คำสั่งที่รันโค้ด Ruby ในแอปครั้งเดียวแล้วจบ เหมือนพิมพ์ใน console หนึ่งบรรทัด = อีก process หนึ่ง แยกจาก server):
B before {"count":"0","n":0}
saved
B after 3s {"count":"0","n":0} ← ไม่เด้ง
B after reload {"count":"1","n":1} ← คอมเมนต์อยู่ในฐานข้อมูลจริง กด refresh ถึงเห็น
async เหมือนร้านสาขาเดียวที่ไมค์ต่อตรงกับลำโพงในร้าน · คนที่พูดจากตึกข้าง ๆ (terminal อีกหน้าต่าง) ไมค์ไม่ได้ต่อกับลำโพงร้านเรา ลูกค้าเลยไม่ได้ยิน · Solid Cable เหมือนร้านหลายสาขาที่ตกลงกันว่าจะเขียนประกาศลงกระดานกลาง แล้วทุกสาขาส่งคนไปดูกระดานทุก 0.1 วินาที เจอข่าวใหม่ก็ประกาศในสาขาตัวเอง
ที่ไม่เหมือนกัน: คนเดินไปดูกระดานทุก 0.1 วินาทีฟังดูเหนื่อยและช้า แต่คอมพิวเตอร์ทำได้สบาย · สไลด์อ้างว่าความหน่วงอยู่ระดับไม่กี่สิบมิลลิวินาทีและพอสำหรับงานส่วนใหญ่ — เราไม่ได้วัดใน production เอง · และ Redis ไม่ได้ "ดูกระดาน" แต่ส่งข่าวต่อทันที — แลกกับต้องมีอีกโปรแกรมให้ดูแล
- pub/sub (publish/subscribe)
- รูปแบบที่ฝั่งหนึ่งประกาศในช่อง อีกฝั่งขอฟังช่อง โดยสองฝั่งไม่ต้องรู้จักกัน · ถาม senior ได้ว่า “ตอนนี้ pub/sub ของ Action Cable เราใช้ adapter อะไรใน production”
- cable adapter · async · Solid Cable
- adapter = สถานีกลางที่ Action Cable ใช้ส่งข่าวข้าม server · async = เก็บในหน่วยความจำ ใช้ได้แค่ process เดียว (development) · Solid Cable = ใช้ตารางในฐานข้อมูล (production ของ Rails 8) · พิมพ์บอก AI ได้ว่า “we use the async cable adapter in development, so broadcasts from bin/rails console won't reach the browser”
- hub (ในโลก Go)
- โค้ดที่เก็บรายชื่อการเชื่อมต่อทั้งหมดแล้วกระจายข้อความ · Action Cable คือ hub สำเร็จรูป · ใช้อธิบายให้คนสาย Go ฟังว่า “Action Cable is the hub, Turbo is the client”
ราคาของบรรทัดเดียว — ข้อควรระวัง
สไลด์หน้า 46 เตือนว่า broadcasts_to คือ callback ใน model ที่ไปทำเรื่องของคนอื่น ·
ทวนบท 112: callback ที่ทำเรื่องของตัวเอง (เช่นสร้าง slug) ใช้สบาย · แต่ callback ที่แตะของนอกตัว (ส่งอีเมล · ประกาศ) เริ่มมีผลข้างเคียง ·
บรรทัดนี้ทำ 2 เรื่องที่ไม่ใช่งานของคนดูแลสต็อก: จัดจาน (render partial ของ view) และประกาศ
ราคาที่ 1 — สร้างข้อมูลเยอะ ๆ = ประกาศเยอะ ๆ
callback ทำงานทุกครั้งที่สร้างคอมเมนต์ ไม่ว่าจะมาจากหน้าเว็บ · seed · หรือสคริปต์นำเข้าข้อมูล ·
เราลองสร้างคอมเมนต์ 100 อันด้วย bin/rails runner แล้วนับจาก log: มีงานประกาศถูกฝากไว้ 200 ชิ้น (2 ชิ้นต่อคอมเมนต์ — รายการ + ตัวนับ) ·
สไลด์ยกตัวอย่าง 10,000 แถว = ประกาศ 10,000 ครั้ง (คิดตามสัดส่วนที่วัดได้ ของเราจะเป็น 20,000 งาน เพราะมีบรรทัดตัวนับด้วย)
ทางแก้ตอน seed/import คือห่อด้วย suppressing_turbo_broadcasts = "ช่วงนี้ห้ามประกาศ" · ลองใน bin/rails console (ผลจริง ตัดบางส่วนเป็น …):
wongnok-blog-all-in-one(dev):001> Comment.suppressing_turbo_broadcasts { Post.find(25).comments.create!(author_name: "seed", body: "เงียบ") }; nil
Post Load (2.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 25 LIMIT 1
TRANSACTION (0.7ms) BEGIN
Comment Create (1.4ms) INSERT INTO "comments" (…) VALUES ('เงียบ', 'seed', 25, …) RETURNING "id"
TRANSACTION (0.5ms) COMMIT
Comment Count (0.4ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 25
=> nil
wongnok-blog-all-in-one(dev):002> Post.find(25).comments.create!(author_name: "ปกติ", body: "ดัง"); nil
Post Load (3.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 25 LIMIT 1
TRANSACTION (1.4ms) BEGIN
Comment Create (3.6ms) INSERT INTO "comments" (…) VALUES ('ดัง', 'ปกติ', 25, …) RETURNING "id"
TRANSACTION (1.7ms) COMMIT
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: b1ac1546-…) to Async(default) with arguments: "Z2lk…UG9zdC8yNQ", {action: :append, target: "comments", …
↳ (wongnok-blog-all-in-one):2:in '<main>'
Comment Count (1.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 25
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: b1b59b6c-…) to Async(default) with arguments: "Z2lk…UG9zdC8yNQ", {action: :update, target: "comment_count", …, html: 2, …
↳ app/models/comment.rb:7:in 'block in <class:Comment>'
=> nil
…
Comment.suppressing_turbo_broadcasts { … }— ทุกอย่างในปีกกา Comment จะไม่ประกาศ · ออกจากปีกกาแล้วกลับมาประกาศตามปกติเอง; nilท้ายคำสั่ง — แค่ให้ console ไม่พิมพ์คอมเมนต์ทั้งก้อนออกมา (อ่านง่ายขึ้น)- บรรทัด 001: ไม่มี
Enqueuedเลย ทั้งรายการและตัวนับ · บรรทัด 002 (นอกปีกกา):Enqueued2 ชิ้นตามปกติ - สังเกตว่าในปีกกายังมี
Comment Count— Ruby นับpost.comments.countก่อนแล้วค่อยเช็คว่า "ห้ามประกาศ" · ข้อมูลไม่ผิด แค่เสีย query เปล่าหนึ่งครั้งต่อแถว (100 แถวที่ลองใน runner =Comment Count100 ครั้ง · งานประกาศ 0 ชิ้น) - console เป็นอีก process หนึ่ง (เหมือน runner) — ต่อให้ประกาศ เบราว์เซอร์ก็ไม่ได้ยินอยู่ดี เพราะ adapter async · แต่งานยังถูกสร้างและทำจริง
(บรรทัดที่ตัดเป็น
…ท้ายสุดคือ job ที่ทำงานใน console เองจนถึงBroadcasting to— ประกาศออกไปโดยไม่มีใครฟัง เปลืองแรงเปล่า) จึงควรห่อเสมอ - seeds ของเรา (บท 120) ไม่ได้สร้างคอมเมนต์ จึงยังไม่ต้องห่อ · ถ้าวันหลังเพิ่มคอมเมนต์ใน seeds ให้ห่อด้วยบล็อกนี้
ราคาที่ 2 — model รู้จัก view
ปกติคนดูแลสต็อก (model) ไม่ยุ่งกับการจัดจาน (view) · แต่ broadcasts_to ทำให้ model ไปเรียก partial comments/_comment เอง ·
ผลที่สไลด์เตือน: เปลี่ยนชื่อ partial แล้ว model พังโดยไม่รู้ตัว · test ของ model ต้อง render HTML ด้วยจึงช้าลง (เรื่อง test อยู่ Day 4)
ทางเลือกที่สไลด์เสนอ: ไม่ใส่ใน model แต่ให้ controller เป็นคนสั่งประกาศ หลังบันทึกสำเร็จ — seed/console ไม่โดนลูกหลง เพราะไม่ได้ผ่าน controller ·
หน้าตาประมาณนี้ (เราลองเรียกคำสั่งนี้จาก runner แล้วได้ Broadcasting to จริง · บทเรียนไม่ได้เปลี่ยนไปใช้แบบนี้ ไม่ต้องพิมพ์ตาม ·
สไลด์เขียน broadcast_append_to ซึ่งทำ HTML และประกาศทันทีในคำขอ · ตรงนี้ใช้รุ่น _later_ ให้ส่งต่อให้ job ตามหลักเดียวกับ broadcasts_to):
# ใน CommentsController#create หลัง @comment.save สำเร็จ (ตัวอย่างเท่านั้น)
Turbo::StreamsChannel.broadcast_append_later_to(
@post, target: "comments", partial: "comments/comment", locals: { comment: @comment }
)
Turbo::StreamsChannel— "ตัวประกาศ" ตัวเดียวกับที่broadcasts_toใช้อยู่เบื้องหลัง แต่คราวนี้เราเรียกเองตรง ๆbroadcast_append_later_to(@post, …)— ประกาศ append ในช่องของ@post·later= ฝากเป็นงาน · บอก target · partial · locals เองทั้งหมด (ไม่มีการเดาให้)
ข้อดี: อ่าน controller แล้วรู้เลยว่าใครสั่งประกาศ · ข้อเสีย: ทุกที่ที่สร้างคอมเมนต์ (เช่นหน้า admin ในอนาคต) ต้องจำใส่เอง · ไม่มีคำตอบเดียว — สไลด์สรุปว่า "ความสะดวกบรรทัดเดียวมีราคาเสมอ แค่ต้องรู้ว่าราคาคืออะไร"
ทำไมต้องหลัง commit — และ later: true ในสไลด์
broadcasts_to และบรรทัดตัวนับของเราใช้ after_*_commit ทั้งหมด · ทวนบท 112: ถ้าประกาศตอน after_save (ก่อน COMMIT) แล้ว transaction ถูกยกเลิก
ทุกแท็บจะเห็นคอมเมนต์ที่ไม่มีอยู่จริง — เหมือนส่งไลน์บอกลูกค้าก่อนตู้เซ็นรับ ·
เราลองสร้างคอมเมนต์ใน transaction แล้วสั่ง raise ActiveRecord::Rollback (คำสั่ง "ยกเลิกทั้งกล่อง" ของ transaction): log มี BEGIN → ROLLBACK และไม่มี Enqueued สักบรรทัด = ไม่ประกาศของที่ถูกยกเลิก ✅
⚠️ สไลด์หน้า 46 และ 52 ให้เขียน broadcasts_to :post, later: true · ใน turbo-rails 2.0.23 ที่เราใช้ broadcasts_to ไม่มีตัวเลือก later: ·
ใส่แล้วไม่ error แต่ไม่มีผลอะไร (มันใช้ job ให้อยู่แล้วตอนสร้างและแก้ — เห็น Enqueued ใน log ข้างบน) · บทเรียนจึงไม่ใส่ — รายละเอียดท้ายบท
morphing และ data-turbo-permanent — refresh ทั้งหน้าแบบไม่สะดุด
สไลด์หน้า 47 พูดถึงอีกความสามารถหนึ่งของ Turbo 8 (รุ่นที่เราใช้) ซึ่งไม่ได้อยู่ในโค้ดของบทเรียน แต่ควรรู้จัก · ก่อนอื่นต้องรู้จัก page refresh = Turbo โหลดหน้าเดิมซ้ำ ·
เกิดบ่อยตอนส่งฟอร์มแล้ว server พากลับมาหน้าเดิม (redirect) หรือตอน server ประกาศว่า "ทุกคนโหลดหน้านี้ใหม่" (คำสั่ง broadcasts_refreshes_to — อีกทางเลือกแทน broadcasts_to ไม่ได้ใช้ในบทนี้)
ปกติ Turbo เปลี่ยนทั้ง <body> (บท 130) → หน้าเลื่อนกลับไปบนสุด ช่องที่กำลังพิมพ์หลุด ·
ถ้าใส่ 2 บรรทัดนี้ใน <head> ของ layout Turbo จะเทียบหน้าเก่ากับหน้าใหม่ทีละชิ้น แล้วแก้เฉพาะจุดที่ต่าง (เรียกว่า morphing):
<meta name="turbo-refresh-method" content="morph"> <%# refresh แบบเทียบแล้วแก้เฉพาะที่ต่าง %>
<meta name="turbo-refresh-scroll" content="preserve"> <%# refresh แล้วไม่เลื่อนกลับบนสุด %>
และถ้ามีชิ้นไหนที่ห้ามแตะเด็ดขาด (เช่นเครื่องเล่นเพลงที่กำลังเล่น) ให้ติด data-turbo-permanent พร้อม id:
<div id="player" data-turbo-permanent>
<audio controls src="…"></audio>
</div>
เปิดหน้า morph แล้ว · กำลังพิมพ์คอมเมนต์ค้างไว้ (ยังไม่กดส่ง) แล้วหน้าเกิด refresh — ข้อความที่พิมพ์ค้างจะอยู่ไหม?
เราทดลองบนหน้าบทความ (ใส่ meta ชั่วคราว · พิมพ์ในช่องความคิดเห็น · เลื่อนจอลง · สั่ง refresh ด้วย Turbo.session.refresh(location.href) ใน Chrome) — ผลจริง:
| แบบ | ข้อความที่พิมพ์ค้าง | เคอร์เซอร์ยังอยู่ในช่อง | ตำแหน่งเลื่อนจอ |
|---|---|---|---|
| ปกติ (ไม่มี meta) | ❌ หาย | ❌ หลุด | ❌ กลับบนสุด (297 → 0 · ตัวเลขขึ้นกับขนาดหน้าต่าง) |
| morph + preserve | ❌ หาย | ✅ อยู่ | ✅ คงที่ (297) |
morph + ติด data-turbo-permanent ที่ช่องพิมพ์ (ตอนทดลองติดด้วย JavaScript ก่อนสั่ง refresh) | ✅ อยู่ | ✅ อยู่ | ✅ คงที่ |
morph ทำให้หน้าตรงกับ HTML ใหม่จาก server · HTML ใหม่มีช่องความคิดเห็นว่าง ๆ → morph จึงทำให้ช่องเราว่างตาม (แต่ยังเป็นช่องเดิม เคอร์เซอร์ไม่หลุด) ·
data-turbo-permanent คือการบอกว่า "ชิ้นนี้ไม่ต้องเทียบ ปล่อยไว้แบบที่ผู้ใช้ทำ" ข้อความจึงรอด ·
สรุป: morph ช่วยเรื่องตำแหน่งและ focus · ของที่ผู้ใช้กรอกค้างต้องกันเองด้วย permanent
refresh แบบปกติเหมือนพนักงานยกของบนโต๊ะออกหมดแล้ววางชุดใหม่ · morph เหมือนพนักงานถือรายการใหม่มาเทียบกับโต๊ะทีละจาน
จานไหนเหมือนเดิมไม่แตะ จานไหนต่างค่อยเปลี่ยน ลูกค้าแทบไม่รู้สึก ·
data-turbo-permanent คือป้าย "ของลูกค้า ห้ามเก็บ" ที่วางบนแก้วของเขาเอง
ที่ไม่เหมือนกัน: พนักงานจริงเห็นกระดาษที่ลูกค้าเขียนค้างไว้ก็คงไม่ลบ · morph ไม่รู้ว่าอะไรคือ "ของที่ลูกค้ากำลังทำ" มันทำให้ตรงกับรายการใหม่เป๊ะ ๆ · ต้องติดป้าย permanent เองทุกชิ้นที่อยากให้รอด
ลองเองได้ (ไม่บังคับ): ใส่ 2 บรรทัด <meta> ข้างบนไว้ใต้ <%= yield :head %> ใน app/views/layouts/application.html.erb ·
พิมพ์ในช่องความคิดเห็น เลื่อนจอลง เปิด DevTools แท็บ Console แล้วพิมพ์ Turbo.session.refresh(location.href)
(JavaScript หนึ่งบรรทัด: "Turbo ช่วย refresh หน้านี้" · location.href = URL ของหน้าที่เปิดอยู่) ·
ดูเสร็จแล้ว git restore app/views/layouts/application.html.erb — โค้ดของบทเรียนไม่ได้ใช้ morph
- page refresh · morphing
- page refresh = Turbo โหลดหน้าเดิมซ้ำ · morphing = แทนที่จะเปลี่ยนทั้ง body ให้เทียบทีละชิ้นแล้วแก้เฉพาะที่ต่าง (Turbo ใช้ library ชื่อ idiomorph) · พิมพ์บอก AI ได้ว่า “enable morphing with turbo-refresh-method=morph and preserve scroll on refresh”
data-turbo-permanent- ติดที่ element ที่มี id เพื่อบอก Turbo ว่าห้ามแตะชิ้นนี้ (ทั้งตอน morph และตอนเปลี่ยนหน้า) · ถาม senior ได้ว่า “ช่องไหนในหน้านี้ควรเป็น permanent ถ้าเปิด morph”
Turbo เหมาะกับงานแบบไหน
หลักง่าย ๆ: Turbo เก่งเมื่อ server เป็นคนตัดสินว่าหน้าจอควรเป็นแบบไหน และการรอ server ตอบสักครู่ไม่เป็นปัญหา · ทุกอย่างที่เราทำมาตั้งแต่บท 130 อยู่ในหลักนี้ — ข้อมูลชุดเดียวอยู่ที่ครัว (ประโยคหลักข้อ 1 ของวัน: ไม่มีสมุดจดเล่มที่สองบนโต๊ะ ก็ไม่มีบั๊ก sync)
| ✅ เหมาะ (สไลด์หน้า 47) | ทำไม |
|---|---|
| รายการที่เพิ่ม ลบ แก้ (CRUD ทั่วไป) | ทุกการเปลี่ยนต้องผ่าน server อยู่แล้ว |
| ฟอร์มและ validation error | กฎตรวจอยู่ที่ model บน server (บท 132 แทนฟอร์มที่มี error) |
| แจ้งเตือน · ตัวนับ · สถานะที่เปลี่ยนเป็นครั้งคราว | เปลี่ยนไม่บ่อย ส่ง HTML ชิ้นเล็กพอ |
| แชท · คอมเมนต์ · กระดานงาน | คือสิ่งที่เพิ่งทำในบทนี้ |
| dashboard ที่อัปเดตทุกไม่กี่วินาที | ไม่กี่วินาทีครั้ง = server ตามทัน |
| ❌ ไม่เหมาะ (สไลด์หน้า 47) | ทำไม |
|---|---|
| ลากวาง เรียงลำดับด้วยเมาส์ | ต้องขยับตามมือทุกเสี้ยววินาที รอ server ไม่ทัน |
| canvas (พื้นที่วาดรูปในหน้าเว็บ) · แผนที่ · กราฟที่ซูมและลากเลื่อนได้ | หน้าจอเปลี่ยนตามเมาส์ล้วน ๆ ไม่ใช่ HTML จาก server |
| เห็น cursor ของคนอื่นขยับแบบ Figma | ข้อความถี่มาก ส่งเป็นใบสั่ง HTML หนักเกินไป |
| แอปที่ต้องทำงานตอนไม่มีเน็ต | Turbo ต้องถาม server ทุกครั้ง ไม่มีเน็ต = ไม่มีครัว |
| ของที่ต้องตอบสนองทุกครั้งที่กดแป้นพิมพ์ โดยไม่รอ server | เช่นตัวนับอักษรตอนพิมพ์ — ไม่ต้องถามครัวเลย |
งานแบบตารางที่สอง ถ้าเป็นชิ้นเล็ก ๆ ในหน้า (เช่นตัวนับอักษร · ปุ่มคัดลอก) ใช้ Stimulus = JavaScript ชิ้นเล็กที่ HTML เป็นคนเรียก (บท 134) · ถ้าทั้งแอปเป็นแบบนั้น (เช่นโปรแกรมวาดรูป) เครื่องมืออย่าง React ที่เรียนในฝั่ง FRONT จะเหมาะกว่า
ให้ AI ทำ แล้วตรวจยังไง
ตอนนี้คุณทำเองเป็นแล้ว · ถ้าจะสั่ง AI ทำ real-time ให้ส่วนอื่น ใช้ prompt 4 ส่วน (บท 89 · 121) แบบนี้ — อย่าก็อป later: true จากสไลด์ไปด้วย:
## เป้าหมาย
คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าบทความเดียวกัน · ตัวนับเปลี่ยนตาม
## ข้อกำหนด
- ใช้ broadcasts_to :post ใน app/models/comment.rb
- ใส่ turbo_stream_from @post ใน app/views/posts/show.html.erb
- partial comments/_comment ต้องมี id="<%= dom_id comment %>"
## ห้าม
- ใช้ after_save (ต้องเป็น after_commit)
- ส่งข้อความที่ผู้ใช้พิมพ์ผ่าน html:
- เขียน JavaScript เอง
## เกณฑ์ตรวจรับ
- [ ] เปิดสองหน้าต่างวางข้างกัน พิมพ์หน้าซ้าย หน้าขวาขึ้นเองทั้งคอมเมนต์และตัวนับ
- [ ] หน้าที่กดส่งเองไม่มีคอมเมนต์ซ้ำ
Checklist ตรวจงาน AI (ทำเองได้ทุกข้อ ไม่ต้องเชื่อคำบอกของ AI):
grep -n "turbo_stream_from" app/views/posts/show.html.erb— ต้องเจอ 1 บรรทัด (ลืม = พังเงียบ)grep -n "after_save" app/models/comment.rb— ต้องไม่เจอgrep -n "dom_id" app/views/comments/_comment.html.erb— ต้องเจอ- เปิดสองหน้าต่างแล้วลองจริง + ดู log ว่ามี
transmittingเท่าจำนวนแท็บ — สไลด์หน้า 52 บอกว่าเกณฑ์แบบนี้ "AI โกงไม่ได้" เพราะพิสูจน์ด้วยการรัน
🎯 ภารกิจ: เปิดสองหน้าต่างวางข้างกัน แล้วพิมพ์ (~10 นาที)
- ถ้ายังไม่ได้ทำตามบท: แก้
app/models/comment.rbให้ตรงกับไฟล์เต็มในหัวข้อ "ให้ตัวนับในแท็บอื่นขยับด้วย" และเพิ่ม<%= turbo_stream_from @post %>ในหน้า show ตามหัวข้อ "ชิ้นที่ 2" - เปิด server ด้วย
bin/dev(ถ้ายังไม่ได้เปิด) - เปิดสองหน้าต่างเบราว์เซอร์ (ไม่ใช่สองแท็บในหน้าต่างเดียว — จะได้เห็นพร้อมกัน) ไปที่
http://localhost:3000/posts/1ทั้งคู่ แล้ววางซ้าย–ขวา - พิมพ์ชื่อและความคิดเห็นในหน้าต่างซ้าย กดส่ง · มองหน้าต่างขวา
- ดู terminal ที่รัน
bin/devหาบรรทัดBroadcasting toและนับtransmitting - ถ้าทุกอย่างถูก:
git add . && git commit -m "hotwire: broadcast คอมเมนต์ทุกแท็บ"
ควรเห็น: หน้าต่างขวามีคอมเมนต์ใหม่ต่อท้าย และเลขใน "ความคิดเห็น (…)" เพิ่มขึ้น 1 เองโดยไม่ต้องกด refresh ·
หน้าต่างซ้ายมีคอมเมนต์อันเดียว · log มี Broadcasting to 2 บรรทัด (รายการ · ตัวนับ) และแต่ละอันตามด้วย transmitting 2 บรรทัด (สองหน้าต่าง)
ถ้าเจออาการนี้ แปลว่า…
- หน้าต่างขวาไม่ขยับ · log มี
Broadcasting toแต่ไม่มีtransmitting→ หน้า show ยังไม่มีturbo_stream_fromหรือพิมพ์เป็น<% %>(ไม่มี=) · แก้แล้วกด refresh ทั้งสองหน้าต่าง - คอมเมนต์ขึ้นในหน้าต่างขวา แต่ตัวนับยังเป็นเลขเดิม → ยังไม่ได้เพิ่มบรรทัด
after_create_commit … comment_count … - หน้าต่างซ้ายมีคอมเมนต์ซ้ำ 2 อัน →
_comment.html.erbไม่มีid="<%= dom_id comment %>"แล้ว ·git restore app/views/comments/_comment.html.erb - ลองสร้างคอมเมนต์จาก
bin/rails consoleแล้วไม่เด้ง → ปกติ ไม่ใช่บั๊ก · development ใช้ adapter async ซึ่งได้ยินกันแค่ใน server ตัวเดียว
ก่อน commit ตรวจว่าเปลี่ยนแค่ 2 ไฟล์ 7 บรรทัด (ผลจริงจากโค้ดของบทเรียน):
$ git diff --stat
app/models/comment.rb | 5 +++++
app/views/posts/show.html.erb | 2 ++
2 files changed, 7 insertions(+)
ถ้าตัวเลขต่าง (เช่นมี layouts/application.html.erb หรือ _comment.html.erb โผล่มา) แปลว่ายังมีของจากการทดลองค้างอยู่ — git restore <ไฟล์นั้น> ก่อน commit ·
ไม่แน่ใจว่าหน้าต่างขวาขึ้นเพราะ broadcast หรือเพราะเผลอ refresh: ลองพิมพ์อะไรสักตัวในช่องชื่อของหน้าต่างขวาไว้ก่อน แล้วส่งจากหน้าต่างซ้าย ·
ถ้าตัวอักษรในหน้าต่างขวายังอยู่ แปลว่าหน้าไม่ได้โหลดใหม่ คอมเมนต์มาทางเสียงตามสายจริง (เราลองแล้ว: พิมพ์ค้างในช่องชื่อของแท็บขวา → คอมเมนต์ใหม่โผล่ + ตัวนับเปลี่ยน + ข้อความในช่องชื่อยังอยู่ครบ)
Quiz
1. ส่งคอมเมนต์จากแท็บ A แล้วแท็บ B ไม่ขยับ · ใน log เห็น Broadcasting to Z2lk… แต่ไม่มี transmitting เลย · สาเหตุที่น่าจะเป็นที่สุดคือ?
ตัวเลือกที่ 1 ผิด เพราะเห็น Broadcasting to แปลว่า model ประกาศแล้ว ·
ตัวเลือกที่ 2 ผิด เพราะ broadcasts_to ใช้ after_*_commit ให้เองอยู่แล้ว และต่อให้ใช้ after_save ข่าวก็ยังถูกส่ง ไม่ได้หาย ·
ตัวเลือกที่ 4 ผิด เพราะ development โหลดไฟล์ใน app/ ใหม่ให้เอง (เราทดลองทั้งบทโดยไม่ restart) ·
ตัวเลือกที่ 3 ถูก: transmitting คือการส่งถึงลำโพงแต่ละตัว ไม่มีเลย = ไม่มีลำโพง
จุดที่ต้องจำ: real-time ต้องมีสองฝั่ง — คนประกาศ (model) กับคนฟัง (turbo_stream_from ใน view) · ขาดฝั่งฟังแล้วพังเงียบ
2. (ดัดแปลงจากสไลด์หน้า 51) "เพิ่มคอมเมนต์แล้วหน้าเราเปลี่ยน 3 จุด" (บท 132) กับ "คนอื่นเพิ่มคอมเมนต์แล้วหน้าเราขึ้นเอง" (บทนี้) ใช้ Turbo Stream ทั้งคู่ · ต่างกันตรงไหน?
ตัวเลือกที่ 1 ผิด เพราะทั้งคู่เป็นใบสั่ง <turbo-stream> ที่เป็น HTML เหมือนกัน (เห็นใน log) ·
ตัวเลือกที่ 3 ผิด เพราะทั้งสองทางใช้คำสั่งได้เหมือนกัน — บทนี้เองก็ broadcast ทั้ง append และ update ·
ตัวเลือกที่ 4 สลับกัน: แบบที่สองต่างหากที่ต้องมี turbo_stream_from · แบบแรกเป็นคำตอบของฟอร์มจึงไม่ต้องฟังอะไร ·
ตัวเลือกที่ 2 ถูก: ของข้างในเหมือนกัน ต่างแค่ทางที่มันเดินทางมา
จุดที่ต้องจำ: Turbo Stream คือ "ใบสั่งงาน" · ส่งได้สองทาง — เป็นคำตอบของคำขอ (ถึงคนที่ถามคนเดียว) หรือ broadcast ผ่าน WebSocket (ถึงทุกคนที่ฟังช่องนั้น)
3. ทำไมการประกาศคอมเมนต์ใหม่ต้องเกิดหลัง COMMIT (after_commit) ไม่ใช่ตอน after_save?
ตัวเลือกที่ 2 ผิด เพราะ after_save เกิดก่อน after_commit ไม่ได้ช้ากว่า · ตัวเลือกที่ 3 ผิด เพราะ after_save ใช้กับ model ไหนก็ได้ · ตัวเลือกที่ 4 ผิด เพราะ WebSocket ไม่รู้จักฐานข้อมูลเลย ใครสั่งส่งมันก็ส่ง · ตัวเลือกที่ 1 ถูก: เราลอง ROLLBACK แล้วไม่มีการประกาศ เพราะ after_commit ไม่ทำงานเมื่อกล่องถูกยกเลิก
จุดที่ต้องจำ: เรื่องที่ออกไปนอกร้าน (ประกาศ · อีเมล) ทำหลังตู้เซ็นรับเท่านั้น — ส่งออกไปแล้วดึงกลับไม่ได้
4. งานไหนไม่เหมาะจะทำด้วย Turbo มากที่สุด?
ตัวเลือกที่ 1 เหมาะ — เปลี่ยนเป็นครั้งคราว ข้อมูลอยู่ที่ server (เหมือนคอมเมนต์ในบทนี้) · ตัวเลือกที่ 2 เหมาะ — กฎตรวจอยู่บน server ส่งฟอร์มที่มี error กลับมาแทนที่ (บท 132) · ตัวเลือกที่ 3 เหมาะ — ทุกสองสามวินาที server ตามทัน · ตัวเลือกที่ 4 ไม่เหมาะ: ต้องตอบสนองตามมือทุกเสี้ยววินาที รอ server ไม่ได้ และหน้าจอไม่ได้มาจาก HTML ของ server
จุดที่ต้องจำ: Turbo เหมาะเมื่อ server เป็นคนตัดสินหน้าจอและรอสักครู่ได้ · งานที่ต้องตอบทุกการขยับของมือ ใช้ JavaScript ฝั่งเบราว์เซอร์
💬 ถามกลับได้เลย — เช่น "ถ้าอยากให้คอมเมนต์ใหม่ขึ้นบนสุดแทนต่อท้าย ต้องแก้ตรงไหน" หรือ "ถ้าลบคอมเมนต์ ตัวนับในแท็บอื่นจะลดไหม"
(ใบ้: ดูว่า broadcasts_to ทำอะไรตอนลบ และบรรทัดตัวนับของเราเป็น after_create_commit)
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 46 · 52 | broadcasts_to :post, later: true |
ใน turbo-rails 2.0.23 broadcasts_to รับแค่ inserts_by: · target: และตัวเลือกของ partial · later: true ตกไปเป็นตัวเลือกของ partial ซึ่งไม่มีผล ·
ตอนสร้าง/แก้ใช้ job (*_later_to) ให้อยู่แล้ว (ลองทั้งแบบใส่และไม่ใส่ log ได้ Enqueued เหมือนกัน) |
สนใจ — อย่าก็อปไปใส่ prompt · หลักการ "ส่งต่อให้ job" ยังถูก แค่มันทำให้เองแล้ว |
| 45 | Solid Cable — Rails 8 ใช้ตาราง PostgreSQL เป็นช่องทาง pub/sub | จริงเฉพาะ production · config/cable.yml ของ repo นี้ development ใช้ async (ในหน่วยความจำ) |
สนใจ — เป็นเหตุผลที่สร้างคอมเมนต์จาก console แล้วไม่เด้ง |
| 45 | latency "ไม่กี่สิบมิลลิวินาที" | ไม่ได้วัดใน production · config ตั้งให้ดูตารางทุก 0.1 วินาที | ไม่ต้อง |
| 45 | <%= render @post.comments %> | ของเราใช้ render @comments (เรียงตามเวลา จากบท 132) | ไม่ต้อง |
| 52 | เกณฑ์ "ตัวนับจำนวน comment อัปเดตพร้อมกัน" | broadcasts_to อย่างเดียวไม่ทำ (ผลจริง: ตัวนับแท็บขวาค้าง 0) ต้องเพิ่มบรรทัด after_create_commit เอง | สนใจ |
| 47 | morph — "ช่องที่กำลังพิมพ์อยู่ไม่เสีย focus" | focus กับตำแหน่งจออยู่จริง แต่ข้อความที่พิมพ์ค้างหาย ถ้าไม่ติด data-turbo-permanent (ผลทดลองในบท) | สนใจถ้าจะเปิด morph |
อ้างอิง
- Turbo Handbook — Come Alive with Turbo Streams — อ่านหัวข้อ Integration with Server-Side Frameworks (ส่ง Stream ผ่าน WebSocket)
- Turbo Reference — Streams — อ่านใต้ Append: ประโยคเรื่อง id ซ้ำแล้ว "replaced instead of appended" (เหตุผลที่แท็บซ้ายไม่ซ้ำ)
- turbo-rails 2.0.23 — broadcastable.rb — ดู
def broadcasts_to(ไม่มีlater:) และdef suppressing_turbo_broadcasts· คอมเมนต์ต้นไฟล์อธิบายว่าทำไมควรใช้_later - Rails Guides — Action Cable Overview — หัวข้อ Subscription Adapter (async · Solid Cable · Redis) และ Terminology (pub/sub · broadcastings)
- rails/solid_cable — README อธิบายว่าใช้ตารางในฐานข้อมูลแทน Redis และ
polling_interval - Rails Guides — Active Record Callbacks — หัวข้อ Transaction Callbacks (
after_commit·after_create_commit) - Turbo Handbook — Smooth page refreshes with morphing — หัวข้อ Morphing · Scroll preservation · Exclude sections from morphing (
data-turbo-permanent) - MDN — The WebSocket API — ย่อหน้าแรก: WebSocket คืออะไร ต่างจากการถาม-ตอบปกติยังไง