Chrome sans interface graphique: une réponse aux sites JavaScript avec rendu côté serveur

Addy Osmani
Addy Osmani

Découvrez comment utiliser les API Puppeteer pour ajouter des fonctionnalités de rendu côté serveur (SSR) à un serveur Web Express. Le meilleur est que votre application nécessite de très légères modifications du code. Le serveur headless s'occupe de tout.

En quelques lignes de code, vous pouvez générer le code côté serveur de n'importe quelle page et obtenir son balisage final.

import puppeteer from 'puppeteer';

async function ssr(url) {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();
  return html;
}

Pourquoi utiliser Chrome sans interface utilisateur ?

Chrome headless peut vous intéresser si:

Certains frameworks comme Preact sont fournis avec des outils qui gèrent le rendu côté serveur. Si votre framework dispose d'une solution de prérendu, utilisez-la plutôt que d'intégrer Puppeteer et Chrome sans interface utilisateur à votre workflow.

Explorer le Web moderne

Les robots d'exploration des moteurs de recherche, les plates-formes de partage sur les réseaux sociaux et même les navigateurs se sont traditionnellement appuyés exclusivement sur le balisage HTML statique pour indexer le Web et afficher le contenu. Le Web moderne est devenu bien différent. Les applications basées sur JavaScript sont là pour durer, ce qui signifie que, dans de nombreux cas, nos contenus peuvent être invisibles pour les outils d'exploration.

Googlebot, notre robot d'exploration de la recherche, traite le code JavaScript tout en s'assurant de ne pas nuire à l'expérience des internautes qui consultent le site. Vous devez tenir compte de certaines différences et limites lors de la conception de vos pages et applications afin de permettre aux robots d'exploration d'accéder au contenu et de l'afficher.

Précharger des pages

Tous les robots d'exploration comprennent le langage HTML. Pour nous assurer que les robots d'exploration peuvent indexer JavaScript, nous avons besoin d'un outil qui:

  • Savoir exécuter tous les types de JavaScript modernes et générer du code HTML statique
  • Restent à jour à mesure que le Web ajoute des fonctionnalités.
  • S'exécute avec peu ou pas de modifications de code dans votre application.

Cela vous convient-il ? Cet outil est le navigateur ! Chrome sans tête ne se soucie pas de la bibliothèque, du framework ou de la chaîne d'outils que vous utilisez.

Par exemple, si votre application est créée avec Node.js, Puppeteer vous permet de travailler facilement avec Chrome sans interface utilisateur.

Commencez par une page dynamique qui génère son code HTML avec JavaScript:

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
function renderPosts(posts, container) {
  const html = posts.reduce((html, post) => {
    return `${html}
      <li class="post">
        <h2>${post.title}</h2>
        <div class="summary">${post.summary}</div>
        <p>${post.content}</p>
      </li>`;
  }, '');

  // CAREFUL: this assumes HTML is sanitized.
  container.innerHTML = `<ul id="posts">${html}</ul>`;
}

