Streamer son rendu React Server Side (SSR)

Les applications React isomorphiques sont de plus en plus utilisées en production. Globalement, l'idée est d'exécuter le même code coté client et serveur.

Ainsi on va faire le premier rendu de l'application coté serveur, pour envoyer une vue toute faite au client. Le code de l'application est ensuite exécuté à nouveau coté client et prend la main sur ce premier rendu statique.

Le rendu synchrone

En React, l'implémentation ressemble souvent à ça:

server.get('/', (req, res) => {
  const applicationMarkup = ReactDOMServer.renderToString(<App />)
  res.send(template, ({
    body: applicationMarkup,
    title: 'Hello World from the server'
  }))
})

À chaque appel a /, le serveur va faire un rendu de l'application, et l'insérer dans le template qui contient le dom de base(head, body, et la div ou incorporer l'app React).

Hélas avec cette approche, le rendu est fait de manière synchrone. C’est-à-dire que pendant tout le temps ou ReactDOMServer.renderToString s'exécute, notre serveur nodeJS ne fera rien d'autre ! Si votre rendu prend un peu de temps (ex 250 ms), cela veut dire que votre serveur ne peut répondre qu'a 4 requêtes de ce type par seconde ! Pire encore, toutes les autres requêtes sont bloquées pendant ce rendu.

Si vous ne voyez toujours pas le problème, je vous invite à lire mon article sur l'asynchrone en nodeJS.

Passons donc à la solution...

Rendre le rendu asynchrone

Pour permettre à nodeJS de gérer notre rendu et en même temps de continuer à répondre aux autres requêtes, on doit donc faire le rendu de manière asynchrone.

Heureusement, React inclue cette fonctionnalité dans son API ReactDOMServer via la fonction renderToNodeStream.

Comme son nom l'indique, react va faire le rendu vers un flux (stream) de nodeJS. Les flux en nodeJS étant par default asynchrones, on ne bloque plus l'event loop et les autres actions pendant un tel calcul.

L'implémentation non bloquante ressemble donc à ça:

server.get('/', (req, res) => {
  //Create a promise that resolve on render terminate
  const render = () => new Promise((resolve, reject) => {
    const body = [];
    const bodyStream = ReactDOMServer.renderToNodeStream(reactComponent); 
    bodyStream.on('data', (chunk) => { 
        body.push(chunk.toString());   // Each time react rendered some nodes, we push them
    }); 
    bodyStream.on('error', (err) => {
        reject(err);  // Rendering errored
    });
    bodyStream.on('end', () => {   
        resolve(body.join(''));  // Rendering finished, join all chunks and resolve
    });
  }

  render().then(
    applicationMarkup => {
      res.send(template, ({
        body: applicationMarkup,
        title: 'Hello World from the server'
      }))
    },
    error => {
      console.error(error)
      res.send(someErrorTemplate)
    }
  )
})

Et voila ! Notre rendu asynchrone ne bloque plus NodeJs, et une fois ce rendu terminé on renvoie la réponse au client comme on le faisait avant !

Je n'ai pas de benchmark à proposer, car le gain en performance de ce genre de code est difficile à apprécier en développement lorsque l'on ne fait que quelques requêtes en simultané.

En revanche, cette approche correspond totalement à la philosophie asynchrone de node. Et en plus comme on réduit légèrement et volontairement la performance de cette route pour augmenter la performance de toutes les autres le gain en production est non négligeable !

Qu'avez vous pensé de cet article?