background blurbackground mobile blur

1/1/1970

เราใส่ SSG เข้าไปในเว็บใน 2 วันยังไง

หวัดดี! เมื่อปีที่แล้วเราเคยคิดว่าของแบบนี้เป็นไปไม่ได้เลย แต่เพิ่งทำ Static Site Generation (SSG) ให้ Foony เสร็จภายใน 2 วันไปสดๆ ร้อนๆ ตอนนี้เลยกำลังตื่นเต้นมาก นี่ไม่ใช่ครั้งแรกที่เรา พยายามจะแก้ปัญหา SSG ให้ Foony ด้วยนะ ก่อนหน้านี้เราเคยดูทั้ง NextJS, Vike, Astro, Gatsby แล้วก็โซลูชันอื่นๆ อีกหลายตัว เคยเริ่มลงมือกับ NextJS ไปแล้วรอบนึงด้วย แต่ดันติดปัญหาความซับซ้อนของ SPA ของ Foony กับไฟล์เป็นพันๆ ไฟล์ การย้ายไปคงกลายเป็นฝันร้ายที่ต้องใช้เวลาหลายเดือน แถมยังจะเพิ่มความซับซ้อนให้คนอื่นในทีม เพราะต้องมานั่งเรียนรู้ NextJS กับพฤติกรรมเฉพาะของมันกันอีก

เราอยากได้อะไรที่เบาๆ ง่ายๆ ต่อการลงมือทำ อะไรที่ให้เรายังเขียนโค้ดแบบเดิมได้ โดยไม่ต้องเอาเรื่อง SSG มานั่งคิดตลอดเวลา (ยกเว้น useMediaQuery อันนี้เลี่ยงไม่ได้จริงๆ) ด้านล่างนี้เราจะแยกเล่าให้ฟังว่า ทำไมถึงสุดท้ายไปจบที่โซลูชันแบบทำเอง ปัญหาเฉพาะที่เจอระหว่างทาง (โดยเฉพาะที่เกี่ยวกับ Suspense boundary ของ React) แล้วเราจัดการมันยังไง

ทำไมไม่ใช้โซลูชันมาตรฐานล่ะ

ตอนแรกที่เริ่มมองเรื่อง SSG ให้ Foony แน่นอนว่าเราก็คิดถึง NextJS (ตัวท็อปของวงการ), Vike แล้วก็ Astro ก่อนเลย

NextJS: ต้องย้ายของเยอะเกิน

NextJS ทรงพลังมากก็จริง แต่ถ้าจะใช้กับเรา มันต้องย้าย React SPA ของ Foony แทบทั้งโปรเจกต์ เรามีไฟล์เป็นพันๆ ไฟล์ มี routing ที่ค่อนข้างซับซ้อน แล้วก็มี infrastructure แบบ custom อีกเยอะ ถ้าจะย้ายไป NextJS นี่ต้องยอมแลกกับ:

  • ต้องเขียนระบบ routing ใหม่ทั้งก้อน
  • ต้องเปลี่ยนโครงสร้างวิธีโหลดเกมกับ component ต่างๆ
  • ทำงานกันเป็นเดือนๆ แค่เพื่อให้ฟีเจอร์กลับมาพร้อมเท่าของเดิม
  • เสี่ยงทำให้ผู้ใช้เจอปัญหา compatibility
  • ต้องเปลี่ยนวิธีจัดการรูปภาพทั้งหมด
  • เวลา build ช้าลงแบบรู้สึกได้ (อาจจะ 5–30 นาที อันนี้ไม่มีตัวเลขชัดๆ นอกจาก ดิสคัสบน GitHub เมื่อ 5 ปีก่อน)
  • ทั้งทีมต้องมาเรียนรู้ของใหม่ (NextJS) แล้ว velocity ของ dev ก็จะช้าลงไปอีกยาวๆ
  • ทุกครั้งที่ NextJS มี breaking change ก็ต้องคอย migrate โค้ดตามมันอีก

