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

Stimulus — ให้ HTML เป็นคนบอกว่าต้องการ JavaScript อะไร

~22 นาที · ควรผ่าน บทเรียน 130 (Turbo Drive) · 132 (ฟอร์มคอมเมนต์ที่ตอบด้วย Turbo Stream) และ 133 มาก่อน

Turbo ทำให้หน้าเว็บเปลี่ยนได้โดยไม่ต้องโหลดใหม่ทั้งหน้า · แต่บางอย่างไม่ต้องถาม server เลย เช่น นับว่าพิมพ์ไปกี่ตัวอักษรแล้ว
อ่านจบบทนี้ คุณจะเขียน JavaScript ชิ้นเล็ก ๆ ด้วย Stimulus ได้เอง · ทำตัวนับอักษรใต้ช่องคอมเมนต์ ("12 / 1000") · รู้ว่าทำไมมันไม่พังตอน Turbo เปลี่ยนหน้า · รู้ว่าต้อง "เก็บกวาด" อะไรบ้าง · และตอบได้ว่างานไหนใช้ Turbo งานไหนใช้ Stimulus

🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้

🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · stimulus-rails 1.3.4 · turbo-rails 2.0.23 · พฤติกรรมในเบราว์เซอร์ตรวจด้วย Chrome จริง (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน เรื่องในบทนี้ไม่ต่างกัน)

🗂 โค้ดเริ่มจาก: แอปหลังจบบท 133 (คอมเมนต์เด้งขึ้นทุกแท็บ · ตามไม่ทันดูบท 133) · บทนี้เพิ่มโค้ดจริง 2 ไฟล์ (ตัวนับอักษร) แล้ว commit · ส่วนการทดลองอื่น ๆ ทำแล้วทิ้ง

ผลทุกก้อนรันจริงแล้วก็อปมา · เลขท้ายชื่อไฟล์ (-b787bcc6) ของคุณอาจไม่ตรง ถ้าไฟล์ของคุณต่างจากในบทแม้ตัวเดียว

ปัญหาเดิม: JavaScript ที่ไปตามหาของ "ตอนหน้าโหลด"

งานของบทนี้: ใต้ช่องพิมพ์คอมเมนต์ ให้มีตัวเลขบอกว่าพิมพ์ไปกี่ตัวแล้ว · งานนี้ไม่ต้องถาม server เบราว์เซอร์นับเองได้ จึงต้องใช้ JavaScript

ศัพท์ JavaScript 3 คำที่จะเจอทั้งบท:

ถ้าเขียนแบบที่ตัวอย่างบนอินเทอร์เน็ตส่วนใหญ่สอน จะได้หน้าตาแบบนี้ · ไม่ต้องพิมพ์ตาม เราทดลองให้ดูแล้ว (ใส่ไว้ท้าย app/javascript/application.js และเพิ่ม <p id="old-counter"></p> ใต้ช่องพิมพ์ชั่วคราว):

// ตัวนับแบบเดิม (ทดลอง)
document.addEventListener("DOMContentLoaded", () => {
  const input = document.querySelector("#comment_body")
  const output = document.querySelector("#old-counter")
  input.addEventListener("input", () => {
    output.textContent = input.value.length
  })
})

ผลจริงใน Chrome:

ทำอะไรตัวนับแบบเดิมเพราะ
เปิด /posts/30 ตรง ๆ แล้วพิมพ์ "อร่อย"✅ ขึ้น 5หน้าโหลดจริง DOMContentLoaded ยิง เจอช่องพิมพ์
ส่งคอมเมนต์ แล้วพิมพ์ต่อในฟอร์มใบใหม่❌ ว่างเปล่า ไม่ขยับTurbo Stream เปลี่ยนเป็นฟอร์มใบใหม่ · listener ผูกไว้กับช่องใบเก่าที่หายไปแล้ว
กดลิงก์ไปบทความอื่น (Turbo Drive) แล้วพิมพ์❌ ว่างเปล่า ไม่ขยับDOMContentLoaded ไม่ยิงซ้ำ (บท 130)
เปิด /posts ตรง ๆ (หน้านี้ไม่มีฟอร์ม)💥 console แดงหาช่องไม่เจอ ได้ null แล้วไปสั่ง null.addEventListener

ข้อความ error จริงในแถวสุดท้าย:

TypeError: Cannot read properties of null (reading 'addEventListener')

แถวที่ 2 และ 3 น่ากลัวที่สุด — ไม่มี error สักบรรทัด ผู้ใช้แค่เห็นว่าตัวนับไม่ขยับ · ต้นเหตุคือโค้ดนี้ผูกกับเวลา ("ตอนหน้าโหลด") ไม่ได้ผูกกับของ ("ตอนมีช่องพิมพ์อยู่บนหน้า")

ปัญหาที่สอง: JS ต้องรู้ชื่อ id/คลาสใน HTML เอง · ใครเปลี่ยนชื่อใน HTML โดยไม่ได้เปิดไฟล์ JS ก็พังโดยไม่รู้ตัว

Stimulus — ให้ HTML แปะป้ายบอกเองว่าต้องการ JS ตัวไหน

Stimulus คือ library JavaScript ตัวเล็ก ๆ ที่มากับ Rails 8 (ชิ้นที่สองของ Hotwire · ชิ้นแรกคือ Turbo ที่มี Drive · Frame · Stream) · แทนที่ JS จะไปไล่หา element เอง เราแปะป้ายบน HTML ว่า "ตรงนี้ใช้ JS ตัวไหน · ปุ่มนี้กดแล้วเรียกอะไร · element ไหนคือของที่ JS ต้องใช้"

