Headless Chrome: een antwoord op server-side rendering JS-sites

Leer hoe u Puppeteer API's kunt gebruiken om server-side rendering (SSR)-mogelijkheden toe te voegen aan een Express-webserver. Het beste is dat uw app zeer kleine codewijzigingen vereist. Headless doet al het zware werk.

Met een paar regels code kun je elke pagina SSRen en de definitieve opmaak krijgen.

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

Waarom Headless Chrome gebruiken?

Mogelijk bent u geïnteresseerd in Headless Chrome als:

Sommige raamwerken zoals Preact worden geleverd met tools die weergave aan de serverzijde aanpakken. Als uw framework een prerendering-oplossing heeft, blijf daar dan bij in plaats van Puppeteer en Headless Chrome in uw workflow op te nemen.

Het moderne web doorzoeken

Crawlers van zoekmachines, sociale deelplatforms en zelfs browsers hebben historisch gezien uitsluitend vertrouwd op statische HTML-opmaak om het web te indexeren en inhoud naar boven te halen. Het moderne web is geëvolueerd naar iets heel anders. Op JavaScript gebaseerde applicaties zijn niet meer weg te denken, wat betekent dat onze inhoud in veel gevallen onzichtbaar kan zijn voor crawltools.

De Googlebot, onze zoekcrawler, verwerkt JavaScript en zorgt ervoor dat dit de ervaring van gebruikers die de site bezoeken niet verslechtert. Er zijn enkele verschillen en beperkingen waarmee u rekening moet houden bij het ontwerpen van uw pagina's en toepassingen, zodat u rekening kunt houden met de manier waarop crawlers toegang krijgen tot uw inhoud en deze weergeven.

Pagina's vooraf renderen

Alle crawlers begrijpen HTML. Om ervoor te zorgen dat crawlers JavaScript kunnen indexeren, hebben we een tool nodig die:

  • Weet hoe u alle soorten moderne JavaScript moet uitvoeren en statische HTML moet genereren.
  • Blijft up-to-date terwijl internet functies toevoegt.
  • Werkt met weinig tot geen code-updates voor uw applicatie.

Klinkt goed toch? Dat hulpmiddel is de browser ! Headless Chrome maakt het niet uit welke bibliotheek, raamwerk of toolketen je gebruikt.

Als uw applicatie bijvoorbeeld is gebouwd met Node.js, is Puppeteer een gemakkelijke manier om met 0.headless Chrome te werken.

Laten we beginnen met een dynamische pagina die HTML genereert met JavaScript:

publiek/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-functie

Vervolgens nemen we de functie ssr() van eerder en versterken deze een beetje:

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

De belangrijkste veranderingen:

  • Caching toegevoegd. Het cachen van de weergegeven HTML is de grootste overwinning om de reactietijden te versnellen. Wanneer de pagina opnieuw wordt opgevraagd, vermijdt u dat u volledig headless Chrome gebruikt. Andere optimalisaties bespreek ik later.
  • Voeg basisfoutafhandeling toe als er een time-out optreedt bij het laden van de pagina.
  • Voeg een aanroep toe aan page.waitForSelector('#posts') . Dit zorgt ervoor dat de berichten in de DOM bestaan ​​voordat we de geserialiseerde pagina dumpen.
  • Voeg wetenschap toe. Registreer hoe lang het headless duurt om de pagina weer te geven en retourneer de weergavetijd samen met de HTML.
  • Plak de code in een module met de naam ssr.mjs .

Voorbeeld webserver

Eindelijk is hier de kleine expresserver die alles samenbrengt. De hoofdhandler geeft de URL http://localhost/index.html (de startpagina) vooraf weer en geeft het resultaat als antwoord weer. Gebruikers zien berichten onmiddellijk wanneer ze op de pagina terechtkomen, omdat de statische opmaak nu deel uitmaakt van het antwoord.

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

Om dit voorbeeld uit te voeren, installeert u de afhankelijkheden ( npm i --save puppeteer express ) en voert u de server uit met Node 8.5.0+ en de vlag --experimental-modules :

Hier is een voorbeeld van het antwoord dat door deze server is teruggestuurd:

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

Een perfecte use case voor de nieuwe Server-Timing API

De Server-Timing API communiceert serverprestatiestatistieken (zoals aanvraag- en responstijden of databasezoekopdrachten) terug naar de browser. Clientcode kan deze informatie gebruiken om de algehele prestaties van een web-app bij te houden.

Een perfect gebruiksscenario voor Server-Timing is om te rapporteren hoe lang het duurt voordat Chrome zonder hoofd een pagina vooraf weergeeft! Om dat te doen, voegt u gewoon de Server-Timing header toe aan het serverantwoord:

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

Op de client kunnen de Performance API en PerformanceObserver worden gebruikt om toegang te krijgen tot deze statistieken:

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

Prestatieresultaten

