background blurbackground mobile blur

1/1/1970

ผมทำ SSG เสร็จได้ใน 2 วันได้ยังไง

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

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

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

ตอนแรกที่ผมคิดจะเพิ่ม SSG ให้ Foony ผมก็พิจารณา NextJS (มาตรฐานในวงการ), Vike และ Astro โดยธรรมชาติ

NextJS: ต้อง migrate เยอะเกินไป

NextJS ทรงพลังมาก แต่จะต้อง migrate React SPA ของ Foony ที่มีอยู่แบบมโหฬาร เรามีไฟล์เป็นพันๆ ไฟล์, logic การ routing ที่ซับซ้อน และโครงสร้างพื้นฐานที่ปรับแต่งเยอะมาก การ migrate ไป NextJS จะหมายถึง:

  • เขียนระบบ routing ทั้งหมดใหม่
  • ปรับโครงสร้างวิธีโหลดเกมและคอมโพเนนต์ใหม่
  • ใช้เวลาหลายเดือนเพียงเพื่อกลับมาที่ความสามารถเท่าเดิม
  • อาจมี breaking changes ที่กระทบผู้ใช้
  • เปลี่ยนวิธีการจัดการรูปภาพ
  • เวลา build ช้าลงมาก (อาจจะ 5-30 นาที ผมไม่มีตัวเลขแน่ชัดยืนยัน นอกจากการพูดคุยอายุ 5 ปีบน GitHub นี้)
  • ทั้งทีมต้องเรียนรู้ของใหม่ (NextJS) แล้ว developer velocity จะช้าลงตลอดไป
  • ต้อง migrate โค้ดทุกครั้งที่ NextJS ตัดสินใจปล่อย breaking changes

ผมเคยลองเริ่มกับ NextJS แบบเก้ๆ กังๆ มาแล้วด้วย แต่รู้ตัวเร็วว่าค่าใช้จ่ายในการ migrate สูงเกินไป ความซับซ้อนนี้ไม่คุ้มค่าเลย

Vike: ซับซ้อนพอๆ กัน

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

Astro: สถาปัตยกรรมไม่เข้ากัน

Astro เยี่ยมมากสำหรับเว็บที่เน้น content แต่ Foony เป็นแพลตฟอร์มเกมแบบ multiplayer ที่ซับซ้อน เราต้องการ real-time updates, การเชื่อมต่อ WebSocket และ React components แบบไดนามิก สถาปัตยกรรมของ Astro มันไม่เข้ากันกับสิ่งที่เรากำลังสร้าง

โซลูชัน: SSG เฉพาะกิจของเราเอง

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

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

แนวทางนี้ง่ายมาก:

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

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

มันเร็วมากด้วยนะ ประมาณ 2,800 routes render เสร็จใน 10 วินาที เจ๋ง! เร็วกว่า NextJS, Gatsby และ Astro อย่างเห็นได้ชัด <img alt="console 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" }} />

ผมพูดเรื่องความเรียบง่ายได้ไม่จบไม่สิ้น แม้ว่ามันอาจไม่ทำให้คุณได้เลื่อนตำแหน่งในบริษัทใหญ่เพราะ "ขาดความซับซ้อน" แต่โค้ดที่เรียบง่ายมันสวยงาม ดูแลรักษาได้ดี และโดยรวมดีต่อ developer velocity มาก นี่คือสิ่งที่ผมชื่นชมหลักการ 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 boundaries แม้คุณจะ await stream.allReady แล้วก็ตาม ผมเดาว่าเพราะมันคือ "stream" ที่ออกแบบมาให้ส่งให้ client ทันทีที่ได้รับ bytes

React Output อะไรออกมา

เมื่อคุณใช้ renderToReadableStream กับ Suspense, React จะ output 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 จะ match กับ S:0 ด้วยตัวเลข (index เริ่มจาก 0)

ถ้าไม่มี JavaScript, search engines (มองคุณอยู่นะ Bing) และ LLMs จะเห็นหน้าที่เกือบว่างเปล่ามีแค่ template placeholder ซึ่งทำลายจุดประสงค์ทั้งหมดของ SSG เลย!

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

การเทสการแปลง

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

  • การแทนที่ boundary แบบง่าย (รายการบล็อก)
  • Boundary ซับซ้อนที่มีเนื้อหาระหว่าง template และ comment ปิด
  • Boundary หลายตัว
  • Boundary ที่ไม่มี comment markers
  • Edge cases

"TDD" แบบนี้มีประโยชน์มากสำหรับ use case ที่คุณมี input และ output ที่คาดหวังไว้ชัดเจน

อย่าสับสนกับ "TDD ทุกอย่างเพราะ Robert C. Martin บอกให้ทำ" (ซึ่งจะทำให้ developer velocity ของทีมช้าลง) คุณไม่ควรใช้ 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 engines และ LLMs จะเห็นหน้าที่ render เต็มสมบูรณ์