ป้ายพวกนี้คือ attribute ที่ขึ้นต้นด้วย data- · HTML ยอมให้เราตั้งชื่อ data-อะไรก็ได้ เอง เบราว์เซอร์ไม่เอาไปแสดงผล แต่ JS อ่านได้ · (เคยเห็นแล้วตอน data-theme ของ daisyUI บท 128)

ตัวอย่างจากสไลด์หน้า 48 — เมนูที่กดแล้วเปิด/ปิด (อ่านอย่างเดียว ยังไม่ต้องพิมพ์):

<div data-controller="dropdown">
  <button data-action="click->dropdown#toggle">เมนู</button>
  <ul data-dropdown-target="menu" class="hidden">
    …
  </ul>
</div>

เปิดไฟล์ HTML ไฟล์เดียวก็รู้แล้วว่าปุ่มนี้ทำอะไร ไม่ต้องไปเปิดไฟล์ JS ไล่ดู · ถ้าเทียบกับ React (บท 73): data-action คล้าย onClick={toggle} ใน JSX — ต่างกันที่ React เป็นคนสร้าง HTML ส่วน Stimulus มาเกาะ HTML ที่ server (ERB) ส่งมาให้แล้ว

⏱ เทียบให้เห็นภาพ — อุปกรณ์บนโต๊ะ และป้ายบอกว่าเป็นอุปกรณ์อะไร

ร้านของเรา: ครัวจัดจานเสร็จแล้วเสิร์ฟ (server ส่ง HTML) · Turbo คือพนักงานที่เปลี่ยนจานบนโต๊ะให้โดยไม่ต้องรื้อโต๊ะ · แต่บางอย่างลูกค้าไม่ต้องเรียกครัว เช่น นาฬิกาจับเวลาบนโต๊ะ — กดแล้วนับเอง
Stimulus คืออุปกรณ์บนโต๊ะพวกนี้ · data-controller คือป้ายบนโต๊ะ "โต๊ะนี้มีนาฬิกาจับเวลา" · พนักงานเห็นป้ายเมื่อไหร่ก็ติดตั้งอุปกรณ์ให้
วิธีเดิมคือพนักงานที่เดินแจกนาฬิกาตอนร้านเปิดรอบเดียว — โต๊ะที่จัดใหม่หลังจากนั้นไม่ได้ของ (แถว 2–3 ในตาราง)

ที่ไม่เหมือนกัน: พนักงานจริงลืมติดตั้งก็ยังมีคนทัก · แต่ถ้าป้ายเขียนชื่อผิดแม้ตัวเดียว (เช่น dropdwon) Stimulus จะเมินเฉย ๆ ไม่บอกใคร เพราะมันคิดว่าเป็นป้ายของอุปกรณ์ที่ร้านไม่มี (เดี๋ยวเห็นของจริง)

🗣 ศัพท์ที่เพิ่งเรียนไป
Stimulus controller
class JavaScript หนึ่งไฟล์ที่ผูกกับ element ที่มี data-controller · พิมพ์บอก AI ได้ว่า "write a Stimulus controller for this, no querySelector, use targets"
identifier
ชื่อที่เขียนใน data-controller="…" เช่น dropdown · ต้องตรงกับชื่อไฟล์ (ดูหัวข้อถัดไป)
action · descriptor
data-action = ผูก event กับ method · ข้อความ click->dropdown#toggle เรียกว่า action descriptor · ถาม senior ได้ว่า "ปุ่มนี้ action descriptor ควรเป็นอะไร"
target
element ข้างในที่ controller ต้องใช้ ประกาศด้วย data-ชื่อcontroller-target="ชื่อ" แล้วเรียกเป็น this.ชื่อTarget

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

controller ทุกตัวอยู่ในโฟลเดอร์ app/javascript/controllers/ ชื่อไฟล์ลงท้าย _controller.js · ไม่ต้องลงทะเบียนที่ไหนเพิ่ม (แบบเดียวกับ theme ในบท 129) — สร้างไฟล์ปุ๊บ Rails เจอเอง

เบื้องหลังมีสองบรรทัดที่ Rails เขียนไว้ให้แล้ว: pin_all_from ใน config/importmap.rb ลงชื่อทุกไฟล์ในโฟลเดอร์ลงสมุด importmap · และ eagerLoadControllersFrom(…) ใน app/javascript/controllers/index.js โหลดทุกไฟล์ แล้วตั้งชื่อ controller จากชื่อไฟล์ · ชื่อไฟล์จึงสำคัญมาก เพราะมันคือชื่อที่ต้องเขียนใน HTML

กฎตั้งชื่อ (ตัวนับของเรา):

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

เราลองสะกดผิดให้ดูแล้วใน Chrome (พิมพ์ "อร่อย" ลงช่อง แล้วดูตัวนับ):

เขียนผิดแบบนี้ผลจริง
data-controller="character_counter" (ขีดล่าง)ตัวนับว่าง · console ไม่มีอะไรเลย
data-character-counter-target="ouput" (ตก t)ตัวนับว่าง · console แดง:
Error connecting controller
Error: Missing target element "output" for "character-counter" controller

แบบแรกเงียบเพราะ Stimulus มีแค่ controller ชื่อ character-counter · hello · theme (เราเปิดดูรายชื่อใน Chrome แล้ว) · ป้าย character_counter ไม่ตรงกับตัวไหนก็ถูกข้ามไป · ตัวนับไม่ทำงานและ console เงียบ → เช็คชื่อใน data-controller ก่อนอย่างอื่น

ลงมือ: ตัวนับอักษรใต้ช่องคอมเมนต์