(async() => {
  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

Fonction SSR

Ensuite, prenez la fonction ssr() précédente et renforcez-la un peu:

ssr.mjs

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();

async function ssr(url) {
  if (RENDER_CACHE.has(url)) {
    return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  }

  const start = Date.now();

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  try {
    // networkidle0 waits for the network to be idle (no requests for 500ms).
    // The page's JS has likely produced markup by this point, but wait longer
    // if your site lazy loads, etc.
    await page.goto(url, {waitUntil: 'networkidle0'});
    await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
  } catch (err) {
    console.error(err);
    throw new Error('page.goto/waitForSelector timed out.');
  }

  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  const ttRenderMs = Date.now() - start;
  console.info(`Headless rendered page in: ${ttRenderMs}ms`);

  RENDER_CACHE.set(url, html); // cache rendered page.

  return {html, ttRenderMs};
}

export {ssr as default};

Voici les principales modifications:

  • Ajout de la mise en cache. La mise en cache du code HTML généré est le meilleur moyen d'accélérer les temps de réponse. Lorsque la page est à nouveau demandée, vous évitez d'exécuter Headless Chrome. Je reviendrai sur d'autres optimisations plus tard.
  • Ajoutez une gestion des erreurs de base si le chargement de la page expire.
  • Ajoutez un appel à page.waitForSelector('#posts'). Cela garantit que les posts existent dans le DOM avant que nous ne vidangions la page sérialisée.
  • Ajoutez la science. Enregistrez le temps nécessaire à l'interface headless pour afficher la page et renvoyez le temps de rendu avec le code HTML.
  • Collez le code dans un module nommé ssr.mjs.

Exemple de serveur Web

Enfin, voici le petit serveur Express qui rassemble tout cela. Le gestionnaire principal prérend l'URL http://localhost/index.html (la page d'accueil) et affiche le résultat en tant que réponse. Les utilisateurs voient immédiatement les posts lorsqu'ils accèdent à la page, car le balisage statique fait désormais partie de la réponse.

server.mjs

import express from 'express';
import ssr from './ssr.mjs';

const app = express();

app.get('/', async (req, res, next) => {
  const {html, ttRenderMs} = await ssr(`${req.protocol}://${req.get('host')}/index.html`);
  // Add Server-Timing! See https://w3c.github.io/server-timing/.
  res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
  return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));

Pour exécuter cet exemple, installez les dépendances (npm i --save puppeteer express) et exécutez le serveur à l'aide de Node 8.5.0 ou version ultérieure et de l'indicateur --experimental-modules:

Voici un exemple de réponse envoyée par ce serveur:

<html>
<body>
  <div id="container">
    <ul id="posts">
      <li class="post">
        <h2>Title 1</h2>
        <div class="summary">Summary 1</div>
        <p>post content 1</p>
      </li>
      <li class="post">
        <h2>Title 2</h2>
        <div class="summary">Summary 2</div>
        <p>post content 2</p>
      </li>
      ...
    </ul>
  </div>
</body>
<script>
...
</script>
</html>

Un cas d'utilisation parfait pour la nouvelle API Server-Timing

L'API Server-Timing transmet les métriques de performances du serveur (telles que les temps de requête et de réponse ou les recherches dans la base de données) au navigateur. Le code client peut utiliser ces informations pour suivre les performances globales d'une application Web.

Un cas d'utilisation parfait pour Server-Timing est de signaler le temps qu'il faut à Chrome headless pour prérendre une page. Pour ce faire, il vous suffit d'ajouter l'en-tête Server-Timing à la réponse du serveur:

res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);

Côté client, vous pouvez utiliser l'API Performance et PerformanceObserver pour accéder à ces métriques:

const entry = performance.getEntriesByType('navigation').find(
    e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());

{
  "name": "Prerender",
  "duration": 3808,
  "description": "Headless render time (ms)"
}

Résultats de performance

Les résultats suivants intègrent la plupart des optimisations des performances abordées plus loin.

Dans un exemple d'application, Chromium headless prend environ une seconde pour afficher la page sur le serveur. Une fois la page mise en cache, l'émulation lente 3G de DevTools accélère le FCP de 8,37 s par rapport à la version côté client.

First Paint (FP)First Contentful Paint (FCP)
Application côté client4 s 11 s
Version SSR2,3 s~2,3 s

Ces résultats sont encourageants. Les utilisateurs voient des contenus pertinents beaucoup plus rapidement, car la page dont le rendu est généré côté serveur ne s'appuie plus sur JavaScript pour charger et afficher les posts.

Empêcher la réhydratation

Vous vous souvenez quand j'ai dit que nous n'avions apporté aucune modification de code à l'application côté client ? C'était un mensonge.

Notre application Express reçoit une requête, utilise Puppeteer pour charger la page dans headless et renvoie le résultat en réponse. Cependant, cette configuration présente un problème.

Le même code JavaScript qui s'exécute dans Chrome headless sur le serveur s'exécute à nouveau lorsque le navigateur de l'utilisateur charge la page sur le frontend. Deux emplacements génèrent du balisage. #doublerender !

Pour résoudre ce problème, indiquez à la page que son code HTML est déjà en place. Une solution consiste à demander au code JavaScript de la page de vérifier si <ul id="posts"> est déjà dans le DOM au moment du chargement. Si c'est le cas, vous savez que la page a été générée par SSR et vous pouvez éviter de réajouter des posts. 👍

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by JS (below) or by prerendering (server). Either way,
         #container gets populated with the posts markup:
      <ul id="posts">...</ul>
    -->
  </div>