ตอนนี้ SSG ทำงานได้ดีโดยไม่ต้องใช้ JavaScript! <img alt="SSG ของ Foony's blogs ที่ไม่ใช้ 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 resolution เมื่อมีโซลูชันที่ดีกว่าสำหรับหน้าที่โหลดแบบ lazy (และต้องใช้ Suspense boundaries)

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

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

เวลาทั้งหมดที่ใช้กับ hydration: 3 วัน บวกอีก 1 วันเพื่อแทนที่แนวทาง dehydration

ส่วนที่ยากที่สุดคือทำให้ hydrate ขั้นต่ำสุดทำงานได้ในครั้งแรกนั่นแหละ พอผมจัดการ render "Hello World" พร้อม navbar ได้ ผมก็มั่นใจขึ้นว่า ใช่ มันคงไม่ใช้เวลาทั้งเดือนหรอก!

<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 engines และ LLMs โดยที่ developer ไม่ต้องคิดเรื่อง Suspense boundaries

ความท้าทาย

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

ในกรณีของเรา SSG ทำให้แย่ลงในหลายแง่:

  1. เรา post-process HTML เพื่อลบ/resolve artifact ของ Suspense streaming ของ React 18 (ซึ่งดีสำหรับ bots)
  2. Client ไม่ได้มีข้อมูลเหมือนกับที่ server มีตอนเวลา (t = 0) เสมอไป (SSG data, blog metadata ฯลฯ)
  3. i18n ของเราเป็น "lazy" by default ซึ่งหมายความว่าคำแปลอาจจะไม่มีตอน render ครั้งแรก เว้นแต่คุณจะบันทึกว่าคำแปลไหนถูกใช้ใน SSG และ inject พวกมันก่อน React จะ render

สิ่งที่ใช้ได้ผล (แนวทางเริ่มต้น: Dehydration)

ตอนแรกผมลองทำอะไรเก๋ๆ น่ารักๆ: ผมใช้ command pattern เพื่อบันทึก command ที่ใช้ในการ resolve Suspense boundaries ของ HTML แล้ว return command การแปลงแบบย้อนกลับเพื่อจะ restore HTML กลับเป็นแบบที่ React ต้องการสำหรับ hydration ผมหวังว่าจะ ship bytes น้อยลงใน index.html ด้วยวิธี command นี้ แต่อย่างที่โซลูชันเก๋ๆ ส่วนใหญ่เป็น มันล้มเหลวเพราะ browser ปรับเปลี่ยน HTML แบบแอบๆ เช่น ลบหรือเพิ่ม ; หรือ / ซึ่งทำให้ index ของการแทนที่ผิดเพี้ยน ในทางเทคนิคคุณอาจจะลองคำนึงถึงการเปลี่ยนแปลงเล็กๆ ของ browser เหล่านี้ได้ แต่ผมไม่ปล่อยให้อะไรเปราะบางขนาดนั้นออกไปแน่นอน แทนที่จะพยายาม "ย้อนกลับ" การแปลง Suspense-boundary กลับไปเป็น streaming markup ของ React ผมทำอะไรง่ายๆ มากๆ:

Bundle HTML เดิมที่ยังไม่ resolve ใน <script type="text">

แนวทาง "dehydration" นี้ใช้ได้ แต่ผมใช้เวลาเพิ่มอีก 1 วันเพื่อแทนที่ด้วยโซลูชันที่ดีกว่า

แนวทางที่ดีกว่า: การแทนที่ Suspense Boundary แบบ Critical Path

หลังจากการ implement ครั้งแรก ผมยังเจอปัญหาบางอย่างกับ Suspense boundaries อยู่ ตอนนั้นแหละที่ผมรู้ว่ามีโซลูชันที่สะอาดกว่า ดีกว่า และง่ายกว่า ผมแทนที่แนวทาง dehydration ด้วย การแทนที่ Suspense boundary แบบ critical path ซึ่ง:

  • โหลด critical path ก่อน hydration: คอมโพเนนต์ที่ถูก preload ระหว่าง SSR จะถูกระบุและ preload ที่ client ก่อนที่ hydrateRoot จะถูกเรียก
  • ดูแลรักษาง่ายกว่า: ไม่ต้องใช้ React internals หรือ AST parsing (แนวทาง dehydration ต้อง parse และ restore HTML)
  • Ship bytes น้อยลง: เราไม่ bundle response SSR ดั้งเดิมจาก React ใน script tag อีกต่อไป
  • ป้องกันการ flash ที่อาจเกิดขึ้น: ไม่ต้อง dehydrate/rehydrate HTML กำจัดการ flash ที่อาจเกิดขึ้น

