Chrome headless: una risposta ai siti JS con rendering lato server

Scopri come utilizzare le API di Puppeteer per aggiungere funzionalità di rendering lato server (SSR) a un server web Express. La parte migliore è che l'app richiede modifiche molto piccole al codice. Senza testa si fa tutto il lavoro pesante.

Con un paio di righe di codice puoi SSR per qualsiasi pagina e ottenere il markup finale.

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;
}

Perché utilizzare Chrome headless?

Ti potrebbe interessare Chrome headless se:

Alcuni framework come Preact sono forniti con strumenti che gestiscono il rendering lato server. Se il tuo framework dispone di una soluzione di prerendering, rispettala invece di utilizzare Puppeteer e Chrome Headless nel flusso di lavoro.

La scansione del web moderno

I crawler dei motori di ricerca, le piattaforme di condivisione sui social e anche i browser hanno sempre fatto affidamento esclusivamente sul markup HTML statico per indicizzare il web e mostrare i contenuti. Il web moderno si è evoluto in qualcosa di molto diverso. Le applicazioni basate su JavaScript sono destinate a durare nel tempo, il che significa che in molti casi i nostri contenuti possono essere invisibili agli strumenti di scansione.

Googlebot, il nostro crawler della Ricerca, elabora JavaScript, assicurandosi che l'esperienza degli utenti che visitano il sito non venga compromessa. Quando progetti le tue pagine e le tue applicazioni, devi tenere conto di alcune differenze e limitazioni, per adeguarti al modo in cui i crawler accedono ai contenuti e ne visualizzano il rendering.

Prerendering delle pagine

Tutti i crawler comprendono il codice HTML. Per garantire che i crawler possano indicizzare JavaScript, è necessario uno strumento che:

  • Sa come eseguire tutti i tipi di codice JavaScript moderno e generare codice HTML statico.
  • Rimane aggiornato man mano che il web aggiunge funzionalità.
  • Funziona con aggiornamenti del codice minimi o nulli all'applicazione.

Va bene, vero? Quello strumento è il browser. a Chrome headless non importa quale libreria, framework o catena di strumenti usi.

Ad esempio, se la tua applicazione è creata con Node.js, Puppeteer è un modo semplice per lavorare con Chrome 0.headless.

Iniziamo con una pagina dinamica che genera il codice HTML con 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>

Funzione SSR

Quindi, esamineremo la funzione ssr() di cui abbiamo parlato in precedenza e la perfezionaremo:

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};

I cambiamenti principali:

  • Aggiunta della memorizzazione nella cache. La memorizzazione nella cache del codice HTML sottoposto a rendering è la soluzione migliore per accelerare i tempi di risposta. Quando la pagina viene richiesta nuovamente, eviti di eseguire Chrome headless del tutto. Parlerò di altre ottimizzazioni più avanti.
  • Aggiungi una gestione di base degli errori in caso di timeout del caricamento della pagina.
  • Aggiungi una chiamata a page.waitForSelector('#posts'). Ciò garantisce che i post esistano nel DOM prima di eseguire il dump della pagina serializzata.
  • Aggiungi la scienza. Registra il tempo necessario per il rendering della pagina e restituisce il tempo di rendering insieme al codice HTML.
  • Inserisci il codice in un modulo denominato ssr.mjs.

Server web di esempio

Infine, ecco il piccolo server express che riunisce tutto. Il gestore principale esegue il prerendering dell'URL http://localhost/index.html (la home page) e pubblica il risultato come risposta. Gli utenti vedono immediatamente i post quando visitano la pagina perché il markup statico ora fa parte della risposta.

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

Per eseguire questo esempio, installa le dipendenze (npm i --save puppeteer express) ed esegui il server utilizzando Node 8.5.0+ e il flag --experimental-modules:

Ecco un esempio della risposta inviata da questo server:

<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 caso d'uso perfetto per la nuova API Server-Timing

L'API Server-Timing comunica le metriche delle prestazioni del server (come i tempi di richieste e risposte o le ricerche nel database) al browser. Il codice client può usare queste informazioni per monitorare le prestazioni complessive di un'app.

Un caso d'uso perfetto per la funzionalità Tempi del server è registrare il tempo necessario a Chrome headless per eseguire il prerendering di una pagina. Per farlo, aggiungi l'intestazione Server-Timing alla risposta del server:

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

Sul client, è possibile utilizzare l'API Performance e PerformanceObserver per accedere a queste metriche:

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)"
}