</body>
<script>
...
(async() => {
  const container = document.querySelector('#container');

  // Posts markup is already in DOM if we're seeing a SSR'd.
  // Don't re-hydrate the posts here on the client.
  const PRE_RENDERED = container.querySelector('#posts');
  if (!PRE_RENDERED) {
    const posts = await fetch('/posts').then(resp => resp.json());
    renderPosts(posts, container);
  }
})();
</script>
</html>

Optimisations

En plus de mettre en cache les résultats affichés, nous pouvons effectuer de nombreuses optimisations intéressantes sur ssr(). Certaines sont des gains rapides, tandis que d'autres peuvent être plus spéculatives. Les avantages en termes de performances que vous constatez peuvent finalement dépendre des types de pages que vous prérendiez et de la complexité de l'application.

Arrêter les requêtes non essentielles

Actuellement, la page entière (et toutes les ressources qu'elle demande) est chargée sans condition dans Chrome headless. Toutefois, nous ne nous intéressons qu'à deux éléments:

  1. Balisage affiché.
  2. Requêtes JavaScript ayant généré ce balisage.

Les requêtes réseau qui ne créent pas de DOM sont inutiles. Les ressources telles que les images, les polices, les feuilles de style et les médias ne participent pas à la création du code HTML d'une page. Ils stylisent et complètent la structure d'une page, mais ne la créent pas explicitement. Nous devons demander au navigateur d'ignorer ces ressources. Cela réduit la charge de travail pour Chrome headless, économise de la bande passante et accélère potentiellement le prérendu pour les pages plus grandes.

Le protocole DevTools est compatible avec une fonctionnalité puissante appelée interception réseau, qui permet de modifier les requêtes avant qu'elles ne soient émises par le navigateur. Puppeteer prend en charge l'interception réseau en activant page.setRequestInterception(true) et en écoutant l'événement request de la page. Cela nous permet d'annuler les requêtes pour certaines ressources et de laisser les autres continuer.

ssr.mjs

async function ssr(url) {
  ...
  const page = await browser.newPage();

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. Ignore requests for resources that don't produce DOM
    // (images, stylesheets, media).
    const allowlist = ['document', 'script', 'xhr', 'fetch'];
    if (!allowlist.includes(req.resourceType())) {
      return req.abort();
    }

    // 3. Pass through all other requests.
    req.continue();
  });

  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  return {html};
}

Ressources critiques intégrées

Il est courant d'utiliser des outils de compilation distincts (tels que gulp) pour traiter une application et intégrer le code CSS et JavaScript critique dans la page au moment de la compilation. Cela peut accélérer la première peinture significative, car le navigateur envoie moins de requêtes lors du chargement initial de la page.

Au lieu d'utiliser un outil de compilation distinct, utilisez le navigateur comme outil de compilation. Nous pouvons utiliser Puppeteer pour manipuler le DOM de la page, insérer des styles, du code JavaScript ou tout autre élément que vous souhaitez ajouter à la page avant de la prérendre.

Cet exemple montre comment intercepter les réponses pour les feuilles de style locales et intégrer ces ressources dans la page en tant que balises <style>:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  const stylesheetContents = {};

  // 1. Stash the responses of local stylesheets.
  page.on('response', async resp => {
    const responseUrl = resp.url();
    const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
    const isStylesheet = resp.request().resourceType() === 'stylesheet';
    if (sameOrigin && isStylesheet) {
      stylesheetContents[responseUrl] = await resp.text();
    }
  });

  // 2. Load page as normal, waiting for network requests to be idle.
  await page.goto(url, {waitUntil: 'networkidle0'});

  // 3. Inline the CSS.
  // Replace stylesheets in the page with their equivalent <style>.
  await page.$$eval('link[rel="stylesheet"]', (links, content) => {
    links.forEach(link => {
      const cssText = content[link.href];
      if (cssText) {
        const style = document.createElement('style');
        style.textContent = cssText;
        link.replaceWith(style);
      }
    });
  }, stylesheetContents);

  // 4. Get updated serialized HTML of page.
  const html = await page.content();
  await browser.close();

  return {html};
}

