

1/1/1970
Πώς Υλοποίησα SSG σε 2 Μέρες
Γεια χαρά! Πριν από έναν χρόνο, νόμιζα ότι αυτό ήταν αδύνατο. Όμως μόλις τελείωσα την υλοποίηση Static Site Generation (SSG) για το Foony σε 2 μέρες, και είμαι αρκετά ενθουσιασμένος γι' αυτό. Δεν είναι η πρώτη μου φορά που προσπαθώ να λύσω το SSG για το Foony. Έχω εξετάσει το NextJS, Vike, Astro, Gatsby, και μερικές άλλες λύσεις στο παρελθόν. Είχα μάλιστα μια αποτυχημένη απόπειρα με το NextJS, αλλά συνάντησα δυσκολίες με την πολυπλοκότητα του SPA του Foony και τα χιλιάδες αρχεία. Η μετανάστευση θα ήταν εφιάλτης και θα έπαιρνε μήνες. Επίσης, θα πρόσθετε επιπλέον πολυπλοκότητα για όλους τους άλλους που δουλεύουν στο site, καθώς θα έπρεπε να μάθουν το NextJS και τις ιδιαιτερότητές του.
Ήθελα κάτι ελαφρύ και εύκολο στην υλοποίηση. Κάτι που θα μας επέτρεπε να συνεχίσουμε να γράφουμε κώδικα όπως πάντα, χωρίς να χρειάζεται να σκεφτόμαστε το SSG (με εξαίρεση το useMediaQuery, δεν υπάρχει πραγματικός τρόπος να αποφύγεις αυτό). Παρακάτω θα αναλύσω γιατί επέλεξα μια εξατομικευμένη λύση, τις συγκεκριμένες προκλήσεις που συνάντησα (ειδικά με τα Suspense boundaries του React), και πώς τις έλυσα.
Γιατί Όχι Στάνταρ Λύσεις;
Όταν πρωτοεξέτασα την προσθήκη SSG στο Foony, φυσικά σκέφτηκα το NextJS (βιομηχανικό στάνταρ), το Vike, και το Astro.
NextJS: Πάρα Πολλή Μετανάστευση
Το NextJS είναι ισχυρό, αλλά θα απαιτούσε μαζική μετανάστευση του υπάρχοντος React SPA του Foony. Έχουμε χιλιάδες αρχεία, σύνθετη λογική δρομολόγησης, και πολύ προσαρμοσμένη υποδομή. Η μετανάστευση στο NextJS θα σήμαινε:
- Επανεγγραφή ολόκληρου του συστήματος δρομολόγησής μας
- Αναδιάρθρωση του τρόπου που φορτώνουμε παιχνίδια και components
- Μήνες δουλειάς απλώς για να επιστρέψουμε στην ίδια λειτουργικότητα
- Πιθανές αλλαγές που θα έσπαγαν την εμπειρία των χρηστών
- Αλλαγή του τρόπου που χειριζόμαστε εικόνες
- Σημαντικά πιο αργοί χρόνοι build (πιθανώς 5-30 λεπτά. Δεν έχω συγκεκριμένα νούμερα να το υποστηρίξω, εκτός από αυτή τη συζήτηση 5 ετών στο GitHub)
- Όλη η ομάδα να μάθει κάτι καινούργιο (NextJS), και πιο αργή ταχύτητα ανάπτυξης για πάντα
- Μετανάστευση του κώδικα κάθε φορά που το NextJS αποφασίζει να κάνει breaking changes.
Δοκίμασα μάλιστα μια αποτυχημένη απόπειρα με το NextJS, αλλά γρήγορα συνειδητοποίησα ότι το κόστος μετανάστευσης ήταν πολύ υψηλό. Η πολυπλοκότητα δεν άξιζε.
Vike: Παρόμοια Πολυπλοκότητα
Το Vike (πρώην vite-plugin-ssr) είχε παρόμοια προβλήματα. Αν και είναι πιο ευέλικτο από το NextJS, θα απαιτούσε εξακολουθήση σημαντική αναδιάρθρωση της κωδικής μας βάσης. Η καμπύλη μάθησης και η προσπάθεια μετανάστευσης δεν δικαιολογούσαν τα οφέλη.
Astro: Λάθος Αρχιτεκτονική
Το Astro είναι υπέροχο για sites με πολύ περιεχόμενο, αλλά το Foony είναι μια σύνθετη πλατφόρμα multiplayer παιχνιδιών. Χρειαζόμαστε real-time updates, συνδέσεις WebSocket, και δυναμικά React components. Η αρχιτεκτονική του Astro απλά δεν ταιριάζει με αυτό που χτίζουμε.
Η Λύση: Εξατομικευμένο SSG
Παίρνοντας θάρρος από την προσέγγιση "ψεύτικου SSG" που υλοποίησα πριν από λίγες μέρες μετά το i18n, κατέληξα σε μια μικρή, ελαφριά, εξατομικευμένη λύση για το SSG του Foony.
Η προσέγγιση "ψεύτικου SSG" μου περιλάμβανε την εξαγωγή του περιεχομένου των blog posts από σελίδες με blog posts (διαδρομές
/postsκαι σελίδες παιχνιδιών), και την τοποθέτησή τους ακριβώς εκεί όπου θα τις απέδιδε ο client, ειδικά για τις μηχανές αναζήτησης και τα LLMs ώστε να βοηθήσουν στην κατανόηση του Foony. Επίσης εφάρμοζε ld+json schema και κάποια μικρά SEO πράγματα.
Η προσέγγιση είναι απλή:
- Χτίσιμο πάνω στο υπάρχον React SPA: Δεν χρειάζεται μετανάστευση, απλώς προσθήκη παραγωγής SSG στο build time.
- Χρήση του
renderToReadableStream: Το streaming SSR API του React 18 χειρίζεται το Suspense εγγενώς. - Παραγωγή στατικών αρχείων HTML: Pre-render των διαδρομών στο build time και σερβίρισμά τους ως στατικά αρχεία, χρησιμοποιώντας τον SitemapGenerator μας για να πάρουμε λίστα διαδρομών.
- Ελάχιστες αλλαγές στην υπάρχουσα κωδική βάση: Τα περισσότερα components δουλεύουν ως έχουν.
Η βασική υλοποίηση βρίσκεται στο client/src/generators/GenerateShellSsgFromSitemap.ts. Διαβάζει ένα sitemap, αποδίδει κάθε διαδρομή χρησιμοποιώντας το renderToReadableStream του React, και γράφει το HTML σε στατικά αρχεία. Απλό, όπως μου αρέσει!
Αυτό αποδείχθηκε αρκετά γρήγορο επίσης. Περίπου 2.800 διαδρομές αποδόθηκαν σε 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" }} />
Θα μπορούσα να μιλάω συνέχεια για την απλότητα. Ακόμα και αν δεν θα σου εξασφαλίσει προαγωγή σε μεγάλες εταιρείες λόγω "έλλειψης πολυπλοκότητας", ο απλός κώδικας είναι όμορφος, συντηρήσιμος, και γενικά πολύ καλύτερος για την ταχύτητα ανάπτυξης. Αυτό είναι κάτι που θαυμάζω πραγματικά στις αρχές 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" και σχεδιασμένο να περνάει στους clients καθώς λαμβάνονται τα bytes.
Τι Παράγει το React
Όταν χρησιμοποιείς το renderToReadableStream με Suspense, το React παράγει HTML σαν αυτό:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Πραγματικό περιεχόμενο εδώ -->
</div>
...
<script>/*Script που αντικαθιστά τα suspense boundaries*/</script>
Το <template id="B:0"> είναι ένα placeholder όπου πρέπει να πάει το περιεχόμενο. Το <div hidden id="S:0"> περιέχει το πραγματικό αποδοθέν περιεχόμενο. Το B:0 ταιριάζει με το S:0 βάσει αριθμού (0-based index).
Χωρίς JavaScript, οι μηχανές αναζήτησης (σε σένα μιλάω, Bing) και τα LLMs θα έβλεπαν μια σχεδόν κενή σελίδα με μόνο το template placeholder. Αυτό αναιρεί όλο τον σκοπό του SSG!
Δεν έβλεπα κανέναν καθαρό τρόπο να αφαιρέσω αυτά τα Suspense boundaries, οπότε η λύση μου ήταν να γράψω κάποια tests και μια συνάρτηση resolveSuspenseBoundaries για να τα ανταλλάσσω. Αυτό ήταν γρηγορότερο από το να αναλύσω το HTML και να εκτελώ το script με κάτι σαν το JSDOM. Και, πιο σημαντικά, ήταν προϋπόθεση για αυτό που σχεδίαζα: ένα ωραίο, ευανάγνωστο site για μηχανές αναζήτησης / LLMs χωρίς JavaScript, αλλά με υποστήριξη για Suspense boundaries και hydration στον client.
Δοκιμή του Μετασχηματισμού
Ξεκίνησα γράφοντας tests για τον μετασχηματισμό, παίρνοντας κάποια παραδείγματα στο DOM από αυτό που είχα (JavaScript απενεργοποιημένο), και αυτό που ήθελα (JavaScript ενεργοποιημένο). Τα έδωσα σε ένα LLM και του ανέθεσα τη δημιουργία των tests, κάτι στο οποίο είναι αρκετά καλό.
Αυτά τα tests βρίσκονται στο client/src/generators/ssr/renderRoute.test.ts και διασφαλίζουν ότι ο μετασχηματισμός λειτουργεί σωστά. Τα tests καλύπτουν:
- Απλή αντικατάσταση boundary (λίστα blog)
- Σύνθετα boundaries με περιεχόμενο μεταξύ template και κλειστού σχόλιου
- Πολλαπλά boundaries
- Boundaries χωρίς δείκτες σχολίων
- Edge cases
Αυτό το είδος "TDD" είναι στην πραγματικότητα αρκετά χρήσιμο για αυτή την περίπτωση όπου έχεις αναμενόμενες εισόδους και εξόδους.
Αυτό δεν πρέπει να συγχέεται με το "TDD για όλα επειδή το είπε ο Robert C. Martin" (που θα επιβραδύνει την ταχύτητα ανάπτυξης της ομάδας σου). ΔΕΝ πρέπει να χρησιμοποιείς TDD για UI ή περιοχές του κώδικα που αλλάζουν συνεχώς!
Η Λύση: resolveSuspenseBoundaries
Τώρα που τα tests ήταν στη θέση τους, ζήτησα από το 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};
}
Αυτό διασφαλίζει ότι αντί να βλέπουν μια σχεδόν κενή σελίδα, οι μηχανές αναζήτησης και τα LLMs βλέπουν μια πλήρως αποδοθείσα σελίδα.
Τώρα έχουμε SSG που λειτουργεί καλά χωρίς JavaScript!
<img alt="SSG χωρίς JavaScript για τα 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 μόλις έχω καλύτερη λύση για τις σελίδες που φορτώνονται lazy (και επομένως απαιτούν Suspense boundaries).
Στρατηγική Hydration (Update: Αυτό Πήρε 3 Μέρες + 1 Επιπλέον Μέρα)
Το hydration είναι δύσκολο. Το ήξερα. Αλλά, μετά από λίγη δουλειά, κατάφερα να το κάνω να δουλέψει!
Συνολικός χρόνος για hydration: 3 μέρες, συν 1 επιπλέον μέρα για να αντικαταστήσω την προσέγγιση dehydration.
Το πιο δύσκολο μέρος ήταν απλώς να φτάσω σε εκείνο το πρώτο ελάχιστο, λειτουργικό hydrate. Μόλις κατάφερα να αποδώσω ένα "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.
Η Πρόκληση
Το hydration του React είναι εξαιρετικά κυριολεκτικό: αν το DOM δεν μοιάζει με αυτό που περιμένει το React για εκείνο το πρώτο render, παίρνεις αυτό το ωραίο, σχεδόν άχρηστο μήνυμα σφάλματος στην κονσόλα σου, και το React πετάει τα πάντα και κάνει re-render από την αρχή. Ούτε καν ένα diff για να σου πει τι πήγε στραβά!
Στην περίπτωσή μας, το SSG το έκανε χειρότερο με μερικούς τρόπους:
- Επεξεργαζόμασταν εκ των υστέρων το HTML για να αφαιρέσουμε/επιλύσουμε streaming Suspense artifacts του React 18 (που είναι υπέροχο για bots).
- Ο client δεν είχε πάντα διαθέσιμα ακριβώς τα ίδια δεδομένα στον χρόνο (t = 0) με αυτά που είχε το server render (SSG data, blog metadata κ.λπ.).
- Το i18n μας είναι "lazy" από προεπιλογή, που σημαίνει ότι μπορεί να λείπουν μεταφράσεις για το πρώτο render εκτός αν καταγράψεις ποιες μεταφράσεις χρησιμοποιήθηκαν για το SSG και τις εισάγεις πριν αποδώσει το React.
Τι Δούλεψε (Αρχική Προσέγγιση: Dehydration)
Στην αρχή, δοκίμασα κάτι έξυπνο και χαριτωμένο: χρησιμοποίησα ένα command pattern για να καταγράφω τις εντολές που χρησιμοποιούνταν για την επίλυση των Suspense boundaries του HTML, και επέστρεφα τις αντίστροφες εντολές μετασχηματισμού ώστε να μπορώ να επαναφέρω το HTML σε αυτό που χρειάζεται το React για το hydration.
Η ελπίδα μου ήταν ότι θα μπορούσα να στείλω πολύ λιγότερα bytes στο index.html με αυτή τη μέθοδο εντολών. Αλλά, όπως οι περισσότερες έξυπνες λύσεις, αυτό απέτυχε επειδή οι browsers τροποποιούν το HTML με λεπτούς τρόπους, όπως αφαίρεση ή προσθήκη ενός ; ή /, που χαλούσε τους δείκτες αντικατάστασης.
Τεχνικά θα μπορούσες πιθανώς να λάβεις υπόψη αυτές τις λεπτές αλλαγές browser, αλλά δεν επρόκειτο να στείλω σε production κάτι τόσο εύθραυστο.
Αντί να προσπαθώ να "αντιστρέψω" τον μετασχηματισμό Suspense-boundary πίσω στο streaming markup του React, έκανα κάτι πολύ απλό:
Ομαδοποίηση του αρχικού, μη επιλυμένου HTML σε ένα <script type="text">.
Αυτή η προσέγγιση "dehydration" δούλεψε, αλλά πέρασα μια επιπλέον μέρα αντικαθιστώντας την με μια καλύτερη λύση.
Η Καλύτερη Προσέγγιση: Αντικατάσταση Suspense Boundary Κρίσιμης Διαδρομής
Μετά την αρχική υλοποίηση, αντιμετώπιζα ακόμα κάποια προβλήματα με τα Suspense boundaries. Τότε συνειδητοποίησα ότι υπήρχε μια καθαρότερη, καλύτερη, απλούστερη λύση. Αντικατέστησα την προσέγγιση dehydration με αντικατάσταση Suspense boundary κρίσιμης διαδρομής, η οποία:
- Φορτώνει την κρίσιμη διαδρομή πριν το hydration: Components που έγιναν preload κατά τη διάρκεια του SSR αναγνωρίζονται και γίνονται preload στον client πριν κληθεί το
hydrateRoot
- Είναι πιο απλή στη συντήρηση: Δεν απαιτούνται React internals ή AST parsing (η προσέγγιση dehydration χρειαζόταν να αναλύει και να επαναφέρει HTML)
- Στέλνει λιγότερα bytes: Δεν ομαδοποιούμε πλέον την αρχική απάντηση SSR από το React σε ένα script tag
- Αποτρέπει ένα πιθανό flash: Δεν χρειάζεται dehydrate/rehydrate HTML, εξαλείφοντας ένα πιθανό οπτικό flash
Η υλοποίηση παρακολουθεί ποια lazy components έγιναν preload κατά τη διάρκεια του SSR (μέσω του SSRLazyComponentTracker), συμπεριλαμβάνει τις διαδρομές import τους στα δεδομένα hydration, και τα κάνει preload σύγχρονα πριν το hydration. Τα components κρίσιμης διαδρομής αποδίδονται απευθείας χωρίς Suspense boundaries, ταιριάζοντας ακριβώς το SSR output.
Για όλα τα άλλα, κάνουμε το πρώτο client render να λειτουργεί ως SSR/SSG. Αυτό σημαίνει χρήση των ίδιων εισόδων, και διαθεσιμότητα αυτών των εισόδων σύγχρονα πριν το hydrateRoot. Αυτό γίνεται μέσω ομαδοποίησης μέσω του "ssg-data" μας.
Συγκεκριμένα, οι ρυθμίσεις ήταν:
Ομαδοποίηση εισόδων SSR σε ένα μοναδικό text script
- Κατά τη διάρκεια του SSG, εισάγουμε ένα
<script type="text/foony-ssg" id="foony-ssg-data">...</script> ακριβώς πριν από το module entrypoint του Vite.
- Αυτό το script περιέχει:
html: το επιλυμένο HTML που πραγματικά στείλαμε στο στατικό αρχείο
ssgData: το serialized SSGData που χρησιμοποιείται από το SSR wrapper. Σχεδιάζω να το ενημερώσω σε Proxy ή κάτι παρόμοιο ώστε να συμπεριλαμβάνονται μόνο τα δεδομένα στα οποία έχει γίνει πρόσβαση.
translationData: τα blobs μετάφρασης key-value που αγγίξαμε κατά τη διάρκεια του SSR
Εισαγωγή αυτών των εισόδων ακριβώς πριν το hydration
- Στο
main.tsx, σύγχρονα:
- θέτουμε το
#root.innerHTML στο serialized επιλυμένο HTML (ώστε το DOM να είναι ακριβώς αυτό που βλέπει το hydration)
- τυλίγουμε την εφαρμογή σε
SSGDataProvider ώστε τα components να έχουν τα ίδια SSGData στο πρώτο render
Κάνουμε το i18n άμεσο εισάγοντας τιμές μετάφρασης
- Καταγράφουμε τα πραγματικά αντικείμενα μετάφρασης στα οποία έγινε πρόσβαση κατά τη διάρκεια του SSR και τα στέλνουμε στο SSG script.
- Στον client, τα εισάγουμε απευθείας στο 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.
- Patch του React για καλύτερα diffs
Έλπιζα ότι θα μπορούσα απλώς να χρησιμοποιήσω το hydration-overlay. Αλλά δεν συντηρείται ενεργά, υποστηρίζεται μόνο μέχρι το React 18, και δεν ήταν production-ready. Έτσι έβαλα ένα LLM να κλωνοποιήσει το repo για έμπνευση, και μετά δημιούργησε ένα ελάχιστο hydration overlay σε λίγα λεπτά. Δεν χρειαζόμουν τίποτα φανταχτερό, απλώς κάτι που θα εμφανιζόταν κατά την ανάπτυξη ώστε να μπορώ να καταλάβω πού πήγαν στραβά τα πράγματα.
Αυτό το νέο overlay είναι πολύ βασικό, οπότε τα diffs δεν είναι ακριβώς τέλεια. Το React αφαιρεί σχόλια, προσθέτει ; μετά από style attributes, τροποποιεί whitespace, και μερικά άλλα μικρά πράγματα τα οποία το overlay μας δεν λαμβάνει υπόψη (ακόμα). Το overlay μας περιλαμβάνει επίσης HTML σχόλια τα οποία το 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 client first-page 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 χωρίς ασύγχρονα translation races ή το
useMediaQuery να χαλάει τα πράγματα.
- 1 επιπλέον μέρα για να αντικαταστήσω την προσέγγιση dehydration με αντικατάσταση Suspense boundary κρίσιμης διαδρομής (πιο απλή, λιγότερα bytes, χωρίς πιθανό flash).
- ~200 γραμμές βασικού κώδικα παραγωγής SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 γραμμές επίλυσης Suspense boundary (
resolveSuspenseBoundaries στο renderRoute.tsx). Σημείωση: Αυτό αντικαταστάθηκε αργότερα από την προσέγγιση κρίσιμης διαδρομής
- ~50 γραμμές εργαλείων SSR (
isSSRMode.ts)
- ~100 γραμμές tests (
renderRoute.test.ts)
- ~150 γραμμές polyfills για SSR (
setupSSREnvironment)
- Ελάχιστες αλλαγές σε υπάρχοντα components (κυρίως προσθήκη ελέγχων
useIsSSRMode())
Η λύση είναι ελαφριά και συντηρήσιμη. Δεν απαιτεί μετανάστευση framework, και λειτουργεί με το υπάρχον React SPA μας.
Βασικά Συμπεράσματα
Μερικές Φορές μια Εξατομικευμένη Λύση Είναι Καλύτερη
Δεν χρειάζεται κάθε πρόβλημα ένα framework. Για το Foony, μια μικρή, εξατομικευμένη λύση SSG ήταν η σωστή επιλογή. Είναι:
- Ελαφριά: Χωρίς βαριές εξαρτήσεις ή overhead framework
- Συντηρήσιμη: Απλός κώδικας που καταλαβαίνουμε
- Ευέλικτη: Εύκολη στην τροποποίηση και επέκταση όπως χρειάζεται
- Συμβατή: Δουλεύει με το υπάρχον React SPA μας χωρίς μετανάστευση
Το Streaming SSR του React Έχει Ιδιαιτερότητες
Το renderToReadableStream του React είναι ωραίο για το χειρισμό Suspense, αλλά έχει ιδιαιτερότητες. Ακόμα και με await stream.allReady, εξακολουθείς να παίρνεις Suspense boundaries στο output. Αυτό δεν είναι bug, είναι σχεδιαστική επιλογή για streaming. Αλλά για SSG, χρειαζόμαστε πλήρως επιλυμένο HTML. Φαίνεται σαν αποτυχία της ομάδας του React να μη χειριστεί αυτό το σενάριο με καθαρό τρόπο.
Η λύση μου ήταν να επεξεργάζομαι μετέπειτα το HTML και να επιλύω boundaries. Δεν είναι όμορφο, αλλά είναι γρήγορο και αρκετά ευέλικτο για την περίπτωσή μου.
Το TDD Μπορεί Να Είναι Χρήσιμο Για LLMs
Ο μετασχηματισμός HTML είναι επιρρεπής σε σφάλματα. Ένα μικρό bug και θα μπορούσες να σπάσεις ολόκληρο το SSG output και να χαλάσεις την εμπειρία του τελικού χρήστη. Έβαλα ένα LLM να γράψει εκτενή tests (με τη συμβολή μου) για να διασφαλίσω ότι ο μετασχηματισμός λειτουργεί σωστά.
Συμπέρασμα
Το SSG λειτουργεί τώρα για το Foony. Οι σελίδες αποδίδονται πλήρως για τις μηχανές αναζήτησης και τα LLMs, και η λύση είναι συντηρήσιμη και ελαφριά. Το hydration για τις διαδρομές SSG πήρε περισσότερο από όσο περίμενα (3 μέρες), και πέρασα μια επιπλέον μέρα αντικαθιστώντας την αρχική προσέγγιση dehydration με αντικατάσταση Suspense boundary κρίσιμης διαδρομής. Η νέα προσέγγιση είναι πιο απλή στη συντήρηση, στέλνει λιγότερα bytes, και αποτρέπει πιθανά οπτικά flashes από dehydration/rehydration HTML.
Είμαι ακόμα σοκαρισμένος που πήρε μόνο 2 μέρες να υλοποιήσω μια εξατομικευμένη λύση για SSG. Αλλά μερικές φορές η σωστή λύση είναι η πιο απλή.
Η μελλοντική δουλειά περιλαμβάνει την ολοκλήρωση του ταιριάσματος hydration και πιθανώς patching του React για καλύτερο debugging. Αλλά προς το παρόν, το Foony έχει λειτουργικό SSG. Θα παρακολουθώ το Google Search Console και τα Bing Webmaster Tools τις επόμενες εβδομάδες για να δω τι επίδραση θα έχει στο SEO μας.