Hier erfahren Sie, wie Sie mit Puppeteer APIs einem Express-Webserver Funktionen für das serverseitige Rendering (SSR) hinzufügen. Das Beste daran ist, dass für Ihre App nur sehr kleine Codeänderungen erforderlich sind. Headless übernimmt die ganze Arbeit.
Mit nur wenigen Codezeilen können Sie jede Seite per SSR abrufen und das endgültige Markup erhalten.
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;
}
Warum sollte ich Headless Chrome verwenden?
Headless Chrome ist unter folgenden Umständen interessant:
- Sie haben eine Webanwendung erstellt, die von Suchmaschinen nicht indexiert wird.
- Sie möchten schnell die JavaScript-Leistung und die First Meaningful Paint optimieren.
Einige Frameworks wie Preact werden mit Tools geliefert, die das serverseitige Rendering unterstützen. Wenn Ihr Framework eine Lösung für das Pre-Rendering bietet, sollten Sie diese verwenden, anstatt Puppeteer und Headless Chrome in Ihren Workflow aufzunehmen.
Das moderne Web crawlen
Suchmaschinen-Crawler, Plattformen für die soziale Medien-Nutzung und sogar Browser haben sich bisher ausschließlich auf statisches HTML-Markup verlassen, um das Web zu indexieren und Inhalte zu präsentieren. Das moderne Web hat sich jedoch stark weiterentwickelt. JavaScript-basierte Anwendungen sind nicht wegzudenken. Das bedeutet, dass unsere Inhalte in vielen Fällen für Crawling-Tools nicht sichtbar sind.
Der Googlebot, unser Suchcrawler, verarbeitet JavaScript, ohne dabei die Nutzerfreundlichkeit der Website zu beeinträchtigen. Beim Design Ihrer Seiten und Apps sind einige Unterschiede und Einschränkungen zu beachten, damit Crawler Ihre Inhalte auch richtig crawlen und rendern.
Seiten vorab rendern
Alle Crawler verstehen HTML. Damit crawler JavaScript indexieren können, benötigen wir ein Tool, das Folgendes bietet:
- Sie wissen, wie Sie alle Arten von modernem JavaScript ausführen und statische HTML-Inhalte generieren.
- Bleibt auf dem neuesten Stand, wenn im Web neue Funktionen hinzukommen.
- Läuft mit wenig bis keinen Codeupdates für Ihre Anwendung.
Klingt gut, oder? Das Tool ist der Browser. Für Headless Chrome spielt es keine Rolle, welche Bibliothek, welches Framework oder welche Toolchain Sie verwenden.
Wenn Ihre Anwendung beispielsweise mit Node.js erstellt wurde, ist Puppeteer eine einfache Möglichkeit, mit der headless Chrome-Version zu arbeiten.
Beginnen Sie mit einer dynamischen Seite, die ihre HTML-Dateien mit JavaScript generiert:
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>
SSR-Funktion
Als Nächstes nehmen wir die Funktion ssr()
aus dem vorherigen Beispiel und optimieren sie ein wenig:
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};
Die wichtigsten Änderungen:
- Caching hinzugefügt. Das Caching des gerenderten HTML-Codes ist die beste Möglichkeit, die Reaktionszeiten zu verkürzen. Wenn die Seite noch einmal angefordert wird, wird die Ausführung der headless Chrome-Version vermieden. Weitere Optimierungen werden später besprochen.
- Fügen Sie eine grundlegende Fehlerbehandlung hinzu, wenn beim Laden der Seite ein Zeitlimit überschritten wird.
- Fügen Sie einen Aufruf von
page.waitForSelector('#posts')
hinzu. So wird sichergestellt, dass die Beiträge im DOM vorhanden sind, bevor wir die serialisierte Seite dumpen. - Fügen Sie Wissenschaft hinzu. Sie protokollieren, wie lange das Rendern der Seite im Headless-Modus dauert, und geben die Renderzeit zusammen mit dem HTML zurück.
- Fügen Sie den Code in ein Modul mit dem Namen
ssr.mjs
ein.
Beispiel für einen Webserver
Und hier ist der kleine Express-Server, der alles zusammenbringt. Der Haupt-Handler rendert die URL http://localhost/index.html
(die Startseite) vorab und gibt das Ergebnis als Antwort zurück. Nutzer sehen Beiträge sofort, wenn sie die Seite aufrufen, da das statische Markup jetzt Teil der Antwort ist.
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'));
Um dieses Beispiel auszuführen, installieren Sie die Abhängigkeiten (npm i --save puppeteer express
) und führen Sie den Server mit Node 8.5.0 oder höher und dem Flag --experimental-modules
aus:
Hier ein Beispiel für die Antwort, die von diesem Server zurückgesendet wird:
<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>
Ein perfekter Anwendungsfall für die neue Server Timing API
Die Server-Timing API sendet Serverleistungsmesswerte wie Anfrage- und Antwortzeiten oder Datenbankabfragen an den Browser zurück. Mithilfe dieser Informationen kann der Clientcode die Gesamtleistung einer Webanwendung erfassen.
Ein perfekter Anwendungsfall für Server-Timing ist die Meldung, wie lange es dauert, bis eine Seite von der headless Chrome-Version vorab gerendert wird. Fügen Sie dazu einfach den Server-Timing
-Header zur Serverantwort hinzu:
res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);
Auf dem Client können die Performance API und PerformanceObserver verwendet werden, um auf diese Messwerte zuzugreifen:
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)"
}
Leistungsergebnisse
Die folgenden Ergebnisse umfassen die meisten der später beschriebenen Leistungsoptimierungen.
In einer Beispiel-App benötigt die Headless-Variante von Chrome etwa eine Sekunde, um die Seite auf dem Server zu rendern. Nachdem die Seite im Cache gespeichert wurde, ist der FCP mit der 3G-Langsame-Emulation in den DevTools 8,37 Sekunden schneller als die clientseitige Version.
First Paint (FP) | First Contentful Paint (FCP) | |
---|---|---|
Clientseitige App | 4s | 11 s |
SSR-Version | 2,3 s | ~2,3 s |
Diese Ergebnisse sind vielversprechend. Nutzer sehen relevante Inhalte viel schneller, da die serverseitig gerenderte Seite nicht mehr auf JavaScript angewiesen ist, um Beiträge zu laden und anzuzeigen.
Wiederbefeuchtung verhindern
Erinnern Sie sich, als ich sagte, dass wir keine Codeänderungen an der clientseitigen App vorgenommen haben? Das war eine Lüge.
Unsere Express-Anwendung nimmt eine Anfrage entgegen, lädt die Seite mit Puppeteer in headless und gibt das Ergebnis als Antwort aus. Diese Konfiguration hat jedoch ein Problem.
Das gleiche JavaScript, das in headless Chrome ausgeführt wird, wird auf dem Server noch einmal ausgeführt, wenn der Browser des Nutzers die Seite im Frontend lädt. Das Markup wird an zwei Stellen generiert. #doublerender!
Um das Problem zu beheben, müssen Sie der Seite mitteilen, dass der HTML-Code bereits vorhanden ist.
Eine Lösung besteht darin, im JavaScript der Seite zu prüfen, ob <ul id="posts">
zum Zeitpunkt des Ladens bereits im DOM vorhanden ist. Wenn das der Fall ist, weißt du, dass die Seite mithilfe von SSR erstellt wurde, und kannst Beiträge vermeiden, die du noch einmal hinzufügen müsstest. 👍
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>
Optimierungen
Neben dem Caching der gerenderten Ergebnisse gibt es viele interessante Optimierungsmöglichkeiten für ssr()
. Einige sind schnell umsetzbar, andere sind möglicherweise spekulativer. Die Leistungsvorteile, die Sie sehen, hängen letztendlich von den Arten von Seiten ab, die Sie vorrendern, und von der Komplexität der App.
Unnötige Anfragen abbrechen
Derzeit wird die gesamte Seite (und alle angeforderten Ressourcen) bedingungslos in Headless Chrome geladen. Uns interessieren jedoch nur zwei Dinge:
- Das gerenderte Markup.
- Die JS-Anfragen, die dieses Markup generiert haben.
Netzwerkanfragen, die kein DOM erstellen, sind verschwenderisch. Ressourcen wie Bilder, Schriftarten, Stylesheets und Medien werden nicht für den Aufbau des HTML-Codes einer Seite verwendet. Sie gestalten und ergänzen die Struktur einer Seite, erstellen sie aber nicht explizit. Wir sollten dem Browser mitteilen, diese Ressourcen zu ignorieren. Dadurch wird die Arbeitslast für headless Chrome reduziert, die Bandbreite gespart und die Pre-Rendering-Zeit für größere Seiten möglicherweise beschleunigt.
Das DevTools-Protokoll unterstützt eine leistungsstarke Funktion namens Netzwerk-Weiterleitung, mit der Anfragen geändert werden können, bevor sie vom Browser gesendet werden.
Puppeteer unterstützt die Netzwerkabfangung, indem page.setRequestInterception(true)
aktiviert und auf das request
-Ereignis der Seite gewartet wird.
So können wir Anfragen für bestimmte Ressourcen abbrechen und andere weiterlaufen lassen.
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};
}
Wichtige Ressourcen inline
Es ist üblich, separate Build-Tools wie gulp
zu verwenden, um eine App zu verarbeiten und wichtige CSS- und JS-Dateien zur Laufzeit in die Seite einzubetten. Dies kann die Zeit bis zum „Inhalte weitgehend gezeichnet“ verkürzen, da der Browser beim ersten Laden der Seite weniger Anfragen sendet.
Verwenden Sie stattdessen den Browser als Build-Tool. Mit Puppeteer können wir das DOM der Seite bearbeiten, Stile, JavaScript oder alles andere einfügen, was wir auf der Seite haben möchten, bevor sie vorgerendert wird.
In diesem Beispiel wird gezeigt, wie Sie Antworten für lokale Stylesheets abfangen und diese Ressourcen als <style>
-Tags in die Seite einfügen:
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};
}
Eine einzelne Chrome-Instanz für mehrere Rendervorgänge wiederverwenden
Das Starten eines neuen Browsers für jedes Pre-Rendering verursacht viel Overhead. Stattdessen können Sie eine einzelne Instanz starten und sie zum Rendern mehrerer Seiten wiederverwenden.
Puppeteer kann eine Verbindung zu einer vorhandenen Chrome-Instanz herstellen, indem puppeteer.connect()
aufgerufen und die URL für die Remote-Fehlerbehebung der Instanz übergeben wird. Um eine langlebige Browserinstanz beizubehalten, können wir den Code, der Chrome startet, aus der ssr()
-Funktion in den Express-Server verschieben:
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};
}
Beispiel: Cronjob für die regelmäßige Vorab-Rendering-Funktion
Wenn Sie mehrere Seiten gleichzeitig rendern möchten, können Sie eine freigegebene Browserinstanz verwenden.
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!');
});
Fügen Sie außerdem einen clearCache()
-Export zu ssr.js hinzu:
...
function clearCache() {
RENDER_CACHE.clear();
}
export {ssr, clearCache};
Weitere Hinweise
Signal für die Seite erstellen: „Sie werden headless gerendert“
Wenn Ihre Seite von headless Chrome auf dem Server gerendert wird, kann dies für die clientseitige Logik der Seite hilfreich sein. In meiner App habe ich diesen Hook verwendet, um Teile meiner Seite zu „deaktivieren“, die beim Rendern des Markups der Beiträge keine Rolle spielen. Ich habe beispielsweise Code deaktiviert, der firebase-auth.js per Lazy Loading lädt. Es gibt keinen Nutzer, der sich anmelden kann.
Wenn Sie der Render-URL einen ?headless
-Parameter hinzufügen, können Sie der Seite ganz einfach einen Hook hinzufügen:
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};
}
Auf der Seite können wir nach diesem Parameter suchen:
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>
Zu viele Seitenaufrufe in Analytics vermeiden
Wenn Sie Analytics auf Ihrer Website verwenden, sollten Sie vorsichtig sein. Das Vorab-Rendern von Seiten kann zu einer überhöhten Anzahl von Seitenaufrufen führen. Konkret sehen Sie doppelt so viele Treffer: einen Treffer, wenn die Seite in Chrome ohne User-Interface gerendert wird, und einen weiteren, wenn sie im Browser des Nutzers gerendert wird.
Wie kann ich das Problem beheben? Verwenden Sie die Netzwerk-Weiterleitung, um alle Anfragen abzubrechen, bei denen versucht wird, die Analytics-Bibliothek zu laden.
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();
});
Seitenaufrufe werden nie erfasst, wenn der Code nie geladen wird. Boom! 💥
Alternativ können Sie Ihre Analytics-Bibliotheken weiterhin laden, um zu sehen, wie viele Vorab-Renderings Ihr Server ausführt.
Fazit
Mit Puppeteer können Sie Seiten ganz einfach serverseitig rendern, indem Sie Headless Chrome als Companion auf Ihrem Webserver ausführen. Das Beste an diesem Ansatz ist, dass Sie die Ladeleistung und die Indexierbarkeit Ihrer App ohne größere Codeänderungen verbessern.
Wenn Sie sich eine funktionierende App ansehen möchten, in der die hier beschriebenen Techniken verwendet werden, sehen Sie sich die devwebfeed-App an.
Anhang
Diskussion des Stands der Technik
Das serverseitige Rendering clientseitiger Apps ist schwierig. Wie schwer? Sehen Sie sich nur an, wie viele npm-Pakete es zu diesem Thema gibt. Es gibt unzählige Muster, Tools und Dienste, die beim SSR von JS-Anwendungen helfen.
Isomorphes / universelles JavaScript
Das Konzept von Universal JavaScript bedeutet: Derselbe Code, der auf dem Server ausgeführt wird, wird auch auf dem Client (dem Browser) ausgeführt. Sie teilen Code zwischen Server und Client und alle sind zufrieden.
Headless Chrome ermöglicht „isomorphes JS“ zwischen Server und Client. Das ist eine gute Option, wenn Ihre Bibliothek auf dem Server (Node) nicht funktioniert.
Tools zum Vorab-Rendering
Die Node-Community hat zahlreiche Tools für SSR-JS-Anwendungen entwickelt. Das ist nicht weiter überraschend. Ich habe festgestellt, dass die Ergebnisse bei einigen dieser Tools je nach Fall variieren. Informieren Sie sich also unbedingt, bevor Sie sich für eines entscheiden. Einige SSR-Tools sind beispielsweise älter und verwenden keinen headless Chrome (oder einen anderen headless Browser). Stattdessen wird PhantomJS (das alte Safari) verwendet. Das bedeutet, dass Ihre Seiten nicht richtig gerendert werden, wenn sie neuere Funktionen verwenden.
Eine der bemerkenswerten Ausnahmen ist Prerender. Prerender ist interessant, da es eine headless Chrome-Version verwendet und eine Drop-in-Middleware für Express enthält:
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
Hinweis: Prerender enthält keine Details zum Herunterladen und Installieren von Chrome auf verschiedenen Plattformen. Oft ist es ziemlich schwierig, das richtig hinzubekommen. Das ist einer der Gründe, warum Puppeteer das für Sie erledigt.