รู้จักป้ายครบแล้ว มาทำของจริงกัน · 2 ไฟล์ · เริ่มจากโค้ดตอนจบบท 133 · ในโค้ดจะเจอ method ชื่อ connect กับ disconnect — ตอนนี้จำแค่ว่า "ทำตอนฟอร์มโผล่" กับ "ทำตอนฟอร์มหายไป" หัวข้อถัดจากนี้จะพิสูจน์ให้ดู

ขั้น 1 — สร้างไฟล์ controller

ภาษาคนก่อน — controller ตัวนี้ทำ 3 อย่าง: ① ฟอร์มโผล่ → ฝาก listener ไว้กับช่องพิมพ์ แล้วนับครั้งแรก · ② ทุกครั้งที่พิมพ์ → เขียนตัวเลขใหม่ลงช่องโชว์ · ③ ฟอร์มหายไป → ถอด listener ออก

สร้างไฟล์ใหม่ app/javascript/controllers/character_counter_controller.js (ชื่อนี้ → ใน HTML เรียก character-counter):

import { Controller } from "@hotwired/stimulus"

// ตัวนับอักษรของช่องพิมพ์คอมเมนต์ (อยู่ใน comments/_form.html.erb) แสดงเป็น "12 / 1000"
export default class extends Controller {
  static targets = ["input", "output"]

  // ฟอร์มโผล่ (โหลดหน้า หรือ Turbo Stream ใส่ฟอร์มใหม่): เริ่มฟังการพิมพ์
  connect() {
    this.onInput = this.update.bind(this)
    this.inputTarget.addEventListener("input", this.onInput)
    this.update()
  }

  // ฟอร์มหายไป: ถอด listener ที่ connect ผูกไว้
  disconnect() {
    this.inputTarget.removeEventListener("input", this.onInput)
  }

  update() {
    this.outputTarget.textContent = `${this.inputTarget.value.length} / ${this.inputTarget.maxLength}`
  }
}

ทีละบรรทัด:

วิธีที่สั้นกว่าและ "ปกติ" กว่าคือใส่ data-action="input->character-counter#update" บนช่องพิมพ์ แล้ว Stimulus ผูก/ถอดให้เอง ไม่ต้องมี disconnect · เราเขียนแบบผูกเองเพราะเกณฑ์ตรวจรับของ Lab (บท 135) ข้อ "Stimulus controller มี disconnect" — ได้ฝึกคู่ connect/disconnect ของจริง

ขั้น 2 — แปะป้ายใน ERB

เปิด app/views/comments/_form.html.erb (ฟอร์มคอมเมนต์จากบท 132) หาบล็อกช่องความคิดเห็น · แก้ 3 จุด: ① ป้าย controller บน <div> ② ป้าย target + maxlength บนช่องพิมพ์ ③ เพิ่ม <p> ที่โชว์ตัวเลข

ก่อน

    <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 data-controller="character-counter">
      <%= form.label :body, "ความคิดเห็น", class: "block font-medium" %>
      <%= form.textarea :body, rows: 3, maxlength: 1000, data: { character_counter_target: "input" },
            class: ["textarea w-full mt-2", { "textarea-error": comment.errors[:body].any? }] %>
      <p class="mt-1 text-right text-sm text-base-content/70" data-character-counter-target="output"></p>
    </div>

HTML ที่เบราว์เซอร์ได้จริง:

$ curl -s localhost:3000/posts/30 | grep -A5 'data-controller="character-counter"'
    <div data-controller="character-counter">
      <label class="block font-medium" for="comment_body">ความคิดเห็น</label>
      <textarea rows="3" maxlength="1000" data-character-counter-target="input" class="textarea w-full mt-2" name="comment[body]" id="comment_body">
</textarea>
      <p class="mt-1 text-right text-sm text-base-content/70" data-character-counter-target="output"></p>
    </div>

บรรทัด <textarea …> มาจาก form.textarea — เห็น maxlength="1000" และ data-character-counter-target="input" ที่ Rails แปลงจาก hash ให้แล้ว

ขั้น 3 — เปิดดู

ใช้ bin/dev อยู่แล้ว → แค่ refresh หน้า (คลาสใหม่ text-right ถูกสร้าง CSS ให้อัตโนมัติ) · ถ้าเปิดด้วย bin/rails server เฉย ๆ ให้สั่ง bin/rails tailwindcss:build ก่อน · ไม่ต้อง bin/importmap pin และไม่ต้อง restart server

เปิด http://localhost:3000/posts/30 แล้วพิมพ์ในช่องความคิดเห็น (ตอนส่ง ใส่ชื่อด้วย ไม่งั้นฟอร์มเด้งกลับมาพร้อม error) · ผลจริงใน Chrome:

เปิดหน้า            0 / 1000
พิมพ์ "อร่อย"        5 / 1000
พิมพ์ต่อ "มาก"       8 / 1000
ส่งคอมเมนต์          0 / 1000      ← ฟอร์มใบใหม่ connect แล้วนับเอง

"อร่อย" นับได้ 5 เพราะวรรณยุกต์ ่ นับเป็นหนึ่งตัว (อ ร ่ อ ย) · Ruby ก็นับแบบเดียวกัน ตัวนับจึงตรงกับที่ validation 1000 ตัวนับ

อยากเห็นว่าไฟล์ใหม่ถูกส่งไปเบราว์เซอร์จริง ลองค้นใน HTML ของหน้า (ไม่ทำก็ได้):

$ curl -s localhost:3000/posts/30 | grep character_counter
    "controllers/character_counter_controller": "/assets/controllers/character_counter_controller-b787bcc6.js",
<link rel="modulepreload" href="/assets/controllers/character_counter_controller-b787bcc6.js">