This code:

  1. Use a page.on('response') handler to listen for network responses.
  2. Stashes the responses of local stylesheets.
  3. Finds all <link rel="stylesheet"> in the DOM and replaces them with an equivalent <style>. See page.$$eval API docs. The style.textContent is set to the stylesheet response.

Auto-minify resources

Another trick you can do with network interception is to modify the responses returned by a request.

As an example, say you want to minify the CSS in your app but also want to keep the convenience having it unminified when developing. Assuming you've setup another tool to pre-minify styles.css, one can use Request.respond() to rewrite the response of styles.css to be the content of styles.min.css.

ssr.mjs

import fs from 'fs';

async function ssr(url) {
  ...

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. If request is for styles.css, respond with the minified version.
    if (req.url().endsWith('styles.css')) {
      return req.respond({
        status: 200,
        contentType: 'text/css',
        body: fs.readFileSync('./public/styles.min.css', 'utf-8')
      });
    }
    ...

    req.continue();
  });
  ...

  const html = await page.content();
  await browser.close();

  return {html};
}

Réutiliser une seule instance Chrome pour tous les rendus

Lancer un nouveau navigateur pour chaque prérendu crée beaucoup de frais généraux. Vous pouvez plutôt lancer une seule instance et la réutiliser pour afficher plusieurs pages.

Puppeteer peut se reconnecter à une instance existante de Chrome en appelant puppeteer.connect() et en lui transmettant l'URL de débogage à distance de l'instance. Pour conserver une instance de navigateur de longue durée, nous pouvons déplacer le code qui lance Chrome à partir de la fonction ssr() vers le serveur Express:

server.mjs

import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';

let browserWSEndpoint = null;
const app = express();

app.get('/', async (req, res, next) => {
  if (!browserWSEndpoint) {
    const browser = await puppeteer.launch();
    browserWSEndpoint = await browser.wsEndpoint();
  }

  const url = `${req.protocol}://${req.get('host')}/index.html`;
  const {html} = await ssr(url, browserWSEndpoint);

  return res.status(200).send(html);
});

ssr.mjs

import puppeteer from 'puppeteer';

/**
 * @param {string} url URL to prerender.
 * @param {string} browserWSEndpoint Optional remote debugging URL. If
 *     provided, Puppeteer's reconnects to the browser instance. Otherwise,
 *     a new browser instance is launched.
 */
async function ssr(url, browserWSEndpoint) {
  ...
  console.info('Connecting to existing Chrome instance.');
  const browser = await puppeteer.connect({browserWSEndpoint});

  const page = await browser.newPage();
  ...
  await page.close(); // Close the page we opened here (not the browser).

  return {html};
}

Exemple: tâche Cron pour prérendre régulièrement

Pour afficher plusieurs pages à la fois, vous pouvez utiliser une instance de navigateur partagée.

import puppeteer from 'puppeteer';
import * as prerender from './ssr.mjs';
import urlModule from 'url';
const URL = urlModule.URL;

app.get('/cron/update_cache', async (req, res) => {
  if (!req.get('X-Appengine-Cron')) {
    return res.status(403).send('Sorry, cron handler can only be run as admin.');
  }

  const browser = await puppeteer.launch();
  const homepage = new URL(`${req.protocol}://${req.get('host')}`);

  // Re-render main page and a few pages back.
  prerender.clearCache();
  await prerender.ssr(homepage.href, await browser.wsEndpoint());
  await prerender.ssr(`${homepage}?year=2018`);
  await prerender.ssr(`${homepage}?year=2017`);
  await prerender.ssr(`${homepage}?year=2016`);
  await browser.close();

  res.status(200).send('Render cache updated!');
});

Ajoutez également une exportation clearCache() dans ssr.js:

...
function clearCache() {
  RENDER_CACHE.clear();
}

export {ssr, clearCache};

Autres points à noter

Créez un signal pour la page: "Vous êtes affiché en mode headless"