เราเคยเริ่มทำกับ NextJS ไปแล้วรอบนึง แต่ก็รู้ตัวเร็วมากว่าต้นทุนการย้ายมันสูงเกิน ความซับซ้อนไม่คุ้มเลย

Vike: ความซับซ้อนคล้ายๆ กัน

Vike (ชื่อเดิม vite-plugin-ssr) ก็มีปัญหาคล้ายๆ กัน ถึงแม้มันยืดหยุ่นกว่า NextJS แต่มันก็ยังบังคับให้เราต้องรื้อโครงสร้าง codebase เดิมเยอะอยู่ดี ทั้ง learning curve และค่าแรงย้ายของ ไม่ได้คุ้มประโยชน์ที่จะได้

Astro: สถาปัตยกรรมคนละแบบ

Astro เท่มากสำหรับเว็บคอนเทนต์เยอะๆ แต่ Foony เป็นแพลตฟอร์มเกม multiplayer ที่ค่อนข้างซับซ้อน เราต้องการ real-time update, การต่อ WebSocket แล้วก็ React component แบบ dynamic เยอะ Astro ถูกออกแบบมาคนละทิศกับสิ่งที่เรากำลังสร้างอยู่เลย

ทางออก: SSG แบบทำเอง (Bespoke)

พอได้กำลังใจมาจากวิธี "fake SSG" ที่เราพึ่งทำไปไม่กี่วันก่อน หลังจากทำ i18n เสร็จ เราก็เลยเลือกโซลูชันเล็กๆ เบาๆ แบบ bespoke สำหรับ SSG ของ Foony

วิธี "fake SSG" ของเราคือ ดึงเนื้อหาบล็อกออกมาจากหน้าที่มีบล็อก (/posts route และหน้าของเกม) แล้วเอามาแปะไว้ตรงตำแหน่งเดียวกับที่ client จะ render จริงๆ เพื่อให้ search engine และ LLM เข้าใจ Foony ง่ายขึ้น แถมยังใส่ schema ld+json กับของจุกจิกด้าน SEO เพิ่มให้นิดหน่อยด้วย

แนวคิดหลักง่ายมาก:

  1. ต่อยอดจาก React SPA เดิม: ไม่ต้อง migrate แค่ยิงกระบวนการ generate SSG ตอน build
  2. ใช้ renderToReadableStream: API streaming SSR ของ React 18 จัดการ Suspense ให้ในตัว
  3. สร้างไฟล์ HTML แบบ static: pre-render route ต่างๆ ตอน build แล้วเสิร์ฟเป็นไฟล์ static โดยใช้ SitemapGenerator เพื่อเก็บลิสต์ route
  4. แก้โค้ดที่มีอยู่น้อยที่สุด: component ส่วนใหญ่ใช้ต่อได้เลย

โค้ดหลักๆ อยู่ที่ client/src/generators/GenerateShellSsgFromSitemap.ts มันอ่าน sitemap, render แต่ละ route ด้วย renderToReadableStream ของ React แล้วก็เขียน HTML ลงเป็นไฟล์ static จบ ง่าย แบบที่เราชอบ

แถมออกมาค่อนข้างเร็วด้วย ประมาณ 2,800 route ใช้เวลา render แค่ 10 วินาที โอเคมาก เร็วกว่าพวก NextJS, Gatsby, Astro แบบรู้สึกได้ <img alt="log บนคอนโซลของ SSG แสดงเวลาในการรัน" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

เรื่องความเรียบง่ายนี่เราพูดทั้งวันได้เลย ถึงมันอาจจะไม่ทำให้คุณได้โปรโมตในบริษัทใหญ่ๆ เพราะ "ดูไม่ซับซ้อนพอ" แต่โค้ดที่เรียบง่ายสวยงาม ดูแลรักษาง่าย แล้วก็ช่วยให้ทีม dev ทำงานได้เร็วกว่าเยอะ นี่เป็นอย่างหนึ่งที่เราชอบมากใน หลัก Zen

