

1/1/1970
Cách Mình Triển Khai SSG Trong 2 Ngày
Xin chào! Một năm trước, mình từng nghĩ chuyện này là bất khả thi. Vậy mà mình vừa hoàn thành việc triển khai Static Site Generation (SSG) cho Foony chỉ trong 2 ngày, và mình khá là phấn khích về điều đó. Đây cũng không phải lần đầu mình thử giải quyết bài toán SSG cho Foony. Trước đây mình đã xem xét NextJS, Vike, Astro, Gatsby và vài giải pháp khác. Mình thậm chí đã khởi động hụt với NextJS, nhưng gặp khó khăn vì độ phức tạp của SPA Foony và hàng nghìn file. Việc di chuyển sẽ là một cơn ác mộng và mất hàng tháng trời. Nó cũng sẽ tăng thêm độ phức tạp cho mọi người khác làm việc trên site vì họ sẽ phải học NextJS và những đặc điểm riêng của nó.
Mình muốn một thứ gì đó nhẹ nhàng và dễ triển khai. Một thứ cho phép chúng mình tiếp tục viết code theo cách cũ mà không cần phải nghĩ đến SSG (ngoại trừ useMediaQuery, không có cách nào tránh được cái này). Phía dưới mình sẽ phân tích lý do mình chọn giải pháp tự thiết kế, những thử thách cụ thể mình đã gặp (đặc biệt là với Suspense boundaries của React), và cách mình giải quyết chúng.
Tại Sao Không Dùng Giải Pháp Tiêu Chuẩn?
Khi lần đầu xem xét việc thêm SSG vào Foony, mình tự nhiên cân nhắc NextJS (tiêu chuẩn ngành), Vike và Astro.
NextJS: Quá Nhiều Công Việc Di Chuyển
NextJS rất mạnh, nhưng nó sẽ đòi hỏi phải di chuyển hàng loạt SPA React hiện tại của Foony. Chúng mình có hàng nghìn file, logic định tuyến phức tạp và rất nhiều hạ tầng tùy chỉnh. Di chuyển sang NextJS sẽ đồng nghĩa với:
- Viết lại toàn bộ hệ thống định tuyến
- Tái cấu trúc cách chúng mình tải game và component
- Hàng tháng làm việc chỉ để quay lại được tính năng tương đương
- Có khả năng gây lỗi cho người dùng
- Thay đổi cách xử lý hình ảnh
- Thời gian build chậm hơn đáng kể (có thể từ 5-30 phút. Mình không có con số cụ thể để chứng minh ngoài cuộc thảo luận 5 năm tuổi này trên GitHub)
- Cả team phải học một thứ mới (NextJS), và tốc độ phát triển sẽ chậm đi mãi mãi
- Phải di chuyển code mỗi khi NextJS quyết định tạo ra những thay đổi gây lỗi.
Mình thậm chí đã thử khởi động hụt với NextJS, nhưng nhanh chóng nhận ra chi phí di chuyển quá cao. Độ phức tạp không xứng đáng.
Vike: Độ Phức Tạp Tương Tự
Vike (trước đây là vite-plugin-ssr) cũng có vấn đề tương tự. Mặc dù linh hoạt hơn NextJS, nó vẫn đòi hỏi tái cấu trúc đáng kể codebase của chúng mình. Đường cong học tập và công sức di chuyển không xứng đáng với lợi ích nhận được.
Astro: Kiến Trúc Sai
Astro tuyệt vời cho các site nặng về nội dung, nhưng Foony là một nền tảng game multiplayer phức tạp. Chúng mình cần cập nhật thời gian thực, kết nối WebSocket và các component React động. Kiến trúc của Astro đơn giản là không phù hợp với những gì chúng mình đang xây dựng.
Giải Pháp: SSG Tự Thiết Kế
Được tiếp thêm dũng khí từ phương pháp "SSG giả" mà mình đã triển khai vài ngày trước sau khi làm i18n, mình quyết định chọn một giải pháp nhỏ gọn, nhẹ nhàng và tự thiết kế cho SSG của Foony.
Phương pháp "SSG giả" của mình bao gồm việc lấy nội dung blog post từ các trang có blog post (các route
/postsvà trang game), và đặt chúng đúng vị trí mà client sẽ render, đặc biệt cho công cụ tìm kiếm và LLM hiểu Foony. Nó cũng áp dụng schema ld+json và một vài thứ SEO nhỏ.
Cách tiếp cận rất đơn giản:
- Xây dựng trên SPA React hiện có: Không cần di chuyển, chỉ cần thêm việc tạo SSG vào lúc build.
- Dùng
renderToReadableStream: API streaming SSR của React 18 xử lý Suspense một cách tự nhiên. - Tạo các file HTML tĩnh: Pre-render các route lúc build và phục vụ chúng dưới dạng file tĩnh, sử dụng SitemapGenerator của chúng mình để lấy danh sách các route.
- Thay đổi tối thiểu codebase hiện có: Hầu hết các component hoạt động như cũ.
Phần triển khai cốt lõi nằm trong client/src/generators/GenerateShellSsgFromSitemap.ts. Nó đọc một sitemap, render mỗi route bằng renderToReadableStream của React, và ghi HTML vào các file tĩnh. Đơn giản, đúng kiểu mình thích!
Cách này hóa ra cũng khá nhanh. Khoảng 2.800 route được render trong 10 giây. Tuyệt. Nhanh hơn đáng kể so với NextJS, Gatsby và Astro. <img alt="Console log SSG hiển thị thời gian thực hiện" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Mình có thể nói mãi về sự đơn giản. Ngay cả khi nó không giúp bạn được thăng chức ở các công ty lớn vì "thiếu độ phức tạp", code đơn giản vẫn đẹp, dễ bảo trì và nói chung tốt hơn nhiều cho tốc độ phát triển. Đây là điều mình thực sự ngưỡng mộ ở các nguyên tắc Zen.
Vấn Đề Suspense Boundary
Vậy là mình đã có SSG, và nội dung xuất hiện trong HTML... nhưng các trang lại trống không! Sao lại thế?! <img alt="Trang trống của 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" }} />
Hóa ra renderToReadableStream vẫn có Suspense boundaries, ngay cả khi bạn await stream.allReady. Mình đoán là vì nó là một "stream", được thiết kế để truyền cho client khi các byte được nhận.
React Xuất Ra Gì
Khi bạn dùng renderToReadableStream với Suspense, React xuất ra HTML kiểu này:
<!--$?-->
<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"> là chỗ giữ chỗ nơi nội dung sẽ đi vào. <div hidden id="S:0"> chứa nội dung thực sự đã được render. B:0 khớp với S:0 theo số (chỉ số bắt đầu từ 0).
Nếu không có JavaScript, công cụ tìm kiếm (đang nói bạn đó, Bing) và LLM sẽ thấy một trang gần như trống không với chỉ chỗ giữ chỗ template. Điều đó phá hỏng toàn bộ mục đích của SSG!
Mình không thấy cách nào sạch sẽ để loại bỏ những Suspense boundary này, nên giải pháp của mình là viết một số test và một hàm resolveSuspenseBoundaries để hoán đổi chúng. Cách này nhanh hơn so với việc parse HTML và thực thi script với thứ gì đó như JSDOM. Và quan trọng hơn, đó là yêu cầu cho thứ mình đã lên kế hoạch: một site đẹp, dễ đọc cho công cụ tìm kiếm / LLM mà không cần JavaScript, nhưng có hỗ trợ Suspense boundary và hydration ở phía client.
Test Việc Chuyển Đổi
Mình bắt đầu bằng việc viết test cho việc chuyển đổi bằng cách lấy một số ví dụ trong DOM từ những gì mình đang có (JavaScript bị tắt) và những gì mình muốn (JavaScript được bật). Mình đưa những thứ này vào một LLM và để nó xử lý việc tạo test, một việc nó khá giỏi.
Những test này nằm trong client/src/generators/ssr/renderRoute.test.ts và đảm bảo việc chuyển đổi hoạt động chính xác. Các test bao gồm:
- Thay thế boundary đơn giản (danh sách blog)
- Boundary phức tạp với nội dung giữa template và comment đóng
- Nhiều boundary
- Boundary không có dấu comment
- Các trường hợp biên
Kiểu "TDD" này thực sự khá hữu ích cho trường hợp sử dụng này khi bạn có đầu vào và đầu ra mong đợi.
Đừng nhầm lẫn với "TDD mọi thứ vì Robert C. Martin nói vậy" (sẽ làm chậm tốc độ phát triển của team). Bạn KHÔNG nên dùng TDD cho UI hoặc các phần code thường xuyên thay đổi!
Giải Pháp: resolveSuspenseBoundaries
Bây giờ test đã sẵn sàng, mình để LLM viết hàm cho resolveSuspenseBoundaries. Mình chọn cheerio cho việc này để tránh sự dễ vỡ của RegEx, mặc dù dùng RegEx ở đây sẽ giảm thời gian SSG khoảng 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};
}
Điều này đảm bảo rằng thay vì thấy một trang gần như trống không, công cụ tìm kiếm và LLM sẽ thấy một trang được render đầy đủ.
Giờ thì SSG đã hoạt động tốt mà không cần JavaScript!
<img alt="SSG không JavaScript cho blog của Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Về lâu dài, có thể React sẽ thay đổi định dạng Suspense của họ. Mình có thể sẽ loại bỏ code resolve Suspense một khi mình có giải pháp tốt hơn cho các trang được lazy-load (và do đó cần Suspense boundary).
Chiến Lược Hydration (Cập Nhật: Việc Này Mất 3 Ngày + 1 Ngày Thêm)
Hydration là thử thách. Mình biết điều đó. Nhưng sau một chút công sức, mình đã làm cho nó hoạt động!
Tổng thời gian cho hydration: 3 ngày, cộng thêm 1 ngày để thay thế phương pháp dehydration.
Phần khó nhất chính là việc đạt được lần hydrate tối thiểu, hoạt động được đầu tiên đó. Một khi mình render được một "Hello World" với navbar, mình có thêm tự tin rằng, vâng, việc này có thể không mất cả tháng!
<img alt="Hello World của Foony hydrate thành công với 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" }} />
Đối với lần hydrate tối thiểu, hoạt động được đầu tiên, mình có một thử thách độc đáo: mình muốn hydration, nhưng mình cũng muốn SEO tốt cho công cụ tìm kiếm và LLM mà không cần developer phải nghĩ đến Suspense boundary.
Thử Thách
Hydration của React cực kỳ khắt khe: nếu DOM trông không giống với những gì React mong đợi cho lần render đầu tiên đó, bạn sẽ nhận được một thông báo lỗi đẹp đẽ và gần như vô dụng trong console, và React sẽ vứt mọi thứ đi và render lại từ đầu. Thậm chí không có một bản diff để cho bạn biết điều gì đã sai!
Trong trường hợp của chúng mình, SSG làm điều này tệ hơn theo vài cách:
- Chúng mình post-process HTML để loại bỏ/giải quyết các artifact streaming Suspense của React 18 (rất tốt cho bot).
- Client không phải lúc nào cũng có sẵn cùng dữ liệu chính xác tại thời điểm (t = 0) như render server (dữ liệu SSG, metadata blog, v.v.).
- i18n của chúng mình mặc định là "lười", nghĩa là bản dịch có thể bị thiếu cho lần render đầu tiên trừ khi bạn ghi lại những bản dịch nào đã được sử dụng cho SSG và inject chúng trước khi React render.
Cái Gì Đã Hoạt Động (Cách Tiếp Cận Ban Đầu: Dehydration)
Đầu tiên, mình thử một thứ thông minh và đáng yêu: mình dùng command pattern để ghi lại các lệnh dùng để giải quyết Suspense boundary của HTML, và trả về các lệnh chuyển đổi ngược để mình có thể khôi phục HTML về cái mà React cần cho hydration.
Hy vọng của mình là mình có thể ship ít byte hơn nhiều trong index.html với phương pháp lệnh này. Nhưng, như hầu hết các giải pháp thông minh, cái này thất bại vì trình duyệt sửa đổi HTML theo những cách tinh vi, như xóa hoặc thêm ; hoặc /, làm sai lệch các chỉ số thay thế.
Về mặt kỹ thuật bạn có thể tính đến những thay đổi tinh vi này của trình duyệt, nhưng mình không định ship một thứ dễ vỡ như vậy.
Thay vì cố "đảo ngược" việc chuyển đổi Suspense-boundary trở lại thành markup streaming của React, mình làm một thứ siêu đơn giản:
Đóng gói HTML gốc, chưa được giải quyết trong một <script type="text">.
Cách "dehydration" này hoạt động, nhưng mình đã dành thêm một ngày để thay thế nó bằng giải pháp tốt hơn.
Cách Tốt Hơn: Thay Thế Suspense Boundary Theo Đường Dẫn Quan Trọng
Sau lần triển khai ban đầu, mình vẫn gặp một số vấn đề với Suspense boundary. Đó là khi mình nhận ra có một giải pháp sạch hơn, tốt hơn, đơn giản hơn. Mình thay thế cách dehydration bằng thay thế Suspense boundary theo đường dẫn quan trọng, cách này:
- Tải đường dẫn quan trọng trước hydration: Các component đã được preload trong SSR được xác định và preload trên client trước khi
hydrateRoot được gọi
- Đơn giản hơn để bảo trì: Không cần internals của React hay parse AST (cách dehydration cần parse và khôi phục HTML)
- Ship ít byte hơn: Chúng mình không còn đóng gói response SSR gốc từ React trong thẻ script
- Ngăn chặn flash tiềm ẩn: Không cần dehydrate/rehydrate HTML, loại bỏ flash hình ảnh tiềm ẩn
Việc triển khai theo dõi những lazy component nào đã được preload trong SSR (qua SSRLazyComponentTracker), bao gồm đường dẫn import của chúng trong dữ liệu hydration, và preload chúng đồng bộ trước hydration. Các component đường dẫn quan trọng render trực tiếp mà không có Suspense boundary, khớp chính xác với output SSR.
Đối với mọi thứ khác, chúng mình làm cho lần render client đầu tiên hoạt động như SSR/SSG. Điều đó có nghĩa là sử dụng cùng input, và làm cho những input đó có sẵn đồng bộ trước hydrateRoot. Việc này được thực hiện bằng cách đóng gói qua "ssg-data" của chúng mình.
Cụ thể, các điều chỉnh là:
Đóng gói các input SSR vào một text script duy nhất
- Trong SSG, chúng mình inject một
<script type="text/foony-ssg" id="foony-ssg-data">...</script> ngay trước entrypoint module Vite.
- Script đó chứa:
html: HTML đã được giải quyết mà chúng mình thực sự ship trong file tĩnh
ssgData: SSGData đã được serialize được sử dụng bởi wrapper SSR. Mình dự định cập nhật cái này thành một Proxy hoặc thứ gì đó để chỉ dữ liệu được truy cập mới được bao gồm.
translationData: các blob key-value bản dịch chúng mình đã chạm vào trong SSR
Inject những input đó ngay trước hydration
- Trong
main.tsx, chúng mình đồng bộ:
- đặt
#root.innerHTML thành HTML đã giải quyết được serialize (để DOM chính xác là cái mà hydration thấy)
- bọc app trong
SSGDataProvider để các component có cùng SSGData trên lần render đầu tiên
Làm cho i18n tức thì bằng cách inject các giá trị bản dịch
- Chúng mình ghi lại các đối tượng bản dịch thực tế được truy cập trong SSR và ship chúng trong script SSG.
- Trên client, chúng mình inject chúng thẳng vào cache của
LocaleQueryer qua một method LocaleQueryer.inject() chuyên biệt, để các bản dịch có sẵn ngay lập tức.
Và với điều đó, lần render đầu tiên có cùng dữ liệu mà SSR đã có!
Hook useIsSSRMode() đã được triển khai trong 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 này trả về true trong SSR và trên lần render client đầu tiên (hydration), sau đó chuyển sang false sau khi mount. Các component như UserBanner, Navbar, và Dialog đã sử dụng cái này để ngăn ngừa hydration mismatch.
- Vá React để có diff tốt hơn
Mình hy vọng có thể chỉ cần dùng hydration-overlay. Nhưng nó không được duy trì tích cực, chỉ hỗ trợ đến React 18, và chưa sẵn sàng cho production. Vì vậy mình bảo một LLM clone repo để lấy cảm hứng, và sau đó nó tạo ra một hydration overlay tối thiểu trong vài phút. Mình không cần thứ gì đó cầu kỳ, chỉ cần một thứ sẽ hiện ra trong quá trình phát triển để mình có thể tìm ra chỗ nào sai.
Overlay mới này siêu cơ bản, nên các diff không hoàn toàn hoàn hảo. React loại bỏ comment, thêm ; sau thuộc tính style, sửa đổi khoảng trắng và một vài thứ nhỏ khác mà overlay của chúng mình chưa tính đến (chưa). Overlay của chúng mình cũng bao gồm comment HTML mà React bỏ qua cho hydration của nó.
<img alt="Hydration overlay mới của chúng mình" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Nhưng nó đủ tốt để tìm ra cái gì cần sửa.
<img alt="diff của SSG so với render trang đầu tiên trên client cho 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" }} />
Theo Số Liệu
Để bạn hình dung việc triển khai này bao gồm những gì:
- 2 ngày làm việc (từ lúc bắt đầu đến SSG hoạt động). Đây chỉ hơn 24 giờ trong kỳ nghỉ.
- 4 ngày làm việc để hydration hoạt động trơn tru mà không có cuộc đua bản dịch async hay
useMediaQuery làm hỏng mọi thứ.
- 1 ngày thêm để thay thế cách dehydration bằng thay thế Suspense boundary theo đường dẫn quan trọng (đơn giản hơn, ít byte hơn, không có flash tiềm ẩn).
- ~200 dòng code tạo SSG cốt lõi (
GenerateShellSsgFromSitemap.ts)
- ~120 dòng giải quyết Suspense boundary (
resolveSuspenseBoundaries trong renderRoute.tsx) - Lưu ý: Cái này sau đó được thay thế bằng cách tiếp cận đường dẫn quan trọng
- ~50 dòng tiện ích SSR (
isSSRMode.ts)
- ~100 dòng test (
renderRoute.test.ts)
- ~150 dòng polyfill cho SSR (
setupSSREnvironment)
- Thay đổi tối thiểu với các component hiện có (chủ yếu là thêm các kiểm tra
useIsSSRMode())
Giải pháp nhẹ nhàng và dễ bảo trì. Nó không yêu cầu di chuyển framework, và nó hoạt động với SPA React hiện có của chúng mình.
Những Bài Học Chính
Đôi Khi Giải Pháp Tự Thiết Kế Tốt Hơn
Không phải vấn đề nào cũng cần một framework. Đối với Foony, một giải pháp SSG nhỏ gọn, tự thiết kế là lựa chọn đúng đắn. Nó:
- Nhẹ nhàng: Không có dependency nặng nề hay overhead framework
- Dễ bảo trì: Code đơn giản mà chúng mình hiểu
- Linh hoạt: Dễ sửa đổi và mở rộng khi cần
- Tương thích: Hoạt động với SPA React hiện có mà không cần di chuyển
Streaming SSR của React Có Đặc Điểm Riêng
renderToReadableStream của React rất tuyệt để xử lý Suspense, nhưng nó có những đặc điểm riêng. Ngay cả với await stream.allReady, bạn vẫn nhận được Suspense boundary trong output. Đây không phải lỗi, đó là thiết kế cho streaming. Nhưng đối với SSG, chúng ta cần HTML được giải quyết hoàn toàn. Cảm giác như đó là một thất bại của team React khi không xử lý tình huống này một cách sạch sẽ.
Giải pháp của mình là post-process HTML và giải quyết các boundary. Nó không đẹp, nhưng nó nhanh và đủ linh hoạt cho trường hợp của mình.
TDD Có Thể Hữu Ích Cho LLM
Chuyển đổi HTML dễ gây lỗi. Một bug nhỏ và bạn có thể phá hỏng toàn bộ output SSG và phá hỏng trải nghiệm người dùng cuối. Mình bảo một LLM viết các test toàn diện (với input từ mình) để đảm bảo việc chuyển đổi hoạt động chính xác.
Kết Luận
SSG giờ đã hoạt động cho Foony. Các trang được render đầy đủ cho công cụ tìm kiếm và LLM, và giải pháp dễ bảo trì và nhẹ nhàng. Hydration cho các route SSG mất nhiều thời gian hơn mình mong đợi (3 ngày), và mình dành thêm một ngày để thay thế cách dehydration ban đầu bằng thay thế Suspense boundary theo đường dẫn quan trọng. Cách tiếp cận mới đơn giản hơn để bảo trì, ship ít byte hơn và ngăn ngừa flash hình ảnh tiềm ẩn từ việc dehydrate/rehydrate HTML.
Mình vẫn shock vì chỉ mất 2 ngày để triển khai một giải pháp tự thiết kế cho SSG. Nhưng đôi khi giải pháp đúng là giải pháp đơn giản nhất.
Công việc tương lai bao gồm hoàn thành việc khớp hydration và có thể vá React để debug tốt hơn. Nhưng bây giờ, Foony đã có SSG hoạt động. Mình sẽ theo dõi Google Search Console và Bing Webmaster Tools trong những tuần tới để xem điều này có ảnh hưởng gì đến SEO của chúng mình.