renderToReadableStream
renderToReadableStream
fait le rendu d’un arbre React dans un flux Readable Web Stream.
const stream = await renderToReadableStream(reactNode, options?)
- Référence
- Utilisation
- Faire le rendu d’un arbre React sous forme HTML au moyen d’un flux Readable Web Stream
- Streamer plus de contenu au fil du chargement
- Spécifier le contenu de l’enveloppe
- Journaliser les plantages côté serveur
- Se rétablir après une erreur dans l’enveloppe
- Se rétablir après une erreur hors de l’enveloppe
- Définir le code de réponse HTTP
- Différencier la gestion selon l’erreur rencontrée
- Attendre que tout le contenu soit chargé pour les moteurs d’indexation web et la génération statique
- Abandonner le rendu côté serveur
Référence
renderToReadableStream(reactNode, options?)
Appelez renderToReadableStream
pour faire le rendu HTML d’un arbre React au moyen d’un flux Readable Web Stream.
import { renderToReadableStream } from 'react-dom/server';
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}
Côté client, appelez hydrateRoot
pour rendre interactif ce HTML généré côté serveur.
Voir d’autres exemples ci-dessous.
Paramètres
-
reactNode
: un nœud React dont vous souhaitez produire le HTML. Ça pourrait par exemple être un élément JSX tel que<App />
. C’est censé produire le document entier, de sorte que le composantApp
devrait produire la balise<html>
. -
options
optionnelles : un objet avec des options de streaming.bootstrapScriptContent
optionnel : s’il est fourni, ce code source sera placé dans une balise<script>
en ligne du flux de sortie.bootstrapScripts
optionnels : un tableau d’URL sous format texte pour des balises<script>
à émettre dans la page. Utilisez-le pour inclure le<script>
qui appellerahydrateRoot
. Vous pouvez vous en passer si vous ne souhaitez pas exécuter React côté client.bootstrapModules
optionnels : joue le même rôle quebootstrapScripts
, mais émet plutôt des balises<script type="module">
.identifierPrefix
optionnel : un préfixe textuel utilisé pour les ID générés paruseId
. Pratique pour éviter les conflits entre les ID au sein de racines multiples sur une même page. Doit être le même préfixe que celui passé àhydrateRoot
.namespaceURI
optionnel : l’URI textuel de l’espace de noms racine pour le flux. Par défaut, celui du HTML standard. Passez'http://www.w3.org/2000/svg'
pour SVG ou'http://www.w3.org/1998/Math/MathML'
pour MathML.nonce
optionnel : unnonce
textuel pour permettre les scripts avec une Content-Security-Policy contenantscript-src
.onError
optionnelle : une fonction de rappel déclenchée pour toute erreur serveur, qu’elle soit récupérable ou pas. Par défaut, ça fait juste unconsole.error
. Si vous l’écrasez pour journaliser les rapports de plantage, assurez-vous de continuer à appelerconsole.error
. Vous pouvez aussi vous en servir pour ajuster le code de réponse HTTP avant que l’enveloppe ne soit émise.progressiveChunkSize
optionnel : le nombre d’octets dans un segment d’envoi progressif. Apprenez-en davantage sur l’heuristique par défaut.signal
optionnel : unAbortSignal
qui vous permettra d’abandonner le rendu côté serveur pour faire le reste du rendu côté client.
Valeur renvoyée
renderToReadableStream
renvoie une promesse (Promise
) :
- Si le rendu de l’enveloppe réussit, cette promesse s’accomplira avec un flux Readable Web Stream.
- Si le rendu de l’enveloppe échoue, cette promesse sera rejetée. Utilisez ce scénario pour produire une enveloppe de secours.
Le flux obtenu expose une propriété complémentaire :
allReady
: une promesse qui s’accomplira lorsque le rendu sera complètement terminé, y compris l’enveloppe et tout le contenu additionnel. Vous pouvez faire unawait stream.allReady
avant de renvoyer une réponse pour les moteurs d’indexation web et la génération statique. Si vous optez pour cette approche, vous n’aurez pas de chargement progressif. Le flux contiendra le HTML final.
Utilisation
Faire le rendu d’un arbre React sous forme HTML au moyen d’un flux Readable Web Stream
Appelez renderToReadableStream
pour faire le rendu d’un arbre React sous forme HTML dans un flux Readable Web Stream :
import { renderToReadableStream } from 'react-dom/server';
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}
En plus du composant racine, vous devrez fournir une liste des chemins de <script>
de démarrage. Votre composant racine doit renvoyer le document intégral, donc la balise <html>
racine.
Il pourrait par exemple ressembler à ça :
export default function App() {
return (
<html lang="fr">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css"></link>
<title>Mon appli</title>
</head>
<body>
<Router />
</body>
</html>
);
}
React injectera le doctype et vos balises <script>
de démarrage dans le flux HTML résultant :
<!DOCTYPE html>
<html>
<!-- ... HTML de vos composants ... -->
</html>
<script src="/main.js" async=""></script>
Côté client, votre script de démarrage devrait hydrater le document
entier avec un appel à hydrateRoot
:
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App />);
Ça attachera les gestionnaires d’événements au HTML généré côté serveur pour le rendre interactif.
En détail
Les URL finales de ressources (telles que les fichiers JavaScript et CSS) contiennent souvent une empreinte générée par le build Par exemple, plutôt que styles.css
, vous pourriez vous retrouver avec styles.123456.css
. L’injection d’empreinte dans les noms de fichiers des ressources statiques garantit que chaque nouveau build d’une même ressource aura un nom différent (si son contenu a changé). C’est pratique pour mettre en place sans danger des stratégies de cache à long terme pour les ressources statiques : un fichier avec un nom donné ne changera jamais de contenu.
Seulement voilà, si vous ne connaissez pas les URL des ressources avant la fin du build, vous n’avez aucun moyen de les mettre dans votre code source. Par exemple, ça ne servirait à rien de coder en dur "/styles.css"
dans votre JSX, comme dans le code vu plus haut. Pour garder les noms finaux hors de votre code source, votre composant racine peut lire les véritables noms depuis une table de correspondance qui lui serait passée en prop :
export default function App({ assetMap }) {
return (
<html>
<head>
...
<link rel="stylesheet" href={assetMap['styles.css']}></link>
...
</head>
...
</html>
);
}
Côté serveur, faites le rendu de <App assetMap={assetMap} />
et passez-lui une assetMap
avec les URL des ressources :
// Vous devrez récupérer ce JSON depuis votre outil de build,
// par exemple en lisant son affichage résultat.
const assetMap = {
'styles.css': '/styles.123456.css',
'main.js': '/main.123456.js'
};
app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
bootstrapScripts: [assetMap['main.js']],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});
Dans la mesure où votre serveur fait désormais le rendu de <App assetMap={assetMap} />
, vous devez lui passer cette assetMap
côté client aussi, pour éviter toute erreur lors de l’hydratation. Vous pouvez la sérialiser et la passer au client comme ceci :
// Vous récupérereriez ce JSON depuis votre outil de build.
const assetMap = {
'styles.css': '/styles.123456.css',
'main.js': '/main.123456.js'
};
app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
// Attention : on peut stringify() ça sans danger parce que ça ne vient pas des utilisateurs.
bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
bootstrapScripts: [assetMap['main.js']],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});
Dans l’exemple ci-dessus, l’option bootstrapScriptContent
ajoute une balise <script>
en ligne complémentaire qui définit la variable globale window.assetMap
côté client. Ça permet au code client de lire la même assetMap
:
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App assetMap={window.assetMap} />);
À présent le client comme le serveur font le rendu d’App
avec la même prop assetMap
, on n’a donc pas d’erreur d’hydratation.
Streamer plus de contenu au fil du chargement
Le streaming permet à l’utilisateur de commencer à voir votre contenu avant même que toutes les données soient chargées côté serveur. Imaginez par exemple une page de profil qui affiche une image de couverture, une barre latérale avec des ami·e·s et leurs photos, et une liste d’articles :
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Posts />
</ProfileLayout>
);
}
Imaginez que le chargement des données de <Posts />
prenne du temps. Dans l’idéal, vous aimeriez afficher le reste du contenu de la page profil à l’utilisateur, sans le forcer à attendre d’abord les articles. Pour y parvenir, enrobez Posts
dans un périmètre <Suspense>
:
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}
Ça demande à React de commencer à streamer le HTML avant que Posts
ne charge ses données. React enverra dans un premier temps le HTML du contenu de secours (PostsGlimmer
) puis, quand Posts
aura fini de charger ses données, React enverra le HTML restant ainsi qu’une balise <script>
intégrée qui remplacera le contenu de secours avec ce HTML. Du point de vue de l’utilisateur, la page apparaîtra d’abord avec le PostsGlimmer
, qui sera ensuite remplacé par les Posts
.
Vous pouvez même imbriquer les périmètres <Suspense>
afin de créer des séquences de chargement avec une granularité plus fine :
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}
Dans cet exemple, React commencera à streamer la page encore plus tôt. Seuls ProfileLayout
et ProfileCover
devront d’abord terminer leur rendu, car ils ne sont enrobés dans aucun périmètre <Suspense>
. En revanche, si Sidebar
, Friends
ou Photos
ont besoin de charger des données, React enverra le HTML du contenu de secours BigSpinner
à leur place. Ainsi, au fil de la mise à disposition des données, davantage de contenu continuera à être affiché jusqu’à ce que tout soit enfin visible.
Le streaming n’a pas besoin d’attendre que React lui-même soit chargé dans le navigateur, ou que votre appli soit devenue interactive. Le contenu HTML généré côté serveur sera envoyé et affiché progressivement avant le chargement de n’importe quelle balise <script>
.
Apprenez-en davantage sur le fonctionnement du streaming HTML.
Spécifier le contenu de l’enveloppe
La partie de votre appli à l’extérieur de tout périmètre <Suspense>
est appelée l’enveloppe :
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}
Elle détermine le tout premier état de chargement que vos utilisateurs sont susceptibles de voir :
<ProfileLayout>
<ProfileCover />
<BigSpinner />
</ProfileLayout>
Si vous enrobez toute l’appli dans un périmètre <Suspense>
à la racine, l’enveloppe ne contiendra qu’un indicateur de chargement. Ce n’est hélas pas une expérience utilisateur agréable, car voir un gros indicateur de chargement à l’écran peut sembler plus lent et plus irritant que d’attendre un instant pour voir arriver la véritable mise en page. C’est pourquoi vous voudrez généralement positionner vos périmètres <Suspense>
de façon à ce que l’enveloppe donne une impression minimale mais complète — comme si elle représentait un squelette intégral de la page.
Un appel asynchrone à renderToReadableStream
s’accomplira avec un stream
lorsque l’enveloppe entière a fini son rendu. C’est généralement là que vous commencerez le streaming en créant puis renvoyant une réponse basée sur le stream
:
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}
Lorsque la réponse basée sur le stream
est renvoyée, les composants à l’intérieur des périmètres <Suspense>
peuvent encore être en train de charger leurs données.
Journaliser les plantages côté serveur
Par défaut, toutes les erreurs côté serveur sont affichées dans la console. Vous pouvez remplacer ce comportement pour journaliser vos rapports de plantage :
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}
Si vous fournissez votre propre implémentation pour onError
, n’oubliez pas de continuer à afficher les erreurs en console, comme ci-dessus.
Se rétablir après une erreur dans l’enveloppe
Dans l’exemple ci-dessous, l’enveloppe contient ProfileLayout
, ProfileCover
et PostsGlimmer
:
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}
Si une erreur survient lors du rendu de ces composants, React n’aura pas de HTML exploitable à envoyer au client. Enrobez votre appel à renderToReadableStream
dans un try...catch
pour envoyer en dernier recours un HTML de secours qui n’aurait pas besoin d’un rendu côté serveur :
async function handler(request) {
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Ça sent le pâté…</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}
Si une erreur est survenue lors de la génération de l’enveloppe, tant onError
que votre bloc catch
seront déclenchés. Utilisez onError
pour signaler l’erreur et votre bloc catch
pour envoyer le document HTML de secours. Votre HTML de secours n’est d’ailleurs pas nécessairement une page d’erreur. Vous pourriez plutôt proposer une enveloppe alternative qui affiche votre appli en mode 100% client.
Se rétablir après une erreur hors de l’enveloppe
Dans l’exemple qui suit, le composant <Posts />
est enrobé par <Suspense>
, ce qui signifie qu’il ne fait pas partie de l’enveloppe :
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}
Si une erreur survient au sein du composant Posts
ou d’un de ses enfants, React tentera automatiquement de retomber sur ses pieds :
- Il émettra le contenu de secours du périmètre
<Suspense>
le plus proche (PostsGlimmer
) dans le HTML. - Il « laissera tomber » le rendu côté serveur du contenu de
Posts
. - Lorsque le code JavaScript côté client aura fini de charger, React retentera le rendu de
Posts
, côté client.
Si la tentative de rendu de Posts
côté client plante aussi, React lèvera l’erreur côté client. Comme pour toutes les erreurs survenant lors du rendu, le périmètre d’erreur le plus proche détermine la façon dont l’erreur sera présentée à l’utilisateur. En pratique, ça signifie que l’utilisateur verra un indicateur de chargement jusqu’à ce que React soit certain que l’erreur n’est pas récupérable.
Si la tentative de rendu de Posts
côté client réussit, l’indicateur de chargement issu du serveur sera remplacé par le résultat du rendu côté client. L’utilisateur ne saura pas qu’une erreur est survenue côté serveur. En revanche, les fonctions de rappel onError
côté serveur et onRecoverableError
côté client seront déclenchées pour que vous soyez notifié·e de l’erreur.
Définir le code de réponse HTTP
Le streaming implique des compromis. Vous souhaitez commencer à streamer la page aussitôt que possible, pour que l’utilisateur voie du contenu plus tôt. Seulement, dès que vous commencer à streamer, vous ne pouvez plus définir le code de réponse HTTP.
En découpant votre appli avec d’un côté l’enveloppe (au-dessus de tous les périmètres <Suspense>
) et de l’autre le reste du contenu, vous avez déjà en partie résolu ce problème. Si l’enveloppe rencontre une erreur, ça exécutera votre bloc catch
qui vous permettra de définir le code de réponse pour l’erreur. Dans le cas contraire, vous savez que l’appli devrait être capable de se rétablir côté client, vous pouvez donc envoyer « OK ».
async function handler(request) {
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Ça sent le pâté…</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}
Si un composant hors de l’enveloppe (par exemple dans un périmètre <Suspense>
) lève une erreur, React n’arrêtera pas le rendu. Ça signifie que la fonction de rappel onError
sera déclenchée, mais votre code continuera à s’exécuter sans entrer dans le bloc catch
. C’est parce que React tentera de retomber sur ses pieds côté client, comme décrit plus haut.
Ceci étant dit, si vous préférez, vous pouvez prendre en compte la survenue d’une erreur pour ajuster votre code de réponse HTTP :
async function handler(request) {
try {
let didError = false;
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: didError ? 500 : 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Ça sent le pâté…</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}
Ça ne capturera que les erreurs hors de l’enveloppe qui sont survenues pendant le rendu initial de l’enveloppe, ce n’est donc pas exhaustif. Si vous estimez impératif de savoir si une erreur est survenue pour un contenu donné, vous pouvez le déplacer dans l’enveloppe.
Différencier la gestion selon l’erreur rencontrée
Vous pouvez créer vos propres sous-classes d’Error
et utiliser l’opérateur instanceof
pour déterminer quelle erreur est survenue. Vous pouvez par exemple définir une NotFoundError
sur-mesure et la lever depuis votre composant. À partir de là, vous pouvez sauvegarder l’erreur dans onError
pour différencier ensuite votre gestion en fonction du type d’erreur :
async function handler(request) {
let didError = false;
let caughtError = null;
function getStatusCode() {
if (didError) {
if (caughtError instanceof NotFoundError) {
return 404;
} else {
return 500;
}
} else {
return 200;
}
}
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
caughtError = error;
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: getStatusCode(),
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Ça sent le pâté…</h1>', {
status: getStatusCode(),
headers: { 'content-type': 'text/html' },
});
}
}
Gardez à l’esprit qu’une fois que vous avez émis l’enveloppe et commencé à streamer, vous ne pourrez plus changer le code de réponse HTTP.
Attendre que tout le contenu soit chargé pour les moteurs d’indexation web et la génération statique
Le streaming offre une meilleure expérience utilisateur parce que l’utilisateur peut voir le contenu au fur et à mesure de sa mise à disposition.
Ceci étant, lorsqu’un moteur d’indexation web visite votre page, ou si vous générez ces pages au moment du build, vous pourriez vouloir attendre que tout le contenu soit d’abord chargé pour ensuite produire le résultat HTML final, plutôt que de le révéler progressivement.
Vous pouvez attendre que tout le contenu soit disponible en attendant la promesse stream.allReady
:
async function handler(request) {
try {
let didError = false;
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
let isCrawler = // ... dépend de votre stratégie de détection de bot ...
if (isCrawler) {
await stream.allReady;
}
return new Response(stream, {
status: didError ? 500 : 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Ça sent le pâté…</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}
Un visiteur normal recevra le flux de contenu chargé progressivement. Un moteur d’indexation web recevra le résultat HTML final, une fois toutes les données chargées. Ça signifie cependant que ce moteur devra attendre toutes les données, dont certaines peuvent être lentes à charger ou causer une erreur. Selon la nature de votre appli, vous pourriez choisir d’envoyer juste l’enveloppe aux moteurs d’indexation web.
Abandonner le rendu côté serveur
Vous pouvez forcer le rendu côté serveur à « laisser tomber » au bout d’un certain temps :
async function handler(request) {
try {
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 10000);
const stream = await renderToReadableStream(<App />, {
signal: controller.signal,
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
// ...
React enverra les contenus de secours restants en HTML puis tentera de faire la fin du rendu côté client.