ปัญหาใหญ่: Suspense Boundary

พอเราได้ SSG แล้ว เนื้อหาก็โผล่มาใน HTML เรียบร้อย แต่หน้ากลับว่างเปล่า! เกิดอะไรขึ้นเนี่ย?! <img alt="หน้าที่ใช้ SSG แล้วกลายเป็นหน้าเปล่า" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

ปรากฏว่า renderToReadableStream ยังคง มี Suspense boundary อยู่ ถึงคุณจะ await stream.allReady แล้วก็ตาม เราเดาว่าเพราะมันถูกออกแบบให้เป็น "stream" จริงๆ เอาไว้ส่งต่อไปที่ client ตาม byte ที่ไหลเข้ามา

React ยิงอะไรออกมาบ้าง

เวลาใช้ renderToReadableStream ร่วมกับ Suspense React จะสร้าง HTML ประมาณนี้:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

<template id="B:0"> คือ placeholder ว่าตรงนี้ควรจะมีเนื้อหา ส่วน <div hidden id="S:0"> คือเนื้อหาจริงที่ render แล้ว B:0 กับ S:0 จะจับคู่กันด้วยเลข (index เริ่มจาก 0)

ถ้าไม่มี JavaScript search engine ทั้งหลาย (มองไปที่นายแหละ Bing) กับ LLM ก็จะเห็นหน้าเว็บเกือบว่างเปล่า เหลือแค่ template placeholder แค่นั้น ซึ่งมันขัดกับจุดประสงค์ของ SSG แบบเต็มๆ

เราไม่เห็นวิธีสวยๆ ในการลบ Suspense boundary เหล่านี้ออก เลยเลือกเขียนเทสต์แล้วก็ฟังก์ชัน resolveSuspenseBoundaries ขึ้นมาเพื่อสลับตรงนี้แทน แบบนี้เร็วกว่าการ parse HTML แล้วรันสคริปต์ด้วยอะไรอย่าง JSDOM เยอะ ที่สำคัญ มันเป็น requirement พอดีสำหรับสิ่งที่เราอยากได้ คือเว็บที่อ่านง่ายสำหรับ search engine / LLM แบบไม่ต้องใช้ JavaScript แต่ยังรองรับ Suspense boundary กับ hydration ฝั่ง client อยู่

ทดสอบการแปลง HTML

เราเริ่มจากการเขียนเทสต์สำหรับการแปลงนี้ โดยไปหยิบตัวอย่าง DOM มาสองแบบ แบบที่เรา มี (ปิด JavaScript) กับแบบที่เรา อยากได้ (เปิด JavaScript) แล้วก็เอาสองอย่างนี้ไปให้ LLM ช่วยสร้างเคสเทสต์ให้ ซึ่งเป็นอย่างที่มันถนัดอยู่แล้ว เทสต์พวกนี้อยู่ใน client/src/generators/ssr/renderRoute.test.ts เอาไว้เช็กว่าการแปลงทำงานถูกต้อง ครอบคลุมเคสพวกนี้:

  • การแทนที่ boundary แบบง่ายๆ (หน้า list บล็อก)
  • boundary แบบซับซ้อนที่มีเนื้อหาอื่นแทรกอยู่ระหว่าง template กับคอมเมนต์ปิด
  • หลาย boundary อยู่ในหน้าเดียว
  • boundary ที่ไม่มีคอมเมนต์กำกับ
  • เคสมุมๆ ต่างๆ

"TDD" แบบนี้ค่อนข้างเวิร์กกับเคสที่เรามี input/output ชัดเจน

อย่าไปสับสนกับ "TDD ทุกอย่างเพราะ Robert C. Martin บอกว่าใช้" นะ แบบนั้นจะทำให้ทีมคุณเดินช้าลงเยอะมาก คุณ ไม่ควร ใช้ TDD กับ UI หรือส่วนที่เปลี่ยนบ่อยๆ ตลอดเวลา