Lorsque votre page est affichée par Chrome headless sur le serveur, il peut être utile que la logique côté client de la page le sache. Dans mon application, j'ai utilisé ce crochet pour"désactiver" les parties de ma page qui ne jouent aucun rôle dans le rendu du balisage des posts. Par exemple, j'ai désactivé le code qui charge de manière paresseuse firebase-auth.js. Aucun utilisateur ne peut se connecter.

Ajouter un paramètre ?headless à l'URL de rendu est un moyen simple de donner un crochet à la page:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  // Add ?headless to the URL so the page has a signal
  // it's being loaded by headless Chrome.
  const renderUrl = new URL(url);
  renderUrl.searchParams.set('headless', '');
  await page.goto(renderUrl, {waitUntil: 'networkidle0'});
  ...

  return {html};
}

Sur la page, nous pouvons rechercher ce paramètre:

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
...

(async() => {
  const params = new URL(location.href).searchParams;

  const RENDERING_IN_HEADLESS = params.has('headless');
  if (RENDERING_IN_HEADLESS) {
    // Being rendered by headless Chrome on the server.
    // e.g. shut off features, don't lazy load non-essential resources, etc.
  }

  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

Éviter d'augmenter artificiellement le nombre de pages vues dans Analytics

Faites attention si vous utilisez Analytics sur votre site. Le prérendu des pages peut entraîner un nombre plus important de pages vues. Plus précisément, vous verrez le double du nombre de requêtes, une requête lorsque Chrome headless génère la page et une autre lorsque le navigateur de l'utilisateur la génère.

Quelle est la solution ? Utilisez l'interception réseau pour interrompre toute ou certaines requêtes qui tentent de charger la bibliothèque Analytics.

page.on('request', req => {
  // Don't load Google Analytics lib requests so pageviews aren't 2x.
  const blockist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
  if (blocklist.find(regex => req.url().match(regex))) {
    return req.abort();
  }
  ...
  req.continue();
});

Les appels de page ne sont jamais enregistrés si le code ne se charge jamais. Boom 💥.

Vous pouvez également continuer à charger vos bibliothèques Analytics pour obtenir des informations sur le nombre de prérendus effectués par votre serveur.

Conclusion

Puppeteer facilite le rendu côté serveur en exécutant Chrome sans interface graphique, en tant que compagnon, sur votre serveur Web. La "fonctionnalité" que je préfère de cette approche est que vous améliorez les performances de chargement et l'indexabilité de votre application sans modification de code significative.

Si vous souhaitez voir une application fonctionnelle qui utilise les techniques décrites ici, consultez l'application devwebfeed.

Annexe

Discussion sur l'état de l'art

Le rendu côté serveur des applications côté client est difficile. À quel point ? Il suffit de regarder le nombre de packages npm dédiés à ce sujet. Il existe d'innombrables modèles, outils et services pour vous aider à mettre en œuvre le SSR dans les applications JavaScript.

JavaScript isomorphe / universel

Le concept d'Universal JavaScript signifie que le même code qui s'exécute sur le serveur s'exécute également sur le client (le navigateur). Vous partagez du code entre le serveur et le client, et tout le monde se sent zen.

Chrome headless permet le "JS isomorphique" entre le serveur et le client. C'est une excellente option si votre bibliothèque ne fonctionne pas sur le serveur (Node).

Outils de prérendu

La communauté Node a développé de nombreux outils pour gérer les applications JS SSR. Rien de surprenant à cela. Personnellement, j'ai constaté que les résultats variaient avec certains de ces outils. Faites donc vos devoirs avant de vous engager. Par exemple, certains outils SSR sont plus anciens et n'utilisent pas Chrome headless (ni aucun autre navigateur headless). Ils utilisent plutôt PhantomJS (l'ancien Safari), ce qui signifie que vos pages ne s'afficheront pas correctement si elles utilisent des fonctionnalités plus récentes.

L'une des exceptions notables est Prerender. Le prérendu est intéressant, car il utilise Chrome headless et est fourni avec un middleware pour Express intégré:

const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();

Il est à noter que Prerender ne fournit pas les détails du téléchargement et de l'installation de Chrome sur différentes plates-formes. C'est souvent assez délicat à faire, ce qui est l'une des raisons pour lesquelles Puppeteer le fait pour vous.