De volgende resultaten omvatten de meeste prestatie- optimalisaties die later worden besproken.

Op een van mijn apps ( code ) heeft headless Chrome ongeveer 1 seconde nodig om de pagina op de server weer te geven. Zodra de pagina in de cache is geplaatst, zorgt DevTools 3G Slow-emulatie ervoor dat FCP sneller op 8,37 seconden gaat dan de clientversie.

Eerste verf (FP) Eerste inhoudsvolle verf (FCP)
App aan de clientzijde 4s 11s
SSR-versie 2,3 s ~2,3s

Deze resultaten zijn veelbelovend. Gebruikers zien betekenisvolle inhoud veel sneller omdat de weergegeven pagina op de server niet langer afhankelijk is van JavaScript om berichten te laden + toont berichten .

Voorkomt rehydratatie

Weet je nog dat ik zei: "We hebben geen codewijzigingen aangebracht in de app aan de clientzijde"? Dat was een leugen.

Onze Express-app accepteert een verzoek, gebruikt Puppeteer om de pagina in headless te laden en geeft het resultaat als antwoord weer. Maar deze opstelling heeft een probleem.

Dezelfde JS die in headless Chrome op de server wordt uitgevoerd, wordt opnieuw uitgevoerd wanneer de browser van de gebruiker de pagina op de frontend laadt. We hebben twee plaatsen die markup genereren. #dubbelrender !

Laten we het oplossen. We moeten de pagina vertellen dat de HTML al aanwezig is. De oplossing die ik vond was om de pagina JS te laten controleren of <ul id="posts"> zich al in de DOM bevindt tijdens het laden. Als dit het geval is, weten we dat de pagina een SSR-bewerking heeft ondergaan en kunnen we voorkomen dat berichten opnieuw worden toegevoegd. 👍

publiek/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>

Optimalisaties

Naast het cachen van de weergegeven resultaten, zijn er tal van interessante optimalisaties die we kunnen aanbrengen in ssr() . Sommige zijn snelle overwinningen, terwijl andere misschien speculatiever zijn. De prestatievoordelen die u ziet, kunnen uiteindelijk afhangen van de typen pagina's die u vooraf rendert en de complexiteit van de app.

Breek niet-essentiële verzoeken af

Op dit moment wordt de hele pagina (en alle bronnen die erom worden gevraagd) onvoorwaardelijk in headless Chrome geladen. We zijn echter slechts in twee dingen geïnteresseerd:

  1. De weergegeven opmaak.
  2. De JS-verzoeken die deze markup hebben geproduceerd.

Netwerkverzoeken die geen DOM opbouwen, zijn verspillend . Bronnen zoals afbeeldingen, lettertypen, stylesheets en media nemen niet deel aan het opbouwen van de HTML van een pagina. Ze vormen en vullen de structuur van een pagina aan, maar ze creëren deze niet expliciet. We moeten de browser vertellen deze bronnen te negeren. Dit vermindert de werklast voor headless Chrome, bespaart bandbreedte en versnelt mogelijk de pre-renderingtijd voor grotere pagina's.

Het DevTools Protocol ondersteunt een krachtige functie genaamd Netwerkinterceptie , die kan worden gebruikt om verzoeken te wijzigen voordat ze door de browser worden uitgegeven. Puppeteer ondersteunt netwerkonderschepping door page.setRequestInterception(true) in te schakelen en te luisteren naar de request van de pagina . Hierdoor kunnen we aanvragen voor bepaalde bronnen afbreken en andere door laten gaan.

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

Inline kritische bronnen

Het is gebruikelijk om afzonderlijke bouwtools (zoals gulp ) te gebruiken om een ​​app te verwerken en tijdens het bouwen kritische CSS en JS in de pagina te inline. Dit kan de eerste betekenisvolle verf versnellen, omdat de browser minder verzoeken doet tijdens het laden van de pagina.

In plaats van een aparte bouwtool, gebruik je de browser als je bouwtool ! We kunnen Puppeteer gebruiken om de DOM van de pagina, inliningstijlen, JavaScript of wat dan ook dat u op de pagina wilt plakken te manipuleren voordat u deze vooraf weergeeft.

Dit voorbeeld laat zien hoe u reacties voor lokale stylesheets kunt onderscheppen en deze bronnen in de pagina kunt plaatsen als <style> -tags:

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

Hergebruik van één Chrome-instantie voor verschillende renders

Het starten van een nieuwe browser voor elke prerenderering zorgt voor veel overhead. In plaats daarvan wilt u wellicht één exemplaar starten en dit opnieuw gebruiken voor het weergeven van meerdere pagina's.

Puppeteer kan opnieuw verbinding maken met een bestaand exemplaar van Chrome door puppeteer.connect() aan te roepen en de URL voor foutopsporing op afstand van het exemplaar door te geven. Om een ​​langlopende browserinstantie te behouden, kunnen we de code die Chrome start vanuit de ssr() functie naar de Express-server verplaatsen:

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