การ implement จะติดตามว่าคอมโพเนนต์ lazy ตัวไหนถูก preload ระหว่าง SSR (ผ่าน SSRLazyComponentTracker), รวม import paths ของพวกมันใน hydration data และ preload พวกมันแบบ synchronous ก่อน hydration คอมโพเนนต์ critical path render โดยตรงโดยไม่มี Suspense boundaries ตรงกับ output ของ SSR เป๊ะ

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

อย่างเป็นรูปธรรม การปรับเปลี่ยนคือ:

  1. Bundle SSR inputs เป็น text script เดียว

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

    • ใน main.tsx เราทำแบบ synchronous:
      • ตั้ง #root.innerHTML เป็น HTML ที่ resolve แล้วที่ serialize ไว้ (เพื่อให้ DOM ตรงกับสิ่งที่ hydration เห็นเป๊ะ)
      • ห่อ app ใน SSGDataProvider เพื่อให้คอมโพเนนต์มี SSGData เดียวกันใน render ครั้งแรก
  3. ทำให้ i18n ทันทีโดย inject ค่า translation

    • เราบันทึก object translation จริงๆ ที่ถูกเข้าถึงระหว่าง SSR และ ship พวกมันใน script SSG
    • ที่ client เรา inject พวกมันลง cache ของ LocaleQueryer โดยตรงผ่านเมธอด LocaleQueryer.inject() เฉพาะ เพื่อให้ translations พร้อมใช้งานทันที

แค่นั้นเอง render ครั้งแรกก็มีข้อมูลเดียวกับที่ SSR มี!

Hook useIsSSRMode() ถูก implement แล้วใน 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 หลัง mount คอมโพเนนต์อย่าง UserBanner, Navbar และ Dialog ใช้สิ่งนี้แล้วเพื่อป้องกัน hydration mismatch

  1. Patch React เพื่อให้ diff ดีขึ้น

ผมหวังว่าจะใช้ hydration-overlay ได้เลย แต่มันไม่ได้ดูแลแล้ว, รองรับแค่ React 18 และไม่พร้อมใช้ใน production ผมเลยให้ LLM clone repo มาเพื่อแรงบันดาลใจ แล้วมันก็สร้าง hydration overlay ขั้นต่ำในไม่กี่นาที ผมไม่ต้องการอะไรหรูหรา แค่อะไรที่จะแสดงตอน development เพื่อให้ผมหาว่าอะไรผิดพลาดได้

Overlay ใหม่นี้พื้นฐานมาก ดังนั้น diff ก็เลย ไม่ค่อย สมบูรณ์แบบ React ลบ comment, เพิ่ม ; หลัง style attribute, ปรับ whitespace และอื่นๆ เล็กน้อย ซึ่ง overlay ของเราไม่ได้คำนึงถึง (ยัง) overlay ของเรายังรวม HTML comments ที่ 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 vs render หน้าแรกที่ client สำหรับ React hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

ตัวเลขที่น่าสนใจ

เพื่อให้คุณเห็นภาพว่าการ implement นี้เกี่ยวข้องกับอะไรบ้าง:

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

โซลูชันนี้น้ำหนักเบาและดูแลรักษาง่าย ไม่ต้อง migrate framework และทำงานกับ React SPA ที่เรามีอยู่ได้

สิ่งที่ได้เรียนรู้

บางครั้งโซลูชันเฉพาะกิจก็ดีกว่า

ไม่ใช่ทุกปัญหาที่ต้องการ framework สำหรับ Foony โซลูชัน SSG เฉพาะกิจขนาดเล็กเป็นทางเลือกที่ถูกต้อง เพราะมัน:

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

Streaming SSR ของ React มีอะไรยุบยิบ

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

โซลูชันของผมคือ post-process HTML และ resolve boundaries มันไม่สวยหรอก แต่เร็วและยืดหยุ่นพอสำหรับ use case ของผม

TDD มีประโยชน์สำหรับ LLMs

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

บทสรุป

ตอนนี้ SSG ทำงานได้แล้วสำหรับ Foony หน้าเว็บถูก render เต็มสำหรับ search engines และ LLMs และโซลูชันก็ดูแลรักษาง่ายและน้ำหนักเบา Hydration สำหรับ route SSG ใช้เวลานานกว่าที่ผมคาด (3 วัน) และผมใช้เวลาอีก 1 วันแทนที่แนวทาง dehydration เริ่มต้นด้วยการแทนที่ Suspense boundary แบบ critical path แนวทางใหม่ดูแลรักษาง่ายกว่า, ship bytes น้อยกว่า และป้องกันการ flash ที่อาจเกิดจากการ dehydrate/rehydrate HTML

ผมยังตกใจอยู่ที่ใช้เวลาแค่ 2 วันในการ implement โซลูชันเฉพาะกิจสำหรับ SSG แต่บางครั้งโซลูชันที่ถูกต้องก็คือโซลูชันที่ง่ายที่สุด

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

8 Ball Pool online multiplayer billiards icon