Risultati relativi al rendimento

I seguenti risultati incorporano la maggior parte delle ottimizzazioni del rendimento discusse più avanti.

In una delle mie app (codice), Chrome headless impiega circa 1 secondo per il rendering della pagina sul server. Una volta che la pagina è memorizzata nella cache, l'emulazione lenta 3G di DevTools imposta FCP a una velocità di 8,37 secondi più veloce rispetto alla versione lato client.

First Paint (FP)First Contentful Paint (FCP)
App lato client4 sec 11s
Versione SSR2,3 s~2,3 s

Questi risultati sono promettenti. Gli utenti vedono i contenuti significativi molto più velocemente perché la pagina visualizzata lato server non si basa più su JavaScript per caricare e mostrare i post.

Prevenzione della reidratazione

Ricordi quando ho detto "non abbiamo apportato modifiche al codice all'app lato client"? Era una bugia.

La nostra app Express accetta una richiesta, utilizza Puppeteer per caricare la pagina in headless e mostra il risultato in risposta. ma questa configurazione presenta un problema.

Lo stesso codice JS che viene eseguito in Chrome headless sul server viene eseguito di nuovo quando il browser dell'utente carica la pagina sul frontend. Ci sono due posti per generare il markup. #Doublerender!

Risolviamo il problema. Dobbiamo comunicare alla pagina che il suo codice HTML è già presente. La soluzione che ho trovato consiste nel far controllare alla pagina JS se <ul id="posts"> è già nel DOM al momento del caricamento. Se lo è, sappiamo che la pagina è stata SSR e possiamo evitare di aggiungere di nuovo i post. 👍

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>

Ottimizzazioni

Oltre alla memorizzazione nella cache dei risultati di cui è stato eseguito il rendering, ci sono molte ottimizzazioni interessanti che possiamo apportare a ssr(). Alcune sono vittorie rapide, mentre altre più speculative. I vantaggi in termini di prestazioni possono dipendere in definitiva dai tipi di pagine di cui esegui il prerendering e dalla complessità dell'app.

Interrompi le richieste non essenziali

Al momento, l'intera pagina (e tutte le risorse richieste) viene caricata senza condizioni in Chrome headless. Tuttavia, ci interessano solo due aspetti:

  1. Il markup visualizzato.
  2. Le richieste JS che hanno generato il markup.

Le richieste di rete che non creano un DOM sono sprechi. Risorse come immagini, caratteri, fogli di stile e contenuti multimediali non contribuiscono allo sviluppo del codice HTML di una pagina. Adattano e completano la struttura di una pagina, ma non la creano in modo esplicito. Dovremmo dire al browser di ignorare queste risorse. In questo modo si riduce il carico di lavoro per Chrome headless, si risparmia larghezza di banda e viene potenzialmente accelerato il tempo di prerendering per pagine più grandi.

Il protocollo DevTools supporta una potente funzionalità denominata intercettazione della rete che può essere utilizzata per modificare le richieste prima che vengano inviate dal browser. Puppeteer supporta l'intercettazione della rete attivando page.setRequestInterception(true) e ascoltando l'evento request della pagina. Questo ci consente di interrompere le richieste relative a determinate risorse e di consentire ad altre di continuare.

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};
}

Risorse critiche incorporate

È prassi comune utilizzare strumenti di creazione separati (ad esempio gulp) per elaborare un'app e incorporare codice CSS e JS fondamentali nella pagina al momento della creazione. In questo modo, puoi velocizzare la prima visualizzazione significativa perché il browser effettua meno richieste durante il caricamento iniziale della pagina.

Invece di uno strumento di creazione separato, utilizza il browser come strumento di creazione. Possiamo usare Puppeteer per manipolare il DOM, gli stili incorporati, JavaScript o qualsiasi altra risorsa della pagina prima di eseguire il prerendering.

Questo esempio mostra come intercettare le risposte per i fogli di stile locali e incorporare queste risorse nella pagina come tag <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};
}

Riutilizzo di una singola istanza di Chrome nei rendering

L'avvio di un nuovo browser per ogni prerendering crea un notevole sovraccarico. Ti consigliamo invece di avviare una singola istanza e riutilizzarla per il rendering di più pagine.

Puppeteer può riconnettersi a un'istanza esistente di Chrome chiamando puppeteer.connect() e trasmettendo l'URL di debug remoto dell'istanza. Per mantenere un'istanza del browser a lunga esecuzione, possiamo spostare il codice che avvia Chrome dalla funzione ssr() al server 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};
}