ขั้น 4 — เก็บงาน

$ git add -A
$ git commit -m "Stimulus ตัวนับอักษรช่องคอมเมนต์"

commit ไว้ก่อน เพราะสองหัวข้อถัดไปและภารกิจท้ายบทจะทดลองแก้ไฟล์เดิม แล้วคืนด้วย git restore . (= "ทิ้งที่แก้ไว้ กลับไปเป็นแบบ commit ล่าสุด") · ส่วนไฟล์ prompts/03-hotwire.md และกฎข้อ 9–12 ใน prompts/00-context.md ยังไม่ต้องทำ — ทำในบท 135

connect / disconnect — ผูกกับ "ตอนที่ element มีอยู่" ไม่ใช่ "ตอนหน้าโหลด"

นี่คือหัวใจของ Stimulus (สไลด์หน้า 49) · controller มี method พิเศษสองตัว: connect() ทำงานทุกครั้งที่ element ที่มี data-controller โผล่บนหน้า · disconnect() ทำงานทุกครั้งที่มันหายไป

Stimulus รู้ได้ยังไง? เบราว์เซอร์มีเครื่องมือชื่อ MutationObserver = "ยามที่คอยดูว่ามี element ถูกเพิ่มหรือลบจากหน้าไหม แล้วรายงาน" · Stimulus จ้างยามคนนี้เฝ้าทั้งหน้าตลอดเวลา · ไม่ว่าใครเปลี่ยนหน้า — Turbo Drive · Turbo Stream · broadcast — ยามเห็นหมด

มาดูด้วยตา: ใส่บรรทัด console.log("ตัวนับ: connect") เป็นบรรทัดแรกใน connect() และ console.log("ตัวนับ: disconnect") ใน disconnect() ของตัวนับที่เพิ่งทำ · console.log("…") = พิมพ์ข้อความลงแท็บ Console ของ DevTools (กด F12 หรือ ⌥⌘J บน Mac) · ลองทำตามได้เลย เสร็จแล้ว git restore .

1  เปิด /posts                        (หน้ารวม ไม่มีฟอร์มคอมเมนต์)
2  คลิกบทความ "นมเย็น"                  (Turbo Drive → /posts/30)
3  พิมพ์ "อร่อยมาก" แล้วส่งคอมเมนต์       (Turbo Stream แทนฟอร์ม)
4  คลิก "กลับไปหน้ารวม"
5  กดปุ่ม Back ของเบราว์เซอร์
6  คลิก "บทความ" บน navbar
7  คลิก "นมเย็น" อีกรอบ (เคยเปิดแล้ว)

แต่ละขั้นจะเห็น ตัวนับ: connect หรือ ตัวนับ: disconnect บรรทัดไหนบ้าง? ขั้นที่ 3 เห็นอะไร ทั้งที่ไม่ได้เปลี่ยนหน้าเลย?

ผลจริงจาก Chrome:

1 เปิด /posts                 (ไม่มีอะไร)
2 คลิกบทความ                   ตัวนับ: connect
3 ส่งคอมเมนต์                  ตัวนับ: disconnect
                               ตัวนับ: connect
4 กลับไปหน้ารวม                ตัวนับ: disconnect
5 กด Back                     ตัวนับ: connect
6 คลิก บทความ บน navbar         ตัวนับ: disconnect
7 คลิกนมเย็นอีกรอบ              ตัวนับ: connect
                               ตัวนับ: disconnect
                               ตัวนับ: connect
  • ขั้น 3 — URL ไม่เปลี่ยน แต่ Turbo Stream เปลี่ยนฟอร์มเป็นใบใหม่ → ตัวเก่า disconnect ตัวใหม่ connect · นี่คือสิ่งที่ DOMContentLoaded ทำไม่ได้
  • ขั้น 5 (กด Back) connect ครั้งเดียว — Turbo หยิบภาพที่จำไว้ (cache) มาโชว์เลย ไม่ไปขอ server ใหม่ (เราดู log ของ server แล้ว ไม่มี request เข้ามาตอนกด Back)
  • ขั้น 7 มี connect สองครั้ง — หน้าที่เคยเปิดแล้ว Turbo โชว์ภาพที่จำไว้ (cache) ก่อนทันที แล้วค่อยเอาหน้าใหม่จาก server มาแทน → element โผล่ หาย โผล่ใหม่ · ระหว่าง connect สองครั้งมี disconnect คั่นเสมอ
🍽 เทียบให้เห็นภาพ — ติดตั้งเมื่อจัดโต๊ะ เก็บกวาดเมื่อเก็บโต๊ะ

connect = พนักงานเห็นป้าย "นาฬิกาจับเวลา" บนโต๊ะที่เพิ่งจัด → ติดตั้งนาฬิกาทันที · disconnect = โต๊ะถูกเก็บ → เก็บนาฬิกาออก ปิดสวิตช์
ไม่สำคัญว่าโต๊ะถูกจัดตอนร้านเปิด หรือจัดใหม่กลางมื้อ (Stream) — เห็นป้ายเมื่อไหร่ ติดตั้งเมื่อนั้น

ที่ไม่เหมือนกัน: พนักงานจริงเก็บนาฬิกาออกเองแน่นอน · แต่ Stimulus เก็บให้เฉพาะของที่มันติดตั้งเอง (เช่น data-action) · ของที่เราติดตั้งเองใน connect เราต้องเขียนเก็บเองใน disconnect — หัวข้อถัดไป

เทียบกับ React (บท 74): connect() ≈ โค้ดใน useEffect ตอน component โผล่ · disconnect() ≈ ฟังก์ชันที่ return ออกจาก useEffect · ปัญหาเดียวกัน คำตอบคนละหน้าตา

