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

Découvrez comment utiliser les API Puppeteer pour ajouter des fonctionnalités de rendu côté serveur à un serveur Web Express. L'avantage, c'est que votre application nécessite de très petites modifications du code. Sans interface graphique, vous devez faire le gros du travail.

Il suffit de quelques lignes de code pour que chaque page soit accompagnée d'un 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 graphique ?

Vous êtes peut-être intéressé par Chrome sans interface graphique si:

Certains frameworks tels que Preact sont livrés avec des outils qui gèrent le rendu côté serveur. Si votre framework comporte une solution de prérendu, respectez-la au lieu d'intégrer Puppeteer et Headless Chrome dans votre workflow.

Exploration du Web moderne

Jusqu'à présent, 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 appuyés exclusivement sur le balisage HTML statique pour indexer le Web et afficher le contenu. Le Web moderne a évolué pour devenir quelque chose de très différent. Les applications basées sur JavaScript vont perdurer, ce qui signifie que, dans de nombreux cas, notre contenu peut être invisible pour les outils d'exploration.

Le robot d'exploration de la recherche Googlebot traite JavaScript sans nuire à l'expérience des internautes qui consultent le site. Il existe des différences et des limites que vous devez prendre en compte lorsque vous concevez vos pages et vos applications afin de tenir compte de la façon dont les robots d'exploration accèdent à votre contenu et l'affichent.

Précharger les pages

Tous les robots d'exploration maîtrisent le langage HTML. Pour que les robots d'exploration puissent indexer JavaScript, nous avons besoin d'un outil qui:

  • Sait exécuter tous les types de code JavaScript moderne et générer du code HTML statique.
  • Se met à jour en fonction des nouvelles fonctionnalités ajoutées sur le Web.
  • s'exécute avec peu ou pas de mises à jour de code dans votre application ;

Cela vous convient, n'est-ce pas ? Cet outil, c'est le navigateur ! Chrome sans interface graphique 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 conçue avec Node.js, Puppeteer est un moyen simple de travailler avec Chrome 0.headless.

Commençons 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

Nous allons ensuite prendre la fonction ssr() de plus tôt et l'améliorer 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 principaux changements:

  • Ajout de la mise en cache. La mise en cache du code HTML rendu constitue la plus grande victoire pour accélérer les temps de réponse. Lorsque la page est à nouveau demandée, évitez d'exécuter Chrome sans interface graphique. Nous aborderons les autres optimisations ultérieurement.
  • Ajoutez une gestion des erreurs de base si le chargement de la page dépasse le délai.
  • Ajoutez un appel à page.waitForSelector('#posts'). Cela garantit que les posts existent dans le DOM avant de vider la page sérialisée.
  • Ajoutez la science. Consignez le temps nécessaire sans interface graphique pour afficher la page et renvoyer le temps d'affichage avec le code HTML.
  • Placez 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écharge l'URL http://localhost/index.html (la page d'accueil) et diffuse 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 renvoyé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>

Cas d'utilisation idéal de la nouvelle API Server-Timing

L'API Server-Timing communique les métriques de performances du serveur (telles que les temps de requêtes et de réponses 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.

Le cas d'utilisation idéal de Server-Timing est le temps nécessaire au préchargement d'une page dans Chrome sans interface graphique. Pour ce faire, ajoutez simplement l'en-tête Server-Timing à la réponse du serveur:

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

Sur le client, vous pouvez accéder à ces métriques à l'aide de l'API Performance et de PerformanceObserver:

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 performances

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

Sur l'une de mes applications (code), Chrome sans interface graphique met environ une seconde à afficher la page sur le serveur. Une fois la page mise en cache, l'émulation 3G lente des outils de développement place FCP à 8,37 s plus rapide que la version côté client.

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

Ces résultats sont prometteurs. Les utilisateurs voient beaucoup plus rapidement un contenu pertinent, car la page affichée côté serveur n'utilise plus JavaScript pour charger et afficher les posts.

Prévenir la réhydratation

Vous vous souvenez quand j'ai dit « nous n'avons 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 sans interface graphique et diffuse le résultat en tant que réponse. Mais cette configuration a un problème.

Le même code JS qui s'exécute dans Chrome sans interface graphique sur le serveur s'exécute à nouveau lorsque le navigateur de l'utilisateur charge la page sur l'interface. Le balisage est généré à deux endroits. #doublerender!

Résolvons à présent ce problème. Nous devons indiquer à la page que son code HTML est déjà en place. La solution que j'ai trouvée consistait à demander à la page JS de vérifier si <ul id="posts"> se trouvait déjà dans le DOM au moment du chargement. Si c'est le cas, nous savons que la page était en version SSR et nous pouvons éviter d'ajouter à nouveau des articles. 👍

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, il existe de nombreuses optimisations intéressantes que nous pouvons apporter à ssr(). Certains sont des gains rapides, tandis que d'autres peuvent être plus spéculatifs. Les avantages en termes de performances que vous obtenez peuvent dépendre des types de pages que vous préchargez et de la complexité de l'application.

Annuler les demandes non essentielles

