วิธีการทำงานของ Inertia Server-side Rendering (SSR) บน Laravel

ตอนเปิดหน้าครั้งแรก Laravel ส่ง page JSON ให้ Node รัน React วาดเป็น HTML แล้วใส่ลงใน Blade เบราว์เซอร์เห็นเนื้อหาทันที จากนั้น React แค่ hydrate เกาะ HTML เดิม เมื่อคลิกไปหน้าอื่น Inertia จะรับแค่ JSON เหมือนเดิม และถ้า SSR ล่ม เว็บก็ยังเปิดได้ เพียงแต่ไม่มี HTML จาก server

เผยแพร่
เวลาอ่าน
4 นาที

บอทเห็นเป็นหน้าว่างได้ถ้ายังไม่เปิด SSR

Inertia ปกติส่ง HTML มาแค่โครง เนื้อหาจริงอยู่ใน JSON ก้อนหนึ่ง แล้วรอ React ในเบราว์เซอร์อ่าน JSON นั้นมาวาดหน้า ลองเปิด view-source ของเว็บ Inertia ที่ไม่ได้เปิด SSR จะเห็นประมาณนี้

<body>
  <script data-page="app" type="application/json">
    {"component":"Blog/Show","props":{"post":{"title":"SSH tunnel คืออะไร", ...}},"url":"/blog/ssh-tunnel","version":"2de2e10a..."}
  </script>
  <div id="app"></div>
</body>

คนเปิดเว็บไม่ค่อยเดือดร้อน JavaScript โหลดเสร็จก็เห็นหน้าครบ แต่โปรแกรมที่อ่านแค่ HTML จะเห็น <div id="app"> ว่าง ๆ บอทที่ทำ link preview ตอนเราแปะลิงก์ใน LINE หรือ Facebook ส่วนใหญ่ไม่รัน JavaScript จึงไม่เห็นชื่อเรื่อง รูป og:image หรือ JSON-LD ที่ React ใส่ใน <head> ส่วน Google รัน JavaScript ได้ แต่หน้าต้องเข้าคิว render อีกรอบก่อน Google จะเห็นเนื้อหา

SSR (server-side rendering) แก้ตรงนี้ ให้ server รัน React วาด HTML ของหน้านั้นให้เสร็จตั้งแต่ response แรก

ตอนเปิดหน้าครั้งแรก Laravel จะส่งหน้าให้ Node วาดเป็น HTML

เปิดหน้าครั้งแรก Laravel ส่ง page JSON ให้ Node วาด แล้วเบราว์เซอร์ hydrate

เปิดหน้าครั้งแรกผ่าน SSR สามขั้น จากเบราว์เซอร์ไป Laravel ไป Node แล้วกลับมาที่ Blade

controller ยังเขียนเหมือนเดิมทุกอย่าง Inertia::render('Blog/Show', [...]) ที่ต่างคือก่อนส่ง response ตัว Laravel adapter ของ Inertia เอา page JSON ก้อนเดียวกับข้างบนไป POST ให้ SSR server ซึ่งเป็น Node process ที่รันอยู่ข้าง ๆ (โปรโตคอลของ Inertia)

POST http://127.0.0.1:13714/render
Content-Type: application/json

{"component":"Blog/Show","props":{"post":{"title":"SSH tunnel คืออะไร", ...}},"url":"/blog/ssh-tunnel","version":"2de2e10a..."}

Node โหลด component Blog/Show ตัวเดียวกับที่เบราว์เซอร์ใช้ รันด้วย renderToString ของ React แล้วตอบกลับมาสองส่วน

{
  "head": [
    "<title>SSH tunnel คืออะไร · Phattarachai</title>",
    "<meta name=\"description\" content=\"...\">",
    "<script type=\"application/ld+json\">{...}</script>"
  ],
  "body": "<script data-page=\"app\" type=\"application/json\">{...}</script><div data-server-rendered=\"true\" id=\"app\"><article>...</article></div>"
}
  • head คือแท็กที่ component ประกาศไว้ผ่าน <Head> ของ Inertia ทั้ง title, meta description, og:image และ JSON-LD
  • body คือ HTML ของหน้าเต็ม ๆ มี page JSON ติดมาเหมือนเดิม และ <div id="app"> มีเนื้อหาแล้ว พร้อม attribute data-server-rendered="true"

