Scopri come utilizzare le API Puppeteer per aggiungere funzionalità di rendering lato server (SSR) a un server web Express. Il bello è che la tua app richiede modifiche molto piccole al codice. La versione headless si occupa di tutto il lavoro più pesante.
Con un paio di righe di codice puoi eseguire SSR su qualsiasi pagina e ottenere il relativo 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 Headless Chrome?
Headless Chrome potrebbe interessarti se:
- Hai creato un'app web che non viene indicizzata dai motori di ricerca.
- Speri di ottenere un risultato rapido per ottimizzare le prestazioni di JavaScript e migliorare il primo paint significativo.
Alcuni framework come Preact sono dotati di strumenti che gestiscono il rendering lato server. Se il tuo framework ha una soluzione di prerendering, utilizzala anziché integrare Puppeteer e Headless Chrome nel tuo flusso di lavoro.
Scansione del web moderno
I crawler dei motori di ricerca, le piattaforme di condivisione 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 rimanere, 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. Esistono alcune differenze e limitazioni di cui devi tenere conto quando progetti le tue pagine e le tue applicazioni, così da adattarle al modo in cui i crawler accedono e riproducono i tuoi contenuti.
Precarica le pagine
Tutti i crawler comprendono l'HTML. Per assicurarci che i crawler possano indicizzare JavaScript, abbiamo bisogno di uno strumento che:
- Deve sapere come eseguire tutti i tipi di JavaScript moderni e generare HTML statico.
- Rimane aggiornato man mano che il web aggiunge funzionalità.
- Funziona con pochi o nessun aggiornamento del codice dell'applicazione.
Va bene, giusto? Lo strumento è il browser. Chrome headless non è interessato alla libreria, al framework o alla catena di strumenti che utilizzi.
Ad esempio, se la tua applicazione è creata con Node.js, Puppeteer è un modo semplice per lavorare con Chrome headless.
Inizia 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
A questo punto, prendi la funzione ssr()
di cui abbiamo parlato in precedenza e migliorala un po':
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};
Le modifiche principali:
- È stata aggiunta la memorizzazione nella cache. La memorizzazione nella cache dell'HTML visualizzato è il modo migliore per velocizzare i tempi di risposta. Quando la pagina viene richiesta di nuovo, eviti di eseguire del tutto Chrome headless. Parleremo di altre ottimizzazioni più avanti.
- Aggiungi la gestione di errori di base se il caricamento della pagina scade.
- Aggiungi una chiamata a
page.waitForSelector('#posts')
. In questo modo, i post esistono nel DOM prima di eseguire il dump della pagina serializzata. - Aggiungi scienza. Registra il tempo necessario a headless per eseguire il rendering della pagina e restituisce il tempo di rendering insieme all'HTML.
- Inserisci il codice in un modulo denominato
ssr.mjs
.
Server web di esempio
Infine, ecco il piccolo server Express che riunisce tutto. L'handler principale precompila l'URL http://localhost/index.html
(la home page) e restituisce il risultato come risposta. Gli utenti vedono immediatamente i post quando accedono alla 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
)
e avvia il server utilizzando Node 8.5.0 o versioni successive e il flag --experimental-modules
:
Ecco un esempio di 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 al browser le metriche sul rendimento del server (ad esempio i tempi di richiesta e risposta o le ricerche nel database). Il codice client può utilizzare queste informazioni per monitorare il rendimento complessivo di un'app web.
Un caso d'uso perfetto per Server-Timing è segnalare il tempo necessario a Chromium 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, l'API Performance e PerformanceObserver possono essere utilizzati 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 sul rendimento
I risultati riportati di seguito includono la maggior parte delle ottimizzazioni del rendimento discusse in seguito.
In un'app di esempio, Chromium headless impiega circa un secondo per eseguire il rendering della pagina sul server. Una volta memorizzata la pagina nella cache, Emulazione lenta 3G di DevTools imposta il FCP su 8,37 secondi più veloce rispetto alla versione lato client.
First Paint (FP) | First Contentful Paint (FCP) | |
---|---|---|
App lato client | 4 s | 11 sec |
Versione SSR | 2,3 s | ~2,3 s |
Questi risultati sono promettenti. Gli utenti vedono contenuti significativi molto più rapidamente perché la pagina con rendering lato server non si basa più su JavaScript per caricare e mostrare i post.
Evitare la reidratazione
Ricordi quando ho detto "non abbiamo apportato modifiche al codice dell'app lato client"? Era una bugia.
La nostra app Express riceve una richiesta, utilizza Puppeteer per caricare la pagina in headless e restituisce il risultato come risposta. Tuttavia, questa configurazione presenta un problema.
Il stessa codice JavaScript che viene eseguito in Chrome headless sul server viene eseguito di nuovo quando il browser dell'utente carica la pagina sul frontend. Abbiamo due punti in cui viene generato il markup. #doublerender.
Per risolvere il problema, indica alla pagina che il codice HTML è già presente.
Una soluzione è fare in modo che il codice JavaScript della pagina controlli se <ul id="posts">
è già nel DOM al momento del caricamento. In questo caso, significa che la pagina è stata sottoposta a SSR e puoi 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 a memorizzare nella cache i risultati visualizzati, esistono moltissime ottimizzazioni interessanti che possiamo apportare a ssr()
. Alcune sono facili da implementare, mentre altre possono essere più speculative. I vantaggi in termini di prestazioni che vedi potrebbero dipendere in ultima analisi dai tipi di pagine che prerenderizzi 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 cose:
- Il markup visualizzato.
- Le richieste JS che hanno prodotto il markup.
Le richieste di rete che non generano il DOM sono uno spreco. Risorse come immagini, caratteri, fogli di stile e contenuti multimediali non contribuiscono alla creazione del codice HTML di una pagina. Aggiungono stile e completano la struttura di una pagina, ma non la creano esplicitamente. Dobbiamo 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 si può potenzialmente velocizzare il tempo di prerendering per le pagine più grandi.
Il protocollo DevTools supporta una potente funzionalità chiamata Intercettazione della rete che può essere utilizzata per modificare le richieste prima che vengano inviate dal browser.
Puppeteer supporta l'intercettazione di rete attivando
page.setRequestInterception(true)
e ascoltando
l'evento request
della pagina.
In questo modo possiamo interrompere le richieste di determinate risorse e consentire alle 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 in linea
È comune utilizzare strumenti di compilazione separati (come gulp
) per elaborare un'app e incorporare codice CSS e JS fondamentale nella pagina in fase di compilazione. In questo modo, puoi accelerare la prima visualizzazione con contenuti perché il browser effettua meno richieste durante il caricamento iniziale della pagina.
Anziché uno strumento di compilazione separato, utilizza il browser come strumento di compilazione. Possiamo utilizzare Puppeteer per manipolare il DOM della pagina, inserire stili, JavaScript o qualsiasi altro elemento che vuoi inserire nella pagina prima del prerendering.
Questo esempio mostra come intercettare le risposte per i fogli di stile locali
e inserire queste risorse in linea 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:
- Use a
page.on('response')
handler to listen for network responses. - Stashes the responses of local stylesheets.
- Finds all
<link rel="stylesheet">
in the DOM and replaces them with an equivalent<style>
. Seepage.$$eval
API docs. Thestyle.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};
}
Riutilizzare una singola istanza di Chrome nei vari rendering
L'avvio di un nuovo browser per ogni prerendering comporta un notevole overhead. In alternativa, ti consigliamo di avviare una singola istanza e riutilizzarla per il rendering di più pagine.
Puppeteer può ricollegarsi a un'istanza esistente di Chrome chiamando
puppeteer.connect()
e passando l'URL di debug remoto dell'istanza. Per mantenere un'istanza del browser di lunga durata, possiamo spostare il codice che avvia Chrome dalla funzione ssr()
nel 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 il prerendering periodico
Per eseguire il rendering di più pagine contemporaneamente, puoi utilizzare un'istanza del browser condivisa.
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!');
});
Aggiungi anche un'esportazione clearCache()
in ssr.js:
...
function clearCache() {
RENDER_CACHE.clear();
}
export {ssr, clearCache};
Altre considerazioni
Crea un indicatore per la pagina: "Il rendering viene eseguito in modalità headless"
Quando la pagina viene visualizzata da Chrome headless sul server, potrebbe essere utile per la logica lato client della pagina. Nella mia app, ho utilizzato questo hook per "disattivare" parti della pagina che non hanno un ruolo nel rendering del markup dei post. Ad esempio, ho disattivato il codice che carica dinamicamente firebase-auth.js. Non c'è nessun utente da autenticare.
L'aggiunta di un parametro ?headless
all'URL di rendering è un modo semplice per assegnare alla pagina 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};
}
Nella pagina possiamo cercare il 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 gonfiare le visualizzazioni di pagina di Analytics
Fai attenzione se utilizzi Analytics sul tuo sito. Il prerendering delle pagine potrebbe comportare un aumento artificiale delle visualizzazioni di pagina. Nello specifico, visualizzerai 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 è la soluzione? Utilizza l'intercettazione di rete per interrompere le richieste che tentano di caricare la libreria 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 di pagina non vengono mai registrati se il codice non viene mai caricato. Boom 💥.
In alternativa, continua a caricare le librerie di Analytics per ottenere informazioni sul numero di pre-rendering eseguiti dal server.
Conclusione
Puppeteer semplifica il rendering lato server delle pagine eseguendo Chrome headless come companion sul tuo server web. La mia "funzionalità" preferita di questo approccio è che migliora 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 dell'arte nota
Il rendering lato server delle app lato client è difficile. Quanto è difficile? Basta guardare quanti pacchetti npm sono stati scritti e sono dedicati all'argomento. Esistono innumerevoli pattern, strumenti e servizi disponibili per aiutarti con le app JS SSR.
Isomorphic / Universal JavaScript
Il concetto di JavaScript universale significa che lo stesso codice che viene eseguito sul server viene eseguito anche sul client (il browser). Condividi il codice tra il server e il client e tutti provano un momento di zen.
Chrome headless abilita il "JS isomorfo" tra server e client. È un'ottima opzione se la tua libreria non funziona sul server (Node).
Strumenti di prerendering
La community di Node ha creato tantissimi strumenti per gestire le app JS SSR. Non c'è da stupirsi. Personalmente, ho riscontrato che YMMV con alcuni di questi strumenti, quindi fai le tue ricerche prima di sceglierne uno. Ad esempio, alcuni strumenti SSR sono meno recenti e non utilizzano Chrome headless (o qualsiasi browser headless). Utilizzano invece PhantomJS (ovvero il vecchio Safari), il che significa che le tue pagine non verranno visualizzate correttamente se utilizzano funzionalità più recenti.
Una delle eccezioni più importanti è Prerender. Prerender è interessante in quanto utilizza Chrome headless e include un middleware per Express plug-and-play:
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
Vale la pena notare che Prerender non include i dettagli del download e dell'installazione di Chrome su piattaforme diverse. Spesso, è abbastanza complicato ottenere risultati corretti, ed è uno dei motivi per cui Puppeteer lo fa per te.