🗣 ศัพท์ที่เพิ่งเรียนไป
lifecycle callback
method ที่ระบบเรียกให้เองตามช่วงชีวิต เช่น connect · disconnect (เหมือน before_validation ของ Rails บท 112) · ถาม senior ได้ว่า "โค้ดนี้ควรอยู่ใน connect หรือรอให้กดปุ่มก่อน"
MutationObserver
API ของเบราว์เซอร์ที่แจ้งเมื่อ DOM ถูกเพิ่ม/ลบ/แก้ · Stimulus ใช้มันเฝ้าหน้า · ไม่ต้องเขียนเองแต่ควรรู้ชื่อ
DOM
ต้นไม้ของ element ที่เบราว์เซอร์สร้างจาก HTML และ JS แก้ได้ · "element โผล่บน DOM" = มันอยู่บนหน้าแล้ว

ของที่สร้างใน connect ต้องเก็บกวาดใน disconnect

ถ้า connect สร้างของที่อยู่นอก element ของเรา ของพวกนั้นไม่หายไปพร้อม element มันอยู่ต่อจนกว่าจะปิดแท็บ · ตัวอย่างเช่น

ทดลองให้ดู (ลองตามได้ แล้ว git restore .): เพิ่มสองบรรทัดนี้ท้าย connect() ของตัวนับ แต่ไม่เพิ่มอะไรใน disconnect · ความหมาย: "เก็บฟังก์ชันที่ log คำว่า กดคีย์ ไว้ใน this.onKey · แล้วฝากไว้กับทั้งหน้า ให้ทำทุกครั้งที่กดแป้น (event keydown)"

    this.onKey = () => console.log("กดคีย์")
    document.addEventListener("keydown", this.onKey)
เปิด /posts/30 ตรง ๆ → กดตัว a ในช่องคอมเมนต์ 1 ครั้ง
"กลับไปหน้ารวม" → คลิกนมเย็น → กด a 1 ครั้ง        (รอบ 1)
"กลับไปหน้ารวม" → คลิกนมเย็น → กด a 1 ครั้ง        (รอบ 2)
"กลับไปหน้ารวม" (ไม่มีฟอร์มแล้ว) → กด a 1 ครั้ง

แต่ละครั้งที่กด a จะเห็น กดคีย์ กี่บรรทัด? (ใบ้: นึกถึง connect สองครั้งในขั้น 7 ของเฉลยที่แล้ว)

ผลจริง — ซ้ายคือลืมเก็บกวาด · ขวาคือเพิ่ม document.removeEventListener("keydown", this.onKey) ใน disconnect():

กด a เมื่อลืมเก็บกวาดเก็บกวาดแล้ว
เปิดหน้าแรก1 บรรทัด1 บรรทัด
หลังรอบ 13 บรรทัด1 บรรทัด
หลังรอบ 25 บรรทัด1 บรรทัด
อยู่หน้ารวม (ไม่มีฟอร์ม)5 บรรทัด0 บรรทัด

เพิ่มทีละ 2 เพราะกลับมาแต่ละรอบ connect ยิงสองครั้ง (ภาพจาก cache + หน้าจริง) · และไปหน้าที่ไม่มีฟอร์มแล้วก็ยังทำงาน 5 ตัว — นี่แหละ "ค้างอยู่ตลอดอายุของแท็บ" ที่สไลด์เตือน · ถ้าเป็น setInterval ที่ยิงไป server ทุกวินาที เท่ากับยิงซ้อน 5 เท่าโดยไม่มีใครเห็น

สังเกตว่าเราเก็บฟังก์ชันไว้ใน this.onKey (เหมือนเก็บใน @on_key ของ Ruby) · เพราะ removeEventListener ต้องได้ฟังก์ชันตัวเดียวกันเป๊ะกับตอน add ถึงจะถอดออก · ถ้าเขียนฟังก์ชันใหม่หน้าตาเหมือนกัน มันถือว่าเป็นคนละตัว ถอดไม่ออก

เทียบ: React เก็บกวาดใน return () => clearInterval(id) ของ useEffect · Go ใช้ defer (บท 08) ปิดของตอนออกจากฟังก์ชัน — ความคิดเดียวกัน "เปิดอะไร ต้องปิดสิ่งนั้น"

ของที่ Stimulus เก็บให้เอง ไม่ต้องเขียน disconnect: listener ที่มาจาก data-action · ค่าที่เปลี่ยนใน element ของตัวเอง · ต้องเก็บเอง: setInterval/setTimeout · listener บน window/document · library ภายนอก · WebSocket

ถ้าลองลบบรรทัด removeEventListener ของตัวนับเราออก จะยังไม่เห็นอาการในการใช้ปกติ เพราะ listener ติดอยู่กับช่องพิมพ์ ซึ่งถูกแทนเป็นช่องใหม่ทุกครั้ง ของเก่าหายไปพร้อมช่องเก่า · แต่อย่าพึ่งดวง — ถ้าวันหนึ่งย้ายไปฟังบน document แบบการทดลองข้างบน จะรั่วทันที · เขียนคู่ add/remove ไว้เสมอ

ย้อนอ่านปุ่มสลับธีมของบท 129 — ตอนนี้อ่านออกทุกบรรทัด

บรรทัดใน toggle() (อ่าน localStorage · matchMedia · ? :) อธิบายทีละบรรทัดไว้แล้วในบท 129 · รอบนี้ดูเฉพาะส่วนที่เป็นเรื่อง Stimulus (ตัดบรรทัด import / export หัวไฟล์ · คอมเมนต์ และ } ปิดท้ายออก ให้เหลือแค่สอง method)

<%# app/views/shared/_navbar.html.erb (เฉพาะปุ่ม) %>
<button type="button" class="btn btn-ghost btn-sm" aria-label="สลับธีมสว่าง/มืด"
        data-controller="theme" data-action="theme#toggle">🌓</button>
// app/javascript/controllers/theme_controller.js
  connect() {
    const saved = localStorage.getItem("theme")
    if (saved) document.documentElement.dataset.theme = saved
  }

  toggle() {
    const html = document.documentElement
    const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
    const current = html.dataset.theme || (systemDark ? "dark" : "light")
    const next = current === "dark" ? "light" : "dark"

    html.dataset.theme = next
    localStorage.setItem("theme", next)
  }

values · classes · outlets — ส่งของจาก HTML ให้ JS แบบมีชื่อ

ตัวนับของเราอ่านเลข 1000 จาก maxlength ได้เพราะเป็น attribute มาตรฐาน · แต่ถ้าอยากส่งค่าอื่นจาก server (ERB) ให้ JS หรืออยากบอกชื่อคลาส CSS ให้ JS ใช้ Stimulus มีช่องทางมาตรฐานให้ (สไลด์หน้า 50) · ดูหน้าตาไว้ก่อน เดี๋ยวได้ลองในภารกิจ

ใน HTML (บนกล่อง controller)ประกาศใน JSใช้ใน JS
values
ค่าจาก server
data-character-counter-warn-at-value="10"static values = { warnAt: Number }this.warnAtValue → 10 (เป็นตัวเลขแล้ว)
classes
ชื่อคลาส CSS
data-character-counter-warning-class="text-error"static classes = ["warning"]this.warningClass → "text-error"
outlets
คุยกับ controller อื่น
แค่รู้จักชื่อไว้ · เช่น ปุ่มในหน้าสั่ง controller modal อีกตัวให้เปิด (this.modalOutlet.open()) · ใช้ไม่บ่อยในงานเริ่มต้น
🗣 ศัพท์ที่เพิ่งเรียนไป
Values API
ส่งค่าจาก HTML เข้า controller แบบมีชนิด ผ่าน data-ชื่อ-ชื่อค่า-value · พิมพ์บอก AI ได้ว่า "pass the limit from ERB with a Stimulus value, don't hardcode it in JS"
Classes API
ส่งชื่อคลาส CSS เข้า controller ผ่าน data-ชื่อ-ชื่อคลาส-class · กันชื่อคลาสกระจายอยู่ในไฟล์ JS
Outlets API
ให้ controller หนึ่งเรียก method ของ controller อีกตัว · ถาม senior ได้ว่า "เคสนี้ควรใช้ outlet หรือส่ง event หากัน"

เส้นแบ่ง: งานไหน Stimulus งานไหน Turbo

ถามคำถามเดียว (สไลด์หน้า 50): "คำตอบต้องมาจาก server ไหม?" · ต้อง → Turbo · ไม่ต้อง เบราว์เซอร์ทำเองได้ → Stimulus

งานต้องถาม server?ใช้
นับอักษรในช่องพิมพ์ไม่ — นับจากข้อความในช่องStimulus
เปิด/ปิดเมนู dropdown · modalไม่ — แค่สลับคลาส hiddenStimulus
คัดลอกข้อความไป clipboardไม่ — เบราว์เซอร์มีคำสั่งให้Stimulus
ส่งฟอร์มอัตโนมัติเมื่อเลือกตัวเลือก · ถามยืนยันก่อนลบไม่ — แค่สั่งส่ง/ถาม (การบันทึก/ลบจริงยังเป็นงานของ server)Stimulus · (ปุ่มลบในแอปเราใช้ turbo_confirm ที่ Turbo มีให้แล้ว ไม่ต้องเขียนเอง)
โหลดรายการหน้าถัดไปต้อง — ข้อมูลอยู่ใน DBTurbo
เช็คว่าชื่อนี้มีคนใช้แล้วหรือยังต้อง — ต้องถาม DBTurbo

สี่แถวแรกคือ "ของที่เขียนบ่อยที่สุด 5 อย่าง" ของสไลด์หน้า 50 · ถ้าเจองานเหล่านี้ ให้นึกถึง Stimulus controller ตัวเล็ก ๆ ก่อนจะเขียน JS แบบอื่น

🧂 เทียบให้เห็นภาพ — ใช้ของบนโต๊ะ หรือเรียกพนักงานไปถามครัว

ลูกค้าอยากรู้ว่ากินไปกี่นาทีแล้ว → ดูนาฬิกาบนโต๊ะเอง (Stimulus) · ลูกค้าอยากรู้ว่าเมนูนี้ยังมีของไหม → ต้องเรียกพนักงานไปถามครัว เพราะสต็อกอยู่ที่ครัว (Turbo) · ครัวยังเป็นเจ้าของสมุดสต็อกเล่มเดียว ไม่มีสมุดเล่มที่สองบนโต๊ะ

ที่ไม่เหมือนกัน: ในร้านจริงเลือกผิดก็แค่เสียเวลา · ในเว็บ ถ้าใช้ Stimulus ทำงานที่ควรถาม server (เช่น เก็บรายการคอมเมนต์ไว้ใน JS เอง) จะกลับไปมีข้อมูลสองชุดที่ต้องคอยทำให้ตรงกัน — ปัญหาที่ Hotwire ตั้งใจหนีมาตั้งแต่บท 130 · Stimulus ควรจำแค่สิ่งเล็ก ๆ ของหน้าจอ ไม่จำข้อมูลของแอป

🗣 ศัพท์ที่เพิ่งเรียนไป
sprinkles of JavaScript
สำนวนสาย Rails/Hotwire: "โรย JS ทีละนิด" บน HTML ที่ server ทำมา แทนการให้ JS ทำทั้งหน้า · ถาม senior ได้ว่า "หน้านี้ควรเป็นแค่ sprinkles หรือถึงขั้นต้องใช้ React แล้ว"
client-side state
ข้อมูลที่ JS ในเบราว์เซอร์จำไว้เอง · Stimulus ควรมีแค่เรื่องหน้าจอ (เมนูเปิดอยู่ไหม) · พิมพ์บอก AI ได้ว่า "keep data on the server, Stimulus only for UI behavior"

🎯 ภารกิจ: ตัวนับเปลี่ยนเป็นสีแดงเมื่อพิมพ์ถึงจำนวนที่กำหนด (~10 นาที)

ใช้ values ส่งตัวเลขเกณฑ์ และ classes ส่งชื่อคลาสสีแดง · เพื่อให้ทดลองง่าย ตั้งเกณฑ์ไว้ที่ 10 ตัว (ของจริงอาจเป็น 900)

  1. ใน _form.html.erb เพิ่ม attribute สองตัวบน <div data-controller="character-counter">: data-character-counter-warn-at-value="10" และ data-character-counter-warning-class="text-error"
  2. ใน character_counter_controller.js ใต้ static targets เพิ่มการประกาศ values (ชื่อ warnAt ชนิด Number) และ classes (ชื่อ warning) · หน้าตาดูได้จากคอลัมน์ "ประกาศใน JS" ของตารางข้างบน
  3. ใน update() หลังบรรทัดที่เขียนตัวเลข เพิ่มบรรทัดเดียว: this.outputTarget.classList.toggle(this.warningClass, this.inputTarget.value.length >= this.warnAtValue) — classList.toggle(ชื่อคลาส, จริงหรือเท็จ) = "ถ้าจริง ใส่คลาส · ถ้าเท็จ ถอดคลาส" · >= = มากกว่าหรือเท่ากับ
  4. refresh หน้าบทความ · พิมพ์ 9 ตัว → 10 ตัว → ลบออก 1 ตัว ดูสีตัวเลข
  5. เสร็จแล้วคืนโค้ดให้กลับเป็นตอนจบขั้น 4: git restore .

ควรเห็น: 9 ตัวสีเทาจางเหมือนเดิม · ครบ 10 ตัวตัวเลขเป็นสีแดง · ลบเหลือ 9 กลับเป็นสีเทา

ถ้าเจออาการนี้ แปลว่า…

ส่วนที่เปลี่ยน (ตัดมาเฉพาะบรรทัดที่เกี่ยว ไม่ใช่ทั้งไฟล์ · … = บรรทัดเดิมที่ไม่ได้แตะ):

// character_counter_controller.js
  static targets = ["input", "output"]
  static values = { warnAt: Number }
  static classes = ["warning"]
  …
  update() {
    const length = this.inputTarget.value.length
    this.outputTarget.textContent = `${length} / ${this.inputTarget.maxLength}`
    this.outputTarget.classList.toggle(this.warningClass, length >= this.warnAtValue)
  }
<%# _form.html.erb %>
    <div data-controller="character-counter"
         data-character-counter-warn-at-value="10"
         data-character-counter-warning-class="text-error">

ผลจริงใน Chrome (ดูคลาสของ <p> ตัวนับ):

เปิดหน้า               0 / 1000    mt-1 text-right text-sm text-base-content/70
พิมพ์ "อร่อยมาก" (8)     8 / 1000    mt-1 text-right text-sm text-base-content/70
พิมพ์ "ๆๆ" (10)        10 / 1000    mt-1 text-right text-sm text-base-content/70 text-error   ← แดง
ลบ 1 ตัว (9)            9 / 1000    mt-1 text-right text-sm text-base-content/70

เฉลยเก็บความยาวไว้ในตัวแปร length ก่อน เพราะใช้สองที่ (เขียน this.inputTarget.value.length ตรง ๆ ตามขั้น 3 ก็ได้ผลเหมือนกัน) · text-error คือสี "error" ของธีม daisyUI (สีตามหน้าที่ บท 128 · ตระกูลเดียวกับ btn-error ของปุ่มลบในบท 129) จึงแดงทั้งธีมสว่างและมืด · คลาสนี้มีอยู่แล้วในแอป (หน้าแก้หัวข้อบท 131 ใช้) CSS จึงมีให้ทันทีแม้ไม่ได้ build ใหม่

อย่าลืม git restore . · git status ต้องสะอาด โค้ดกลับเป็นตัวนับตอนขั้น 4 พร้อมไปบท 135

แบบฝึกหัด

สไลด์หน้า 51 บอกว่าคนตอบผิดกันเยอะที่สุดสองข้อ: (ก) เราเพิ่มคอมเมนต์ แล้วหน้าของเราต้องเปลี่ยน 3 จุด และ (ข) คนอื่นเพิ่มคอมเมนต์ แล้วหน้าของเราขึ้นเอง · ข้อไหนจับคู่ถูก?

ตัวเลือกที่ 3 ถูก — ทั้งสองข้อใช้ Turbo Stream แต่ใครเป็นคนเริ่มต่างกัน · (ก) เรากดส่งฟอร์มเอง server ตอบกลับคำขอของเรา ด้วยคำสั่งหลายข้อ (append · update ตัวนับ · replace ฟอร์ม — บท 132) · (ข) เราไม่ได้กดอะไร server ประกาศเองผ่านสาย WebSocket ไปทุกแท็บที่เปิดฟังอยู่ (บท 133)