ใน Laravel จุดที่ส่งคำขอนี้อยู่ใน Inertia\Ssr\HttpGateway ถ้าเปิด Vite dev server อยู่ จะส่งไปที่ /__inertia_ssr ของ Vite แทน ตอนพัฒนาจึงไม่ต้องเปิด Node แยก

Blade นำ head กับ body ไปวางในช่องที่เตรียมไว้

root template ของ Inertia มีช่องรออยู่สองช่อง @inertiaHead (หรือ <x-inertia::head>) รับ head ส่วน @inertia (หรือ <x-inertia::app>) รับ body

<head>
    @vite(['resources/css/app.css', 'resources/js/app.jsx'])
    <x-inertia::head>
        <title>{{ config('app.name') }}</title>
    </x-inertia::head>
</head>
<body>
    <x-inertia::app />
</body>

<title> ที่อยู่ในช่องของ <x-inertia::head> เป็นค่าสำรอง ใช้เฉพาะตอนไม่มี SSR (เอกสาร Inertia) ถ้าเขียน <title> ไว้ตรง ๆ หน้าที่ผ่าน SSR จะมี <title> สองอัน อันหนึ่งจาก Blade อีกอันจาก React

React ใช้ HTML จาก server ต่อด้วยการ hydrate

เบราว์เซอร์ได้ HTML ครบ ก็แสดงเนื้อหาได้เลยโดยไม่ต้องรอ JavaScript แต่ตอนนี้ปุ่มยังกดไม่ได้ เพราะยังไม่มี React มาผูก event ให้

พอ app.jsx โหลดเสร็จ createInertiaApp เห็น data-server-rendered บน <div id="app"> จึงเรียก hydrateRoot แทน createRoot การ hydrate คือ React render component ชุดเดิมด้วย props ชุดเดิมในหน่วยความจำ แล้วเทียบกับ HTML ที่อยู่บนหน้า ถ้าตรงกัน ก็แค่ผูก event handler กับ state เข้ากับ DOM ที่มีอยู่ ไม่สร้าง DOM ใหม่

ตรงนี้คือเหตุผลที่ HTML จาก Node ต้องออกมาเหมือนที่เบราว์เซอร์จะ render ทุกตัวอักษร ถ้า component แสดงเวลาปัจจุบันด้วย Date.now() หรือสุ่มลำดับด้วย Math.random() ผลสองฝั่งจะไม่ตรงกัน React จะเตือน hydration mismatch ในคอนโซล แล้ววาดส่วนนั้นใหม่ฝั่งเบราว์เซอร์

หน้าถัดไปที่คลิกจะได้ JSON โดยไม่ผ่าน SSR

หลังโหลดหน้าแรก Inertia ทำงานแบบ SPA ตามปกติ คลิก <Link> ไปหน้าอื่น เบราว์เซอร์ยิง XHR พร้อม header X-Inertia: true ตัว Laravel ตอบกลับเป็น page JSON อย่างเดียว ไม่ส่งไปให้ Node แล้ว React ในเบราว์เซอร์ก็สลับ component เอง

curl -s -H 'X-Inertia: true' -H 'X-Inertia-Version: 2de2e10a...' https://phattarachai.dev/blog/ssh-tunnel

ได้ content-type: application/json กลับมา ไม่มี HTML เลย SSR จึงทำงานแค่ตอนโหลดเต็มหน้า คือเปิดลิงก์ใหม่ กด refresh หรือบอทเข้ามา ภาระของ Node เลยน้อยกว่าที่คิด

