Rendu serveur et hydratation
Le même .spd produit un second module, et la différence n'est pas une variante du
premier : c'est un programme beaucoup plus simple.
Sur un serveur, rien ne change après le rendu. Un état est une variable, une dérivée une expression évaluée une fois, un effet n'a pas lieu d'être, un gestionnaire ne sera jamais appelé. Le script est donc recopié presque tel quel — pas un signal, pas une fermeture, pas une allocation — et le balisage devient une suite d'ajouts dans un tableau de chaînes.
Rendre
// serveur.ts
import { renderToString } from '@ajustor/speedster-runtime/server'
import Page from './Page.spd'
const html = renderToString(Page, { titre: 'Accueil' })
Il faut deux builds : un pour le serveur, un pour le navigateur.
// build.ts
import { speedster } from '@ajustor/speedster-plugin'
// Le module de rendu : ni DOM, ni réactivité.
await Bun.build({
entrypoints: ['src/serveur.ts'],
outdir: 'dist/serveur',
target: 'bun',
plugins: [speedster({ mode: 'server' })],
})
// Le module du navigateur.
await Bun.build({
entrypoints: ['src/client.ts'],
outdir: 'dist/client',
target: 'browser',
minify: true,
define: { 'process.env.NODE_ENV': '"production"' },
plugins: [speedster()],
})
Le build du navigateur écrit aussi une feuille, dist/client/client.css : c'est le
défaut du plugin (css: 'file'). Le build serveur n'en écrit pas, ses styles partent
dans le <head> par styleTags(). La section Les styles explique
lequel des deux servir.
Le rendu est synchrone et sans état partagé, donc réentrant : deux requêtes rendues en parallèle ne peuvent pas se marcher dessus.
Bun.serve({
fetch(request) {
const corps = renderToString(Page, { chemin: new URL(request.url).pathname })
return new Response(
`<!doctype html><html lang="fr"><head>…</head><body><div id="app">${corps}</div>` +
`<script type="module" src="/client.js"></script></body></html>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
)
},
})
Reprendre dans le navigateur
// src/client.ts
import { hydrate } from '@ajustor/speedster-runtime'
import Page from './Page.spd'
hydrate(Page, {
target: document.getElementById('app')!,
props: { chemin: location.pathname },
})
Le composant n'est pas remonté : il est branché sur les nœuds qui sont déjà là. Aucun élément n'est créé, aucun texte réécrit — les effets de rendu écrivent les mêmes valeurs, et rien ne touche au DOM tant que la valeur ne change pas.
Ce qui reste à faire est exactement ce que le serveur ne pouvait pas faire : créer le graphe réactif, poser les écouteurs, lancer les actions.
Passez les mêmes propriétés qu'au rendu. Si elles diffèrent, le premier effet corrigera l'affichage — ce qui fonctionne, mais annule le bénéfice.
Reprendre plus tard
Le HTML rendu est déjà lisible et déjà stylé : ce que l'hydratation ajoute, c'est l'interactivité. Rien n'oblige à la payer tout de suite, pour tout.
hydrate(PiedDePage, { target, when: 'idle' })
hydrate(Carte, { target, when: 'visible' })
hydrate(Panneau, { target, when: 'interaction' })
when |
Déclencheur |
|---|---|
now (défaut) |
À l'appel |
idle |
Dès que le navigateur n'a plus rien d'urgent |
visible |
Quand la zone entre dans l'écran |
interaction |
Au premier geste — le clic est rejoué |
interaction mérite un mot. Dans un navigateur, le geste qui réveille est
pointerdown, keydown ou focusin — tous précèdent l'action qui compte. La
reprise étant synchrone, le clic ou la frappe qui suit trouve un composant vivant.
click ne précède rien : c'est le dernier moment. Il est donc intercepté à la
capture, arrêté, puis redistribué sur sa cible une fois le composant repris.
Sans cela, le premier clic serait perdu ; sans l'arrêter, il serait traité deux fois.
Démonter avant que le moment ne vienne annule simplement la reprise.
C'est de quoi construire une page en îlots : plusieurs hydrate sur des zones
distinctes, chacune reprise quand elle devient utile.
Ce que le HTML rendu contient
<ul><!--[--><!--[--><li><!--$-->Écrire</li><!--]--><!--[--><li><!--$-->Mesurer</li><!--]--><!--]--></ul>
Deux marqueurs, qui disent ce que le gabarit du client ne peut pas deviner :
<!--[-->…<!--]-->encadrent une position ancrée — un bloc, un composant, un fragment rendu, du balisage brut. Vue du client, cette région entière tient une seule place dans la fratrie : celle qu'occupe le commentaire d'ancrage du gabarit.<!--$-->précède un texte dynamique. Sans lui, une valeur vide ne produirait aucun nœud, et la navigation par index compterait les frères de travers.
Chaque élément de liste est délimité pour lui-même : c'est ce qui permet de réordonner sans reconstruire dès la première interaction.
Ces marqueurs sont consommés à la lecture — il ne reste au bout du compte que les ancrages dont les blocs ont besoin. Ils sont invisibles au rendu, et se compressent très bien.
Pour une page que personne ne reprendra — un courriel, un flux, une page vraiment statique :
speedster({ mode: 'server', hydratable: false })
Le HTML est alors nu.
Ce qui n'est pas rendu
Rien de ce qui n'existe que dans un navigateur :
| Côté serveur | |
|---|---|
on: |
Rien |
use: |
Rien |
transition:, in:, out: |
Rien |
bind:value, bind:checked, bind:group, bind:open |
La valeur initiale du champ |
bind:this, dimensions, état d'un média |
Rien |
{#key} |
Le corps, sans clé — il n'y a rien à reconstruire au premier rendu |
bind:innerHTML rend son contenu ; bind:value sur un <textarea> devient le
contenu de l'élément, comme le veut HTML.
Pourquoi les deux rendus coïncident
Le client atteint ses positions dynamiques par navigation indicielle — troisième enfant, deuxième frère —, calculée à la compilation. Pour que ces indices tombent juste sur du HTML venu du serveur, les deux générateurs doivent produire exactement la même structure de nœuds : mêmes replis de texte, mêmes blancs supprimés, mêmes éléments sans contenu.
Ces règles vivent donc dans un seul module, partagé par les deux générateurs. Et la propriété est vérifiée directement : une série de tests rend chaque composant côté serveur, monte le même composant dans un DOM, et compare les deux balisages.
Les styles
Les composants rendus portent leur classe de portée, et leurs feuilles s'inlinent
dans le <head> :
import { renderToString, styleTags } from '@ajustor/speedster/server'
import Page from './Page.spd'
const corps = renderToString(Page)
const page = `<!doctype html>
<html lang="fr">
<head>${styleTags()}</head>
<body>${corps}</body>
</html>`
Chaque module de composant s'enregistre à son chargement, pas à son rendu :
styleTags() rend donc le CSS de l'arbre importé, ce qui est plus large que ce qu'un
rendu donné affiche. C'est le bon choix, et il est délibéré : une page qui montre un
{#if} après coup ne doit pas apparaître sans style.
Le <head> ou la feuille, pas les deux
Le build du navigateur écrit le même CSS dans un fichier (voir Où va le CSS). Une page rendue côté serveur a donc deux sources pour un même style, et doit en choisir une :
| Page | Ce qu'elle pose | Ce qu'on y gagne |
|---|---|---|
| Styles inlinés | styleTags() dans le <head>, sans lier client.css |
Aucune requête de plus avant le premier affichage |
| Feuille liée | <link rel="stylesheet" href="/client.css">, sans styleTags() |
Le style est mis en cache d'une page à l'autre |
Poser les deux ne casse rien, mais chaque règle est alors téléchargée deux fois. Le
module client, lui, n'installe rien en mode file : son style est dans la feuille,
pas dans le JavaScript.
En css: 'inject', le style revient dans le module client. Les balises de
styleTags() portent data-spd, et le navigateur les relève avant d'installer les
siennes : le CSS n'est pas posé deux fois. Sans cette marque, chaque composant
réinstallerait une feuille identique à la reprise.
La classe de portée est la même des deux côtés : elle vient du nom du composant et du contenu de son style, que les deux modules partagent. Une divergence rendrait le style présent au premier affichage puis absent après reprise — un défaut qui ne se voit qu'en production.
Internationalisation
Le catalogue fonctionne à l'identique. Le rendu étant synchrone, régler la langue juste avant de rendre est sûr même sur un serveur concurrent :
await locale.set(langueDemandee)
const html = renderToString(Page, props)
Rien ne peut s'intercaler entre les deux : renderToString ne rend jamais la main
en cours de route.
Le rendu en flux, et pourquoi il n'existe pas
Il n'aurait aucun effet ici. Le rendu est entièrement synchrone : rien ne peut partir avant qu'il ne soit fini, et envoyer par morceaux une chaîne déjà construite ne gagnerait pas un millième de seconde.
Un vrai flux suppose des composants capables de suspendre — d'attendre une donnée en laissant partir ce qui précède. C'est une autre architecture, pas une option à ajouter. Il vaut mieux ne pas le proposer que le proposer creux.
En attendant, le chargement de données se fait dans le navigateur, après
l'hydratation, ou côté serveur avant d'appeler renderToString.