ทางแก้: resolveSuspenseBoundaries

พอมีเทสต์แล้ว เราก็ให้ LLM เขียนฟังก์ชัน resolveSuspenseBoundaries ให้ เลือกใช้ cheerio เพื่อเลี่ยงปัญหาความเปราะบางของ RegEx ถึงแม้ถ้าใช้ RegEx ตรงๆ น่าจะทำให้เวลา SSG ลดลงได้อีกราวๆ 40%

export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
  const originalHtml = html;
  const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
  const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];

  // Collect hidden divs with their content and positions.
  const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
  $('div[hidden][id^="S:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const content = $(el).html() || '';
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset === 'number' && typeof endOffset === 'number') {
      hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
    }
  });

  if (hiddenDivs.size === 0) {
    return {html: originalHtml, didResolveSuspense: false};
  }

  // Find templates (B:0) and replace them with the matching hidden content (S:0),
  // following React’s internal $RV behavior.
  $('template[id^="B:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const divInfo = hiddenDivs.get(boundaryId);
    if (!divInfo) {
      return;
    }
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
      return;
    }

    const templateIndex = startOffset;
    const templateLength = endOffset - startOffset;
    const afterTemplate = originalHtml.substring(templateIndex + templateLength);
    const closingCommentMatch = afterTemplate.match(/<!--\/[
amp;]-->/); const removeEndIndex = closingCommentMatch ? templateIndex + templateLength + closingCommentMatch.index! : templateIndex + templateLength; const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1; const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex); const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex); operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex}); operations.push({index: templateIndex, removeLength: 0, insertText: divContent}); operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex}); operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex}); operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex}); }); operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength)); let resultHtml = originalHtml; for (const operation of operations) { resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength); } return {html: resultHtml, didResolveSuspense: true}; }

แบบนี้เราจึงมั่นใจได้ว่า แทนที่ search engine กับ LLM จะเห็นหน้าเกือบว่างเปล่า มันจะเห็นหน้าเว็บที่ render มาเต็มๆ แล้วแทน

ตอนนี้เราได้ SSG ที่ทำงานได้ดีแม้ไม่มี JavaScript แล้ว! <img alt="หน้า SSG ของบล็อก Foony แบบไม่ใช้ JavaScript" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

ระยะยาว React อาจจะเปลี่ยนรูปแบบ Suspense ของมันก็ได้ เราอาจจะถอดโค้ดแก้ Suspense ตัวนี้ออกในอนาคต ถ้าเรามีวิธีจัดการหน้าที่ lazy-load (ที่ต้องใช้ Suspense boundary) ได้ดีพอแล้ว

กลยุทธ์ Hydration (อัปเดต: ใช้เวลา 3 วัน + อีก 1 วันพิเศษ)

Hydration นี่ของโหดอยู่แล้ว เรารู้ตัวตั้งแต่แรก แต่หลังจากลองงมอยู่พักนึง ก็ทำให้มันทำงานได้ในที่สุด!

เวลาที่ใช้กับ hydration รวมๆ: 3 วัน บวกอีก 1 วันไว้แทนที่วิธี dehydration เดิม

ส่วนที่ยากที่สุดคือการได้ hydrate เวอร์ชันเล็กที่สุดที่ "ทำงานจริง" ให้ได้ก่อน พอเราทำให้ "Hello World" กับ navbar render ผ่าน hydration ได้ เราก็เริ่มมั่นใจละว่า เออ นี่ไม่น่าจะลากยาวเป็นเดือนหรอก

<img alt="หน้า Hello World ของ Foony ที่ hydrate ติดพร้อม navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

สำหรับ hydrate เวอร์ชันเล็กสุดที่ใช้งานได้จริง เราเจอโจทย์พิเศษอย่างหนึ่ง คือเราอยากได้ hydration ที่ดี แต่ในขณะเดียวกันก็อยากได้ SEO ดีๆ สำหรับ search engine กับ LLM โดยไม่อยากให้ dev ต้องมานั่งคิดเรื่อง Suspense boundary เองตลอดเวลา