มีกรณีเดียวที่การคลิกกลับไปโหลดเต็มหน้า คือ deploy โค้ดใหม่ไปแล้ว ค่า version ฝั่ง server เปลี่ยน แต่เบราว์เซอร์ยังส่งค่าเก่ามา Laravel ตอบ 409 พร้อม header X-Inertia-Location เบราว์เซอร์จึงโหลดหน้านั้นใหม่ทั้งหน้า รอบนี้ก็ผ่าน SSR อีกครั้ง

เว็บยังเปิดได้ตามปกติแม้ SSR จะล่ม

ถ้า Node ไม่ตอบ หรือ render แล้ว error Laravel adapter จะส่ง HTML แบบไม่มี SSR (โครงกับ page JSON) ไปแทน หน้าเว็บจึงยังเปิดได้ (Error Handling) คนเปิดเว็บยังใช้ได้ปกติ แต่สิ่งที่หายไปคือ HTML แรก ผลเสียไปตกที่ SEO กับ link preview ซึ่งไม่มีใครเห็นด้วยตา

วิธีเช็กง่ายที่สุดคือดู HTML ที่ server ส่งมาจริง

curl -s https://phattarachai.dev/blog/ssh-tunnel | grep -o 'data-server-rendered="true"'

ถ้าคำสั่งนี้พิมพ์ data-server-rendered="true" ออกมา แสดงว่า SSR ทำงาน ถ้าไม่มีผลลัพธ์ แสดงว่าหน้านั้น render ฝั่งเบราว์เซอร์อย่างเดียว ถ้าอยากรู้ตอนเกิดเรื่อง Inertia มี event SsrRenderFailed ให้ฟังแล้วส่ง log หรือแจ้งเตือนได้

โค้ด React ต้องรันบน Node ได้

component ทุกตัวในหน้าถูกรันสองที่ ครั้งแรกบน Node ไม่มี DOM อีกครั้งในเบราว์เซอร์ เรื่องที่ต้องระวังจึงมีสองข้อ

  • อย่าแตะ window, document, localStorage ตอน render Node ไม่มีของพวกนี้ ถ้าเรียกตรง ๆ ใน body ของ component หรือที่ระดับ module จะได้ window is not defined ย้ายไปไว้ใน useEffect หรือ event handler ซึ่งรันแค่ฝั่งเบราว์เซอร์ Inertia v3 แยกประเภท error ให้ด้วย ถ้าเป็นกรณีนี้จะบอกเป็น browser-api พร้อมชื่อ API และบรรทัดที่เรียก
  • ผล render ต้องเหมือนกันทั้งสองฝั่ง อย่างที่เล่าไว้ในเรื่อง hydrate ค่าที่เปลี่ยนทุกครั้งที่รัน เช่นเวลาปัจจุบัน หรือเลขสุ่ม ให้คำนวณใน useEffect หรือส่งมาจาก server เป็น props
// เดิม: พังบน Node
export default function Toolbar() {
    const isWide = window.innerWidth > 1024;
    return isWide ? <FullToolbar /> : <CompactToolbar />;
}

// ใหม่: render แบบเดียวกันก่อน แล้วค่อยเช็กหลัง mount
export default function Toolbar() {
    const [isWide, setIsWide] = useState(false);

    useEffect(() => {
        setIsWide(window.innerWidth > 1024);
    }, []);

    return isWide ? <FullToolbar /> : <CompactToolbar />;
}

component ที่ใช้ browser API หนัก ๆ ทั้งตัว เช่นกราฟหรือแผนที่ ตั้งแต่ Inertia 3.8 มี <WhenMounted> ครอบให้ render เฉพาะในเบราว์เซอร์ ส่วนบน server แสดง fallback แทน (Client-Only Components)

ส่วนวิธีเปิด SSR ในโปรเจกต์ ตั้งแต่ตั้งค่า Vite จนรัน Node บน production ด้วย supervisor แยกไว้อีกโพสต์ ลองอ่านต่อที่ตั้งค่า SSR ให้ Inertia กับ React ใน Laravelได้ครับ

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →