🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- Turbo Drive (บท 130) — กดลิงก์แล้ว Turbo ไปขอหน้าใหม่มา แล้วเปลี่ยนแค่
<body>ไม่โหลดทั้งหน้า · ผลข้างเคียง: สัญญาณDOMContentLoaded("หน้าโหลดเสร็จแล้ว") ดังครั้งเดียวตอนเปิดเว็บครั้งแรก ไม่ดังอีกตอนเปลี่ยนหน้า - Turbo Stream แทนฟอร์ม (บท 132) — ส่งคอมเมนต์แล้ว server ตอบ "เปลี่ยนฟอร์มเก่าเป็นฟอร์มว่างใบใหม่" → ฟอร์มบนหน้าคือ element ชุดใหม่ ไม่ใช่ชุดเดิม
- importmap (บท 127) — สมุดรายชื่อ "ชื่อเล่น → ไฟล์ JS ไหน" · บรรทัด
pin_all_from "app/javascript/controllers"ลงชื่อทุกไฟล์ในโฟลเดอร์นั้นให้เอง - useEffect + cleanup (บท 74) — React รันโค้ดตอน component โผล่ และรันฟังก์ชันที่
returnออกมาตอนมันหายไป - ปุ่มสลับธีม 🌓 (บท 129) — เราใช้ Stimulus ไปแล้วหนึ่งตัวแบบ "พิมพ์ตาม" · ได้รู้จัก
const(ตัวแปร) ·document(หน้าเว็บทั้งหน้า) ·connect()ไปแล้ว · บทนี้จะเข้าใจว่าทำไมมันทำงาน
🧰 ตรวจด้วย 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 คำที่จะเจอทั้งบท:
- element = แท็กหนึ่งตัวที่อยู่บนหน้าแล้ว เช่น ช่องพิมพ์
<textarea>หนึ่งช่อง · หน้าเว็บทั้งหน้าคือต้นไม้ของ element พวกนี้ (ชื่อทางการ DOM — อยู่ในกล่องศัพท์ข้างล่าง) - event = สัญญาณที่เบราว์เซอร์ส่งเมื่อมีอะไรเกิดขึ้น เช่น คลิก · พิมพ์ · หน้าโหลดเสร็จ
- listener = ฟังก์ชันที่เราฝากไว้กับ element ว่า "ถ้าสัญญาณนี้ดัง ให้ทำสิ่งนี้" · คล้าย
onClick={…}ใน React (บท 73)
ถ้าเขียนแบบที่ตัวอย่างบนอินเทอร์เน็ตส่วนใหญ่สอน จะได้หน้าตาแบบนี้ · ไม่ต้องพิมพ์ตาม เราทดลองให้ดูแล้ว
(ใส่ไว้ท้าย 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
})
})
document.addEventListener("DOMContentLoaded", () => { … })— ฝาก listener ไว้กับทั้งหน้า: "พอหน้าโหลดเสร็จ ให้ทำสิ่งในวงเล็บปีกกา" ·() => { … }คือฟังก์ชันแบบย่อ (เหมือนที่เขียนใน React บท 73)document.querySelector("#comment_body")— "ไล่หาทั้งหน้า เอา element แรกที่ id เป็นcomment_body" (#= หาจาก id · id นี้form.textarea :bodyตั้งให้เอง) · หาไม่เจอได้null(ค่าว่าง เหมือนnilของ Ruby)input.addEventListener("input", …)— ฝาก listener ไว้กับช่องพิมพ์: "ทุกครั้งที่ช่องนี้ถูกพิมพ์ ให้ทำ…" ·value.length= ข้อความยาวกี่ตัว ·textContent= ข้อความใน element
ผลจริงใน 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>
data-controller="dropdown"— "กล่องนี้ (และทุกอย่างข้างใน) ใช้ JS ชื่อdropdown" · JS ตัวนี้เรียกว่า controller (คนละอย่างกับ controller ของ Rails ฝั่ง server — ชื่อซ้ำกันเฉย ๆ)data-action="click->dropdown#toggle"— อ่านว่า "เมื่อclick→ ให้ controllerdropdownทำ methodtoggle" · รูปแบบคือevent->ชื่อcontroller#ชื่อmethoddata-dropdown-target="menu"— "element นี้คือของชื่อmenuที่ controllerdropdownจะใช้" · ใน JS เรียกมันว่าthis.menuTargetได้เลย ไม่ต้องquerySelector(this= controller ตัวนี้เอง เหมือนselfของ Ruby)class="hidden"— คลาส Tailwind (บท 72) ที่ซ่อน element ·toggleของ dropdown แค่ใส่/ถอดคลาสนี้ เมนูก็เปิด/ปิด
เปิดไฟล์ 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-controller | character-counter | ขีดกลาง · ไม่มีคำว่า controller |
| HTML target | data-character-counter-target="input" | ชื่อ controller อยู่กลางชื่อ attribute |
| ใน ERB | data: { character_counter_target: "input" } | เขียนขีดล่างได้ Rails แปลงเป็นขีดกลางให้ |
| ใน JS | this.inputTarget | ชื่อ target + คำว่า Target |
เราลองสะกดผิดให้ดูแล้วใน Chrome (พิมพ์ "อร่อย" ลงช่อง แล้วดูตัวนับ):
| เขียนผิดแบบนี้ | ผลจริง |
|---|---|
data-controller="character_counter" (ขีดล่าง) | ตัวนับว่าง · console ไม่มีอะไรเลย |
data-character-counter-target="ouput" (ตก t) | ตัวนับว่าง · console แดง:Error connecting controllerError: 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}`
}
}
ทีละบรรทัด:
import { Controller } from "@hotwired/stimulus"— ดึงแม่แบบControllerมาจาก Stimulus (ชื่อ@hotwired/stimulusอยู่ในสมุด importmap แล้ว บท 127)export default class extends Controller— "ไฟล์นี้ส่งออก class หนึ่งตัว ที่สืบทอดจากController" · เหมือน Rubyclass X < Controllerแต่ไม่ตั้งชื่อ เพราะชื่อมาจากชื่อไฟล์static targets = ["input", "output"]— ประกาศว่า controller นี้ใช้ target สองตัว · Stimulus อ่านรายชื่อจากบรรทัดนี้แล้วสร้างthis.inputTargetและthis.outputTargetให้ · (static= ค่าที่ติดกับ class ทั้งก้อน ไม่ใช่ของแต่ละตัว — ตอนนี้จำแค่ว่า "รายการประกาศ" ของ Stimulus ขึ้นต้นด้วยstatic)connect() { … }·disconnect() { … }·update() { … }— method ของ class · JS เขียนแค่ชื่อตามด้วย() { }ไม่มีคำว่าdefแบบ Rubythis.onInput = …— เก็บของไว้ในตัว controller ให้ method อื่นหยิบใช้ได้ · เหมือน@on_input = …ของ Rubythis.update.bind(this)— หยิบ methodupdateออกมาเป็นฟังก์ชันที่ฝากให้เบราว์เซอร์ได้ พร้อมมัดไว้ว่าthisคือ controller ตัวนี้ · ถ้าไม่bindพอเบราว์เซอร์เรียกthisจะกลายเป็นช่องพิมพ์แทน แล้วthis.outputTargetหาไม่เจอ · เก็บไว้ในthis.onInputเพื่อเอาตัวเดียวกันไปถอดใน disconnectaddEventListener("input", this.onInput)— eventinputดังทุกครั้งที่ข้อความในช่องเปลี่ยน (พิมพ์ ลบ วาง) ·removeEventListenerคือคำสั่งถอดออกthis.update()ใน connect — นับทันทีหนึ่งรอบตอนฟอร์มโผล่ (ฟอร์มที่กลับมาพร้อมข้อความเดิมจะได้ตัวเลขถูกตั้งแต่แรก)`${…} / ${…}`— ข้อความที่แทรกค่าได้ เหมือน"#{…}"ของ Ruby ·value.length= จำนวนตัวอักษร ·maxLength= ค่าmaxlengthของช่อง (1000 · ใส่ในขั้น 2)
วิธีที่สั้นกว่าและ "ปกติ" กว่าคือใส่ 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>
data-controller="character-counter"บน<div>ที่ห่อ label + ช่องพิมพ์อยู่แล้ว — กรอบของ controller · target ต้องอยู่ข้างในกรอบนี้maxlength: 1000— attribute มาตรฐานของ HTML: เบราว์เซอร์ไม่ยอมให้พิมพ์เกิน 1000 ตัวเอง (ตรงกับ validation ของ Comment ที่จำกัด 1000) · JS อ่านเลขนี้ไปโชว์ ไม่ต้องจำเลขซ้ำdata: { character_counter_target: "input" }— hashdata:ของ helper กลายเป็นdata-…และขีดล่างกลายเป็นขีดกลาง- บรรทัด
class: [ … ]คือของเดิมจากบท 132 ไม่ได้แก้ แค่ย้ายลงบรรทัดใหม่ให้อ่านง่าย (Ruby ยอมให้ขึ้นบรรทัดใหม่หลังจุลภาค) <p … data-character-counter-target="output"></p>— ที่โชว์ตัวเลข · ตั้งใจให้ว่าง ให้ JS เติมเอง ถ้าผู้ใช้ปิด JS จะไม่มีเลข "0" ค้างหลอกตา- คลาสของ
<p>:mt-1เว้นระยะด้านบนนิดหน่อย ·text-rightชิดขวา ·text-smตัวเล็กลง ·text-base-content/70สีตัวอักษรของธีมแบบจางลง (บท 128)
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">
- บรรทัดแรก — อยู่ในสมุด importmap (บท 127): ชื่อเล่น
controllers/character_counter_controller→ ไฟล์จริงที่มีเลขรุ่นต่อท้าย - บรรทัดที่สอง —
modulepreload= บอกเบราว์เซอร์ให้ "โหลดไฟล์นี้มารอไว้เลย" จะได้ไม่ต้องรอตอนใช้
ขั้น 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 มันอยู่ต่อจนกว่าจะปิดแท็บ · ตัวอย่างเช่น
setInterval(ฟังก์ชัน, 1000)— ตั้งนาฬิกาให้ทำซ้ำทุก 1000 มิลลิวินาที (1 วินาที) ไปเรื่อย ๆ · ปิดด้วยclearInterval- listener บน
windowหรือdocument— ฝากไว้กับทั้งหน้า ไม่ใช่กับ element ของเรา - library ภายนอกที่เปิดไว้ · สาย WebSocket ที่ต่อเอง (สายที่ต่อค้างไว้ บท 133)
ทดลองให้ดู (ลองตามได้ แล้ว 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 บรรทัด |
| หลังรอบ 1 | 3 บรรทัด | 1 บรรทัด |
| หลังรอบ 2 | 5 บรรทัด | 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)
}
data-controllerกับdata-actionอยู่บนปุ่มตัวเดียวกัน ได้ — ปุ่มเป็นทั้งกรอบและตัวกดdata-action="theme#toggle"ไม่มีclick->— Stimulus มีค่าเริ่มต้นให้: บน<button>= click · บน<textarea>= input · บน<form>= submitconnect()— อ่านธีมที่จำไว้ในlocalStorage(ที่เก็บข้อมูลเล็ก ๆ ของเบราว์เซอร์) แล้วใส่data-themeบนแท็ก<html>(daisyUI เปลี่ยนสีทั้งเว็บจากป้ายนี้ บท 128) · navbar อยู่ใน<body>จึงถูกเปลี่ยนทุกครั้งที่ Turbo เปลี่ยนหน้า → เราใส่ log ดูแล้ว connect ยิงทุกหน้าที่เปิด ไม่เป็นไร เพราะแค่ใส่ธีมเดิมซ้ำ- ทำไมไม่มี disconnect? เพราะ connect ไม่ได้สร้างอะไรที่ค้างอยู่ (ไม่มี timer ไม่มี listener) · กฎคือ "สร้างอะไร เก็บสิ่งนั้น" ไม่ได้แปลว่าทุก controller ต้องมี disconnect
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()) · ใช้ไม่บ่อยในงานเริ่มต้น | ||
- ค่าจาก server = ค่าที่ ERB เขียนลงใน HTML ตอนสร้างหน้า เช่น (สมมติ controller ตั้ง
@warn_atไว้)data-character-counter-warn-at-value="<%= @warn_at %>"· JS ไม่ต้องจำเลขเอง - ชื่อใน HTML มีขีดกลาง ใน JS เป็นตัวใหญ่ต่อกัน:
warn-at→warnAt· ระบุชนิดไว้ (Numberตัวเลข ·Stringข้อความ ·Booleanจริง/เท็จ) Stimulus แปลงให้ เพราะทุกอย่างใน HTML เป็นข้อความ · ไม่ใส่ attribute เลย ชนิดNumberได้ค่า0 - ทำไม classes ต้องมาจาก HTML? — สองเหตุผล: คนแก้หน้าตาเปลี่ยนสีได้โดยไม่แตะ JS ·
และกับดักบท 128: Tailwind สร้าง CSS เฉพาะคลาสที่เขียนครบชื่ออยู่ในไฟล์ · ชื่อคลาสครบ ๆ ใน ERB อย่าง
"text-error"Tailwind เห็นแน่นอน
- 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 | ไม่ — แค่สลับคลาส hidden | Stimulus |
| คัดลอกข้อความไป clipboard | ไม่ — เบราว์เซอร์มีคำสั่งให้ | Stimulus |
| ส่งฟอร์มอัตโนมัติเมื่อเลือกตัวเลือก · ถามยืนยันก่อนลบ | ไม่ — แค่สั่งส่ง/ถาม (การบันทึก/ลบจริงยังเป็นงานของ server) | Stimulus · (ปุ่มลบในแอปเราใช้ turbo_confirm ที่ Turbo มีให้แล้ว ไม่ต้องเขียนเอง) |
| โหลดรายการหน้าถัดไป | ต้อง — ข้อมูลอยู่ใน DB | Turbo |
| เช็คว่าชื่อนี้มีคนใช้แล้วหรือยัง | ต้อง — ต้องถาม DB | Turbo |
สี่แถวแรกคือ "ของที่เขียนบ่อยที่สุด 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)
- ใน
_form.html.erbเพิ่ม attribute สองตัวบน<div data-controller="character-counter">:data-character-counter-warn-at-value="10"และdata-character-counter-warning-class="text-error" - ใน
character_counter_controller.jsใต้static targetsเพิ่มการประกาศ values (ชื่อwarnAtชนิดNumber) และ classes (ชื่อwarning) · หน้าตาดูได้จากคอลัมน์ "ประกาศใน JS" ของตารางข้างบน - ใน
update()หลังบรรทัดที่เขียนตัวเลข เพิ่มบรรทัดเดียว:this.outputTarget.classList.toggle(this.warningClass, this.inputTarget.value.length >= this.warnAtValue)—classList.toggle(ชื่อคลาส, จริงหรือเท็จ)= "ถ้าจริง ใส่คลาส · ถ้าเท็จ ถอดคลาส" ·>== มากกว่าหรือเท่ากับ - refresh หน้าบทความ · พิมพ์ 9 ตัว → 10 ตัว → ลบออก 1 ตัว ดูสีตัวเลข
- เสร็จแล้วคืนโค้ดให้กลับเป็นตอนจบขั้น 4:
git restore .
ควรเห็น: 9 ตัวสีเทาจางเหมือนเดิม · ครบ 10 ตัวตัวเลขเป็นสีแดง · ลบเหลือ 9 กลับเป็นสีเทา
ถ้าเจออาการนี้ แปลว่า…
- ตัวเลขแดงตั้งแต่ 0 → Stimulus หา attribute ของ value ไม่เจอ ได้ค่าเริ่มต้น
0(0 ≥ 0 จริงเสมอ) · ดูชื่อ attribute ว่าwarn-atมีขีดกลางตรงกับwarnAtไหม - ตัวเลขขยับแต่ไม่แดงเลย · console มี
Missing attribute "data-character-counter-warning-class"→ สะกดชื่อ attribute ของ class ผิด - ตัวเลขไม่ขยับ console เงียบ →
data-controllerสะกดผิด (หัวข้อชื่อไฟล์ ↔ ชื่อใน HTML)
ส่วนที่เปลี่ยน (ตัดมาเฉพาะบรรทัดที่เกี่ยว ไม่ใช่ทั้งไฟล์ · … = บรรทัดเดิมที่ไม่ได้แตะ):
// 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 | ไม่ต้อง |
| 49 | Turbo เปลี่ยน body → disconnect · element ใหม่ → connect | ถูก และหน้าที่เคยเปิดแล้วได้ connect สองครั้ง (ภาพจาก cache + หน้าจริง) | สนใจ — ลืมเก็บกวาดแล้วของซ้อนเร็วเป็นสองเท่า |
| 50 | classes ตัวอย่าง text-red-500 | แอปเราใช้สีของธีม daisyUI text-error (สีตามหน้าที่ บท 128 · ตระกูลเดียวกับ btn-error บท 129) | ไม่ต้อง — วิธีใช้เหมือนกัน |
| 51 | ให้ตอบใน chat แบบ 1-ก 2-ข | เฉลยครบ 6 ข้อ: 1-ข · 2-ง · 3-ฉ · 4-จ · 5-ก · 6-ค | ไว้เทียบคำตอบ |
อ้างอิง
- Stimulus Handbook — Introduction — อ่านทั้งหน้า (สั้น) · ที่มาของแนวคิด "HTML บอกว่าต้องการ JS อะไร" และเหตุผลที่ Stimulus ไม่สร้าง HTML เอง
- Stimulus Reference — Controllers § Naming Conventions — อ่านตาราง Naming Conventions และหัวข้อ Identifiers · ยืนยันกฎชื่อไฟล์ขีดล่าง ↔ identifier ขีดกลาง
- Stimulus Reference — Lifecycle Callbacks § Disconnection — อ่าน Connection · Disconnection · Reconnection · Order and Timing · ยืนยันว่าใช้ MutationObserver และ connect สองครั้งมี disconnect คั่นเสมอ
- Stimulus Reference — Actions § Event Shorthand —
ดูตาราง event เริ่มต้นของแต่ละ element · เหตุผลที่
theme#toggleไม่ต้องมีclick-> - Stimulus Reference — Values และ CSS Classes — อ่านตัวอย่างแรกของแต่ละหน้า · ที่มาของภารกิจท้ายบท · Outlets ดูแค่ย่อหน้าแรกพอ
- MDN — removeEventListener § Matching event listeners for removal —
อ่านหัวข้อนี้หัวข้อเดียว · ทำไมต้องเก็บฟังก์ชันไว้ใน
this.onInputเพื่อถอดตัวเดียวกัน - MDN — MutationObserver — อ่านแค่ย่อหน้าแรก · รู้จัก "ยาม" ที่ Stimulus ใช้เฝ้าหน้า
- github.com/hotwired/stimulus-rails —
อ่าน README ส่วน Usage with import map · อธิบายว่าไฟล์ใน
app/javascript/controllersถูกโหลดเองผ่านpin_all_from