

1/1/1970
Πώς υλοποίησα SSG μέσα σε 2 μέρες
Γεια σου! Πριν από έναν χρόνο, πίστευα ότι αυτό ήταν αδύνατο. Μόλις όμως ολοκλήρωσα την υλοποίηση Static Site Generation (SSG) για το Foony μέσα σε 2 μέρες και είμαι αρκετά ενθουσιασμένος με αυτό. Δεν είναι καν η πρώτη φορά που προσπαθώ να λύσω το θέμα του SSG για το Foony. Στο παρελθόν έχω κοιτάξει NextJS, Vike, Astro, Gatsby και μερικές ακόμα λύσεις. Είχα μάλιστα και μια αποτυχημένη αρχή με NextJS, αλλά κόλλησα στην πολυπλοκότητα του SPA του Foony και στις χιλιάδες αρχεία. Η μετάβαση θα ήταν εφιάλτης και θα έπαιρνε μήνες. Θα πρόσθετε επίσης επιπλέον πολυπλοκότητα για όλους όσους δουλεύουν στο site, επειδή θα έπρεπε να μάθουν NextJS και τις ιδιοτροπίες του.
Ήθελα κάτι ελαφρύ και εύκολο στην υλοποίηση. Κάτι που θα μας άφηνε να συνεχίσουμε να γράφουμε κώδικα όπως ακριβώς τον γράφαμε μέχρι τώρα, χωρίς να χρειάζεται να σκεφτόμαστε το SSG (με εξαίρεση το useMediaQuery--εκεί δεν υπάρχει ρεαλιστικά κάποιος άλλος δρόμος). Παρακάτω εξηγώ γιατί κατέληξα σε μια bespoke λύση, τις συγκεκριμένες δυσκολίες που συνάντησα (ειδικά με τα Suspense boundaries του React) και πώς τις έλυσα.
Γιατί όχι οι κλασικές λύσεις;
Όταν πρωτοκοίταξα το να προσθέσω SSG στο Foony, λογικά σκέφτηκα NextJS (industry standard), Vike και Astro.
NextJS: Πάρα πολλή μετάβαση
Το NextJS είναι δυνατό, αλλά θα απαιτούσε τεράστια μεταφορά του υπάρχοντος React SPA του Foony. Έχουμε χιλιάδες αρχεία, πολύπλοκη λογική routing και αρκετό custom infrastructure. Η μετάβαση σε NextJS θα σήμαινε:
- Ξαναγράψιμο ολόκληρου του routing system
- Αναδόμηση του πώς φορτώνουμε games και components
- Μήνες δουλειάς μόνο και μόνο για να επιστρέψουμε στο ίδιο επίπεδο λειτουργιών
- Πιθανές breaking αλλαγές για τους χρήστες
- Αλλαγή του τρόπου που χειριζόμαστε εικόνες
- Σημαντικά πιο αργά build times (πιθανόν 5–30 λεπτά. Δεν έχω συγκεκριμένα νούμερα, πέρα από αυτή τη 5-ετίας συζήτηση στο GitHub)
- Όλη η ομάδα θα έπρεπε να μάθει κάτι καινούργιο (NextJS), άρα μόνιμα πιο αργό development
- Μεταφορά κώδικα κάθε φορά που το NextJS αποφασίζει να κάνει breaking changes.
Δοκίμασα κι εγώ μια πρώτη προσέγγιση με NextJS, αλλά πολύ γρήγορα κατάλαβα ότι το κόστος της μετάβασης ήταν υπερβολικά μεγάλο. Η πολυπλοκότητα δεν άξιζε.
Vike: Παρόμοια πολυπλοκότητα
Το Vike (πρώην vite-plugin-ssr) είχε παρόμοια θέματα. Αν και είναι πιο ευέλικτο από το NextJS, πάλι θα απαιτούσε σημαντική αναδόμηση του codebase μας. Το learning curve και η προσπάθεια μετάβασης δεν δικαιολογούσαν τα οφέλη.
Astro: Λάθος αρχιτεκτονική
Το Astro είναι εξαιρετικό για sites με πολύ περιεχόμενο, αλλά το Foony είναι μια πολύπλοκη multiplayer game πλατφόρμα. Χρειαζόμαστε real-time updates, WebSocket connections και δυναμικά React components. Η αρχιτεκτονική του Astro απλά δεν ταιριάζει με αυτό που χτίζουμε.
Η λύση: Bespoke SSG
Παίρνοντας θάρρος από το "fake SSG" που είχα φτιάξει λίγες μέρες πριν, μετά το i18n, κατέληξα σε μια μικρή, ελαφριά, bespoke λύση για το SSG του Foony.
Το "fake SSG" που είχα κάνει τραβούσε το περιεχόμενο των blog posts από τις σελίδες που τα περιείχαν (routes
/postsκαι σελίδες παιχνιδιών) και τα τοποθετούσε ακριβώς εκεί που θα τα έκανε render ο client, ειδικά για τις μηχανές αναζήτησης και τα LLMs, ώστε να καταλαβαίνουν καλύτερα το Foony. Εφάρμοζε επίσης ld+json schema και μερικά μικρά SEO πράγματα.
Η προσέγγιση είναι απλή:
- Χτίζουμε πάνω στο υπάρχον React SPA: Καμία μετάβαση, απλά προσθέτουμε SSG generation στο build time.
- Χρησιμοποιούμε
renderToReadableStream: Το streaming SSR API του React 18 χειρίζεται εγγενώς το Suspense. - Γεννάμε static HTML αρχεία: Κάνουμε pre-render routes στο build time και τα σερβίρουμε ως static files, χρησιμοποιώντας το SitemapGenerator μας για να πάρουμε τη λίστα των routes.
- Ελάχιστες αλλαγές στο υπάρχον codebase: Οι περισσότερες components δουλεύουν όπως είναι.
Η βασική υλοποίηση βρίσκεται στο client/src/generators/GenerateShellSsgFromSitemap.ts. Διαβάζει ένα sitemap, κάνει render κάθε route με το renderToReadableStream του React και γράφει το HTML σε static files. Απλό, όπως μου αρέσει!
Και τελικά βγήκε και αρκετά γρήγορο. Περίπου 2.800 routes έγιναν render σε 10 δευτερόλεπτα. Ωραία. Αυτό είναι σημαντικά πιο γρήγορο από NextJS, Gatsby και Astro. <img alt="Καταγραφή κονσόλας 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" }} />
Μπορώ να μιλάω ατελείωτα για την απλότητα. Ακόμα κι αν δεν σου φέρει προαγωγή σε μεγάλες εταιρείες λόγω "έλλειψης πολυπλοκότητας", ο απλός κώδικας είναι όμορφος, συντηρήσιμος και γενικά πολύ καλύτερος για γρήγορο development. Αυτό είναι κάτι που εκτιμώ πολύ στις Zen principles.
Το πρόβλημα με τα Suspense boundaries
Οπότε τώρα είχα 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
Όταν χρησιμοποιείς 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"> περιέχει το κανονικό rendered περιεχόμενο. Το B:0 ταιριάζει με το S:0 με βάση τον αριθμό (index από 0).
Χωρίς JavaScript, οι μηχανές αναζήτησης (σε κοιτάω, Bing) και τα LLMs θα έβλεπαν μια σχεδόν άδεια σελίδα, μόνο με το template placeholder. Αυτό ακυρώνει εντελώς τον σκοπό του SSG!
Δεν είδα κάποιο καθαρό τρόπο να αφαιρέσω αυτά τα Suspense boundaries, οπότε η λύση μου ήταν να γράψω μερικά tests και μια resolveSuspenseBoundaries function που να τα αντικαθιστά. Ήταν πιο γρήγορο από το να κάνω parse το HTML και να εκτελώ το script με κάτι σαν JSDOM. Και, πιο σημαντικό, ήταν προϋπόθεση για αυτό που είχα στο μυαλό μου: ένα ωραίο, ευανάγνωστο site για μηχανές αναζήτησης / LLMs χωρίς JavaScript, αλλά με υποστήριξη για Suspense boundaries και hydration στον client.
Τεστάροντας τη μεταμόρφωση
Ξεκίνησα γράφοντας tests για τη μεταμόρφωση, παίρνοντας παραδείγματα από το DOM: τι είχα (με απενεργοποιημένο JavaScript) και τι ήθελα (με ενεργό JavaScript). Τα έδωσα σε ένα LLM και το άφησα να φτιάξει τα tests, κάτι στο οποίο είναι αρκετά καλό.
Αυτά τα tests βρίσκονται στο client/src/generators/ssr/renderRoute.test.ts και βεβαιώνονται ότι η μεταμόρφωση δουλεύει σωστά. Καλύπτουν:
- Απλή αντικατάσταση boundary (λίστα blog)
- Περίπλοκα boundaries με περιεχόμενο ανάμεσα στο template και το closing comment
- Πολλαπλά boundaries
- Boundaries χωρίς comment markers
- Edge cases
Αυτό το είδος "TDD" είναι πραγματικά χρήσιμο σε αυτή την περίπτωση, όπου έχεις αναμενόμενα inputs και outputs.
Αυτό δεν έχει καμία σχέση με το "TDD παντού επειδή το είπε ο Robert C. Martin" (που θα κόψει την ταχύτητα ανάπτυξης της ομάδας σου). ΔΕΝ πρέπει να χρησιμοποιείς TDD για UI ή για σημεία του κώδικά σου που αλλάζουν συνέχεια!
Η λύση: resolveSuspenseBoundaries
Τώρα που υπήρχαν τα tests, ζήτησα από το LLM να γράψει τη function resolveSuspenseBoundaries. Προτίμησα το cheerio για να αποφύγω την ευθραυστότητα των RegEx, παρόλο που αν χρησιμοποιούσα RegEx εδώ θα έκοβα περίπου 40% από τον χρόνο του SSG.
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};
}
Έτσι, αντί να βλέπουν σχεδόν άδειες σελίδες, οι μηχανές αναζήτησης και τα LLMs βλέπουν πλήρως rendered σελίδες.
Κι έτσι πλέον έχουμε SSG που δουλεύει μια χαρά χωρίς JavaScript!
<img alt="No JavaScript SSG για τα blogs του 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" }} />
Μακροπρόθεσμα, είναι πιθανό το React να αλλάξει τη μορφή του Suspense. Μπορεί να αφαιρέσω τον κώδικα για τη λύση των Suspense boundaries όταν βρω καλύτερη λύση για τις σελίδες που είναι lazy-loaded (και άρα χρειάζονται Suspense boundaries).
Στρατηγική για Hydration (Update: Αυτό πήρε 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 για μηχανές αναζήτησης και LLMs χωρίς να χρειάζεται οι developers να σκέφτονται τα Suspense boundaries.
Η πρόκληση
Το React hydration είναι τρομερά «κατά γράμμα»: αν το DOM δεν είναι ακριβώς όπως το περιμένει το React σε εκείνο το πρώτο render, παίρνεις ένα ωραίο αλλά σχεδόν άχρηστο error message στην κονσόλα, και το React πετάει τα πάντα και κάνει re-render από την αρχή. Ούτε καν ένα diff για να δεις τι πήγε στραβά!
Στη δική μας περίπτωση, το SSG έκανε τα πράγματα χειρότερα με δύο-τρεις τρόπους:
- Κάναμε post-process το HTML για να αφαιρέσουμε/λύσουμε τα React 18 streaming Suspense artifacts (που είναι τέλειο για bots).
- Ο client δεν είχε πάντα ακριβώς τα ίδια δεδομένα διαθέσιμα τη στιγμή (t = 0) όπως στο server render (SSG data, blog metadata κ.λπ.).
- Το i18n μας είναι "lazy" by default, που σημαίνει ότι μπορεί να λείπουν μεταφράσεις στο πρώτο render, εκτός αν καταγράψεις ποιες μεταφράσεις χρησιμοποιήθηκαν στο SSG και τις εισάγεις πριν κάνει render το React.
Τι δούλεψε (Αρχική προσέγγιση: Dehydration)
Στην αρχή δοκίμασα κάτι έξυπνο κι «ωραίο»: χρησιμοποίησα ένα command pattern για να καταγράφω τα commands που χρησιμοποιήθηκαν για να λυθούν τα Suspense boundaries του HTML και επέστρεφα τα αντίστροφα commands ώστε να μπορώ να επαναφέρω το HTML σε αυτό που χρειάζεται το React για hydration.
Η ελπίδα μου ήταν να στείλω πολύ λιγότερα bytes στο index.html με αυτή τη μέθοδο. Αλλά, όπως συμβαίνει συχνά με τις «έξυπνες» λύσεις, απέτυχε επειδή τα browsers τροποποιούν το HTML με διακριτικούς τρόπους, όπως το να προσθέσουν ή να αφαιρέσουν ένα ; ή /, κάτι που χάλαγε εντελώς τα indices για την αντικατάσταση.
Τεχνικά, πιθανότατα μπορείς να λάβεις υπόψη αυτές τις μικρές αλλαγές του browser, αλλά δεν υπήρχε περίπτωση να στείλω κάτι τόσο εύθραυστο σε production.
Αντί να προσπαθήσω να "γυρίσω πίσω" τη μεταμόρφωση των Suspense boundaries στο αρχικό streaming markup του React, έκανα κάτι υπερ-απλό:
Πακετάρισμα του αρχικού, άλυτου HTML σε ένα <script type="text">.
Αυτή η προσέγγιση "dehydration" δούλεψε, αλλά έφαγα μία έξτρα μέρα για να τη στείλω στο αρχείο και να τη βάλω με κάτι καλύτερο.
Η καλύτερη προσέγγιση: Critical path Suspense boundary replacement
Μετά την αρχική υλοποίηση, συνέχιζα να έχω κάποια θέματα με τα Suspense boundaries. Εκεί συνειδητοποίησα ότι υπήρχε μια πιο καθαρή, καλύτερη, πιο απλή λύση. Αντικατέστησα την προσέγγιση με dehydration με critical path Suspense boundary replacement, η οποία:
- Φορτώνει το critical path πριν από το hydration: Τα components που είχαν γίνει preload στο SSR εντοπίζονται και γίνονται preload στον client πριν κληθεί το
hydrateRoot
- Είναι πιο απλή στη συντήρηση: Δεν χρειάζονται React internals ή AST parsing (η προσέγγιση με dehydration χρειαζόταν parsing και επαναφορά HTML)
- Στέλνει λιγότερα bytes: Δεν πακετάρουμε πια την αρχική SSR απόκριση του React σε script tag
- Αποφεύγει πιθανό flash: Δεν χρειάζεται να κάνουμε dehydrate/rehydrate HTML, άρα δεν υπάρχει πιθανό οπτικό flash
Η υλοποίηση παρακολουθεί ποια lazy components έγιναν preload κατά το SSR (μέσω SSRLazyComponentTracker), περιλαμβάνει τα import paths τους στα hydration data και τα κάνει preload συγχρονισμένα πριν το hydration. Τα components στο critical path κάνουν render απευθείας χωρίς Suspense boundaries, οπότε ταιριάζουν ακριβώς με το SSR output.
Για όλα τα υπόλοιπα, κάνουμε το πρώτο client render να συμπεριφέρεται σαν SSR/SSG. Δηλαδή χρησιμοποιούμε τα ίδια inputs και τα κάνουμε διαθέσιμα συγχρονισμένα πριν το hydrateRoot. Αυτό γίνεται με bundling μέσω του "ssg-data" μας.
Πιο συγκεκριμένα, οι προσαρμογές ήταν:
Bundling των SSR inputs σε ένα text script
- Κατά το SSG, κάνουμε inject ένα
<script type="text/foony-ssg" id="foony-ssg-data">...</script> ακριβώς πριν από το Vite module entrypoint.
- Αυτό το script περιέχει:
html: το resolved HTML που στείλαμε τελικά στο static file
ssgData: το serialized SSGData που χρησιμοποίησε το SSR wrapper. Σκοπεύω να το κάνω Proxy ή κάτι παρόμοιο ώστε να συμπεριλαμβάνεται μόνο το data που όντως γίνεται access.
translationData: τα translation key-value blobs που χρησιμοποιήσαμε κατά το SSR
Inject αυτών των inputs λίγο πριν το hydration
- Στο
main.tsx, συγχρονισμένα:
- κάνουμε
#root.innerHTML ίσο με το serialized resolved HTML (ώστε το DOM να είναι ακριβώς αυτό που θα δει το hydration)
- τυλίγουμε την εφαρμογή με
SSGDataProvider ώστε τα components να έχουν το ίδιο SSGData στο πρώτο render
Άμεσο i18n με inject των translation values
- Καταγράφουμε τα πραγματικά translation objects που έγιναν access στο SSR και τα στέλνουμε στο SSG script.
- Στον client, τα κάνουμε inject κατευθείαν στο cache του
LocaleQueryer μέσω μιας ειδικής μεθόδου LocaleQueryer.inject(), ώστε οι μεταφράσεις να είναι άμεσα διαθέσιμες.
Και με αυτό, το πρώτο render έχει τα ίδια δεδομένα που είχε και το 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 και στο πρώτο client render (hydration), και μετά γυρίζει σε false μετά το mount. Components όπως UserBanner, Navbar και Dialog το χρησιμοποιούν ήδη για να αποφεύγουν hydration mismatches.
- Patch στο React για καλύτερα diffs
Ελπίζα ότι θα μπορούσα απλά να χρησιμοποιήσω το hydration-overlay. Αλλά δεν συντηρείται ενεργά, υποστηρίζει μόνο μέχρι React 18 και δεν ήταν production-ready. Οπότε έβαλα ένα LLM να κλωνοποιήσει το repo για έμπνευση και μετά έφτιαξε ένα μίνι hydration overlay μέσα σε λίγα λεπτά. Δεν χρειαζόμουν κάτι φανταχτερό, απλώς κάτι που να εμφανίζεται στο development για να βλέπω τι πάει στραβά.
Αυτό το καινούργιο overlay είναι πολύ βασικό, οπότε τα diffs δεν είναι τέλεια. Το React αφαιρεί comments, προσθέτει ; μετά από style attributes, αλλάζει 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 μας και του πρώτου client render για το 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" }} />
Με αριθμούς
Για να πάρεις μια αίσθηση του τι περιλάμβανε αυτή η υλοποίηση:
- 2 μέρες δουλειάς (από την αρχή μέχρι το πρώτο λειτουργικό SSG). Συνολικά λίγο πάνω από 24 ώρες ενώ ήμουν σε διακοπές.
- 4 μέρες δουλειάς για να συμπεριφέρεται το hydration σωστά, χωρίς async translation races ή το
useMediaQuery να τα χαλάει.
- 1 έξτρα μέρα για να αντικαταστήσω την προσέγγιση με dehydration με critical path Suspense boundary replacement (πιο απλό, λιγότερα bytes, κανένα πιθανό flash).
- ~200 γραμμές βασικού κώδικα SSG generation (
GenerateShellSsgFromSitemap.ts)
- ~120 γραμμές για τη λύση των Suspense boundaries (
resolveSuspenseBoundaries στο renderRoute.tsx) - Σημείωση: Αυτό μετά αντικαταστάθηκε από το critical path approach
- ~50 γραμμές SSR utilities (
isSSRMode.ts)
- ~100 γραμμές tests (
renderRoute.test.ts)
- ~150 γραμμές polyfills για SSR (
setupSSREnvironment)
- Ελάχιστες αλλαγές στα υπάρχοντα components (κυρίως προσθήκη
useIsSSRMode() checks)
Η λύση είναι ελαφριά και συντηρήσιμη. Δεν απαιτεί μετάβαση σε framework και δουλεύει με το υπάρχον React SPA μας.
Τι κρατάμε από όλο αυτό
Μερικές φορές μια bespoke λύση είναι καλύτερη
Δεν χρειάζεται κάθε πρόβλημα framework. Για το Foony, μια μικρή, bespoke λύση SSG ήταν η σωστή επιλογή. Είναι:
- Ελαφριά: Χωρίς βαριές εξαρτήσεις ή framework overhead
- Συντηρήσιμη: Απλός κώδικας που καταλαβαίνουμε
- Ευέλικτη: Εύκολο να την αλλάξεις και να την επεκτείνεις όταν χρειαστεί
- Συμβατή: Δουλεύει με το υπάρχον React SPA χωρίς μετάβαση
Το streaming SSR του React έχει τις ιδιοτροπίες του
Το renderToReadableStream του React είναι ωραίο για το χειρισμό του Suspense, αλλά έχει ιδιοτροπίες. Ακόμα κι αν κάνεις await stream.allReady, συνεχίζεις να παίρνεις Suspense boundaries στο output. Αυτό δεν είναι bug, είναι σχεδιαστικό, επειδή προορίζεται για streaming. Αλλά για SSG, εμείς θέλουμε πλήρως λυμένο HTML. Νιώθω πως είναι αποτυχία της ομάδας του React που δεν χειρίστηκε αυτό το σενάριο με έναν καθαρό τρόπο.
Η δική μου λύση ήταν να κάνω post-process το HTML και να λύσω τα boundaries. Δεν είναι ό,τι πιο κομψό, αλλά είναι γρήγορο και αρκετά ευέλικτο για τη χρήση που το θέλω.
Το TDD μπορεί να είναι χρήσιμο με LLMs
Η μεταμόρφωση HTML είναι επιρρεπής σε λάθη. Ένα μικρό bug και μπορείς να σπάσεις όλο το SSG output και τελικά την εμπειρία του χρήστη. Ζήτησα από ένα LLM να γράψει πλήρη tests (με δικά μου παραδείγματα) ώστε να βεβαιωθώ ότι η μεταμόρφωση δουλεύει σωστά.
Συμπέρασμα
Το SSG δουλεύει πλέον για το Foony. Οι σελίδες είναι πλήρως rendered για μηχανές αναζήτησης και LLMs, και η λύση είναι συντηρήσιμη και ελαφριά. Το hydration για τα SSG routes πήρε περισσότερο χρόνο απ’ όσο περίμενα (3 μέρες), και πέρασα άλλη μία μέρα για να αντικαταστήσω την αρχική προσέγγιση με dehydration με critical path Suspense boundary replacement. Η νέα προσέγγιση είναι πιο απλή στη συντήρηση, στέλνει λιγότερα bytes και αποτρέπει πιθανά visual flashes από dehydrate/rehydrate HTML.
Ακόμα δεν μπορώ να πιστέψω ότι χρειάστηκαν μόνο 2 μέρες για να υλοποιήσω μια bespoke λύση για SSG. Αλλά μερικές φορές, η σωστή λύση είναι η πιο απλή.
Στο μέλλον σκοπεύω να ολοκληρώσω το hydration matching και ίσως να κάνω patch το React για καλύτερο debugging. Προς το παρόν όμως, το Foony έχει πλήρως λειτουργικό SSG. Θα παρακολουθώ το Google Search Console και τα Bing Webmaster Tools τις επόμενες εβδομάδες για να δω τι αποτέλεσμα θα έχει αυτό στο SEO μας.