🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- Turbo Frame = เปลี่ยนแค่ถ้วยใบเดียวบนโต๊ะ · ป้ายเลขถ้วย (
id) ต้องตรงกันสองฝั่ง (บท 131) - Turbo Drive = พนักงานเปลี่ยนจานบนโต๊ะเดิม ไม่รื้อโต๊ะ · Turbo เป็นคนส่งฟอร์มแทนเบราว์เซอร์ (บท 130)
- partial + strict locals = แม่พิมพ์ที่บอกไว้บนสุดว่า "ต้องยื่นอะไรใส่มือบ้าง"
<%# locals: (post:) %>·render @posts= Rails เดาชื่อแม่พิมพ์ให้ (บท 123) form_with= ฟอร์มที่ตั้งชื่อช่องให้เอง เช่นpost[title]และแนบ CSRF token ให้ (บท 124)- has_many / belongs_to / dependent (บท 115 · 116) · route ซ้อน
/posts/:post_id/commentsและparams.expect(บท 117)
🍜 เริ่มจากโค้ดสถานะไหน: โปรเจกต์ wongnok-blog-all-in-one ตอนจบบท 131
(หน้า show กด "แก้ไขหัวข้อ" แล้วช่องพิมพ์โผล่ในที่เดิม) · git status ต้องสะอาด · เปิด bin/dev ค้างไว้
(มีคลาส Tailwind ใหม่ในบทนี้ ต้องให้ตัว watch สร้าง CSS ให้ — บท 128) ·
ตามไม่ทัน → กลับไปทำบท 131 ให้จบก่อน (branch day3-c-done ของอาจารย์มีระบบคอมเมนต์ แต่หน้าตาไฟล์ต่างจากบทเรียนนี้มาก)
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · turbo-rails 2.0.23 (Turbo 8.0.23) · PostgreSQL 18 (สไลด์เขียน Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
ผลรันทุกก้อนรันจริงแล้วก็อปมา · พฤติกรรมในเบราว์เซอร์ตรวจด้วย Chrome จริง · ตัวอย่างใช้บทความ "นมเย็น" ซึ่งในเครื่องที่ตรวจเป็นเลข 30 ·
ค่าที่เปลี่ยนทุกครั้ง เช่น เลข id · เวลา ms · เลขในชื่อไฟล์ migration · token ยาว ๆ · "less than a minute ago" ของคุณจะต่าง ·
log ตัดคอมเมนต์ SQL /*action=…*/ กับบรรทัด ↳ ออกให้อ่านง่าย
โจทย์ — ส่งครั้งเดียว ต้องเปลี่ยนสามกล่อง
เป้าหมายของบทนี้คือส่วนคอมเมนต์ใต้บทความ หน้าตาแบบนี้ · ตอนกดปุ่ม "ส่งความคิดเห็น" มีสามกล่องในหน้าที่ต้องเปลี่ยน และอยู่คนละที่กัน:
ถ้าใช้ Turbo Frame (บท 131) เราเปลี่ยนได้แค่กรอบเดียว — กรอบที่ฟอร์มอยู่ข้างใน · กล่อง ① กับ ② อยู่นอกกรอบ เลยไม่ขยับ ·
ถ้าใช้ redirect_to แบบ scaffold ก็ได้ผลถูก แต่ได้ทั้งหน้าใหม่ทั้งที่อยากเปลี่ยนแค่สามจุด ·
ของที่ตอบโจทย์นี้พอดีชื่อ Turbo Stream · แต่ก่อนจะถึงตรงนั้น ต้องมีคอมเมนต์ให้ส่งก่อน
ทวน Day 2 — สร้าง Comment (พิมพ์ตาม ~5 นาที)
ส่วนนี้ไม่มีเรื่องใหม่ · เป็นท่าเดียวกับที่ทำใน Day 2 ทั้งหมด · คอมเมนต์หนึ่งชิ้นมี ข้อความ (body) · ชื่อคนเขียน (author_name — แอปนี้ยังไม่มีระบบล็อกอิน เลยให้พิมพ์ชื่อเอง ระบบล็อกอินรออยู่ Day 4) · และเป็นของบทความไหน (post)
ขั้น 1 · สร้าง model (คำสั่งตามสไลด์หน้า 52) — รันในโฟลเดอร์โปรเจกต์:
$ bin/rails generate model Comment body:text author_name:string post:references
invoke active_record
create db/migrate/20261107035534_create_comments.rb
create app/models/comment.rb
invoke test_unit
create test/models/comment_test.rb
create test/fixtures/comments.yml
post:references = "คอมเมนต์ชี้ไปหาบทความ" → ได้คอลัมน์ post_id + index + foreign key ให้เอง (บท 115) ·
เลขยาว ๆ หน้าชื่อไฟล์ migration คือเวลาที่คุณรัน ของคุณจะต่าง · ไฟล์ test สองไฟล์ปล่อยไว้ก่อน (เรื่อง test รออยู่ Day 4)
ขั้น 2 · เติม "ห้ามว่าง" ให้ตู้ — เปิดไฟล์ migration ที่เพิ่งได้ เติม , null: false ท้ายบรรทัด body และ author_name:
generator ให้มา
t.text :body
t.string :author_name
t.references :post, null: false, foreign_key: true
หลังแก้
t.text :body, null: false
t.string :author_name, null: false
t.references :post, null: false, foreign_key: true
generator ใส่ null: false ให้แค่บรรทัด references · อีกสองบรรทัดต้องเติมเองก่อน db:migrate ·
"validation + NOT NULL ต้องมาเป็นคู่" คือกฎข้อเดียวกับบท 111 — validation กันที่หน้าร้าน NOT NULL กันที่ตัวตู้
ไฟล์ทั้งไฟล์หลังแก้ (ของคุณชื่อไฟล์ต่างแค่เลขเวลา):
class CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.text :body, null: false
t.string :author_name, null: false
t.references :post, null: false, foreign_key: true
t.timestamps
end
end
end
ขั้น 3 · สร้างลิ้นชักใหม่ในตู้:
$ bin/rails db:migrate
== 20261107035534 CreateComments: migrating ===================================
-- create_table(:comments)
-> 0.0030s
== 20261107035534 CreateComments: migrated (0.0044s) ==========================
ขั้น 4 · กฎของคอมเมนต์ + ความสัมพันธ์ — app/models/comment.rb ทั้งไฟล์ (generator เขียน belongs_to ให้แล้ว เราเติม validates สองบรรทัด):
class Comment < ApplicationRecord
belongs_to :post
validates :author_name, presence: true, length: { maximum: 50 }
validates :body, presence: true, length: { maximum: 1000 }
end
แล้วเปิด app/models/post.rb เพิ่มหนึ่งบรรทัดใต้ belongs_to :category:
has_many :comments, dependent: :destroy
validates … presence: true, length: { maximum: 50 }= ห้ามว่าง และยาวไม่เกิน 50 ตัวอักษร (ชื่อคนสั้น ๆ) · ข้อความไม่เกิน 1000 (บท 110)has_many :comments= "บทความหนึ่งมีหลายคอมเมนต์" → ได้@post.commentsมาใช้ (บท 115)dependent: :destroy= ลบบทความแล้วลบคอมเมนต์ของมันตาม · ถ้าไม่ใส่ ตู้จะไม่ยอมให้ลบบทความที่มีคอมเมนต์ (foreign key ฟ้อง — บท 116) · เลือก:destroyเพราะคอมเมนต์ไม่มีความหมายถ้าไม่มีบทความ (ต่างจากหมวดที่ใช้:restrict_with_error"ห้ามลบถ้ายังมีบทความ")
ทางเข้าของคอมเมนต์ — route ซ้อน และ controller
คอมเมนต์ต้องรู้ว่าเป็นของบทความไหน · เราเลยให้ URL บอกเลขบทความไว้ในตัว: POST /posts/30/comments = "ส่งคอมเมนต์ให้บทความเลข 30" ·
เปิด config/routes.rb เปลี่ยน resources :posts บรรทัดเดียว ให้เป็นสามบรรทัดนี้ (ท่า "ซ้อนกัน" ของบท 117):
resources :posts do
resources :comments, only: :create
end
$ bin/rails routes -c comments
Prefix Verb URI Pattern Controller#Action
post_comments POST /posts/:post_id/comments(.:format) comments#create
only: :create = เปิดแค่ประตูเดียว คือ "สร้างคอมเมนต์" (ยังไม่ต้องมีหน้ารวมคอมเมนต์ หน้าแก้ หน้าลบ) ·
:post_id = ช่องในใบสั่งที่บอกเลขบทความ · ได้ชื่อเล่น post_comments → ใช้เป็น post_comments_path(@post) ได้
ต่อไปคือหัวหน้าเชฟแผนกคอมเมนต์ · ไม่มี generator ให้ — สร้างไฟล์ใหม่ app/controllers/comments_controller.rb แล้วพิมพ์ทั้งไฟล์:
class CommentsController < ApplicationController
before_action :set_post
# POST /posts/1/comments
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream # ใช้ create.turbo_stream.erb
format.html { redirect_to @post, notice: "เพิ่มความคิดเห็นแล้ว" }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { post: @post, comment: @comment }),
status: :unprocessable_content
end
format.html do
@comments = @post.comments.order(:created_at)
render "posts/show", status: :unprocessable_content
end
end
end
end
private
def set_post
@post = Post.find(params.expect(:post_id))
end
def comment_params
params.expect(comment: [ :author_name, :body ])
end
end
before_action :set_post+set_post= ก่อนทำอะไร หาบทความจากเลขใน URL ก่อน ·params.expect(:post_id)= "ต้องมีเลขบทความ" · ไม่มีบทความเลขนั้นได้ 404@post.comments.build(comment_params)= สร้างคอมเมนต์ใหม่ที่ผูกกับบทความนี้ให้แล้ว (เติมpost_idเอง) แต่ยังไม่บันทึกcomment_params= รับเฉพาะช่องauthor_nameกับbody· ใครแอบส่งpost_idมาในฟอร์มก็ถูกทิ้ง (strong parameters — บท 117)respond_to do |format|= "ลูกค้าอยากได้คำตอบแบบไหน ก็เตรียมแบบนั้น" ·format.htmlคือแบบหน้าเว็บเต็ม (เหมือน scaffold) ·format.turbo_streamคือของใหม่ของบทนี้ — อธิบายละเอียดในสองหัวข้อถัดไป- ทางที่ไม่ผ่าน (
else) = ส่งฟอร์มเดิมที่มี error กลับไปพร้อมรหัส422(รหัส "ข้อมูลไม่ผ่าน" แบบเดียวกับ scaffold ในบท 118) — ดูหัวข้อ "ส่งว่าง" ข้างล่าง format.html do … endในทางelse= ทางสำรองตอนไม่มี Turbo (เช่นเบราว์เซอร์ปิด JavaScript — ลองในหัวข้อ Accept) · วาดหน้า show เดิมพร้อม error · ต้องเตรียม@commentsเองตรงนี้ เพราะหน้า show ใช้ตัวแปรนี้ แต่คำขอนี้ไม่ได้ผ่านPostsController#showที่ปกติเตรียมให้# POST /posts/1/comments= คอมเมนต์บอกว่า action นี้รับคำขอแบบไหน (ตามสไตล์ scaffold) ·private= method ข้างล่างใช้กันเองในไฟล์นี้ route เรียกตรง ๆ ไม่ได้
หน้าจอ — หน้า show และแม่พิมพ์สองตัว
ถาดหน้าครัวของหน้า show — เปิด app/controllers/posts_controller.rb เติมสองบรรทัดใน def show (เดิมว่าง):
def show
@comments = @post.comments.order(:created_at)
@comment = Comment.new
end
@comments = คอมเมนต์ของบทความนี้ เรียงเก่า → ใหม่ (ของใหม่อยู่ล่างสุด ตรงกับ "ต่อท้าย") · @comment = คอมเมนต์เปล่าไว้วาดฟอร์ม
หน้า show — เปิด app/views/posts/show.html.erb เลื่อนไปท้ายไฟล์ · ตอนนี้ท้ายไฟล์หน้าตาแบบนี้ (แถวปุ่ม แก้ไข / กลับ / ลบ แล้วปิดด้วย </div> สองตัว):
<%= button_to "ลบบทความ", @post, … %>
</div> ← ปิดแถวปุ่ม
← ★ วางก้อนใหม่ตรงนี้ (เว้นบรรทัดว่างหนึ่งบรรทัดก่อน)
</div> ← บรรทัดสุดท้ายของไฟล์ ปิดกล่องใหญ่ทั้งหน้า
ก้อนที่วาง:
<section class="mt-10 space-y-4">
<h2 class="text-xl font-bold">
ความคิดเห็น (<span id="comment_count"><%= @post.comments.count %></span>)
</h2>
<div id="comments" class="space-y-3">
<%= render @comments %>
</div>
<%= render "comments/form", post: @post, comment: @comment %>
</section>
- สาม
idที่ตัวหนา = ป้ายบนกล่องที่จะถูกสั่งเปลี่ยนทีหลัง (comment_count·comments· และcomment_formซึ่งอยู่ในแม่พิมพ์ฟอร์ม) · จำสามชื่อนี้ไว้ ใช้ทั้งบท <span>= กล่องเล็กที่ครอบเฉพาะตัวเลข · มีไว้ให้เปลี่ยนแค่ตัวเลขได้โดยไม่แตะคำว่า "ความคิดเห็น"<%= render @comments %>= "ใช้แม่พิมพ์comments/_commentกับทุกชิ้น" (Rails เดาชื่อแม่พิมพ์จากชนิดของแฟ้ม — บท 123) · ยังไม่มีคอมเมนต์ก็ไม่วาดอะไร<%= render "comments/form", post: @post, comment: @comment %>= ใช้แม่พิมพ์ฟอร์ม (สร้างข้างล่าง) และยื่นของใส่มือสองอย่าง- คลาส Tailwind (บท 72):
mt-10= เว้นที่ว่างด้านบน ·space-y-4/space-y-3= เว้นระยะแนวตั้งระหว่างของข้างใน ·text-xl font-bold= ตัวใหญ่ หนา
แม่พิมพ์คอมเมนต์หนึ่งชิ้น — สร้างโฟลเดอร์ app/views/comments/ แล้วสร้างไฟล์ _comment.html.erb:
<%# locals: (comment:) -%>
<article id="<%= dom_id comment %>" class="card bg-base-100 shadow-sm">
<div class="card-body p-4 gap-1">
<p class="text-sm text-base-content/70">
<span class="font-semibold text-base-content"><%= comment.author_name %></span>
· <%= time_ago_in_words(comment.created_at) %> ago
</p>
<p class="whitespace-pre-line break-words"><%= comment.body %></p>
</div>
</article>
- บรรทัด 1 strict locals: แม่พิมพ์นี้รับ
commentอย่างเดียว (แบบเดียวกับ_postในบท 126) ·-%>ที่มีขีด = ไม่ทิ้งบรรทัดว่างไว้ใน HTML id="<%= dom_id comment %>"→ ได้comment_1·comment_2… ป้ายเลขถ้วยของแต่ละชิ้น (เจอแล้วในบท 131)<%= time_ago_in_words(comment.created_at) %> ago= helper "ผ่านมานานแค่ไหน" ได้แค่less than a minuteไม่มีคำว่า ago · เลยพิมพ์agoต่อท้ายเอง (บท 124)<%= comment.author_name %>·<%= comment.body %>= ของที่ลูกค้าพิมพ์มา ·<%= %>ใส่ซองใสให้เอง (escape) ใครพิมพ์<script>มาก็ขึ้นเป็นตัวหนังสือ ไม่ทำงาน (บท 125)whitespace-pre-line= คลาส Tailwind "ขึ้นบรรทัดใหม่ตามที่คนพิมพ์" ·break-words= คำยาวไม่มีเว้นวรรคให้ตัดลงบรรทัดใหม่ ไม่ล้นจอมือถือ- คลาสหน้าตา:
cardcard-body= การ์ดของ daisyUI ·bg-base-100= สีพื้นการ์ดตามธีม ·shadow-sm= เงาบาง ๆ ·p-4 gap-1= ขอบในและช่องไฟ ·text-base-content/70= สีตัวหนังสือหลักของธีม แต่จางลงเหลือ 70% (ตัวเลขหลัง/คือความเข้ม) ·text-sm= ตัวเล็ก ·font-semibold= ค่อนข้างหนา (บท 128)
แม่พิมพ์ฟอร์ม — สร้าง app/views/comments/_form.html.erb · หน้าตาเกือบเหมือน posts/_form.html.erb ที่ Lab Vibe 2 แต่งไว้ (กล่อง error สีแดง · กรอบแดงรอบช่องที่ผิด):
<%# locals: (post:, comment:) -%>
<%= form_with model: [ post, comment ], id: "comment_form", class: "card bg-base-100 shadow-sm" do |form| %>
<div class="card-body gap-4">
<h3 class="card-title text-lg">เขียนความคิดเห็น</h3>
<% if comment.errors.any? %>
<div role="alert" class="alert alert-error">
<div>
<p class="font-bold">ส่งไม่ได้ มี <%= comment.errors.count %> จุดที่ต้องแก้:</p>
<ul class="list-disc ml-6">
<% comment.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
<div>
<%= form.label :author_name, "ชื่อ", class: "block font-medium" %>
<%= form.text_field :author_name, class: ["input w-full mt-2", { "input-error": comment.errors[:author_name].any? }] %>
</div>
<div>
<%= form.label :body, "ความคิดเห็น", class: "block font-medium" %>
<%= form.textarea :body, rows: 3, class: ["textarea w-full mt-2", { "textarea-error": comment.errors[:body].any? }] %>
</div>
<div class="card-actions justify-end">
<%= form.submit "ส่งความคิดเห็น", class: "btn btn-primary" %>
</div>
</div>
<% end %>
มีแค่สามจุดที่ต่างจากฟอร์มบทความ:
locals: (post:, comment:)= แม่พิมพ์นี้ต้องยื่นของสองอย่าง — ต้องรู้บทความ เพราะ URL ที่ส่งไปมีเลขบทความform_with model: [ post, comment ]= ใส่สองแฟ้มเป็นรายการ → Rails ต่อ URL ให้แบบซ้อน · ผลจริงที่ได้คือaction="/posts/30/comments" method="post"ตรงกับ route ที่เพิ่งเพิ่มพอดีid: "comment_form"= ติดป้ายidบนแท็ก<form>เอง — สำคัญมากในหัวข้อ replace / update
ที่เหลือคือของเดิมจากฟอร์มบทความ ทวนเร็ว ๆ:
<% if comment.errors.any? %>…<% end %>= วาดกล่องแดงเฉพาะตอน save ไม่ผ่าน ·comment.errors.eachวนทีละข้อ ·error.full_message= ประโยค error เต็ม ๆ (บท 110) · ฟอร์มเปล่าตอนเปิดหน้าครั้งแรกไม่มี error เลยไม่มีกล่องนี้role="alert" class="alert alert-error"= กล่องแจ้งเตือนสีแดงของ daisyUI ·list-disc ml-6= รายการมีจุดนำหน้า เยื้องเข้าform.label :author_name, "ชื่อ"= ป้ายช่องที่เขียนว่า "ชื่อ" ·form.text_field= ช่องพิมพ์บรรทัดเดียว ·form.textarea :body, rows: 3= ช่องพิมพ์หลายบรรทัด สูง 3 บรรทัด (บท 124)class: ["input w-full mt-2", { "input-error": … }]= "คลาสพวกนี้ใส่เสมอ ส่วนinput-error(กรอบแดง) ใส่เฉพาะตอนช่องนี้มี error"card-title= หัวข้อการ์ด ·card-actions justify-end= แถวปุ่มชิดขวา ·btn btn-primary= ปุ่มสีหลักของธีม (บท 128)
ลองก่อนเขียนต่อ — ตอนนี้ยังไม่มีไฟล์ app/views/comments/create.turbo_stream.erb · เปิด http://localhost:3000/posts/30 (หรือบทความไหนก็ได้) ·
พิมพ์ชื่อ ทดลอง ข้อความ ส่งก่อนมีไฟล์ stream แล้วกดส่ง
คิดว่าเกิดอะไร? หน้า error สีแดง? คอมเมนต์ขึ้น? หรืออย่างอื่น? แล้วคอมเมนต์ถูกบันทึกลงตู้ไหม?
ไม่มีอะไรเกิดขึ้นเลยบนหน้าจอ — ไม่มี error · รายการยังว่าง · ตัวนับยัง (0) · ช่องที่พิมพ์ไว้ยังมีข้อความค้างอยู่ (ตรวจใน Chrome) · แต่ log บอกความจริง
(log = บันทึกของ server · ดูได้ใน terminal ที่รัน bin/dev ตรงบรรทัดที่ขึ้นต้นด้วย web.1 | หรือเปิดไฟล์ log/development.log):
Started POST "/posts/30/comments" for ::1 at 2026-11-07 11:00:23 +0700
Processing by CommentsController#create as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "comment" => {"author_name" => "ทดลอง", "body" => "ส่งก่อนมีไฟล์ stream"}, "commit" => "ส่งความคิดเห็น", "post_id" => "30"}
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 30 LIMIT 1
TRANSACTION (1.1ms) BEGIN
Comment Create (2.7ms) INSERT INTO "comments" ("body", "author_name", "post_id", "created_at", "updated_at") VALUES ('ส่งก่อนมีไฟล์ stream', 'ทดลอง', 30, '2026-11-07 04:00:23.752225', '2026-11-07 04:00:23.752225') RETURNING "id"
TRANSACTION (1.1ms) COMMIT
No template found for CommentsController#create, rendering head :no_content
Completed 204 No Content in 12ms (ActiveRecord: 4.6ms (2 queries, 0 cached) | GC: 0.2ms)
INSERT+COMMIT= บันทึกลงตู้แล้วจริง · กดรีเฟรชหน้าจะเห็นความคิดเห็น (1)และคอมเมนต์ "ทดลอง" โผล่No template found … rendering head :no_content= เชฟทำอาหารเสร็จ แต่ไม่มีแม่แบบจาน · Rails เลยตอบ204 No Content= "เรียบร้อย แต่ไม่มีอะไรให้" · Turbo ได้ของว่าง ก็ไม่เปลี่ยนอะไรบนหน้า
อาการ "บันทึกแล้วแต่หน้าจอไม่ขยับ" นี้เจอบ่อยมากตอนสั่ง AI แล้วมันลืมสร้างไฟล์ stream · ดู log หาคำว่า No template found คือทางลัด · คอมเมนต์ "ทดลอง" เก็บไว้ได้เลย ตัวอย่างข้างล่างนับต่อจากมัน
Turbo Stream คืออะไร — ใบสั่งงานหลายข้อ
ปกติ server ตอบกลับมาเป็นหน้าเว็บ · Turbo Stream คือคำตอบอีกแบบที่ไม่ใช่หน้าเว็บ แต่เป็นรายการคำสั่ง · แต่ละข้อบอกสามอย่าง: ทำอะไร (ต่อท้าย / แทน / ลบ…) · กับกล่อง id ไหน · ด้วย HTML ชิ้นไหน · Turbo ในเบราว์เซอร์อ่านทีละข้อแล้วทำตาม — ข้อเดียวก็ได้ สิบข้อก็ได้ กล่องอยู่ตรงไหนของหน้าก็ได้
บท 131 (Frame) เหมือนพนักงานยกถ้วยใหม่มาเปลี่ยนถ้วยเดียวบนโต๊ะ · Turbo Stream เหมือนครัวยื่นใบสั่งงานหลายข้อให้พนักงานเสิร์ฟถือไปที่โต๊ะ: "① วางจานนี้ต่อท้ายแถวจานคอมเมนต์ · ② เปลี่ยนป้ายจำนวนเป็น 2 · ③ เก็บกระดาษสั่งที่เขียนแล้ว วางใบใหม่ที่ว่าง" · พนักงานทำตามทีละข้อ โต๊ะเดิม ผ้าปูเดิม ลูกค้าไม่ต้องลุก · ของที่วางลงโต๊ะครัวจัดจานเสร็จแล้วทุกชิ้น (HTML) พนักงานไม่ได้ปรุงอะไรเอง
ที่ไม่เหมือนกัน: พนักงานคนนี้ไม่ถามกลับ — ถ้าหาโต๊ะ/ป้ายตามที่ใบสั่งเขียนไม่เจอ เขาข้ามข้อนั้นไปเงียบ ๆ ไม่มีใครรู้ (ภารกิจท้ายบทจะเจอกับตัว) · และใบสั่งไปถึงแค่โต๊ะที่สั่ง (แท็บที่กดส่ง) · โต๊ะอื่นที่เปิดหน้าเดียวกันอยู่ไม่รู้เรื่อง (ลองแล้ว: เปิดสองแท็บ ส่งจากแท็บซ้าย แท็บขวายังเป็นตัวเลขเดิมจนกว่าจะรีเฟรช — ทำให้ทุกโต๊ะได้ยินคือบท 133)
เทียบกับ React ที่เพิ่งเรียน (บท 73): ถ้าทำหน้านี้ด้วย React เราจะได้ข้อมูลคอมเมนต์ใหม่มาเป็น JSON แล้วต้องเขียนเองในเบราว์เซอร์ว่า
"เติมเข้า state รายการ · บวกตัวนับ 1 · ล้าง state ของฟอร์ม" — สามบรรทัด setXxx(…) และเบราว์เซอร์ต้องจำข้อมูลชุดที่สอง (สมุดจดบนโต๊ะลูกค้า) ·
แบบ Turbo Stream เบราว์เซอร์ไม่ได้จำอะไรเลย · ตัวเลข 2 มาจาก server นับในตู้ใหม่ทุกครั้ง · นี่คือประโยคหลักข้อ ① ของวัน: ถ้า server เป็นเจ้าของข้อมูลชุดเดียว บั๊ก "ตัวเลขไม่ตรงกัน" ก็ไม่มี
- Turbo Stream
- คำตอบจาก server ที่เป็นรายการคำสั่งเปลี่ยน HTML ในหน้า หลายจุดพร้อมกัน · พิมพ์บอก AI ได้ว่า "ตอบ create ด้วย Turbo Stream: append รายการ, update ตัวนับ, replace ฟอร์ม"
- action
- คำสั่งในแต่ละข้อ เช่น
appendupdatereplace(มีทั้งหมด 8 ตัว — ตารางข้างล่าง) - target
idของกล่องที่คำสั่งนั้นทำงานด้วย · ถาม senior ได้ว่า "target ของ stream นี้ควรเป็น id ธรรมดาหรือใช้dom_idดีครับ"<template>- แท็ก HTML มาตรฐานที่ห่อ "ของที่จะเอาไปวาง" ไว้ · เนื้อในแท็กนี้เบราว์เซอร์ไม่โชว์จนกว่าโค้ดจะหยิบออกไปใช้ — Turbo หยิบไปวางตาม action
เขียน create.turbo_stream.erb — ใบสั่งงานสามข้อ
สร้างไฟล์ app/views/comments/create.turbo_stream.erb — ทั้งไฟล์มีแค่นี้:
<%# 1) ต่อท้ายรายการ 2) อัปเดตตัวนับ 3) แทนฟอร์มด้วยฟอร์มว่าง %>
<%= 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 } %>
ชื่อไฟล์อ่านเป็นสามท่อน: create = ใช้กับ action create · turbo_stream = ใช้ตอนลูกค้าขอคำตอบแบบใบสั่งงาน · erb = แม่แบบที่มีช่องให้เติมเหมือนไฟล์ view ทุกไฟล์ (บท 122)
- บรรทัด 1
<%# … %>= คอมเมนต์ ไม่ขึ้นจาน - บรรทัด 2
turbo_stream.append "comments", @comment= "ข้อ ①: ต่อท้ายในกล่องid="comments"ด้วยคอมเมนต์นี้" · ส่ง@comment(แฟ้ม) ไปเฉย ๆ Rails เดาแม่พิมพ์comments/_commentให้ แบบเดียวกับrender @comment - บรรทัด 3
turbo_stream.update "comment_count", @post.comments.count= "ข้อ ②: เปลี่ยนของข้างใน<span id="comment_count">เป็นตัวเลขนี้" ·count= ถามตู้นับใหม่ (SELECT COUNT(*)) ได้เลขจริงเสมอ - บรรทัด 4
turbo_stream.replace "comment_form", partial: "comments/form", locals: { post: @post, comment: Comment.new }= "ข้อ ③: เอาฟอร์มใบเก่าออกทั้งใบ แล้ววางฟอร์มใหม่จากแม่พิมพ์_formที่ใส่คอมเมนต์เปล่า" → ช่องว่างทั้งหมด
ทำไม controller แทบไม่ต้องแก้: บรรทัด format.turbo_stream ใน controller ไม่มีบล็อก { … } ต่อท้าย แปลว่า
"ทำตามแม่แบบชื่อเดียวกับ action" → Rails ไปหา create.turbo_stream.erb เอง · ตอนที่ยังไม่มีไฟล์ก็เลยได้ 204 อย่างที่เห็นข้างบน
Rails รู้ได้ยังไงว่าต้องตอบเป็นใบสั่งงาน
ใบสั่ง (request) ทุกใบมีบรรทัดข้อมูลแนบหัวกระดาษที่คนใช้เว็บมองไม่เห็น เรียกว่า header · หนึ่งในนั้นชื่อ Accept = "รับคำตอบแบบไหนได้บ้าง เรียงตามที่อยากได้" ·
ตอนกดส่งฟอร์ม Turbo เป็นคนส่งแทนเบราว์เซอร์ (บท 130) และเขียน Accept ให้เอง · ค่าที่ดักได้จริงใน Chrome:
Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml
ตัวแรก text/vnd.turbo-stream.html = "ขอเป็นใบสั่งงานก่อนนะ" · ถ้าไม่มีค่อยเอาหน้าเว็บ (text/html) ·
Rails อ่านแล้วจดใน log ว่า Processing by CommentsController#create as TURBO_STREAM → respond_to จึงเลือกทาง format.turbo_stream
เคยเจอแล้วฝั่ง Go: header Accept: application/json ในบท 35 คือของชิ้นเดียวกัน — ตอนนั้น client ขอ JSON · ตอนนี้ Turbo ขอ "ใบสั่งงาน" ·
ส่วน respond_to ก็คือ "อ่าน Accept แล้วเลือกวิธีตอบ" ที่ Rails ทำให้ แทนที่เราจะต้องเขียน if ดู header เอง
ใบสั่งอาหารมีช่องเล็ก ๆ ให้ติ๊ก "ขอแบบไหน": ☑ ขอเป็นใบสั่งงานให้พนักงานเปลี่ยนของบนโต๊ะ · ☐ ถ้าไม่ได้ เสิร์ฟจานใหม่ทั้งชุด ·
หัวหน้าเชฟ (respond_to) ดูช่องนี้แล้วเลือกเตรียมให้ตรง — ครัวเดียวกัน ทำอาหารเหมือนกัน (บันทึกคอมเมนต์เหมือนกัน) ต่างกันแค่วิธีเสิร์ฟ
ที่ไม่เหมือนกัน: ลูกค้า (คนที่ใช้เว็บ) ไม่ได้ติ๊กเอง — Turbo ติ๊กให้ · ถ้าเบราว์เซอร์ปิด JavaScript ก็ไม่มี Turbo ไม่มีใครติ๊ก ·
ใบสั่งไปแบบธรรมดา → Rails เลือก format.html → redirect_to ทั้งหน้า + ข้อความ "เพิ่มความคิดเห็นแล้ว" ·
ลองแล้วใน Chrome ที่ปิด JavaScript: ส่งได้ คอมเมนต์ขึ้น แค่หน้าโหลดใหม่ทั้งหน้า (log: as HTML · 302 Found) = เว็บยังใช้ได้แม้ JavaScript พัง
- Accept header
- บรรทัดในใบสั่ง (request) ที่บอกว่ารับคำตอบชนิดไหนได้ · ถาม AI ได้ว่า "request นี้ Accept อะไร ทำไม Rails ถึงตอบเป็น HTML แทน turbo stream"
- MIME type / format
- ชื่อชนิดของข้อมูล เช่น
text/html·text/vnd.turbo-stream.html· Rails ย่อเป็น format:html:turbo_streamใช้ในrespond_to respond_to- ตัวเลือกวิธีตอบตามชนิดที่ลูกค้าขอ · พูดกับ senior ได้ว่า "create มี respond_to สองทาง: turbo_stream ใช้ template, html ทำ redirect เป็น fallback"
- 204 No Content
- รหัสตอบ "สำเร็จ แต่ไม่มีเนื้อหา" · ถ้าเจอหลังส่งฟอร์มแล้วหน้าไม่ขยับ ให้สงสัยว่าลืมไฟล์ template
ของจริงที่ server ส่งกลับ — ใบสั่งงานหน้าตาแบบนี้
เปิดดูใบสั่งด้วยตาตัวเองไปพร้อมกัน: ใน Chrome คลิกขวาที่หน้า → Inspect (ตรวจสอบ) → แท็บ Network เปิดค้างไว้ก่อนกดส่ง (แท็บนี้จดเฉพาะคำขอที่เกิดหลังเปิด — บท 130) ·
แล้วพิมพ์ชื่อ ต้นกล้า ข้อความ ชาเย็นร้านนี้หวานกำลังดี กดส่ง · ผลที่ตรวจใน Chrome จริง:
- ✅ คอมเมนต์ของต้นกล้าต่อท้ายใต้คอมเมนต์ "ทดลอง" (id
comment_2) - ✅ หัวข้อเปลี่ยนเป็น
ความคิดเห็น (2) - ✅ ช่องชื่อและช่องข้อความว่าง
- ✅ แถบที่อยู่ยังเป็น
/posts/30และหน้าไม่ได้โหลดใหม่ (วิธีที่เราตรวจ: ฝากค่าไว้ในหน้าผ่าน Console ก่อนกด หลังกดค่านั้นยังอยู่ — ถ้าหน้าโหลดใหม่ ค่าที่ฝากไว้จะหายหมด)
ในแท็บ Network คลิกแถวชื่อ comments → แท็บ Response · นี่คือของที่ server ส่งมาจริง (ตัด token ยาว ๆ และไส้ฟอร์มบางส่วนออก):
<turbo-stream action="append" target="comments"><template><!-- BEGIN app/views/comments/_comment.html.erb
--><article id="comment_2" class="card bg-base-100 shadow-sm">
<div class="card-body p-4 gap-1">
<p class="text-sm text-base-content/70">
<span class="font-semibold text-base-content">ต้นกล้า</span>
· less than a minute ago
</p>
<p class="whitespace-pre-line break-words">ชาเย็นร้านนี้หวานกำลังดี</p>
</div>
</article>
<!-- END app/views/comments/_comment.html.erb --></template></turbo-stream>
<turbo-stream action="update" target="comment_count"><template>2</template></turbo-stream>
<turbo-stream action="replace" target="comment_form"><template><!-- BEGIN app/views/comments/_form.html.erb
--><form id="comment_form" class="card bg-base-100 shadow-sm" action="/posts/30/comments" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="(ตัด)" />
<div class="card-body gap-4">
<h3 class="card-title text-lg">เขียนความคิดเห็น</h3>
…(ช่องชื่อ · ช่องความคิดเห็น · ปุ่มส่ง — ว่างทั้งหมด)…
</div>
</form><!-- END app/views/comments/_form.html.erb --></template></turbo-stream>
อ่านทีละก้อน:
- สามแท็ก
<turbo-stream>= สามบรรทัดในcreate.turbo_stream.erbตรงตัว ·action="append" target="comments"มาจากบรรทัด 2 ·update/comment_countจากบรรทัด 3 ·replace/comment_formจากบรรทัด 4 - ใน
<template>ของข้อ ① = HTML ที่แม่พิมพ์_commentวาดเสร็จแล้ว (การ์ดid="comment_2") - ข้อ ② มีแค่
2— server นับมาให้แล้ว เบราว์เซอร์ไม่ได้บวกเอง - ข้อ ③ = ฟอร์มใหม่ทั้งใบ มี
id="comment_form"ติดมาด้วย และมีauthenticity_tokenใหม่ฝังมา (CSRF — บท 124) จึงส่งครั้งต่อไปได้เลย <!-- BEGIN app/views/… -->/<!-- END … -->= ป้ายบอกว่า HTML ก้อนนี้มาจากไฟล์ไหน · Rails ใส่ให้เฉพาะตอน development ช่วยไล่หาไฟล์ · production ไม่มี
log ของใบสั่งเดียวกัน:
Processing by CommentsController#create as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "comment" => {"author_name" => "ต้นกล้า", "body" => "ชาเย็นร้านนี้หวานกำลังดี"}, "commit" => "ส่งความคิดเห็น", "post_id" => "30"}
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 30 LIMIT 1
TRANSACTION (0.9ms) BEGIN
Comment Create (2.6ms) INSERT INTO "comments" ("body", "author_name", "post_id", "created_at", "updated_at") VALUES (…) RETURNING "id"
TRANSACTION (1.2ms) COMMIT
Rendering comments/create.turbo_stream.erb
Rendered comments/_comment.html.erb (Duration: 0.2ms | GC: 0.0ms)
Comment Count (1.0ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 30
Rendered comments/_form.html.erb (Duration: 0.4ms | GC: 0.0ms)
Rendered comments/create.turbo_stream.erb (Duration: 3.3ms | GC: 0.0ms)
Completed 200 OK in 13ms (Views: 3.6ms | ActiveRecord: 4.6ms (3 queries, 0 cached) | GC: 0.0ms)
เทียบกับตอนยังไม่มีไฟล์: คราวนี้มี Rendering comments/create.turbo_stream.erb และได้ 200 OK แทน 204 ·
Comment Count คือบรรทัด 3 ของไฟล์ stream ไปถามตู้นับ
ส่งว่าง — error โผล่ในฟอร์ม ที่เดิม
กดส่งโดยไม่พิมพ์อะไรเลย · ผลใน Chrome: กล่องแดง "ส่งไม่ได้ มี 2 จุดที่ต้องแก้: Author name can't be blank · Body can't be blank" ขึ้นในฟอร์ม · ช่องทั้งสองมีกรอบแดง ·
รายการยัง 2 ชิ้น ตัวนับยัง (2) · URL ไม่เปลี่ยน · ถ้าพิมพ์ข้อความไว้แต่ลืมชื่อ ข้อความที่พิมพ์ไม่หาย (ฟอร์มใหม่วาดจาก @comment ตัวที่มีค่าเดิม)
ทางนี้มาจาก else ใน controller:
format.turbo_stream do
render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { post: @post, comment: @comment }),
status: :unprocessable_content
end
เป็นใบสั่งข้อเดียวเขียนตรงใน controller (ไม่ต้องมีไฟล์แยก) — "แทนฟอร์มด้วยฟอร์มที่มี error" · @comment ตัวนี้ save ไม่ผ่าน เลยมี errors ติดอยู่ให้แม่พิมพ์วาดกล่องแดง ·
คำตอบจริงจาก server (ตัดบางส่วน):
<turbo-stream action="replace" target="comment_form"><template><!-- BEGIN app/views/comments/_form.html.erb
--><form id="comment_form" class="card bg-base-100 shadow-sm" action="/posts/30/comments" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="(ตัด)" />
<div class="card-body gap-4">
<h3 class="card-title text-lg">เขียนความคิดเห็น</h3>
<div role="alert" class="alert alert-error">
<div>
<p class="font-bold">ส่งไม่ได้ มี 2 จุดที่ต้องแก้:</p>
<ul class="list-disc ml-6">
<li>Author name can't be blank</li>
<li>Body can't be blank</li>
</ul>
</div>
</div>
<div>
<div class="field_with_errors"><label class="block font-medium" for="comment_author_name">ชื่อ</label></div>
<div class="field_with_errors"><input class="input w-full mt-2 input-error" type="text" value="" name="comment[author_name]" id="comment_author_name" /></div>
</div>
…(ช่องความคิดเห็นหน้าตาแบบเดียวกัน · ปุ่มส่ง)…
</div>
</form><!-- END app/views/comments/_form.html.erb --></template></turbo-stream>
- แท็กเดียว
replace→ รายการกับตัวนับไม่ถูกแตะ <div class="field_with_errors">= Rails ห่อช่องที่ผิดให้เองอัตโนมัติ (ไม่ได้เขียนเอง ไม่มีผลกับหน้าตาในแอปนี้)- ข้อความ error เป็นภาษาอังกฤษ (
Author nameแปลงมาจากชื่อคอลัมน์author_name) เพราะแอปยังไม่ได้ตั้งภาษาไทย — ปกติ ไม่ใช่บั๊กของคุณ - ใน Console ของ Chrome จะมีบรรทัดแดง
Failed to load resource: the server responded with a status of 422 (Unprocessable Content)= ปกติ เบราว์เซอร์แค่รายงานว่าได้รหัส 422
status: :unprocessable_content (รหัส 422) อย่าลืม — ลองแล้ว: เปลี่ยนเป็น 200 หน้าจอก็ยังโชว์ error เหมือนเดิม (Turbo ทำตามใบสั่งโดยไม่ดูรหัส) ·
แต่รหัสคือการบอกความจริงว่า "ไม่สำเร็จ" ให้ log · test (Day 4) และเครื่องมือที่นับ error อ่านถูก · และเป็นข้อห้ามในโจทย์ Lab ของสไลด์หน้า 52 ด้วย
คำสั่งทั้ง 8 ของ Turbo Stream
ในไฟล์เราใช้แค่สามคำสั่ง · ของจริงมีแปด (สไลด์หน้า 44 นับ before/after เป็นข้อเดียวเลยได้ 7 แถว) · ทุกตัวในตารางนี้ลองแล้วใน Chromeกับหน้าคอมเมนต์ของเรา:
| คำสั่ง | ทำอะไรกับกล่อง target | ในร้านอาหาร | ใช้ตอนไหน |
|---|---|---|---|
append | เติมของใหม่ต่อท้ายข้างในกล่อง | วางจานใหม่ปลายแถว | คอมเมนต์ใหม่ (เรียงเก่า→ใหม่) |
prepend | เติมของใหม่ไว้บนสุดข้างในกล่อง | วางจานใหม่หัวแถว | รายการที่ของใหม่อยู่บน เช่น แจ้งเตือน |
replace | เอากล่องทั้งกล่องออก (รวมตัวมันเองและ id) วางของใหม่แทน | ยกถ้วยทั้งใบออก วางใบใหม่ | ฟอร์ม · การ์ดที่ถูกแก้ทั้งใบ |
update | เปลี่ยนเฉพาะของข้างใน กล่องเดิมอยู่ที่เดิม | เทของในถ้วยออก เติมใหม่ ถ้วยเดิม | ตัวเลข · ข้อความสั้น ๆ |
remove | ลบกล่องทิ้ง (ไม่ต้องมี <template>) | เก็บจานออกจากโต๊ะ | ลบคอมเมนต์ |
before / after | แทรกของใหม่ข้างนอกกล่อง ก่อน / หลังมัน | วางจานข้าง ๆ ถ้วย ไม่ใส่ในถ้วย | ข้อความแจ้งเหนือฟอร์ม (ภารกิจ) |
refresh | ให้หน้าไปขอหน้าเดิมจาก server ใหม่ (ผ่าน Turbo Drive · ไม่ต้องมี target) | จัดโต๊ะใหม่ตามสมุดสต็อกของครัว | เปลี่ยนหลายจุดจนเขียนทีละข้อไม่ไหว (บท 133) |
ลองเล่นเองได้โดยไม่แก้ไฟล์ (ไม่บังคับ ข้ามได้): เปิดแท็บ Console ของ Chrome ที่หน้าบทความ แล้วพิมพ์บรรทัดนี้ กด Enter — คือการยื่นใบสั่งงานที่เราเขียนเองให้ Turbo ทำตาม:
Turbo.renderStreamMessage('<turbo-stream action="remove" target="comment_1"></turbo-stream>')
Turbo= ตัว Turbo ที่โหลดอยู่ในหน้าแล้ว ·.renderStreamMessage(…)= "ทำตามใบสั่งนี้" · ข้อความในเครื่องหมาย' 'คือใบสั่งหนึ่งข้อ หน้าตาเดียวกับที่ server ส่งมาcomment_1= id ของคอมเมนต์ "ทดลอง" ในเครื่องที่ตรวจ · ของคุณอาจเป็นเลขอื่น — คลิกขวาที่การ์ดคอมเมนต์ → Inspect ดูid="comment_…"แล้วเปลี่ยนตาม- ถ้าวางแล้ว Chrome ขึ้นข้อความเตือนเรื่องการวางโค้ดและไม่ยอมให้วาง ให้ทำตามที่ข้อความบอก หรือพิมพ์เองทีละตัวอักษร
คอมเมนต์นั้นหายจากหน้าทันที · กดรีเฟรชแล้วมันกลับมา เพราะ Turbo Stream เปลี่ยนแค่ HTML บนจอ ไม่ได้แตะตู้ — การลบจริงต้องผ่าน controller เสมอ
replace กับ update — ต่างกันคำเดียว แต่พังคนละแบบ
สองคำนี้คนสับสนมากที่สุด · กติกาจำง่าย: ป้าย id ติดอยู่ที่ตัวกล่อง ·
update เก็บกล่อง (และป้าย) ไว้ เปลี่ยนแค่ของข้างใน · replace ยกกล่องพร้อมป้ายทิ้ง → ของใหม่ต้องมีป้ายเดิมติดมาเอง ไม่งั้นครั้งหน้าหาไม่เจอ
ถ้วยน้ำจิ้มมีป้ายเลข "comment_count" แปะที่ตัวถ้วย · update = เทน้ำจิ้มเก่าออก เติมใหม่ ถ้วยใบเดิม ป้ายยังอยู่ ·
replace = ยกถ้วยทั้งใบไปล้าง วางของใหม่ลงแทน — ถ้าของใหม่เป็นแค่ "น้ำจิ้มเปล่า ๆ ไม่มีถ้วย" ป้ายก็หายไปกับถ้วยเก่า ·
ใบสั่งงานรอบหน้าที่เขียนว่า "เปลี่ยนน้ำจิ้มในถ้วย comment_count" พนักงานก็หาไม่เจอ
ที่ไม่เหมือนกัน: ในร้านจริงพนักงานจะเดินกลับมาถามว่า "ถ้วยเบอร์นี้ไม่มีครับ" · Turbo ไม่ถาม ไม่ฟ้อง — console ว่างเปล่า หน้าดูปกติ แค่ตัวเลขค้าง
แก้บรรทัด 3 ของ create.turbo_stream.erb จาก update เป็น replace แบบนี้ (ทดลองชั่วคราว):
<%= turbo_stream.replace "comment_count", @post.comments.count %>
บันทึกไฟล์ แล้วรีเฟรชหน้าบทความหนึ่งครั้ง · ถ้าทำตามบทมาตลอด หัวข้อตอนนี้คือ ความคิดเห็น (2) (ทดลอง + ต้นกล้า · ถ้าคุณส่งเล่นไว้เพิ่ม เลขจะมากกว่านี้ ไม่เป็นไร ดูแค่ว่าตัวเลขขยับไหม) · ส่งคอมเมนต์ใหม่ สองครั้งติดกัน
หลังส่งครั้งแรก หัวข้อเป็นอะไร? หลังส่งครั้งที่สองล่ะ? และ Console มี error ไหม?
ผลใน Chrome จริง:
ก่อนส่ง ความคิดเห็น (2) รายการ 2 ชิ้น <span id="comment_count">2</span>
ส่งครั้งที่ 1 ความคิดเห็น (3) รายการ 3 ชิ้น (ไม่มี span แล้ว เหลือแค่ตัวหนังสือ 3)
ส่งครั้งที่ 2 ความคิดเห็น (3) รายการ 4 ชิ้น ← ตัวเลขค้าง
Console: ว่าง ไม่มี error
- ครั้งแรกดูเหมือนถูก —
replaceเอา<span id="comment_count">ทั้งก้อนออก แล้ววางตัวเลข3เปล่า ๆ แทน · ตัวเลขถูก แต่ป้าย id หายไปแล้ว - ครั้งที่สอง Turbo หากล่อง
comment_countไม่เจอ → ข้ามข้อนี้เงียบ ๆ · ข้อ ① กับ ③ ยังทำงาน รายการเลยเพิ่มแต่ตัวเลขค้าง
นี่คือบั๊กที่ทดสอบครั้งเดียวไม่เจอ · ต้องส่งอย่างน้อยสองครั้งเสมอ ·
กลับด้านก็พัง: ถ้าบรรทัด 4 ใช้ update "comment_form" จะได้ฟอร์มซ้อนในฟอร์ม (เพราะ id อยู่บนแท็ก <form> เอง และแม่พิมพ์ก็สร้าง <form> ใหม่ทั้งใบ) — ลองแล้ว ส่งครั้งที่สองหน้าโหลดใหม่ทั้งหน้าแทนที่จะเปลี่ยนแค่สามจุด ·
แก้กลับเป็น update ด้วยมือก่อนไปต่อ (ไฟล์นี้ยังไม่เคย commit จึงใช้ git restore ย้อนไม่ได้)
- replace (outerHTML)
- แทนทั้ง element รวมแท็กของมันเอง · ภาษา JavaScript เรียกส่วนนี้ว่า
outerHTML· บอก AI ได้ว่า "ฟอร์มมี id บน <form> เองต้องใช้ replace และ partial ต้องคืน id เดิม" - update (innerHTML)
- แทนเฉพาะเนื้อข้างใน element · JavaScript เรียกว่า
innerHTML· เหมาะกับตัวเลข/ข้อความในกล่องที่มี id อยู่แล้ว - target ไม่มีในหน้า
- Turbo ข้ามคำสั่งนั้นไปโดยไม่ฟ้อง · เวลาตัวเลขค้างให้ถามตัวเองก่อนว่า "หลังคำสั่งรอบที่แล้ว id นี้ยังอยู่ในหน้าไหม"
Frame หรือ Stream — เลือกยังไง
| Turbo Frame (บท 131) | Turbo Stream ตอบฟอร์ม (บทนี้) | |
|---|---|---|
| เปลี่ยนได้กี่กล่อง | 1 กรอบ — กรอบที่ลิงก์/ฟอร์มอยู่ข้างใน | กี่กล่องก็ได้ อยู่ตรงไหนของหน้าก็ได้ |
| ใครเลือกกล่อง | เบราว์เซอร์ — หา <turbo-frame id> ที่ตรงกันในคำตอบ | server — เขียน target ไว้ในใบสั่งทุกข้อ |
| server ตอบอะไร | หน้า HTML ธรรมดาที่มีกรอบ id เดียวกัน | ใบสั่งงาน <turbo-stream> (ชนิด text/vnd.turbo-stream.html) |
| เหมาะกับ | แก้ในที่เดิม · แท็บ · โหลดทีหลัง | ส่งฟอร์มแล้วต้องเปลี่ยนหลายจุด · เพิ่ม/ลบของในรายการ |
กฎง่าย ๆ: ถามตัวเองว่า "กดแล้วต้องเปลี่ยนกี่กล่อง" · หนึ่งกล่องและเป็นกล่องที่กดอยู่ข้างใน → Frame · มากกว่าหนึ่ง หรืออยู่คนละที่ → Stream · และถามต่ออีกข้อว่า "ต้องเปลี่ยนแค่แท็บของคนกด หรือทุกคนที่เปิดหน้านี้" · แท็บคนกด = Stream ตอบฟอร์ม (บทนี้) · ทุกคน = Stream แบบ broadcast (บท 133) — สองแบบนี้คือข้อที่สไลด์หน้า 51 บอกว่าคนตอบผิดเยอะที่สุด
เก็บงาน — commit
ก่อน commit ดูว่ามีไฟล์ครบไหม (ผลจริง · เลขในชื่อ migration ของคุณจะต่าง):
$ git status --short
M app/controllers/posts_controller.rb
M app/models/post.rb
M app/views/posts/show.html.erb
M config/routes.rb
M db/schema.rb
?? app/controllers/comments_controller.rb
?? app/models/comment.rb
?? app/views/comments/
?? db/migrate/20261107035534_create_comments.rb
?? test/fixtures/comments.yml
?? test/models/comment_test.rb
app/views/comments/ ข้างในต้องมีสามไฟล์ (_comment.html.erb · _form.html.erb · create.turbo_stream.erb) ·
ตรวจว่าบรรทัด 3 ของไฟล์ stream กลับเป็น update แล้ว · จากนั้น:
$ git add . && git commit -m "Comment + ฟอร์มคอมเมนต์ตอบด้วย Turbo Stream"
โค้ดของคุณตอนนี้คือจุดเริ่มต้นของบท 133
🎯 ภารกิจ — ใบสั่งข้อที่ 4: ข้อความ "เพิ่มความคิดเห็นแล้ว" (~10 นาที)
สไลด์หน้า 44 มีข้อ 4 ที่เรายังไม่ได้ทำ: โชว์ข้อความสำเร็จหลังส่ง · ลองเติมเองแล้วดูว่าเกิดอะไร · ต้อง commit ตามหัวข้อก่อนหน้าแล้ว (git status สะอาด)
- เปิด
app/views/comments/create.turbo_stream.erbเพิ่มก้อนนี้ต่อท้ายไฟล์ (บรรทัดที่ 5 เป็นต้นไป) ตามสไลด์เป๊ะ ๆ:
(<%= turbo_stream.prepend "flash" do %> <div class="alert">เพิ่มความคิดเห็นแล้ว</div> <% end %>do … end= แบบมีบล็อก: HTML ที่อยู่ระหว่างกลางคือของที่จะเอาไปวาง ใช้แทนการชี้ไปที่แม่พิมพ์) - ส่งคอมเมนต์หนึ่งครั้ง · ดูหน้าจอ และดูแท็บ Console
- แก้บรรทัดแรกของก้อนนั้นเป็น
<%= turbo_stream.before "comment_form" do %>และแก้คลาสเป็นclass="alert alert-success"· ส่งคอมเมนต์สองครั้ง - เก็บกวาด:
git restore app/views/comments/create.turbo_stream.erbแล้วgit statusต้องว่าง
ก่อนกดเฉลย: ข้อ 2 เห็นข้อความสำเร็จไหม เพราะอะไร? ข้อ 3 ส่งสองครั้งแล้วเห็นข้อความกี่กล่อง เพราะอะไร?
ข้อ 2 — ไม่มีข้อความโผล่เลย (ตรวจใน Chrome: ทั้งหน้าไม่มีกล่อง .alert สักกล่อง · Console ว่าง) · แต่คอมเมนต์ต่อท้าย ตัวนับเพิ่ม ฟอร์มว่าง ตามปกติ ·
เหตุผล: ในหน้าของเราไม่มีกล่องไหนชื่อ id="flash" (layout ของแอปนี้ไม่มี · ข้อความ notice อยู่ในแต่ละหน้าด้วย id="notice" และโผล่เฉพาะตอนมีข้อความ) →
พนักงานหาป้ายไม่เจอ ข้ามข้อนี้เงียบ ๆ = "ที่ไม่เหมือนกัน" ของภาพใบสั่งงาน
ข้อ 3 — ข้อความขึ้นเหนือฟอร์ม · ส่งครั้งที่สองได้สองกล่องซ้อนกัน (ผลจริงใน Chrome: ครั้งแรก 1 กล่อง ครั้งที่สอง 2 กล่อง "เพิ่มความคิดเห็นแล้ว") ·
เพราะ before = "แทรกเพิ่มข้างนอกกล่อง" ทุกครั้งที่ส่ง ไม่ได้แทนของเดิม · กล่อง comment_form ยังมีอยู่เสมอ (ข้อ ③ วางฟอร์มใหม่ที่มี id เดิมกลับมา) จึงหาเจอทุกรอบ
ถ้าอยากให้มีข้อความเดียวเสมอ ทางหนึ่งคือเตรียมกล่องว่างที่มี id ไว้ในหน้า show แล้วใช้ update กับกล่องนั้น · บทนี้ไม่ทำต่อ — เป้าหมายคือเห็นว่าคำสั่งต่างกันให้ผลต่างกัน และ target ต้องมีอยู่จริง
ถ้าเจออาการนี้ แปลว่า…
- หน้า error
ActionView::StrictLocalsError in Comments#create· log มีmissing local: :post for app/views/comments/_form.html.erb— คุณก็อปข้อ 3 ของสไลด์ (render "comments/form", comment: Comment.new) มาด้วย · แม่พิมพ์ฟอร์มของเราประกาศว่าต้องยื่นpost:ด้วย · ลองแล้ว: คอมเมนต์ถูกบันทึกไปแล้วก่อนพัง (พังตอนวาดใบสั่ง) · แก้ด้วยการใช้บรรทัด 4 แบบในบทเรียน - ข้อ 3 แล้วข้อความยังไม่ขึ้น — สะกด
"comment_form"ผิด (เช่นcomments_form) · target ผิดตัวเดียวก็ถูกข้ามเงียบ ๆ เหมือนข้อ 2 git statusหลังข้อ 4 ยังเห็นไฟล์อื่น — คุณยังไม่ได้ commit งานของบทนี้ก่อนเริ่มภารกิจ · อย่าgit restore .ทั้งโฟลเดอร์ จะลบงานทั้งบท ·git add . && git commitก่อน
ทดสอบความเข้าใจ
ผู้ใช้กดส่งคอมเมนต์ แล้วต้องเปลี่ยน 3 จุดในหน้าเฉพาะแท็บของคนที่กดส่ง · ควรใช้อะไร? (ดัดแปลงจาก Quiz C สไลด์หน้า 51)
ตัวเลือกที่ 2 ถูก — หลายกล่อง + เฉพาะคนกด = ใบสั่งงานที่ตอบกลับคำขอของคนนั้น
ตัวเลือกที่ 1 Frame เปลี่ยนได้แค่กรอบเดียว รายการกับตัวนับอยู่นอกกรอบจึงไม่ขยับ · ตัวเลือกที่ 3 broadcast คือส่งให้ทุกคน — เกินโจทย์ (และคือข้อที่สไลด์บอกว่าคนสับสนกับข้อนี้มากที่สุด เรื่องของบท 133) · ตัวเลือกที่ 4 Turbo Drive ได้หน้าใหม่ทั้งหน้าหลัง redirect ไม่ใช่การเปลี่ยนสามจุด
จุดที่ต้องจำ: ถามสองข้อ — เปลี่ยนกี่กล่อง (1 = Frame · หลาย = Stream) และเปลี่ยนให้ใคร (คนกด = ตอบฟอร์ม · ทุกคน = broadcast)
ทำไมบรรทัด "ล้างฟอร์ม" ใน create.turbo_stream.erb ใช้ replace "comment_form" ไม่ใช่ update "comment_form"?
ตัวเลือกที่ 3 ถูก — แม่พิมพ์ _form สร้าง <form id="comment_form"> ใหม่ทั้งใบ · replace เอาใบเก่าออกแล้ววางใบใหม่ (id เดิมติดมา ส่งรอบหน้าก็หาเจอ) · update จะเอาใบใหม่ไปใส่ข้างในใบเก่า (ลองแล้ว: ได้ฟอร์มซ้อนกันสองชั้น)
ตัวเลือกที่ 1 ผิด — update ใส่ HTML อะไรก็ได้ · ตัวเลือกที่ 2 ผิด — ช่องว่างเพราะเราส่ง Comment.new ไปวาด ไม่ใช่เพราะคำสั่ง ·
ตัวเลือกที่ 4 ผิด — ไม่มีคำสั่งไหนในแปดตัวที่โหลดหน้าใหม่ ยกเว้น refresh
จุดที่ต้องจำ: id อยู่บนกล่องที่จะถูกแทนทั้งก้อน → replace + ของใหม่ต้องมี id เดิม · id อยู่บนกล่องที่แค่เปลี่ยนไส้ → update
Rails รู้ได้ยังไงว่าต้องตอบด้วย create.turbo_stream.erb แทนที่จะ redirect_to?
ตัวเลือกที่ 1 ถูก — ค่าที่ดักได้จริง text/vnd.turbo-stream.html, text/html, application/xhtml+xml · log จึงเขียน as TURBO_STREAM แล้ว respond_to เลือก format.turbo_stream
ตัวเลือกที่ 2 ผิด — ช่องซ่อนในฟอร์มมีแค่ authenticity_token (ดูได้ใน Response) · ตัวเลือกที่ 3 ผิด — ปิด JavaScript แล้วไฟล์ก็ยังอยู่ แต่ Rails ตอบ redirect (ลองแล้ว: as HTML · 302) ·
ตัวเลือกที่ 4 ผิด — URL จริงคือ /posts/30/comments ไม่มีนามสกุล
จุดที่ต้องจำ: ลูกค้าบอก "ขอแบบไหน" ผ่าน Accept · Turbo ติ๊กให้ · ไม่มี Turbo ก็ได้หน้าเต็มแบบเดิม
หลังส่งคอมเมนต์ ตัวเลขใน ความคิดเห็น (2) มาจากไหน?
ตัวเลือกที่ 4 ถูก — ใน response เห็น <turbo-stream action="update" target="comment_count"><template>2</template> และใน log เห็น Comment Count … SELECT COUNT(*)
ตัวเลือกที่ 1–3 คือการให้เบราว์เซอร์มี "สมุดจดเล่มที่สอง" ซึ่งเป็นสิ่งที่ Hotwire ตั้งใจเลี่ยง · ถ้าคนอื่นเพิ่มคอมเมนต์ระหว่างนั้น การบวกเองจะได้เลขผิด ส่วนการนับจากตู้ได้เลขจริงเสมอ
จุดที่ต้องจำ: server เป็นเจ้าของข้อมูลชุดเดียว เบราว์เซอร์แค่วางสิ่งที่ server จัดจานมาให้ → ไม่มีบั๊ก sync
ถามกลับได้เลย
ติดตรงไหนถามได้ทุกเรื่อง เช่น "ทำไมต้องมี <template> ห่อ" · "ถ้าอยากให้คอมเมนต์ใหม่อยู่บนสุดต้องแก้กี่ที่" (ใบ้: คำสั่งในไฟล์ stream และการเรียงใน controller) ·
หรือวาง log / Response ของคุณมาให้ดูด้วยกัน
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 44 | render "comments/form", comment: Comment.new | แม่พิมพ์ฟอร์มของเรามี strict locals (post:, comment:) · พิมพ์ตามได้ missing local: :post (ลองแล้ว) | สนใจ ถ้าพิมพ์ตาม — ใช้บรรทัด 4 ของบทนี้ |
| 44 | turbo_stream.prepend "flash" | หน้าของเราไม่มี id="flash" · ไม่มีอะไรขึ้นและไม่มี error (ภารกิจ) | สนใจ — ภารกิจพาลองแล้ว |
| 44 | @post.comments.size | บทนี้ใช้ .count ถามตู้ทุกครั้ง · ตอน save ไม่ผ่าน size นับคอมเมนต์ที่ build ค้างไว้ (ยังไม่บันทึก) รวมด้วย · ลองแล้ว: ในตู้มี 7 · size ได้ 8 · count ได้ 7 | ไม่ต้อง — ใช้ตามบทเรียน |
| 44 | "controller ไม่ต้องแก้เลย" | controller ของเรามี respond_to จึงต้องมีบรรทัด format.turbo_stream ด้วย · ไม่มีไฟล์ template ได้ 204 No Content (ลองแล้ว) | สนใจนิดหนึ่ง |
| 44 | "7 คำสั่งที่มีให้ใช้" | เอกสาร Turbo เรียก "The eight actions" — สไลด์รวม before/after เป็นแถวเดียว | ไม่ต้อง |
| 52 | "real-time ใช้ broadcasts_to :post พร้อม later: true" | เรื่องของบท 133 · บทนี้ทำแค่ข้อ "append + update ตัวนับ + ล้างฟอร์ม" | ยังไม่ต้อง |
อ้างอิง
- Turbo Handbook — Come Alive with Turbo Streams —
อ่านหัวข้อ "Stream Messages and Actions" กับ "Streaming From HTTP Responses" · ยืนยันเรื่อง Accept header และรูปแบบ
<turbo-stream><template>· ส่วน WebSocket ข้ามไปก่อน (บท 133) - Turbo Reference — Streams § The Eight Actions — ตัวอย่าง HTML ของทั้ง 8 คำสั่ง · อ่านคู่กับตารางในบทนี้ · ดูความต่างของ replace กับ update ให้ชัด
- turbo-rails — tag_builder.rb —
ที่มาของ
turbo_stream.append/update/replaceในไฟล์.erb· อ่านแค่คอมเมนต์เหนือแต่ละ method พอ (มีตัวอย่างการเรียกทุกแบบ) - Rails Guides — Routing § Nested Resources —
ทวน route ซ้อน
/posts/:post_id/comments· อ่านถึงหัวข้อ "Limits to Nesting" แล้วหยุด - Rails API — respond_to —
อ่านตัวอย่างแรก (
format.html/format.xml) และประโยคในวงเล็บที่บอกว่า Rails ดูชนิดคำตอบจาก Accept header - MDN — <template> —
ย่อหน้าแรกพอ: ทำไมเนื้อใน
<template>ไม่โชว์บนจอ