Pour le moment, la page entière (et toutes les ressources demandées) est chargée sans condition dans Chrome sans interface. Cependant, nous ne nous intéressons qu'à deux éléments:

  1. Balisage affiché.
  2. Les demandes JS ayant généré ce balisage.

Les requêtes réseau qui ne construisent pas de DOM sont inutiles. Des ressources telles que les images, les polices, les feuilles de style et les fichiers multimé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 indiquer au navigateur d'ignorer ces ressources. Cela réduit la charge de travail du mode sans interface graphique de Chrome, économise la bande passante et peut potentiellement accélérer le prérendu pour les pages plus volumineuses.

Le protocole DevTools propose une fonctionnalité puissante appelée interception de réseau, qui permet de modifier les requêtes avant qu'elles ne soient émises par le navigateur. Puppeteer prend en charge l'interception du 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 d'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 d'intégrer le CSS et le JS critiques dans la page au moment de la compilation. Cela peut accélérer le premier rendu significatif, car le navigateur effectue 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, les styles d'intégration, le code JavaScript ou tout autre élément que vous souhaitez intégrer à la page avant de la précharger.

Cet exemple montre comment intercepter les réponses des 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 sur plusieurs rendus

Le lancement d'un nouveau navigateur pour chaque prérendu génère beaucoup de frais généraux. À la place, vous pouvez 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 depuis 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 effectuer un prérendu périodiquement

Dans mon application de tableau de bord App Engine, j'ai configuré un gestionnaire Cron pour afficher à nouveau régulièrement les pages principales du site. Cela permet aux visiteurs de toujours voir un contenu récent et rapide, et d'éviter de voir le "coût de démarrage" d'un nouveau prérendu, tout en leur évitant de les voir. Générer plusieurs instances de Chrome serait inefficace dans ce cas. Au lieu de cela, j'utilise une instance de navigateur partagée pour afficher plusieurs pages en même temps:

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!');
});

J'ai également ajouté une exportation clearCache() à ssr.js:

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

export {ssr, clearCache};

Autres points à prendre en compte

Créer un signal pour la page: "Vous êtes affiché sans interface graphique"

Lorsque votre page est affichée par Chrome sans interface graphique sur le serveur, il peut être utile que la logique côté client de la page le reconnaisse. Dans mon application, j'ai utilisé ce crochet pour "désactiver" les parties de ma page qui ne jouent pas un rôle dans l'affichage du balisage des posts. Par exemple, j'ai désactivé le code qui charge firebase-auth.js en différé. Il n'y a aucun utilisateur à se connecter !

L'ajout d'un paramètre ?headless à l'URL de rendu est un moyen simple de créer un hook:

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, recherchez 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 de gonfler les pages vues Analytics

Soyez prudent si vous utilisez Analytics sur votre site. Le préaffichage des pages peut entraîner une augmentation du nombre de pages vues. Plus précisément, vous verrez deux fois le nombre d'appels : un appel lorsque la page s'affiche dans Chrome sans interface graphique, et un autre lorsque le navigateur de l'utilisateur l'affiche.

Quelle est la solution ? Utilisez l'interception du réseau pour annuler toute requête qui tente 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. Génial 👀.

Vous pouvez également continuer à charger vos bibliothèques Analytics pour connaître le nombre de préchargements effectués par votre serveur.

Conclusion

Puppeteer facilite l'affichage des pages côté serveur en exécutant Headless Chrome sur votre serveur Web en tant qu'application associée. Ma "fonctionnalité" préférée de cette approche est que vous pouvez améliorer les performances de chargement et l'indexabilité de votre application sans modifications importantes du code.

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

Annexe

Discussion sur l'art antérieur

L'affichage côté serveur pour les applications côté client est difficile. Quelle difficulté ? Il vous suffit de regarder le nombre de packages npm dédiés au sujet écrits par les utilisateurs. Il existe un nombre incalculable de modèles, d'tools et de services pour vous aider à exécuter des applications JavaScript basées sur la reconnaissance vocale.

JavaScript isomorphique / Universal JavaScript

Le concept de JavaScript universel 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 est détendu.

Headless Chrome active le "JS isomorphique" entre le serveur et le client. Il s'agit d'une excellente option si votre bibliothèque ne fonctionne pas sur le serveur (nœud).

Outils de prérendu

La communauté Node a développé un grand nombre d'outils pour gérer les applications JavaScript SSR. Aucune surprise ! Personnellement, j'ai découvert que le YMMV avec certains de ces outils, alors faites vos devoirs avant de vous engager en utilisant un. Par exemple, certains outils SSR sont plus anciens et n'utilisent pas Chrome headless (ni aucun navigateur headless). À la place, elles utilisent PhantomJS (également appelé 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 le prérendu. Le prérendu est intéressant, car il utilise Chrome sans interface graphique et est fourni avec un intergiciel pour Express:

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

Notez que le prérendu ne permet pas de télécharger et d'installer Chrome sur différentes plates-formes. Souvent, c'est assez difficile à comprendre, et c'est l'une des raisons pour lesquelles Puppeteer fait pour vous. J'ai également rencontré des problèmes avec le service en ligne pour afficher certaines de mes applications:

chromestatus affiché dans un navigateur
Site affiché dans un navigateur
chromestatus affiché par le prérendu
Même site affiché par prerender.io