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

Turbo Streams — เพิ่มคอมเมนต์แล้วเปลี่ยนหลายจุดในหน้าพร้อมกัน

~25 นาทีอ่าน + พิมพ์ตามอีก ~20 นาที · ควรผ่าน บท 131 (Turbo Frames) · บท 130 (Turbo Drive) · และ Day 2 บท 115–117 มาก่อน

บท 131 เราเปลี่ยนของในหน้าได้ทีละหนึ่งกล่อง · บทนี้จะทำช่องคอมเมนต์ใต้บทความ ซึ่งกดส่งครั้งเดียวต้องเปลี่ยนสามจุดพร้อมกัน: คอมเมนต์ใหม่ต่อท้ายรายการ · ตัวเลข "ความคิดเห็น (N)" เพิ่มขึ้น · ฟอร์มว่างรอพิมพ์ใหม่ — โดยไม่โหลดหน้าใหม่
อ่านจบแล้วคุณจะ สร้างระบบคอมเมนต์ได้เอง · อ่านไฟล์ .turbo_stream.erb ออกทุกบรรทัด · เปิดดู "ใบสั่งงาน" ที่ server ส่งมาเป็น · และเลือกได้ว่าเมื่อไหร่ใช้ replace เมื่อไหร่ใช้ update

🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

🍜 เริ่มจากโค้ดสถานะไหน: โปรเจกต์ 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=…*/ กับบรรทัด ↳ ออกให้อ่านง่าย

โจทย์ — ส่งครั้งเดียว ต้องเปลี่ยนสามกล่อง

เป้าหมายของบทนี้คือส่วนคอมเมนต์ใต้บทความ หน้าตาแบบนี้ · ตอนกดปุ่ม "ส่งความคิดเห็น" มีสามกล่องในหน้าที่ต้องเปลี่ยน และอยู่คนละที่กัน:

หน้า /posts/30 (นมเย็น) บทความ … แถวปุ่ม แก้ไข / ลบ ← ไม่ต้องขยับ ความคิดเห็น ( 2 ) ← กล่อง ② id="comment_count" ตัวเลขต้องเพิ่ม ┌ id="comments" ────────────────────── │ ทดลอง · ส่งก่อนมีไฟล์ stream │ ต้นกล้า · ชาเย็นร้านนี้หวานกำลังดี │ + คอมเมนต์ใหม่มาต่อท้ายตรงนี้ ← กล่อง ① ต่อท้ายรายการ └───────────────────────────────────── ┌ form id="comment_form" ───────────── │ ชื่อ [ ] ← กล่อง ③ ล้างให้ว่าง │ ความคิดเห็น [ ] [ส่งความคิดเห็น] (หรือโชว์ error ถ้าส่งว่าง) └─────────────────────────────────────

ถ้าใช้ 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

ทางเข้าของคอมเมนต์ — 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

หน้าจอ — หน้า 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>

แม่พิมพ์คอมเมนต์หนึ่งชิ้น — สร้างโฟลเดอร์ 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>

แม่พิมพ์ฟอร์ม — สร้าง 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 %>

มีแค่สามจุดที่ต่างจากฟอร์มบทความ:

ที่เหลือคือของเดิมจากฟอร์มบทความ ทวนเร็ว ๆ:

ลองก่อนเขียนต่อ — ตอนนี้ยังไม่มีไฟล์ 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
คำสั่งในแต่ละข้อ เช่น append update replace (มีทั้งหมด 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)

ทำไม 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 จริง:

ในแท็บ 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>

อ่านทีละก้อน:

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&#39;t be blank</li>
              <li>Body can&#39;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>

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 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 สะอาด)

  1. เปิด app/views/comments/create.turbo_stream.erb เพิ่มก้อนนี้ต่อท้ายไฟล์ (บรรทัดที่ 5 เป็นต้นไป) ตามสไลด์เป๊ะ ๆ:
    <%= turbo_stream.prepend "flash" do %>
      <div class="alert">เพิ่มความคิดเห็นแล้ว</div>
    <% end %>
    (do … end = แบบมีบล็อก: HTML ที่อยู่ระหว่างกลางคือของที่จะเอาไปวาง ใช้แทนการชี้ไปที่แม่พิมพ์)
  2. ส่งคอมเมนต์หนึ่งครั้ง · ดูหน้าจอ และดูแท็บ Console
  3. แก้บรรทัดแรกของก้อนนั้นเป็น <%= turbo_stream.before "comment_form" do %> และแก้คลาสเป็น class="alert alert-success" · ส่งคอมเมนต์สองครั้ง
  4. เก็บกวาด: 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 · แอปของคอร์ส)ต้องสนใจไหม
44render "comments/form", comment: Comment.newแม่พิมพ์ฟอร์มของเรามี strict locals (post:, comment:) · พิมพ์ตามได้ missing local: :post (ลองแล้ว)สนใจ ถ้าพิมพ์ตาม — ใช้บรรทัด 4 ของบทนี้
44turbo_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 ตัวนับ + ล้างฟอร์ม"ยังไม่ต้อง

อ้างอิง