Voorbeeld: cronjob om periodiek vooraf te renderen

In mijn App Engine-dashboard-app heb ik een cron-handler ingesteld om de bovenste pagina's op de site periodiek opnieuw weer te geven. Dit helpt bezoekers altijd snelle, nieuwe inhoud te zien en te vermijden en helpt hen de "opstartkosten" van een nieuwe pre-render te zien. Het voortbrengen van meerdere exemplaren van Chrome zou in dit geval verspilling zijn. In plaats daarvan gebruik ik een gedeelde browserinstantie om meerdere pagina's tegelijk weer te geven:

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

Ik heb ook een clearCache() -export toegevoegd aan ssr.js :

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

export {ssr, clearCache};

Andere Overwegingen

Creëer een signaal voor de pagina: "Je wordt hoofdloos weergegeven"

Wanneer uw pagina wordt weergegeven door headless Chrome op de server, kan het nuttig zijn voor de client-side logica van de pagina om dat te weten. In mijn app heb ik deze hook gebruikt om delen van mijn pagina uit te schakelen die geen rol spelen bij het weergeven van de berichtopmaak. Ik heb bijvoorbeeld code uitgeschakeld die firebase-auth.js lui laadt. Er is geen gebruiker om in te loggen!

Het toevoegen van een ?headless parameter aan de render-URL is een eenvoudige manier om de pagina een hook te geven:

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

En op de pagina kunnen we naar die parameter zoeken:

publiek/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>

Vermijd het opblazen van Analytics-paginaweergaven

Wees voorzichtig als u Analytics op uw site gebruikt. Het vooraf weergeven van pagina's kan leiden tot te hoge paginaweergaven. Concreet ziet u tweemaal het aantal hits : één hit wanneer Chrome zonder hoofd de pagina weergeeft en een andere wanneer de browser van de gebruiker deze weergeeft.

Dus wat is de oplossing? Gebruik netwerkonderschepping om verzoeken af ​​te breken die proberen de Analytics-bibliotheek te 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();
});

Paginahits worden nooit geregistreerd als de code nooit wordt geladen. Boem 💥.

U kunt ook doorgaan met het laden van uw Analytics-bibliotheken om inzicht te krijgen in het aantal pre-renders dat uw server uitvoert.

Conclusie

Puppeteer maakt het gemakkelijk om pagina's op de server weer te geven door Chrome zonder hoofd als begeleidende software op uw webserver uit te voeren. Mijn favoriete "functie" van deze aanpak is dat je de laadprestaties en de indexeerbaarheid van je app verbetert zonder noemenswaardige codewijzigingen !

Als je nieuwsgierig bent naar een werkende app die de hier beschreven technieken gebruikt, bekijk dan de devwebfeed-app .

Bijlage

Bespreking van de stand van de techniek

Server-side rendering van client-side apps is moeilijk. Hoe hard? Kijk maar eens hoeveel NPM-pakketten mensen hebben geschreven die aan dit onderwerp zijn gewijd. Er zijn talloze patronen , tools en services beschikbaar om te helpen met SSRing JS-apps.

Isomorf / universeel JavaScript

Het concept van Universal JavaScript houdt in: dezelfde code die op de server draait, draait ook op de client (de browser). Je deelt code tussen server en client en iedereen voelt een moment van zen.

Headless Chrome maakt "isomorfe JS" mogelijk tussen server en client. Het is een geweldige optie als uw bibliotheek niet werkt op de server (Node).

Prerender-hulpmiddelen

De Node-gemeenschap heeft talloze tools gebouwd voor het omgaan met SSR JS-apps. Geen verrassingen daar! Persoonlijk heb ik ontdekt dat YMMV werkt met sommige van deze tools, dus doe zeker je huiswerk voordat je er een aanschaft. Sommige SSR-tools zijn bijvoorbeeld ouder en gebruiken geen headless Chrome (of welke headless browser dan ook). In plaats daarvan gebruiken ze PhantomJS (ook wel oude Safari genoemd), wat betekent dat uw pagina's niet correct worden weergegeven als ze nieuwere functies gebruiken.

Een van de opmerkelijke uitzonderingen is Prerender . Prerender is interessant omdat het headless Chrome gebruikt en wordt geleverd met drop-in middleware voor Express :

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

Het is vermeldenswaard dat Prerender de details van het downloaden en installeren van Chrome op verschillende platforms weglaat. Vaak is het lastig om dat goed te krijgen, en dat is een van de redenen waarom Puppeteer dat voor je doet . Ik heb ook problemen gehad met het weergeven van enkele van mijn apps door de online service :

chromestatus weergegeven in een browser
Site weergegeven in een browser
chromestatus weergegeven door prerender
Dezelfde site weergegeven door prerender.io