ความท้าทาย

Hydration ของ React นี่ตรงตัวมากๆ คือถ้า DOM หน้าแรก ไม่เหมือน ที่ React คาดหวัง คุณจะได้ error ข้อความสวยๆ แต่แทบไม่มีประโยชน์ในคอนโซล แล้ว React ก็จะทิ้งทุกอย่างแล้ว render ใหม่จากศูนย์ แบบไม่คิด diff ให้ด้วยซ้ำว่าจะบอกเรายังไงว่าพลาดตรงไหน

สำหรับเคสของเรา SSG ทำให้เรื่องนี้แย่ลงไปอีกด้วยสองอย่าง:

  1. เรามีการ post-process HTML เพื่อจัดการ Suspense artifact ของ React 18 streaming (ซึ่งดีสำหรับบอทต่างๆ)
  2. ฝั่ง client ไม่ได้มี data ชุดเดียวกันแบบเป๊ะๆ ตอนเวลา (t = 0) เหมือนฝั่ง server (เช่น SSG data, metadata ของบล็อก ฯลฯ)
  3. ระบบ i18n ของเราเป็นแบบ "lazy" ตามปกติ แปลว่าบางที translation จะยังไม่มาใน render แรก ถ้าเราไม่บันทึกว่า SSG ใช้ key ไหนบ้างแล้ว inject เข้าไปก่อนที่ React จะ render

วิธีที่เวิร์ก (แนวทางแรก: Dehydration)

ตอนแรกเราลองทำอะไรที่ทั้งฉลาดและน่ารักดี คือใช้ command pattern เพื่อบันทึกคำสั่งที่ใช้ตอน resolve Suspense boundary ใน HTML แล้ว return command กลับด้านเพื่อเอาไว้ "ย้อน" HTML กลับเป็นแบบที่ React ต้องการสำหรับ hydration ความหวังของเราคือจะได้ส่ง byte ในน้อยลงใน index.html ด้วยวิธีนี้ แต่เหมือนทุกครั้งที่เราทำอะไร "ฉลาดเกิน" สุดท้ายมันพัง เพราะ browser ดันแก้ HTML ให้เราแบบเนียนๆ เช่นลบหรือเติม ; หรือ / บางจุด ทำให้ index ที่ใช้แทนที่เพี้ยนไปหมด ถึงทางทฤษฎีจะพอรองรับได้ถ้าเราจะนั่งรองรับ edge case ของ browser พวกนี้ให้ครบ แต่เราไม่คิดจะส่งอะไรที่เปราะขนาดนั้นขึ้น production แน่ๆ แทนที่จะพยายาม "ย้อนกลับ" การแปลง Suspense boundary ให้กลายเป็น markup แบบ streaming ของ React อีกที เราเลยเลือกอะไรที่โคตรง่ายแทน:

แพ็ก HTML เดิมที่ยังไม่ได้ resolve ใส่ลงไปใน <script type="text"> เลย

วิธี "dehydration" นี้ใช้ได้ แต่เราก็ใช้เวลาเพิ่มอีกหนึ่งวันเพื่อแทนที่มันด้วยวิธีที่ดีกว่า

วิธีที่ดีกว่า: เปลี่ยน Suspense เฉพาะใน critical path