Esempio: cron job per eseguire periodicamente il prerendering

Nella mia app della dashboard di App Engine, configuro un gestore cron per eseguire periodicamente il rendering delle pagine principali del sito. Ciò consente ai visitatori di vedere sempre contenuti veloci e aggiornati, a evitare e di evitare il "costo di avvio" di un nuovo prerendering. Generare diverse istanze di Chrome sarebbe uno spreco per questo caso. Al contrario, sto utilizzando un'istanza del browser condivisa per visualizzare diverse pagine contemporaneamente:

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

Ho anche aggiunto un'esportazione clearCache() in ssr.js:

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

export {ssr, clearCache};

Altre considerazioni

Crea un indicatore per la pagina: "Viene visualizzato il tuo headless"

Quando la tua pagina viene sottoposta a rendering da Chrome headless sul server, potrebbe essere utile saperlo alla logica lato client della pagina. Nella mia app, ho usato questo hook per "disattivare" le parti della pagina che non contribuiscono al rendering del markup dei post. Ad esempio, ho disabilitato il codice che carica lentamente firebase-auth.js. Non ci sono utenti che accedano.

L'aggiunta di un parametro ?headless all'URL di rendering è un modo semplice per aggiungere un hook alla pagina:

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};
}

Nella pagina, possiamo cercare questo parametro:

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>

Evitare di aumentare artificialmente il numero di visualizzazioni di pagina di Analytics

Fai attenzione se utilizzi Analytics sul tuo sito. Il prerendering delle pagine può comportare un aumento artificioso delle visualizzazioni di pagina. In particolare, vedrai il doppio del numero di hit: un hit quando Chrome headless esegue il rendering della pagina e un altro quando il browser dell'utente la esegue.

Qual è il problema? Utilizza l'intercettazione di rete per interrompere le richieste che tentano di caricare la libreria di 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();
});

Gli hit da pagina non vengono mai registrati se il codice non viene mai caricato. Fantastico :\.

In alternativa, continua a caricare le librerie di Analytics per ottenere insight sul numero di prerendering del server.

Conclusione

Puppeteer semplifica il rendering delle pagine lato server eseguendo Chrome headless, come associato, sul tuo server web. La mia "funzionalità" preferita di questo approccio è che puoi migliorare le prestazioni di caricamento e l'indicizzazione della tua app senza modifiche significative al codice.

Se vuoi vedere un'app funzionante che utilizza le tecniche descritte qui, dai un'occhiata all'app devwebfeed.

Appendice

Discussione sull'arte nota

Il rendering lato server delle app lato client è difficile. Quanto è difficile? Guarda quanti pacchetti npm dedicati all'argomento hanno scritto le persone. Sono disponibili innumerevoli pattern, tools e servizi utili per le app JS SSRing.

JavaScript isomorfico / universale

Il concetto di JavaScript universale indica che lo stesso codice eseguito sul server viene eseguito anche sul client (il browser). Condividi il codice tra server e client e ognuno si sente un momento di zen.

Chrome headless abilita il cosiddetto "JS isomorfico" tra server e client. È un'ottima opzione se la tua libreria non funziona sul server (nodo).

Strumenti di prerendering

La community Node ha creato moltissimi strumenti per gestire le app JS SSR. Non ci sono sorprese. Personalmente ho scoperto che il programma YMMV con alcuni di questi strumenti, quindi, fai sicuramente i compiti prima di impegnarti. Ad esempio, alcuni strumenti SSR sono meno recenti e non utilizzano Chrome headless (o qualsiasi browser headless). ma utilizzano PhantomJS (noto anche come vecchio Safari), il che significa che le pagine non verranno visualizzate correttamente se utilizzano funzionalità più recenti.

Una delle eccezioni più importanti è il prerendering. Il prerendering è interessante in quanto utilizza Chrome headless ed è dotato di middleware per Express incorporato:

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

Vale la pena notare che il prerendering non include i dettagli relativi al download e all'installazione di Chrome su diverse piattaforme. Spesso, è abbastanza difficile da avere ragione, ed è uno dei motivi per cui Puppeteer fa per te. Ho anche avuto problemi con il servizio online di rendering di alcune delle mie app:

chromestatus visualizzato in un browser
Sito visualizzato in un browser
chromestatus visualizzato da prerendering
Stesso sito sottoposto a rendering da prerender.io