ตัวเลือกที่ 1 ผิดเพราะ Frame เปลี่ยนได้แค่กรอบเดียว แต่โจทย์ต้องเปลี่ยน 3 จุด · ตัวเลือกที่ 2 ผิดเพราะคอมเมนต์ต้องบันทึกลง DB = ต้องถาม server ไม่ใช่งาน Stimulus · ตัวเลือกที่ 4 สลับกัน และ Turbo Drive ทำงานตอนเรากดลิงก์เท่านั้น ไม่ได้โหลดเอง

จุดที่ต้องจำ: Stream ตอบฟอร์ม = เราเริ่ม · Stream broadcast = คนอื่นเริ่ม server ประกาศมา

งานไหนเหมาะกับ Stimulus ที่สุด?

ตัวเลือกที่ 2 ถูก — ลิงก์อยู่บนหน้าแล้ว เบราว์เซอร์คัดลอกเองได้ ไม่ต้องถาม server = Stimulus

ตัวเลือกที่ 1 ต้องถาม DB ว่าชื่อซ้ำไหม → Turbo · ตัวเลือกที่ 3 ข้อมูลกราฟมาจาก server → Turbo Frame แบบ lazy (บท 131) · ตัวเลือกที่ 4 ฟอร์มแก้ไขมาจาก server → Turbo Frame (บท 131)

จุดที่ต้องจำ: คำตอบต้องมาจาก server → Turbo · ทำได้เองในเบราว์เซอร์ → Stimulus

ใส่ console.log ใน connect และ disconnect ของตัวนับ แล้วส่งคอมเมนต์สำเร็จ (URL ไม่เปลี่ยน) · Console จะแสดงอะไร?

ตัวเลือกที่ 1 ถูก — เราดูใน Chrome แล้ว: ตัวนับ: disconnect ตามด้วย ตัวนับ: connect · Turbo Stream replace ลบฟอร์มเก่าออกแล้วใส่ใบใหม่ · Stimulus ผูกกับ element ไม่ได้ผูกกับ URL

ตัวเลือกที่ 2 คือความคิดแบบ "ตอนหน้าโหลด" ที่ทำให้ตัวนับแบบเดิมพัง · ตัวเลือกที่ 3 ผิดเพราะฟอร์มเก่าถูกลบออกจากหน้าจริง ๆ → controller ตัวเก่า disconnect แม้ URL จะเดิม · ตัวเลือกที่ 4 ผิดเพราะฟอร์มใบใหม่มี data-controller → connect ยิงทันที ตัวนับขึ้น 0 / 1000

จุดที่ต้องจำ: connect/disconnect ตามการโผล่/หายของ element ไม่ใช่ตามการเปลี่ยนหน้า

controller ตัวไหนต้องมี disconnect ที่เก็บกวาด ไม่งั้นจะมีของค้างทำงานซ้อนกันเมื่อเปลี่ยนหน้าไปมา?

ตัวเลือกที่ 4 ถูก — setInterval ไม่ได้อยู่ใน element · element หายแล้วมันก็ยังทำงานต่อ ต้อง clearInterval ใน disconnect · เหมือนการทดลอง keydown ที่ log ซ้อนเป็น 5 บรรทัด

ตัวเลือกที่ 1 Stimulus ผูก/ถอด listener ของ data-action ให้เอง · ตัวเลือกที่ 2 คือ theme controller ของเรา — ไม่ได้สร้างอะไรที่ค้าง · ตัวเลือกที่ 3 แก้แค่ข้อความใน element ของตัวเอง หายไปพร้อม element (คือ hello_controller.js ที่ Rails สร้างมาให้)

จุดที่ต้องจำ: สร้างอะไรที่อยู่นอก element ใน connect → เก็บสิ่งนั้นใน disconnect

ติดตรงไหน ถามผู้สอนหรือ AI ด้วยประโยคในกล่องศัพท์ได้เลย · หรือแปะ HTML ที่มี data-controller กับไฟล์ controller แล้วถามว่า "ทำไม connect ไม่ทำงาน"

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
48เปลี่ยนชื่อคลาสใน HTML → JS พังเงียบ ๆ ไม่มี errorหาไม่เจอแล้วไปใช้ต่อจะมี TypeError ใน console · ที่เงียบจริงคือตอน Turbo เปลี่ยนหน้า/แทนฟอร์ม · Stimulus เองก็เงียบได้ถ้าสะกด data-controller ผิดนิดหน่อย — Stimulus ไม่ได้กันพิมพ์ผิดให้
48–50ติดป้าย "บล็อค D"เป็นส่วนหนึ่งของบล็อก C (Hotwire) · บล็อก D จริงคือ rich text ใน Part 2ไม่ต้อง
49Turbo เปลี่ยน body → disconnect · element ใหม่ → connectถูก และหน้าที่เคยเปิดแล้วได้ connect สองครั้ง (ภาพจาก cache + หน้าจริง)สนใจ — ลืมเก็บกวาดแล้วของซ้อนเร็วเป็นสองเท่า
50classes ตัวอย่าง text-red-500แอปเราใช้สีของธีม daisyUI text-error (สีตามหน้าที่ บท 128 · ตระกูลเดียวกับ btn-error บท 129)ไม่ต้อง — วิธีใช้เหมือนกัน
51ให้ตอบใน chat แบบ 1-ก 2-ขเฉลยครบ 6 ข้อ: 1-ข · 2-ง · 3-ฉ · 4-จ · 5-ก · 6-คไว้เทียบคำตอบ

อ้างอิง