หลังจากเวอร์ชันแรก เราก็ยังเจอปัญหากับ Suspense boundary อยู่บ้าง ตอนนั้นแหละที่เรารู้สึกว่ามันต้องมีวิธีที่เนียน และง่ายกว่านี้แน่ๆ ก็เลยเปลี่ยนจาก dehydration มาใช้วิธี เปลี่ยน Suspense boundary เฉพาะ critical path ซึ่ง:

  • โหลด critical path ให้เสร็จก่อน hydration: component ที่ถูก preload ตอน SSR จะถูกระบุไว้ แล้ว preload ซ้ำฝั่ง client ให้เสร็จก่อนเรียก hydrateRoot
  • ดูแลง่ายกว่า: ไม่ต้องไปยุ่งกับ internals ของ React หรือ parse AST (ซึ่งวิธี dehydration ต้อง parse/กู้ HTML)
  • ส่ง byte น้อยลง: ไม่ต้องแพ็ก response SSR เดิมจาก React ใส่ใน script tag อีก
  • กันอาการหน้ากระพริบ: ไม่ต้อง dehydrate/rehydrate HTML เลย ลดโอกาสเกิด flash ตอนโหลด

การทำงานคือเราติดตามว่าส่วนไหนของ lazy component ที่ถูก preload ระหว่าง SSR (ผ่าน SSRLazyComponentTracker) แล้วก็ใส่ path สำหรับ import ลงใน hydration data จากนั้นตอน client hydrate เราก็ preload พวกนี้แบบ synchronous ก่อน Critical path component เลย render โดยไม่ต้องมี Suspense boundary ขึ้นมา ทำให้ markup ตรงกับ SSR แบบเป๊ะๆ

สำหรับส่วนอื่นๆ เราให้ render แรกฝั่ง client ทำตัวเหมือน SSR/SSG เลย นั่นคือใช้ input เดิม แล้วทำให้ input พวกนั้นพร้อมใช้งานแบบ synchronous ก่อนเรียก hydrateRoot วิธีคือแพ็กข้อมูลผ่านระบบ "ssg-data" ของเราเอง

ลงรายละเอียดจะประมาณนี้:

  1. แพ็ก input ของ SSR ลงใน script เดียวแบบ text

    • ตอน SSG เรา inject <script type="text/foony-ssg" id="foony-ssg-data">...</script> ต่อท้ายก่อน Vite module entrypoint
    • script นี้จะมี:
      • html: HTML ที่ resolve แล้ว ซึ่งเป็นเวอร์ชันที่เราเขียนลงไฟล์ static จริงๆ
      • ssgData: ข้อมูล SSGData ที่ใช้ใน SSR wrapper แบบ serialize ไว้ เรากะจะอัปเกรดเป็น Proxy หรืออะไรแนวๆ นี้ต่อ เพื่อให้ใส่เฉพาะข้อมูลที่ถูกใช้จริง
      • translationData: ก้อน key-value ของ translation ทั้งหมดที่ถูกใช้ตอน SSR
  2. เสียบ input พวกนี้กลับเข้าไปก่อนเริ่ม hydration

    • ใน main.tsx เราทำแบบ synchronous:
      • เซ็ต #root.innerHTML ให้เท่ากับ HTML เวอร์ชัน resolved ที่ serialize ไว้ (ให้ DOM ตรงกับที่ hydration จะเห็น)
      • wrap app ด้วย SSGDataProvider เพื่อให้ component เข้าถึง SSGData เดียวกับ SSR ได้ตั้งแต่ render แรก
  3. ทำให้ i18n พร้อมใช้ทันทีด้วยการ inject ค่า translation

    • เราบันทึก object translation ที่ถูกใช้จริงระหว่าง SSR แล้วส่งมันไปกับสคริปต์ SSG
    • ฝั่ง client ก็ inject เข้า cache ของ LocaleQueryer โดยตรง ผ่านเมทอด LocaleQueryer.inject() ทำให้ translation พร้อมใช้ทันที ไม่ต้องรอโหลด

แค่นี้ render แรกก็ใช้ data ชุดเดียวกับที่ SSR ใช้แล้ว!

hook useIsSSRMode() ก็เขียนทิ้งไว้ให้เรียบร้อยแล้วใน client/src/generators/ssr/isSSRMode.ts:

