🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- state ใน client · สมุดเล่มที่สอง (บท 122) — ข้อมูลที่ก็อปมาเก็บไว้ในเบราว์เซอร์ (เช่น ใน
useState) ต้องคอยทำให้ตรงกับของจริงบน server · ลืมเมื่อไหร่ = จอโชว์ของเก่า useState·useEffect(บท 73–74) — React จำค่าไว้ในหน้าจอ · และสั่ง "หลังวาดจอแล้วให้ทำ…" (เช่น fetch ข้อมูล)<head>ของ layout (บท 123 · 127) — ส่วนหัวของหน้าที่ลิงก์ไปหาไฟล์ CSS และ JS · ชื่อไฟล์มีเลขรุ่นต่อท้าย (fingerprint) และติดป้ายdata-turbo-track="reload"ไว้ ซึ่งบท 127 บอกว่า "ละเอียดบท 130" — คือบทนี้- importmap (บท 127) — สมุดรายชื่อ "ชื่อเล่น → ไฟล์ JS จริง" ทำให้เขียน
import "ชื่อเล่น"ได้โดยไม่มีnode_modules data-controller="theme"(บท 129) — ปุ่ม 🌓 ใน navbar ที่เราใช้ Stimulus แบบ "แค่ใช้" · บทนี้จะเฉลยว่าทำไมต้องเขียนแบบนั้น
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · turbo-rails 2.0.23 (Turbo 8.0.23) · Chrome 156 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน เรื่องในบทนี้ไม่ต่างกัน)
🗂 โค้ดเริ่มจาก: แอปหลังจบ Lab บท 129 (มี navbar · การ์ด daisyUI · ปุ่มสลับธีม — ตามไม่ทันดูบท 129) ·
บทนี้ทดลองแล้วทิ้ง — จบบทให้ git restore . (บอกอีกทีท้ายภารกิจ)
ผลทุกก้อนรันจริงแล้วก็อปมา · พฤติกรรมในเบราว์เซอร์ตรวจด้วย Chrome จริง · ขนาดไฟล์ (byte) ของคุณจะต่างไปนิดหน่อย เพราะในหน้ามีข้อความที่เปลี่ยนตามเวลา เช่น "about 1 month ago" · และ rack-mini-profiler (ป้ายจับเวลาตอน development) ฝังรหัสสุ่มไว้ในหน้า ยิ่งเปิดบ่อยยิ่งยาวขึ้นนิดหน่อย
บล็อก C: ทำให้เว็บขยับได้ โดยไม่ต้องมีสมุดเล่มที่สอง
ตลอดบล็อก A และ B เราทำหน้าเว็บที่สวยและอ่านรู้เรื่องแล้ว · แต่มันยังเป็นเว็บแบบ "กดแล้วรอหน้าใหม่" · ถ้าอยากแก้หัวข้อบทความในหน้าเดิม หรือให้คอมเมนต์ใหม่เด้งขึ้นเองโดยไม่ต้องรีเฟรช — คนส่วนใหญ่จะนึกถึง React ทันที
บล็อก C จะทำของพวกนั้นด้วย Hotwire แทน · Hotwire คือชื่อรวมของเครื่องมือชุดหนึ่งจากทีมที่สร้าง Rails ·
สองตัวที่เราจะใช้คือ Turbo (เปลี่ยนหน้า/เปลี่ยนบางส่วนของหน้าโดยไม่ต้องเขียน JS) และ Stimulus (JS ชิ้นเล็ก ๆ ที่ HTML เป็นคนเรียก) ·
ทั้งคู่มากับ Rails ตั้งแต่ rails new ไม่ต้องติดตั้งเพิ่ม
| บท | เรื่อง | ของจริงที่จะได้ในแอป |
|---|---|---|
| 130 (บทนี้) | แนวคิด + Turbo Drive | ไม่มีโค้ดใหม่ — พิสูจน์ว่ามันทำงานอยู่แล้ว |
| 131 | Turbo Frames | แก้หัวข้อบทความในหน้าเดิม |
| 132 | Turbo Streams | เพิ่มคอมเมนต์แล้วหลายจุดในหน้าเปลี่ยนพร้อมกัน |
| 133 | broadcast | คอมเมนต์เด้งขึ้นทุกแท็บที่เปิดอยู่ |
| 134 | Stimulus | ตัวนับตัวอักษรในช่องคอมเมนต์ |
| 135 | Lab Vibe 3 | ตรวจรับงานทั้งบล็อก |
คำถามเดียวที่อยู่เบื้องหลัง Hotwire
ทวนบท 122 ก่อน: แอป React ที่ fetch JSON มาเก็บใน useState จะมีข้อมูลสองชุด — ชุดจริงในฐานข้อมูล กับสำเนาในแท็บของผู้ใช้ ·
สองชุดนี้ต้องตรงกันตลอด ไม่งั้นจอโชว์ของเก่า (บั๊ก sync)
Hotwire ตั้งคำถามกลับว่า: "ถ้าเราไม่ยอมให้มีสำเนาชุดที่สองเลย เราจะทำให้หน้าเว็บขยับได้ถึงไหน" · สองฝั่งตอบไม่เหมือนกัน:
| ฝั่ง SPA (React ที่ fetch JSON) | ฝั่ง Hotwire | |
|---|---|---|
| สำเนาข้อมูลในเบราว์เซอร์ | ยอมให้มี แล้วหาเครื่องมือมาช่วยดูแลมัน | ไม่มี — ข้อมูลอยู่ที่ server ที่เดียว |
| อยากให้จอเปลี่ยน | แก้สำเนาใน state → React วาดจอใหม่จากสำเนา | ไปขอ HTML ใหม่ จาก server → เอามาแทนเฉพาะส่วนที่ต้องเปลี่ยน |
| ของที่วิ่งผ่านเน็ต | JSON (ข้อมูลดิบ) | HTML (จัดหน้าเสร็จแล้ว) |
| โค้ดจัดหน้าตา | อยู่ในเบราว์เซอร์ (JSX) | อยู่ใน view ของ Rails (ERB) ที่เดียว |
เครื่องมือที่ฝั่ง SPA ใช้ดูแลสำเนา มีชื่อที่คุณจะได้ยินบ่อย: Redux · Zustand (ที่เก็บ state กลางของทั้งแอป) และ
React Query · SWR (ตัวช่วย fetch ที่จำว่าข้อมูลไหนเก่าแล้วและไปขอใหม่ให้เอง) ·
ถ้าเทียบกับที่เราเรียน: มันคือ useState + useEffect ฉบับอัปเกรด ที่ทำให้ไม่ลืม fetch ใหม่
เครื่องมือพวกนี้เก่งมาก · แต่สังเกตว่าทุกตัวเกิดมาแก้ปัญหาเดียวกัน คือทำให้สำเนาตรงกับของจริง · Hotwire เลือกตัดปัญหาที่ต้นทาง: ไม่มีสำเนา ก็ไม่มีอะไรต้องทำให้ตรง
ร้านแบบ SPA ส่งวัตถุดิบดิบ (JSON) ไปที่โต๊ะ · โต๊ะต้องมีเตาของตัวเอง (React) และสมุดจดของบนโต๊ะเล่มที่สอง · พอร้านใหญ่ขึ้น เจ้าของเลยจ้างผู้ช่วยประจำโต๊ะ (React Query · Redux) คอยวิ่งไปเช็คกับครัวว่าสมุดบนโต๊ะยังตรงไหม
ร้านแบบ Hotwire ไม่มีสมุดบนโต๊ะ · ลูกค้าอยากได้อะไร พนักงานไปบอกครัว ครัวเปิดสมุดเล่มเดียวของครัว จัดจานเสร็จ แล้วเปลี่ยนเฉพาะจานบนโต๊ะ · ไม่ต้องจ้างผู้ช่วยมาคอยเช็คสมุด เพราะไม่มีสมุดให้เช็ค
ที่ไม่เหมือนกัน: ร้าน Hotwire ต้องเดินไปครัวทุกครั้งที่จออยากเปลี่ยน (ต้องมีเน็ต และรอ server ตอบ) · งานที่ต้องตอบสนองทันทีโดยไม่ต้องถามครัว เช่น นับตัวอักษรขณะพิมพ์ ยังต้องมี JS เล็ก ๆ บนโต๊ะอยู่ดี (Stimulus · บท 134) · และโต๊ะอื่นจะไม่รู้เองว่าครัวเปลี่ยนอะไร จนกว่าครัวจะประกาศ (broadcast · บท 133)
- Hotwire · HTML over the wire
- แนวคิด "ส่ง HTML ที่จัดเสร็จแล้วผ่านเน็ต แทนการส่ง JSON" และชื่อชุดเครื่องมือ (Turbo + Stimulus) ของแนวคิดนี้ · ถาม senior ได้ว่า “หน้านี้ทำแบบ HTML over the wire ด้วย Turbo ได้ไหม หรือจำเป็นต้องเป็น SPA”
- SPA (single-page application)
- เว็บที่โหลดหน้าเดียวแล้วให้ JavaScript ในเบราว์เซอร์วาดจอเองทั้งหมดจาก JSON — แบบที่ React ทำ · พิมพ์บอก AI ได้ว่า “don't turn this page into an SPA; render HTML on the server and use Turbo”
- state management library
- เครื่องมือดูแลสำเนาข้อมูลฝั่ง client เช่น Redux · Zustand · React Query · SWR · ถาม senior ได้ว่า “ถ้าใช้ Hotwire แล้ว เรายังต้องมี state management library ไหม” (คำตอบปกติคือไม่ต้อง)
วัดจริง: HTML หนักกว่า JSON แค่ไหน
ข้อเสียที่ทุกคนถามคือ "ส่ง HTML ไม่เปลืองกว่าเหรอ" · ข้อนี้จริง แต่มากแค่ไหนต้องวัดเอง ·
แอปของเราตอบได้ทั้งสองแบบอยู่แล้ว (scaffold ทำ .json ไว้ให้ — บท 122)
เลยวัดบทความชิ้นที่ 1 ได้เลย · เปิด bin/dev ทิ้งไว้ แล้วเปิดหน้าต่าง Terminal อีกอันในโฟลเดอร์แอปมารันคำสั่งนี้:
$ curl -s localhost:3000/posts/1.json
{"id":1,"title":"ต้มยำกุ้งน้ำข้น","description":"ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม กุ้งแม่น้ำตัวใหญ่ หอมตะไคร้ ข่า ใบมะกรูด กินกับข้าวสวยร้อน ๆ ได้ทั้งถ้วย ร้านนี้เปิดมาสามสิบปีแล้ว คิวยาวทุกวันเสาร์อาทิตย์","category_id":1,"created_at":"2026-10-08T10:52:53.123+07:00","updated_at":"2026-11-07T10:52:53.123+07:00","url":"http://localhost:3000/posts/1.json"}
นี่คือ "วัตถุดิบดิบ" (วันเวลาใน created_at · updated_at ของคุณจะต่าง ตามวันที่คุณรัน seed) · ทีนี้นับขนาด (byte) ด้วย wc -c (นับจำนวน byte ที่ได้มา) และลองบีบด้วย gzip (วิธีบีบไฟล์ที่ server จริงมักเปิดไว้ตอนส่งผ่านเน็ต):
$ curl -s localhost:3000/posts/1.json | wc -c
735
$ curl -s localhost:3000/posts/1 | wc -c
6010
$ curl -s localhost:3000/posts/1.json | gzip | wc -c
403
$ curl -s localhost:3000/posts/1 | gzip | wc -c
2279
|— ส่งผลของคำสั่งซ้ายไปให้คำสั่งขวา · อ่านว่า "ขอหน้า แล้ว (บีบ แล้ว) นับ byte"- ไม่ใส่
.json= ได้หน้า HTML ทั้งหน้า แบบที่เบราว์เซอร์ได้ (มี<head>· navbar · ปุ่ม · ทุกอย่าง)
ทั้งหน้าใหญ่กว่า JSON ราว 8 เท่า · แต่ถ้าตัดมาเฉพาะการ์ดบทความ (<article> ที่มาจาก _post.html.erb) ซึ่งเป็นข้อมูลชุดเดียวกับ JSON:
$ curl -s localhost:3000/posts/1 | sed -n '/<article/,/<\/article>/p' | wc -c
(sed -n '/A/,/B/p' = พิมพ์เฉพาะบรรทัดตั้งแต่ที่เจอ A จนถึงที่เจอ B · ตรงนี้คือตัดเอาแค่ <article …> ถึง </article>)
คิดว่าการ์ดหนักกว่า JSON (735 byte) กี่เท่า — 1 เท่ากว่า ๆ · 4 เท่า · หรือ 8 เท่า?
$ curl -s localhost:3000/posts/1 | sed -n '/<article/,/<\/article>/p' | wc -c
943
$ curl -s localhost:3000/posts/1 | sed -n '/<article/,/<\/article>/p' | gzip | wc -c
514
แค่ราว 1.3 เท่า (บีบแล้วก็ราว 1.3 เท่า) · เพราะของที่หนักจริงคือตัวหนังสือไทย (ตัวละ 3 byte) ซึ่งต้องส่งเท่ากันทั้งสองแบบ ·
ส่วนที่ HTML เพิ่มมาคือชื่อแท็กกับชื่อคลาส เช่น class="card bg-base-100 shadow-sm"
| สิ่งที่วัด (บทความชิ้นที่ 1) | ไม่บีบ | บีบด้วย gzip | เทียบกับ JSON |
|---|---|---|---|
JSON /posts/1.json | 735 | 403 | — |
เฉพาะการ์ด <article> | 943 | 514 | ~1.3 เท่า |
HTML ทั้งหน้า /posts/1 | 6,010 | 2,279 | ~8 เท่า (บีบแล้ว ~5.7 เท่า) |
แปลว่าอะไร: HTML หนักกว่าจริง โดยเฉพาะตอนส่งทั้งหน้า ·
(ตอนรันบนเครื่องเรา Rails ยังแถมคอมเมนต์ <!-- BEGIN app/views/... --> ไว้บอกว่าแต่ละท่อนมาจากไฟล์ไหน · เว็บจริงไม่มี)
แต่ทั้งหน้าก็แค่ราว 2–6 KB (KB = พันกว่า byte) · เทียบกับไฟล์ที่หน้าเดียวกันใช้: tailwind-….css 64,279 byte · turbo.min-….js 105,579 byte ·
ไฟล์ใหญ่พวกนี้แหละที่ Turbo Drive ทำให้ไม่ต้องโหลดซ้ำ (สองหัวข้อถัดไป)
สิ่งที่ได้คืนมาจากการยอมส่ง HTML: ไม่ต้องมี state management library สักตัว · และโค้ดจัดหน้าตาอยู่ใน ERB ที่เดียว ไม่ต้องเขียนซ้ำทั้งฝั่ง Rails และฝั่ง JS
สไลด์หน้า 36 อ้างว่า "HTML หนักกว่า JSON ประมาณ 2–4 เท่า" · ของจริงขึ้นกับว่าเทียบกับอะไร: เฉพาะการ์ดต่างกันน้อย · ทั้งหน้าต่างกันหลายเท่าแม้บีบแล้ว · ถ้ามีคนเถียงเรื่องนี้ ให้วัดของจริงแบบที่เพิ่งทำ
Turbo มี 3 ชิ้น — แผนที่ของบล็อก C
Turbo แบ่งงานเป็นสามชิ้น ต่างกันที่ขนาดของส่วนที่เปลี่ยน · ตารางนี้คือแผนที่ของบท 130–133 · หลงเมื่อไหร่กลับมาดูตรงนี้
| ชิ้น | เปลี่ยนแค่ไหน | ในร้านอาหาร | เราต้องเขียนอะไร | ใช้ทำอะไรในแอปเรา |
|---|---|---|---|---|
| Turbo Drive (บทนี้) | ทั้ง <body> เมื่อกดลิงก์หรือส่งฟอร์ม | เปลี่ยนจานบนโต๊ะเดิม ไม่รื้อโต๊ะจัดใหม่ | ไม่ต้องเขียนเลย ทำงานเองตั้งแต่ติดตั้ง | ทุกลิงก์ในเว็บ ไม่กระพริบขาว |
| Turbo Frames (บท 131) | เฉพาะกล่องที่ครอบไว้ | เปลี่ยนแค่ถ้วยใบเดียวบนโต๊ะ | ครอบด้วย turbo_frame_tag | แก้หัวข้อบทความในหน้าเดิม |
| Turbo Streams (บท 132–133) | หลายจุดพร้อมกัน · ส่งจาก server มาเองได้ | ใบสั่งงานหลายข้อให้พนักงานเสิร์ฟ | ไฟล์ .turbo_stream.erb · broadcast | คอมเมนต์ใหม่เด้งขึ้นทุกแท็บ |
กฎเลือกใช้ (สไลด์หน้า 37): ไล่จากหยาบไปละเอียด แล้วเลือกชิ้นที่หยาบที่สุดที่ยังทำงานได้ · ภาษาคน: เริ่มจากของที่ไม่ต้องเขียนอะไร (Drive) · ไม่พอค่อยครอบกล่อง (Frames) · ยังไม่พอค่อยสั่งหลายจุด (Streams) · ชิ้นที่ละเอียดกว่าทำได้มากกว่า แต่มีโค้ดให้ดูแลมากกว่า
Turbo Drive — ทำงานอยู่แล้วตั้งแต่ Lab แรก
ข่าวดี: เราใช้ Turbo Drive มาตลอดโดยไม่รู้ตัว · ทุกครั้งที่คุณกดลิงก์ในแอปตั้งแต่บท 120 หน้าเว็บไม่ได้โหลดใหม่ทั้งใบ ·
มันมาจากบรรทัดเดียวในไฟล์ JS หลักของแอป — เปิด app/javascript/application.js:
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "@hotwired/turbo-rails"— โหลด Turbo · ชื่อเล่นนี้อยู่ในสมุดรายชื่อconfig/importmap.rbบรรทัดpin "@hotwired/turbo-rails", to: "turbo.min.js"(บท 127) · แค่โหลดมา Turbo Drive ก็เริ่มทำงานทันที ไม่ต้องเรียกฟังก์ชันอะไรต่อimport "controllers"— โหลด Stimulus controller ทุกตัวในโฟลเดอร์app/javascript/controllers/(เช่น ปุ่มธีมของบท 129 · ละเอียดบท 134)
พิสูจน์ด้วยตัวแปรใน JavaScript
ถ้าหน้าโหลดใหม่จริง เบราว์เซอร์จะทิ้งทุกอย่างของหน้าเก่า รวมถึงตัวแปร JS · ถ้าตัวแปรยังอยู่หลังกดลิงก์ = หน้าไม่ได้โหลดใหม่ ·
ผมลองใน Chrome: เปิด /posts → พิมพ์ window.marker = "ยังอยู่" ในแท็บ Console ของ DevTools
(ช่องพิมพ์ JS ที่ใช้ในบท 129 · window. = ตัวแปรระดับทั้งหน้า) → กดหัวข้อ "นมเย็น" → กด F5 (รีเฟรช)
| ตอนไหน | URL | window.marker | title ของแท็บ | เบราว์เซอร์สร้างหน้าใหม่ไหม |
|---|---|---|---|---|
| หลังตั้งค่า | /posts | 'ยังอยู่' | บทความทั้งหมด | — |
| หลังกดลิงก์ | /posts/30 | 'ยังอยู่' ✅ | นมเย็น | ไม่ — ใบเดิม |
| หลังกด F5 | /posts/30 | undefined | นมเย็น | ใช่ — ทิ้งของเก่าหมด |
URL เปลี่ยน · หัวข้อแท็บเปลี่ยน · เนื้อหาเปลี่ยน — แต่ตัวแปรยังอยู่ · แปลว่าเบราว์เซอร์ไม่ได้เปิดหน้าใหม่ ·
Turbo แอบไปเอา HTML ของ /posts/30 มาแล้วสลับเนื้อหาให้ (ภารกิจท้ายบทให้คุณลองเองด้วย DevTools)
คุ้นไหม? บท 83 ทำการทดลองเดียวกันกับ <Link> ของ Next.js แล้วตัวแปรก็ยังอยู่ ·
ความรู้สึกเหมือนกัน คือกดแล้วไม่กระพริบ · ต่างกันที่ Next.js ต้องใช้ <Link> แทน <a> ·
ส่วน Turbo ดักลิงก์ <a> ธรรมดาทุกอันให้เอง (link_to ที่เราเขียนมาตลอดก็ได้ผลทันที)
request ที่ Turbo ยิง — server ไม่ต้องรู้ด้วยซ้ำ
ผมดักดู request ที่ Turbo ยิงตอนกดลิงก์นั้น · นอกจาก header (ข้อมูลกำกับ request) ที่เบราว์เซอร์ใส่ให้ทุก request อยู่แล้ว (เช่น User-Agent · Cookie) มีสองตัวนี้ที่ Turbo ใส่เอง:
accept: text/html, application/xhtml+xml
x-turbo-request-id: 1987ae28-93a8-4e4c-9672-007c2029345c
accept: text/html…— "ขอเป็น HTML นะ" เหมือนที่เบราว์เซอร์ขอปกติ (ไม่ใช่ JSON)x-turbo-request-id— เลขสุ่มประจำ request ที่ Turbo ติดไว้ (ของคุณจะเป็นเลขอื่น · ชื่อ header ไม่สนตัวพิมพ์เล็ก-ใหญ่ ใน DevTools อาจเห็นเป็นX-Turbo-Request-Id)
ฝั่ง server (log/development.log) เห็นเหมือนการเปิดหน้าธรรมดาทุกอย่าง:
Started GET "/posts/30" for ::1 at 2026-11-07 11:01:59 +0700
Processing by PostsController#show as HTML
Completed 200 OK in 14ms (Views: 8.4ms | ActiveRecord: 2.6ms (2 queries, 0 cached) | GC: 0.0ms)
for ::1— ใครเป็นคนขอ ·::1คือ "เครื่องตัวเอง" (localhost) แบบที่อ่านยากหน่อยas HTML— controller ตอบเป็น HTML (ถ้าเปิด/posts/30.jsonจะเป็นas JSON)- วันเวลา ·
14ms· ตัวเลขในวงเล็บ — ของคุณจะต่างทุกครั้ง ไม่ต้องสนใจ
as HTML · controller เดิม · view เดิม · ส่ง HTML ทั้งหน้าเหมือนเดิม — เราไม่ได้แก้ controller สักบรรทัด ·
งานทั้งหมดเกิดในเบราว์เซอร์ นี่คือเหตุผลที่ Turbo Drive "ฟรี"
6 ขั้นที่ Turbo Drive ทำตอนเรากดลิงก์
ลำดับตามสไลด์หน้า 38 · ข้างขวาคือสิ่งที่ผมเห็นจริงใน Chrome ตอนกดจาก /posts ไป /posts/30:
- fetch ในขั้น ② — คำสั่ง JS ที่ไปขอข้อมูลจาก server แบบเดียวกับที่ใช้ใน
useEffect(บท 74) · "เบื้องหลัง" = ระหว่างรอ หน้าเดิมยังโชว์อยู่ - History API — ชุดคำสั่งของเบราว์เซอร์ที่ให้ JS เปลี่ยน URL และเพิ่มรายการในประวัติ (ที่ปุ่ม back ใช้) ได้โดยไม่ต้องโหลดหน้า · Turbo ใช้มันทำให้ปุ่ม back ยังทำงานปกติ
- cache ในขั้น ⑥ — ที่เก็บของไว้ใช้ซ้ำ · ตรงนี้คือ Turbo จำหน้าตาของหน้าก่อนไว้ในหน่วยความจำของแท็บ
Turbo เรียกการเปลี่ยนหน้าหนึ่งครั้งว่า visit · กดลิงก์ = ขอ server ใหม่ (ขั้น ①–⑥) · กดปุ่ม back = เอาภาพจากขั้น ⑥ มาโชว์ (ถ้ามีภาพเก็บไว้ ไม่ต้องขอ server)
ขั้น ④ มีข้อยกเว้น: data-turbo-track="reload"
จำป้ายใน <head> จากบท 127 ได้ไหม: <link rel="stylesheet" href="/assets/application-8b441ae0.css" data-turbo-track="reload" /> ·
ป้ายนี้บอก Turbo ว่า "ถ้าหน้าใหม่ชี้ไปไฟล์ชื่ออื่น ให้โหลดใหม่ทั้งหน้า"
ผมลองแก้ app/assets/stylesheets/application.css (เติมคอมเมนต์บรรทัดเดียว) ระหว่างที่หน้าเปิดอยู่ แล้วกดลิงก์ "บทความ" ·
ผล: ชื่อไฟล์ใหม่เป็น application-a9162a1a.css (เลขของคุณจะต่าง ขึ้นกับสิ่งที่เติม) และ window.marker หายไป = Turbo ยอมโหลดใหม่ทั้งหน้า ·
นี่คือการทำงานร่วมกับ fingerprint ของบท 127: เลขรุ่นใหม่บนปกเมนู → ร้านรู้ว่าต้องจัดโต๊ะใหม่ทั้งโต๊ะ ผู้ใช้จะไม่ค้างอยู่กับ CSS เก่า
ฝั่ง JS ก็เหมือนกัน: <script type="importmap"> (สมุดรายชื่อของบท 127) ก็ติดป้ายนี้ · แก้ application.js เมื่อไหร่
การกดลิงก์ครั้งถัดไปจะโหลดใหม่ทั้งหน้าเหมือนกัน (ลองแล้ว) — จำไว้ใช้ในการทดลองข้างล่าง
เว็บแบบเก่า: ลูกค้าสั่งเมนูใหม่ทีไร พนักงานรื้อโต๊ะทั้งตัว ผ้าปู ช้อนส้อม แก้วน้ำ แล้วจัดใหม่หมด (หน้าจอกระพริบขาว · โหลด CSS/JS ใหม่) ·
Turbo Drive: ครัวยังเป็นเจ้าของสมุดสต็อกเล่มเดียวเหมือนเดิม · พนักงานแค่ยกจานเก่าออก วางจานใหม่ (<body>) ·
ผ้าปูกับช้อนส้อม (<head> · CSS · JS) อยู่ที่เดิม · ถ้าเปลี่ยนแค่ป้ายชื่อเมนู (title) ก็เปลี่ยนแค่ป้าย
ที่ไม่เหมือนกัน: โต๊ะจริงพนักงานเก็บเศษของบนโต๊ะทิ้งให้ด้วย · แต่ Turbo ไม่ได้เก็บของที่ JavaScript ทิ้งไว้ —
ตัวแปร (อย่าง window.marker) และงานที่ JS ฝากไว้กับ "ตัวโต๊ะ" (ทั้งหน้า) ยังอยู่ครบ · ดีตรงที่จำค่าได้ · เสียตรงที่มันสะสม (หัวข้อ ★ ข้างล่าง) ·
และถ้าเมนูเล่มพิมพ์รุ่นใหม่ (ไฟล์ที่ติด data-turbo-track เปลี่ยนชื่อ) ร้านจะยอมรื้อโต๊ะจัดใหม่ทั้งโต๊ะ
- Turbo Drive
- ส่วนของ Turbo ที่ดักทุกลิงก์และฟอร์ม แล้วเปลี่ยนหน้าด้วยการสลับ
<body>แทนการโหลดหน้าใหม่ · ถาม senior ได้ว่า “ลิงก์นี้ไปผ่าน Turbo Drive หรือเป็น full page load” - visit (application visit · restoration visit)
- คำที่ Turbo เรียกการเปลี่ยนหน้าหนึ่งครั้ง · กดลิงก์ = application visit (ขอ server ใหม่) · กด back/forward = restoration visit (ใช้ภาพจาก cache ถ้ามี ไม่ต้องขอ server) · พิมพ์บอก AI ได้ว่า “this bug only happens on restoration visits (back button)”
- full page load
- เบราว์เซอร์ทิ้งหน้าเดิมแล้วโหลดใหม่ทั้งใบ — แบบที่เกิดตอนกด F5 · ตัวแปร JS หายหมด · ถาม senior ได้ว่า “ตรงนี้ควรบังคับ full page load ไหม”
data-turbo-track="reload"- ป้ายบน CSS/JS ใน
<head>ให้ Turbo โหลดใหม่ทั้งหน้าถ้าไฟล์เปลี่ยนรุ่น (ชื่อไฟล์เปลี่ยน) · Rails ใส่ให้แล้วใน layout · ถาม AI ได้ว่า “after deploying new CSS, will Turbo reload users' pages because of data-turbo-track?”
สั่งให้มันหยุดยุ่ง: data-turbo="false" และ data-turbo-action="replace"
บางลิงก์ไม่อยากให้ Turbo จัดการ หรืออยากให้ทำต่างจากปกติ · ใส่ป้าย data-… บนลิงก์นั้นได้ ·
ใน ERB ใส่ผ่าน data: { … } ของ link_to (บท 103) · ลองแก้สองไฟล์นี้ (ทดลองแล้วจะ git restore ทิ้ง)
วิธีแก้: หาบรรทัด link_to เดิม แล้วเติม , data: { … } ต่อท้ายก่อน %> · ส่วนอื่นของบรรทัดเหมือนเดิมทุกตัว
app/views/shared/_navbar.html.erb — ลิงก์ "หมวด":
<%= link_to "หมวด", categories_path, class: "btn btn-ghost btn-sm", data: { turbo: false } %>
app/views/shared/_pagination.html.erb — ลิงก์ "ถัดไป":
<%= link_to "ถัดไป →", pagy.page_url(:next), class: "join-item btn btn-sm", data: { turbo_action: "replace" } %>
HTML ที่ได้ออกมา (curl -s localhost:3000/posts · สองบรรทัดนี้อยู่คนละที่ในหน้า ผมตัดมาวางคู่กัน):
<a class="btn btn-ghost btn-sm" data-turbo="false" href="/categories">หมวด</a>
<a class="join-item btn btn-sm" data-turbo-action="replace" href="/posts?page=2">ถัดไป →</a>
data: { turbo: false }→data-turbo="false"— Rails เติมdata-ข้างหน้าให้turbo_action:→data-turbo-action— ขีดล่าง_ใน Ruby กลายเป็นขีดกลาง-ใน HTML ให้เอง (ชื่อ symbol ใน Ruby มีขีดกลางไม่ได้)
ผลใน Chrome (เริ่มที่หน้าหมวด /categories/1 แล้วกดตามลำดับ):
| กดอะไร | URL หลังกด | history.length | window.marker |
|---|---|---|---|
| (เริ่ม) | /categories/1 | 2 | ยังอยู่ |
| "บทความ" (ลิงก์ปกติ) | /posts | 3 (+1) | ยังอยู่ |
| "ถัดไป" (replace) | /posts?page=2 | 3 (ไม่เพิ่ม) | ยังอยู่ |
| "ถัดไป" (replace) | /posts?page=3 | 3 (ไม่เพิ่ม) | ยังอยู่ |
| ปุ่ม back ของเบราว์เซอร์ | /categories/1 ← ข้ามหน้า 1–3 ไปเลย | 3 | ยังอยู่ |
"หมวด" (data-turbo="false") | /categories | 3 | หายไป (โหลดใหม่ทั้งหน้า) |
(history.length = จำนวนรายการในประวัติของแท็บ · ของผมเริ่มที่ 2 เพราะแท็บเปิดหน้าว่างไว้ก่อน ของคุณอาจเริ่มที่เลขอื่น — ดูที่การเพิ่ม)
data-turbo-action="replace"— ยังใช้ Turbo แต่เขียนทับรายการประวัติแทนการเพิ่ม · เหมาะกับการเปลี่ยนหน้า/กรอง/เรียงรายการ ที่ผู้ใช้กดหลายครั้ง แล้วไม่อยากให้ปุ่ม back ต้องย้อนทีละขั้นdata-turbo="false"— ลิงก์นี้ให้เบราว์เซอร์โหลดหน้าใหม่แบบเดิม · เหมาะกับลิงก์ดาวน์โหลดไฟล์ (เช่น PDF) หรือหน้าที่ต้องเริ่มสะอาดทั้งใบ
★ ผลข้างเคียงที่ทุกคนต้องเจอ: DOMContentLoaded ยิงครั้งเดียว
ก่อนไปต่อ ต้องรู้จักสองคำของ JavaScript ฝั่งเบราว์เซอร์:
- event — "เหตุการณ์" ที่เบราว์เซอร์ประกาศ เช่น ผู้ใช้คลิก (
click) · กดปุ่มคีย์บอร์ด (keydown) · อ่าน HTML ของหน้าเสร็จ (DOMContentLoaded) addEventListener("ชื่อ event", ฟังก์ชัน)— ฝากบอกว่า "ถ้าเกิด event นี้ ให้เรียกฟังก์ชันนี้" · ฟังก์ชันที่ฝากไว้เรียกว่า listener (ผู้รอฟัง) · ฝากกับอะไรก็ได้ เช่น ปุ่มหนึ่งปุ่ม หรือdocument(ตัวแทนของทั้งหน้า)
ชื่อ DOMContentLoaded มาจาก DOM = โครงของหน้าที่เบราว์เซอร์สร้างจาก HTML ให้ JS หยิบจับได้ (แท็กแต่ละตัวกลายเป็น element หนึ่งชิ้น) ·
แปลตรง ๆ คือ "สร้างโครงหน้าจาก HTML เสร็จแล้ว"
สมัยก่อนมี Turbo โค้ด JS แทบทุกตัวเริ่มด้วย "รอให้ HTML อ่านเสร็จ (DOMContentLoaded) แล้วค่อยไปหา element มาผูกงาน" — บทความสอน JS เก่า ๆ และโค้ดที่ AI เขียนให้ก็ยังเป็นแบบนี้บ่อย ·
เทียบกับ React คือคล้าย useEffect(() => { … }, []) ที่ทำครั้งเดียวหลังวาดจอ (บท 74)
ปัญหาคือ Turbo ไม่เคยทิ้งหน้าเดิม (เราเพิ่งพิสูจน์) · หน้าเว็บ "เกิด" แค่ครั้งเดียวตอนเปิดเว็บครั้งแรก ·
Turbo จึงมี event ของตัวเองชื่อ turbo:load ที่ยิงทั้งตอนเปิดครั้งแรกและทุกครั้งที่ Turbo เปลี่ยนหน้า · ลองดูว่าสองตัวนี้ยิงกี่ครั้ง
เพิ่มต่อท้าย app/javascript/application.js (ทดลองแล้วลบ):
// ทดลองบท 130 — ลบทิ้งเมื่อจบบท
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded ยิงแล้ว")
})
document.addEventListener("turbo:load", () => {
console.log("turbo:load ยิงแล้ว", location.pathname)
})
() => { … }— ฟังก์ชันแบบลูกศร (arrow function) แบบที่ใช้ใน React · ตรงนี้คือ "งานที่ฝากไว้"console.log(…)— พิมพ์ข้อความลงแท็บ Console ของ DevTools ·location.pathname= path ของหน้าปัจจุบัน เช่น/posts
บันทึกไฟล์ → เปิด localhost:3000/posts แล้วกด F5 หนึ่งครั้ง
(ให้ได้ JS ตัวใหม่ตั้งแต่ต้น · ถ้าไม่กด การกดลิงก์ครั้งแรกจะโหลดใหม่ทั้งหน้าเพราะ data-turbo-track แล้วตัวเลขจะเพี้ยน) → เปิดแท็บ Console (F12 หรือ Cmd+Option+I บน Mac) →
กดลิงก์บน navbar: หมวด → บทความ → หมวด
ใน Console จะมีกี่บรรทัด · DOMContentLoaded กี่บรรทัด · turbo:load กี่บรรทัด?
ผลจริงใน Chrome — 5 บรรทัด: DOMContentLoaded 1 · turbo:load 4 (เปิดครั้งแรก 1 + กดลิงก์ 3)
DOMContentLoaded ยิงแล้ว
turbo:load ยิงแล้ว /posts
turbo:load ยิงแล้ว /categories
turbo:load ยิงแล้ว /posts
turbo:load ยิงแล้ว /categories
ถ้าคุณกด F5 อีกที จะได้ DOMContentLoaded เพิ่มอีกบรรทัด — เพราะ F5 คือ full page load
โค้ด JS แบบดั้งเดิม — พังตั้งแต่หน้าที่สอง
ลบโค้ดเมื่อกี้ แล้วลองแบบที่สไลด์หน้า 40 ยกมา (ผมเปลี่ยน .menu เป็น main เพราะแอปเราไม่มีเมนูชื่อนั้น) ·
ทุกครั้งที่แก้ application.js ให้กด F5 ก่อนเริ่มลอง (เหตุผลเดียวกับข้างบน):
// ทดลองบท 130 — ลบทิ้งเมื่อจบบท
document.addEventListener("DOMContentLoaded", () => {
document.querySelector("main").addEventListener("click", () => {
console.log("คลิกใน main")
})
})
document.querySelector("main")— หา element<main>ตัวแรกในหน้า (คือกล่องเนื้อหาใน layout)- ทั้งก้อนแปลว่า: "พอหน้าอ่านเสร็จ ไปหา
<main>แล้วฝากไว้ว่าถ้ามีคนคลิก ให้พิมพ์ข้อความ"
ผลจริงใน Chrome (คลิกที่ว่าง ๆ ในหน้า ไม่โดนลิงก์):
| ทำอะไร | Console ขึ้น |
|---|---|
เปิด /posts · คลิกในหน้า | คลิกใน main ✅ |
| กด "หมวด" · คลิกในหน้า | (ไม่มีอะไรขึ้น) ❌ |
กด "บทความ" กลับมา /posts · คลิกในหน้า | (ไม่มีอะไรขึ้น) ❌ — แม้จะเป็นหน้าเดิมก็ตาม |
ทำไม: DOMContentLoaded ยิงครั้งเดียว · ตอนนั้นมันผูกงานไว้กับ <main> ตัวแรก ·
พอ Turbo เปลี่ยนหน้า <body> ทั้งก้อนเป็นของใหม่ (ขั้น ③) → <main> ตัวใหม่ไม่มีใครผูกงานให้ ·
ไม่มี error สักบรรทัด ปุ่มแค่ "ตาย" เงียบ ๆ
ท่าแก้เฉพาะหน้า: turbo:load — ใช้ได้ แต่ระวังงานสะสม
เปลี่ยนคำว่า "DOMContentLoaded" ในโค้ดข้างบนเป็น "turbo:load" · ผลจริง: คลิกในหน้าแล้วขึ้น คลิกใน main ครบทุกหน้า หน้าละหนึ่งบรรทัด ✅ ·
เพราะทุกครั้งที่เปลี่ยนหน้า มันไปผูกงานให้ <main> ตัวใหม่
แต่ถ้างานที่ผูกไว้ฝากไว้กับของที่ไม่เคยถูกเปลี่ยน — เช่น document (ตัวแทนทั้งหน้า ซึ่ง Turbo ไม่เคยทิ้ง) — มันจะสะสม ·
งานแบบนี้มีจริงบ่อย เช่น "กด Esc แล้วปิดเมนู" หรือ "คลิกที่อื่นแล้วปิด dropdown" · ลองโค้ดนี้แทน:
// ทดลองบท 130 — ลบทิ้งเมื่อจบบท
let round = 0
document.addEventListener("turbo:load", () => {
round = round + 1
const myRound = round
document.addEventListener("keydown", (event) => {
console.log("กดปุ่ม", event.key, "— listener ตัวที่", myRound)
})
})
let round = 0— ตัวนับว่าturbo:loadยิงไปกี่รอบแล้ว ·const myRound = round— จำเลขรอบไว้ให้ listener ตัวนั้น จะได้เห็นว่ามีกี่ตัว- listener ตัวนอก (
turbo:load) ทำหน้าที่ "ฝาก listener ตัวใน (keydown) เพิ่มอีกหนึ่งตัว" ทุกครั้งที่ยิง (event) =>— เบราว์เซอร์ส่งรายละเอียดของ event มาให้ในตัวแปรนี้ ·event.key= ปุ่มที่กด เช่นk
ผลจริงใน Chrome — เปิด /posts กด k → กด "หมวด" → กด "บทความ" → กด k อีกครั้ง:
| ทำอะไร | Console ขึ้น |
|---|---|
เปิด /posts · กด k | กดปุ่ม k — listener ตัวที่ 1 |
| กด "หมวด" · กด "บทความ" | (ไม่มีอะไรขึ้น) |
กด k ครั้งเดียว | กดปุ่ม k — listener ตัวที่ 1กดปุ่ม k — listener ตัวที่ 2กดปุ่ม k — listener ตัวที่ 3 |
กดครั้งเดียว ทำงาน 3 รอบ · เพราะทุก turbo:load ฝากงานใหม่ไว้กับ document ตัวเดิม และไม่มีใครถอดของเก่าออก ·
ถ้างานนั้นคือ "สลับเมนูเปิด/ปิด" กด 3 รอบ = เปิด-ปิด-เปิด · ยิ่งเปิดหลายหน้ายิ่งเพี้ยน และ refresh ทีเดียวอาการหายเป็นปลิดทิ้ง (เลยหาบั๊กยาก)
พนักงานคนหนึ่งมีนิสัย: ทุกครั้งที่วางจานใหม่ จะผูกกระดิ่งเรียกพนักงานเพิ่มอีกอันไว้ที่ขาโต๊ะ ·
โต๊ะไม่เคยถูกรื้อ (Turbo ไม่ทิ้ง document) → เสิร์ฟไป 3 จาน มีกระดิ่ง 3 อัน → ลูกค้ากดครั้งเดียว พนักงานวิ่งมา 3 คน ·
ส่วนกระดิ่งที่ติดไว้บนจาน (<main>) ถูกยกออกไปพร้อมจานเก่า จึงไม่สะสม
ที่ไม่เหมือนกัน: กระดิ่งจริงเรามองเห็นว่ามีกี่อัน · listener ใน JS มองไม่เห็น ไม่มี error ไม่มีคำเตือน เห็นแค่อาการ "ทำงานเกิน" · และในร้านจริงพนักงานคนต่อไปอาจเอะใจถอดกระดิ่งเก่าออก แต่โค้ดของเราไม่ถอดให้ จนกว่าเราจะเขียนสั่งเอง
ทางออกจริง: เปลี่ยนวิธีคิด
โค้ดทั้งหมดข้างบนเชื่อว่า "หน้าเว็บเกิดครั้งเดียวแล้วตาย" ซึ่งจริงในยุคที่ทุกลิงก์โหลดหน้าใหม่ · พอมี Turbo ความเชื่อนี้ไม่จริงอีกต่อไป · ทางออกที่สไลด์เสนอคือกลับทิศ:
- แทนที่ JS จะเดินไปตามหา element ตอนหน้าโหลด → ให้ element ประกาศเองว่าต้องการ JS อะไร ด้วยป้ายใน HTML
- แล้วมี "ผู้ดูแล" คอยดูว่า element ที่มีป้ายนั้นโผล่มาเมื่อไหร่ (ติดตั้งงาน) และหายไปเมื่อไหร่ (เก็บกวาด)
ผู้ดูแลคนนั้นคือ Stimulus (บท 134) · และคุณใช้มันไปแล้ว: ปุ่ม 🌓 ใน navbar ของบท 129 เขียนว่า
data-controller="theme" data-action="theme#toggle" — ปุ่มประกาศเองว่า "ฉันใช้ JS ชื่อ theme · ถ้าโดนคลิกให้เรียก toggle" ·
ปุ่มนี้จึงใช้ได้ทุกหน้าโดยไม่ต้องสนว่า Turbo เปลี่ยนหน้าไปกี่รอบ (ลองกดลิงก์ไปมาแล้วกด 🌓 ดู)
เทียบกับ React: คล้าย useEffect ที่มีทั้งงานตอนติดตั้ง และ return () => … ไว้เก็บกวาด (cleanup · บท 74) ·
ต่างกันที่ Stimulus ผูกกับการโผล่/หายของ element ใน HTML ไม่ใช่ของ component
- event · event listener
- event = เหตุการณ์ที่เบราว์เซอร์ประกาศ · listener = ฟังก์ชันที่ฝากไว้ด้วย
addEventListenerให้ทำงานเมื่อเกิด event · ถาม senior ได้ว่า “listener ตัวนี้ผูกกับ element หรือกับ document ครับ” DOMContentLoaded·turbo:loadDOMContentLoadedยิงครั้งเดียวต่อการโหลดหน้าเต็ม ·turbo:loadยิงตอนเปิดครั้งแรกและทุกครั้งที่ Turbo เปลี่ยนหน้า (รวมกด back) · พิมพ์บอก AI ได้ว่า “this app uses Turbo Drive; don't rely on DOMContentLoaded”- duplicate listeners · listener leak
- listener ที่ถูกผูกซ้ำจนทำงานหลายรอบ หรือค้างอยู่ทั้งที่ไม่ใช้แล้ว · ถาม senior ได้ว่า “ปุ่มนี้ทำงานสองรอบหลังเปลี่ยนหน้า เป็น duplicate listener จาก turbo:load ไหม”
- Stimulus (ชื่อไว้ก่อน)
- เครื่องมือที่ให้ HTML ประกาศว่าต้องการ JS อะไร แล้วติดตั้ง/เก็บกวาดให้เองเมื่อ element โผล่/หาย · ละเอียดบท 134 · พิมพ์บอก AI ได้ว่า “write this as a Stimulus controller, not a turbo:load listener”
🧹 เก็บกวาดก่อนไปต่อ: ลบโค้ดทดลองทั้งหมดใน application.js · _navbar · _pagination ด้วย git restore . ·
แล้ว git status ต้องขึ้นว่า nothing to commit, working tree clean · บท 131 เริ่มจากสถานะนี้
ภารกิจ
🎯 ภารกิจ — จับ Turbo Drive คาหนังคาเขาด้วย DevTools (~10 นาที)
ทำในแอปของบท 129 (โค้ดสะอาด ไม่มีของทดลองค้าง) · ใช้ Chrome
- รัน
bin/dev· เปิดhttp://localhost:3000/posts - เปิด DevTools (คลิกขวาที่หน้า → Inspect · หรือ F12 · Mac กด Cmd+Option+I) → ไปแท็บ Network
- กด F5 หนึ่งครั้ง → ดูรายการ: ควรเห็นไฟล์
.cssและ.jsที่มีเลขรุ่นต่อท้าย (เช่นtailwind-….css·turbo.min-….js) - ล้างรายการ (ปุ่มรูปวงกลมขีดทับ 🚫 ด้านบนแท็บ Network) → กดหัวข้อบทความหนึ่งชิ้น → นับว่ามีแถวใหม่อะไรบ้าง
- ล้างอีกที → กด "หมวด" บน navbar → นับแถวใหม่
- ไปแท็บ Console พิมพ์
window.marker = "ยังอยู่"กด Enter → กดลิงก์ไปหน้าอื่น → พิมพ์window.markerกด Enter
ก่อนกดเฉลย: ข้อ 4–5 คิดว่าจะเห็นไฟล์ .css/.js ไหม · และข้อ 6 ได้อะไร?
สิ่งที่ควรเห็น (ตรวจด้วย Chrome จริง — ผมนับจากรายการ resource ของหน้า ซึ่งเป็นรายการเดียวกับที่แท็บ Network โชว์):
| ข้อ | แถวใหม่ |
|---|---|
| 3 (F5) | CSS 2 ไฟล์ · JS 8 ไฟล์ (application · turbo · stimulus · controllers …) · icon 2 ไฟล์ · และของ rack-mini-profiler |
| 4 (กดบทความ) | หน้าที่กดหนึ่งแถว (เช่น 30 · ชนิด fetch) + แถว results ของ rack-mini-profiler (ผมเห็น 3 แถว) · ไม่มี .css / .js |
| 5 (กดหมวด) | แบบเดียวกัน: categories หนึ่งแถว (+ results) · ไม่มี .css / .js |
| 6 | 'ยังอยู่' — หน้าไม่ได้โหลดใหม่ |
แถว results มาจาก rack-mini-profiler (ป้ายเวลาเล็ก ๆ มุมซ้ายบนของหน้า ที่มากับ repo ของอาจารย์ · ใช้ใน development เท่านั้น) ไม่เกี่ยวกับ Turbo · ชนิดเป็น fetch คือหลักฐานว่า JS (Turbo) เป็นคนขอ ไม่ใช่เบราว์เซอร์เปิดหน้าเอง
ถ้าเจออาการนี้ แปลว่า…
- กดลิงก์แล้ว .css/.js โผล่มาครบทุกครั้ง และ
window.markerได้undefined— Turbo ไม่ได้ทำงาน · เปิด Console ดูว่ามีตัวแดงไหม · ผมลองลืมปิดวงเล็บในapplication.jsได้SyntaxError: missing ) after argument listแล้ว Turbo ไม่โหลดเลย ลิงก์ทุกอันกลายเป็นโหลดหน้าเต็ม (และปุ่ม 🌓 ก็พังด้วย) · แก้ไฟล์ให้ถูกหรือgit restore - เพิ่งแก้ CSS หรือ JS แล้วกดลิงก์ครั้งแรก หน้าโหลดใหม่ทั้งใบ — ไม่ใช่บั๊ก · คือ
data-turbo-track="reload"เห็นว่าไฟล์เปลี่ยนรุ่น · ครั้งต่อไปจะกลับมาเป็นปกติ - มีลิงก์เดียวที่โหลดเต็มทุกครั้ง — ลิงก์นั้นอาจยังมี
data-turbo="false"ค้างจากการทดลอง ·git statusดูว่ามีไฟล์ค้างไหม
เห็นไม่ตรงกับตาราง หรือหาปุ่มใน DevTools ไม่เจอ ถามกลับมาได้เลย พร้อมแคปหน้าจอแท็บ Network
ทดสอบความเข้าใจ
Redux · React Query · SWR ต่างกันในรายละเอียด แต่สไลด์บอกว่าทุกตัว "เกิดมาแก้ปัญหาเดียวกัน" · ปัญหานั้นคืออะไร และ Hotwire แก้อย่างไร?
ตัวเลือกที่ 3 ถูก — เครื่องมือพวกนั้นช่วยดูแล "สมุดเล่มที่สอง" ให้ตรงกับครัว · Hotwire ตัดปัญหาที่ต้นทาง: อยากได้จอใหม่ก็ไปขอ HTML ใหม่จาก server ที่เป็นเจ้าของข้อมูลชุดเดียว
ตัวเลือกที่ 1 ผิดสองชั้น — HTML หนักกว่า JSON (เราวัดแล้ว) และ Hotwire ไม่ได้ขายความเล็ก · ตัวเลือกที่ 2 ผิด เพราะ Hotwire ให้ server วาด (ERB) ไม่ได้วาดใน JS · ตัวเลือกที่ 4 ผิด เพราะ server ไม่ได้ส่งผิด ปัญหาคือสำเนาที่เก็บไว้เก่า
จุดที่ต้องจำ: ไม่มีสำเนา = ไม่มีบั๊ก sync · ราคาที่จ่ายคือต้องถาม server ทุกครั้ง และ HTML หนักกว่า
ในแอปของเรา กดลิงก์จาก /posts ไป /posts/30 · ข้อไหนบอกสิ่งที่เกิดขึ้นได้ถูก?
ตัวเลือกที่ 2 ถูก — log ฝั่ง server เป็น Processing by PostsController#show as HTML เหมือนเปิดหน้าธรรมดา · Turbo เป็นคนเลือกเอาแค่ <body> กับส่วนของ <head> ที่ต่าง (เช่น title)
ตัวเลือกที่ 1 ผิด เพราะ server ส่งทั้งหน้า (มี <head>) และ CSS ไม่โหลดใหม่ ·
ตัวเลือกที่ 3 ผิด เพราะ request ขอ text/html ไม่มี JSON เลย ·
ตัวเลือกที่ 4 ผิด เพราะถ้าโหลดหน้าใหม่ทั้งใบ ตัวแปรต้องหาย (แบบตอนกด F5) — ที่ตัวแปรยังอยู่ก็เพราะไม่ได้โหลดใหม่
จุดที่ต้องจำ: Turbo Drive = เปลี่ยนจาน ไม่รื้อโต๊ะ · server ไม่ต้องรู้อะไรเลย
โค้ด document.addEventListener("DOMContentLoaded", …) ที่ผูก click ให้ <main> ทำงานหน้าแรก แต่กดลิงก์ไปหน้าอื่นแล้วคลิกไม่ได้ · เพราะอะไร?
ตัวเลือกที่ 1 ถูก — หน้าเว็บไม่ได้เกิดใหม่ DOMContentLoaded จึงไม่ยิงอีก · Turbo สลับ <body> ทั้งก้อน → <main> ที่เคยผูกไว้ถูกยกออกไปพร้อมจานเก่า
ตัวเลือกที่ 2 ผิด เพราะ addEventListener ใช้ได้ปกติ (ตัวอย่าง turbo:load ก็ใช้มัน) แค่ต้องรู้ว่าผูกเมื่อไหร่กับอะไร ·
ตัวเลือกที่ 3 ผิด เพราะ JS ไม่ได้ถูกโหลดใหม่เลยด้วยซ้ำ ปัญหาไม่ใช่โค้ดเก่า/ใหม่ ·
ตัวเลือกที่ 4 ผิด เพราะทุกหน้าที่ server ส่งมามี layout ครบ (มี <main>) — แค่เป็นตัวใหม่
จุดที่ต้องจำ: ใน Turbo หน้าเว็บ "เกิด" ครั้งเดียว · แต่ <body> เกิดใหม่ทุกครั้งที่เปลี่ยนหน้า
เพื่อนเขียน document.addEventListener("turbo:load", () => { document.addEventListener("keydown", (event) => { ปิดเมนู() }) }) แบบเดียวกับตัวอย่างในบท · เปิดเว็บ แล้วเปลี่ยนหน้าอีก 4 ครั้ง แล้วกด Esc ครั้งเดียว · "ปิดเมนู" ทำงานกี่รอบ?
ตัวเลือกที่ 4 ถูก — turbo:load ยิงตอนเปิดครั้งแรก 1 + เปลี่ยนหน้า 4 = 5 ครั้ง · ทุกครั้งฝาก listener ใหม่ไว้กับ document ตัวเดิม (ในบทเปลี่ยนหน้า 2 ครั้งได้ 3 ตัว แบบเดียวกัน)
ตัวเลือกที่ 1 ผิด เพราะไม่มีใครล้างให้ — นี่คือปัญหาทั้งหมด ·
ตัวเลือกที่ 2 ผิด เพราะ Turbo ไม่เคยทิ้ง document (เปลี่ยนแค่ <body>) ·
ตัวเลือกที่ 3 ลืมว่า turbo:load ยิงตอนเปิดเว็บครั้งแรกด้วย
จุดที่ต้องจำ: ผูกงานกับของที่อยู่ถาวร (document · window) ใน turbo:load = สะสม · ทางออกระยะยาวคือ Stimulus ที่ติดตั้งและเก็บกวาดให้ (บท 134)
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Turbo 8.0.23) | ต้องสนใจไหม |
|---|---|---|---|
| 36 | HTML หนักกว่า JSON ประมาณ 2–4 เท่า (gzip แล้วต่างกันน้อยมาก) | วัดบทความชิ้นที่ 1: เฉพาะการ์ด ~1.3 เท่า · ทั้งหน้า ~8 เท่า (gzip แล้ว ~5.7 เท่า) | นิดหน่อย — ตัวเลขขึ้นกับว่าเทียบกับอะไร วัดเองได้ |
| 38 | ขั้น 4: merge <head> เฉพาะที่ต่าง (CSS/JS ไม่โหลดซ้ำ) | ถูก · แต่ถ้าไฟล์ที่ติด data-turbo-track="reload" เปลี่ยนชื่อ Turbo จะโหลดใหม่ทั้งหน้า (ลองแล้ว) | สนใจ — อธิบายอาการ "แก้ CSS แล้วหน้าโหลดเต็มครั้งหนึ่ง" |
| 38 | ตัวอย่าง <a href="/report.pdf" data-turbo="false"> เขียนเป็น HTML ตรง ๆ | ใน ERB ของเราใช้ link_to …, data: { turbo: false } ได้ HTML เดียวกัน | ไม่ต้อง — ความหมายเดียวกัน |
| 40 | ใช้ turbo:load ผูกกับ .menu → "กด 10 หน้าแล้วกดเมนูครั้งเดียว toggle 10 รอบ" | ลองแล้ว: ผูกกับ element ใน <body> ไม่สะสม (body เป็นของใหม่ทุกหน้า) · สะสมจริงเมื่อผูกกับของที่อยู่ถาวร เช่น document (เห็น 3 รอบหลังเปลี่ยน 2 หน้า) | สนใจ — หลักการถูก แต่ตัวอย่าง .menu อาจไม่สะสมตามที่สไลด์บอก |
| 38 | Turbo.session.drive = false ปิดทั้งแอป | ไม่ได้ลองในบทนี้ | ไม่ต้อง — สไลด์เองก็บอกว่าไม่แนะนำ |
อ้างอิง
- Hotwire — HTML Over The Wire — อ่านแค่ย่อหน้าแรกกับหัวข้อ Turbo และ Stimulus · ยืนยันว่า Hotwire คือการส่ง HTML แทน JSON และ Turbo + Stimulus คืออะไร
- Turbo Handbook — Navigate with Turbo Drive —
อ่าน Page Navigation Basics · Application Visits · Restoration Visits (ที่มาของ 6 ขั้นและ
data-turbo-action="replace") · แล้วข้ามไป Disabling Turbo Drive on Specific Links or Forms และ Reloading When Assets Change (ที่มาของdata-turbo-track) - Turbo Handbook — Building Your Turbo Application § Observing Navigation Events —
อ่านหัวข้อนี้หัวข้อเดียว (สั้น) · คู่มือบอกตรง ๆ ว่า
turbo:loadยิงตอนเปิดครั้งแรกและหลังทุก visit และเตือนให้ "clean up behavior installed for the previous page" — คือเรื่องกระดิ่งสะสมของเรา · หัวข้อถัดไป Attaching Behavior With Stimulus คือสะพานไปบท 134 - Turbo Reference — Events —
รายชื่อ event ทั้งหมดของ Turbo · ดูแค่
turbo:loadกับturbo:clickตัวอื่นยังไม่ต้อง - MDN — Document: DOMContentLoaded event — อ่านย่อหน้าแรก · event ที่ยิงเมื่อ HTML ของหน้าอ่านเสร็จ (ครั้งเดียวต่อการโหลดหน้าเต็ม)
- MDN — EventTarget: addEventListener() —
อ่านส่วนต้นหน้า (ก่อนหัวข้อ Syntax) · ตรงนั้นบอกว่าฟังก์ชันตัวเดิมจะไม่ถูกเพิ่มซ้ำ — ของเราสะสมเพราะ
() => { … }สร้างฟังก์ชันตัวใหม่ทุกรอบ · หัวข้อ Memory issues ขยายเรื่องนี้ - Chrome DevTools — Inspect network activity — คู่มือแท็บ Network ที่ใช้ในภารกิจ · อ่านถึงหัวข้อที่สอนกรองรายการ (filter) พอ
- GitHub — hotwired/turbo-rails —
gem ที่ทำให้ Turbo มากับ Rails · ดูแค่ README ส่วนแรก ว่ามันติดตั้งอะไรให้ (การ import ใน
application.js)