export function useIsSSRMode(): boolean {
  const [isSSRMode, setIsSSRMode] = React.useState(true);
  
  React.useEffect(() => {
    // After mount (hydration complete), switch to client mode
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

hook นี้จะคืนค่า true ตอน SSR แล้วก็ใน render แรกของฝั่ง client (คือช่วง hydration) จากนั้นจะสลับเป็น false หลังจาก component mount แล้ว ตัวอย่างอย่าง UserBanner, Navbar, Dialog ก็ใช้ตัวนี้อยู่แล้วเพื่อเลี่ยง hydration mismatch

  1. ไปแตะ React นิดหน่อยเพื่อให้ diff อ่านง่ายขึ้น

ตอนแรกเราอยากใช้ hydration-overlay ตรงๆ เลย แต่ตัวโปรเจกต์ไม่ค่อยได้ดูแลต่อแล้ว รองรับแค่ถึง React 18 แล้วก็ยังไม่พร้อมใช้งาน production เท่าไหร่ เลยให้ LLM clone repo นั้นมาดูเป็นแรงบันดาลใจ แล้วก็ให้มันช่วยทำ hydration overlay แบบมินิมอลให้ภายในไม่กี่นาที เราไม่ได้อยากได้อะไรเวอร์มาก แค่อะไรที่ให้โตๆ ขึ้นมาระหว่าง dev แล้วบอกได้คร่าวๆ ว่าอะไรไม่ตรงกัน

overlay ใหม่ของเราเบามาก เลยยังไม่ได้ให้ diff เป๊ะ ซะทีเดียว React จะลบคอมเมนต์ เติม ; หลัง style attribute เปลี่ยน whitespace เล็กๆ น้อยๆ อีกหลายจุด ซึ่ง overlay ของเรายังไม่ได้รองรับทั้งหมด (ตอนนี้) overlay เองยังรวม HTML comment ที่ React ไม่สนใจตอน hydration ด้วย

<img alt="hydration overlay ตัวใหม่ของเรา" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

แต่แค่นี้ก็พอจะช่วยชี้ให้เห็นแล้วว่าจุดไหนที่ต้องไปแก้จริงๆ

<img alt="diff ระหว่าง SSG กับการ render แรกฝั่ง client เพื่อดู hydration ของ React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

มองภาพรวมเป็นตัวเลข

เพื่อให้เห็นภาพชัดๆ ว่า implementation นี้ต้องทำอะไรบ้าง:

  • 2 วัน ตั้งแต่เริ่มจนได้ SSG ที่ทำงานจริง เป็นเวลารวมราวๆ 24 ชั่วโมง ในช่วงที่เราไปเที่ยวอยู่ด้วย
  • 4 วัน เอาไว้จัดการให้ hydration ทำงานลื่นๆ ไม่มีปัญหาแข่งกันของ async translation หรือ useMediaQuery มาป่วน
  • เพิ่มอีก 1 วัน เพื่อเปลี่ยนจาก dehydration ไปใช้ critical path Suspense boundary replacement (ง่ายกว่า เบากว่า ไม่เสี่ยง flash)
  • ~200 บรรทัด สำหรับโค้ดสร้าง SSG หลัก (GenerateShellSsgFromSitemap.ts)
  • ~120 บรรทัด สำหรับ logic แก้ Suspense boundary (resolveSuspenseBoundaries ใน renderRoute.tsx) - หมายเหตุ: ภายหลังเราแทนที่ด้วยวิธี critical path แล้ว
  • ~50 บรรทัด สำหรับ utility ฝั่ง SSR (isSSRMode.ts)
  • ~100 บรรทัด สำหรับเทสต์ (renderRoute.test.ts)
  • ~150 บรรทัด สำหรับ polyfill ที่ต้องใช้ SSR (setupSSREnvironment)
  • เปลี่ยน component เดิมค่อนข้างน้อย ส่วนใหญ่แค่เพิ่มเช็ก useIsSSRMode()

โซลูชันนี้เบา ดูแลง่าย ไม่ต้องย้าย framework แล้วก็ใช้กับ React SPA ที่มีอยู่เดิมได้อย่างสบายๆ

บทสรุปสำคัญ

บางทีโซลูชันแบบทำเองก็ดีกว่า

ไม่ใช่ทุกปัญหาที่ต้องแก้ด้วย framework สำหรับ Foony โซลูชัน SSG แบบ bespoke ขนาดเล็กคือคำตอบที่เหมาะสุด มัน:

  • เบา: ไม่มี dependency หนักๆ หรือ overhead ของ framework
  • ดูแลง่าย: โค้ดไม่ซับซ้อน ทีมเข้าใจได้หมด
  • ยืดหยุ่น: อยากดัดแปลงหรือเพิ่มอะไรก็ทำได้ง่าย
  • เข้ากันได้: ใช้กับ React SPA เดิมได้โดยไม่ต้อง migrate

Streaming SSR ของ React มีนิสัยเฉพาะตัว

renderToReadableStream ของ React ดีมากเวลาเจอ Suspense แต่ก็มีนิสัยประหลาดของมันเอง ถึงจะ await stream.allReady แล้ว คุณก็ยังจะเห็น Suspense boundary โผล่มาใน output อยู่ดี อันนี้ไม่ใช่บั๊ก มันถูกออกแบบมาเพื่อการ streaming โดยเฉพาะ แต่สำหรับ SSG เราต้องการ HTML ที่ resolve แล้วแบบเต็มๆ ตรงนี้เราเลยรู้สึกว่าทีม React น่าจะรองรับเคสนี้ให้เนียนกว่านี้อีกนิด

วิธีของเราคือ post-process HTML แล้วจัดการ boundary ทีหลัง มันอาจจะดูไม่สวยหรู แต่เร็ว ใช้ง่าย แล้วก็ยืดหยุ่นพอสำหรับเคสของเรา

TDD ช่วยให้ทำงานกับ LLM ง่ายขึ้น

การแปลง HTML นี่เปราะพอสมควร ผิดนิดเดียวอาจทำให้ output จาก SSG พังทั้งหน้า แล้วกระทบประสบการณ์ผู้ใช้ได้ง่ายๆ เราเลยให้ LLM ช่วยเขียนเทสต์แบบจัดเต็ม (โดยเราคอยป้อนตัวอย่างให้) เพื่อให้แน่ใจว่าการแปลงทำงานถูกต้อง

สรุปท้ายเรื่อง

ตอนนี้ SSG สำหรับ Foony ทำงานเรียบร้อยแล้ว หน้าเว็บถูก render มาครบสำหรับ search engine กับ LLM แล้วโค้ดก็ยังเบาและดูแลง่าย ส่วน hydration สำหรับ route ที่ใช้ SSG ใช้เวลามากกว่าที่คิด (3 วัน) แล้ว เรายังเพิ่มเวลาอีกวันเพื่อเปลี่ยนจากแนวทาง dehydration ไปใช้ critical path Suspense boundary replacement แทน วิธีใหม่นี้ดูแลง่ายกว่า ส่ง byte น้อยกว่า แล้วก็ลดโอกาสเกิดแฟลชจากการ dehydrate/rehydrate HTML

เรายังแอบงงอยู่นิดๆ ว่าใช้เวลาทำ SSG แบบ bespoke แค่ 2 วันได้ยังไง แต่บางทีทางออกที่ถูกก็อาจเป็นวิธีที่ง่ายที่สุดนี่แหละ

งานต่อไปคือเก็บงาน hydration ให้จับคู่กันเป๊ะทุกจุด แล้วอาจจะไปแตะ React เพิ่มเพื่อให้ debug ง่ายขึ้น แต่ ณ ตอนนี้ Foony ก็มี SSG ที่ทำงานจริงแล้ว เราจะคอยดูผลบน Google Search Console แล้วก็ Bing Webmaster Tools ในช่วงสัปดาห์ต่อๆ ไป ว่ามันช่วย SEO ของเรายังไงบ้าง

8 Ball Pool online multiplayer billiards icon