Headless Chrome: eine Antwort auf serverseitiges Rendering von JavaScript-Websites

Mit den Puppeteer APIs können Sie einem Express-Webserver serverseitige Rendering-Funktionen (SSR) hinzufügen. Das Beste ist, dass Ihre App nur sehr wenige Codeänderungen erfordert. Die schwere Arbeit übernimmt Headless für die Aufgabe.

Mit ein paar Zeilen Code können Sie eine SSR jede Seite erstellen und deren endgültiges Markup abrufen.

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

Vorteile von Headless Chrome

Die Funktion „Headless Chrome“ könnte in folgenden Fällen für Sie von Interesse sein:

Einige Frameworks wie Preact werden mit Tools geliefert, die serverseitiges Rendering unterstützen. Wenn Ihr Framework eine Pre-Rendering-Lösung hat, sollten Sie diese beibehalten, anstatt Puppeteer und Headless Chrome in Ihren Workflow einzubinden.

Das moderne Web crawlen

Suchmaschinen-Crawler, Plattformen zum Teilen von Inhalten in sozialen Netzwerken und selbst Browser haben sich in der Vergangenheit ausschließlich auf statisches HTML-Markup verlassen, um Web- und Oberflächeninhalte zu indexieren. Das moderne Web hat sich sehr verändert. JavaScript-basierte Anwendungen werden auch in Zukunft erhalten bleiben. Das bedeutet, dass unsere Inhalte in vielen Fällen für Crawling-Tools unsichtbar sind.

Der Googlebot, unser Such-Crawler, verarbeitet JavaScript, ohne dabei die Nutzererfahrung auf der Website zu beeinträchtigen. Es gibt einige Unterschiede und Einschränkungen, die du beim Entwerfen deiner Seiten und Anwendungen beachten musst, damit deine Inhalte von Crawlern aufgerufen und gerendert werden können.

Seiten vorab rendern

Alle Crawler verstehen HTML. Damit Crawler JavaScript indexieren können, benötigen wir ein Tool, das

  • Weiß, wie alle Arten von modernem JavaScript ausgeführt und statisches HTML generiert werden kann
  • Immer auf dem neuesten Stand, da das Web neue Funktionen bietet
  • Es können kaum oder gar keine Codeaktualisierungen für Ihre Anwendung ausgeführt werden.

Klingt gut, oder? Dieses 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 0.headless Chrome zu arbeiten.

Beginnen wir mit einer dynamischen Seite, die ihren HTML-Code 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() von vorhin und bereichern sie etwas:

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 wurde hinzugefügt. Das Caching des gerenderten HTML-Codes ist der beste Vorteil, um die Antwortzeiten zu verkürzen. Wenn die Seite erneut angefordert wird, wird die monitorlose Chrome-Version nicht vollständig ausgeführt. Weitere Optimierungsmöglichkeiten erörtern wir später.
  • Fügen Sie eine grundlegende Fehlerbehandlung hinzu, wenn beim Laden der Seite eine Zeitüberschreitung auftritt.
  • Anruf für page.waitForSelector('#posts') hinzufügen. Dadurch wird sichergestellt, dass die Beiträge im DOM vorhanden sind, bevor die serielle Seite in eine Dumpdatei übertragen wird.
  • Wissenschaftlich hinzufügen. Protokolliere, wie lange es dauert, bis die Seite ohne Bildschirm gerendert wird und die Renderingzeit zusammen mit dem HTML-Code zurückgegeben wird.
  • Fügen Sie den Code in ein Modul mit dem Namen ssr.mjs ein.

Beispiel-Webserver

Und hier kommt noch der kleine Express-Server. Der Haupt-Handler rendert die URL http://localhost/index.html (die Startseite) vorab und gibt das Ergebnis als Antwort aus. 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'));

Installieren Sie zum Ausführen dieses Beispiels die Abhängigkeiten (npm i --save puppeteer express) und führen Sie den Server mit Node 8.5.0+ und dem Flag --experimental-modules aus:

Hier ist ein Beispiel für die Antwort, die von diesem Server zurückgesendet wurde:

<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 Datenbanksuchen zurück an den Browser. Anhand dieser Informationen kann der Clientcode die Gesamtleistung einer Webanwendung verfolgen.

Ein perfekter Anwendungsfall für das Server-Timing ist es, anzugeben, wie lange es dauert, bis ein monitorloser Chrome eine Seite vorab rendert. Dazu musst du der Serverantwort einfach den Header Server-Timing hinzufügen:

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

Auf dem Client können Sie mit der Performance API und PerformanceObserver auf diese Messwerte zugreifen:

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 beinhalten die meisten der später erläuterten Leistungsoptimierungen.

In einer meiner Apps (Code) braucht Chrome im monitorlosen Modus etwa eine Sekunde, um die Seite auf dem Server zu rendern. Sobald die Seite im Cache gespeichert ist, setzt die 3G Slow Emulation der Entwicklertools FCP auf 8,37 s schneller als die clientseitige Version.

First Paint (FP)First Contentful Paint (FCP)
Clientseitige Anwendung4 s 11s
SSR-Version2,3 s~ 2,3 s

Diese Ergebnisse sind vielversprechend. Nutzer sehen viel schneller relevante Inhalte, da die serverseitig gerenderte Seite kein JavaScript zum Laden mehr benötigt und Beiträge anzeigt.

Eine Rehydrierung verhindern

Erinnern Sie sich, als ich sagte: „Wir haben keine Codeänderungen an der clientseitigen App vorgenommen“? Das war eine Lüge.

Unsere Express-Anwendung nimmt eine Anfrage an, lädt die Seite mit Puppeteer in die monitorlose Version und gibt das Ergebnis als Antwort aus. Aber bei dieser Einrichtung gibt es ein Problem.

Dasselbe JS, das in einer monitorlosen Chrome-Version ausgeführt wird, wird noch einmal ausgeführt, wenn der Browser des Nutzers die Seite im Front-End lädt. Es gibt zwei Stellen, an denen Markup generiert wird. #doublerender

Das können wir ändern. Wir müssen der Seite mitteilen, dass ihr HTML-Code bereits vorhanden ist. Als Lösung habe ich festgestellt, dass die JavaScript-Seite der Seite prüft, ob sich <ul id="posts"> beim Laden bereits im DOM befindet. Wenn dies der Fall ist, wissen wir, dass die Seite von SSRs gehört, und können vermeiden, dass erneut Posts hinzugefügt werden. 👍

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 Speichern der gerenderten Ergebnisse im Cache gibt es viele interessante Optimierungen, die wir an ssr() vornehmen können. Einige sind schnelle Erfolge, andere sind eher spekulativ. Die Leistungsvorteile hängen letztendlich von den vorab gerenderten Seiten und der Komplexität der App ab.

Abbrechen nicht wesentlicher Anfragen

Im Moment wird die gesamte Seite (und alle angeforderten Ressourcen) bedingungslos in Chrome geladen. Wir interessieren uns jedoch nur für zwei Dinge:

  1. Das gerenderte Markup.
  2. Die JS-Anfragen, die dieses Markup generiert haben

Netzwerkanfragen, die kein DOM erstellen, sind verschwenderisch. Ressourcen wie Bilder, Schriftarten, Stylesheets und Medien sind nicht am HTML-Code einer Seite beteiligt. Sie gestalten und ergänzen die Struktur einer Seite, aber sie erstellen sie nicht explizit. Wir sollten den Browser anweisen, diese Ressourcen zu ignorieren. Dies reduziert die Arbeitslast für die monitorlose Chrome-Version, spart Bandbreite und verkürzt möglicherweise die Pre-Rendering-Zeit für größere Seiten.

Das DevTools-Protokoll unterstützt eine leistungsstarke Funktion namens Netzwerkabfangen, mit der Anfragen geändert werden können, bevor sie vom Browser gesendet werden. Puppeteer unterstützt das Abfangen von Netzwerken durch Aktivieren von page.setRequestInterception(true) und Überwachen des Ereignisses request der Seite. Dadurch können Anfragen für bestimmte Ressourcen abgebrochen und andere weitergeleitet werden.

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

Kritische Ressourcen inline einbinden

Es ist üblich, separate Build-Tools (z. B. gulp) zu verwenden, um eine App zu verarbeiten und wichtige CSS- und JS-Elemente bei der Erstellung auf der Seite einzufügen. Dadurch kann das erste sinnvolle Painting beschleunigt werden, da der Browser beim ersten Seitenaufbau weniger Anfragen stellt.

Verwenden Sie statt eines separaten Build-Tools den Browser als Build-Tool. Mit Puppeteer können wir vor dem Pre-Rendering das DOM, Inline-Stile, JavaScript oder andere Elemente bearbeiten, die auf der Seite enthalten sein sollen.

In diesem Beispiel wird gezeigt, wie Antworten für lokale Stylesheets abgefangen und diese Ressourcen als <style>-Tags in die Seite eingefügt werden:

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

Einzelne Chrome-Instanz für mehrere Renderings wiederverwenden

Es ist viel Aufwand, für jedes Pre-Rendering einen neuen Browser zu starten. Stattdessen können Sie eine einzelne Instanz starten und zum Rendern mehrerer Seiten wiederverwenden.

Puppeteer kann eine Verbindung zu einer vorhandenen Instanz von Chrome herstellen, indem puppeteer.connect() aufgerufen und die Remote-Debugging-URL der Instanz übergeben wird. Wenn Sie eine Browserinstanz mit langer Ausführungszeit erhalten möchten, können Sie den Code, der Chrome startet, aus der Funktion ssr() auf 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 zum regelmäßigen Pre-Rendering

In meiner App Engine-Dashboard-App habe ich einen Cron-Handler eingerichtet, um die beliebtesten Seiten der Website regelmäßig neu zu rendern. So sehen Besucher immer schnelle, aktuelle Inhalte und können die „Startkosten“ eines neuen Pre-Renderings vermeiden und vermeiden. In diesem Fall wäre es verschwender, mehrere Instanzen von Chrome zu erstellen. Stattdessen verwende ich eine gemeinsam genutzte Browserinstanz, um mehrere Seiten gleichzeitig zu rendern:

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

Ich habe außerdem einen clearCache()-Export zu ssr.js hinzugefügt:

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

export {ssr, clearCache};

Weitere Aspekte

Erstellen Sie ein Signal für die Seite: „Sie werden im monitorlosen Format gerendert.“

Wenn Ihre Seite von einer monitorlosen Chrome-Version auf dem Server gerendert wird, kann es hilfreich sein, wenn die clientseitige Logik der Seite dies erkennt. In meiner App habe ich diesen Hook verwendet, um Teile meiner Seite zu „deaktivieren“, die keine Rolle beim Rendern des Markups für Beiträge spielen. Ich habe beispielsweise Code deaktiviert, der firebase-auth.js per Lazy-Loading lädt. Es gibt keinen Nutzer, der sich anmelden kann.

Das Hinzufügen des ?headless-Parameters zur Rendering-URL ist eine einfache Möglichkeit, die Seite mit einem Hook versehen:

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

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

Übermäßige Analytics-Seitenaufrufe vermeiden

Seien Sie vorsichtig, wenn Sie Analytics auf Ihrer Website verwenden. Das Pre-Rendering von Seiten kann zu überhöhten Seitenaufrufen führen. Insbesondere gibt es doppelt so viele Treffer: einen, wenn die Seite von Chrome ohne grafische Ausgabe gerendert wird, und einen, wenn der Browser des Nutzers sie rendert.

Was ist also die Lösung? Mit dem Netzwerkabfangen können Sie alle Anfragen abbrechen, mit 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();
});

Seitenaufruftreffer werden nie erfasst, wenn der Code nie geladen wird. Bumm 💥.

Alternativ können Sie weiterhin die Analytics-Bibliotheken laden, um Informationen zur Anzahl der Pre-Renderings zu erhalten, die Ihr Server erzielt.

Fazit

Puppeteer vereinfacht das serverseitige Rendering von Seiten, indem es die monitorlose Chrome-Version als Begleiter auf Ihrem Webserver ausführt. Meine Lieblingsfunktion bei diesem Ansatz ist, dass Sie die Ladeleistung und die Indexierbarkeit Ihrer Anwendung ohne wesentliche Codeänderungen verbessern.

Wenn Sie neugierig auf eine funktionierende App, die die hier beschriebenen Techniken verwendet, sehen Sie sich die devwebfeed-App an.

Anhang

Erörterung des Stands der Technik

Das serverseitige Rendering clientseitiger Anwendungen ist schwierig. Wie schwierig? Sehen Sie sich einfach an, wie viele npm-Pakete zu diesem Thema geschrieben wurden. Es gibt unzählige Muster, tools und Dienste, die für SSRing-JS-Anwendungen hilfreich sind.

Isomorphes / universelles JavaScript

Das Konzept des universellen 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 fühlen sich heimlich.

Headless Chrome aktiviert „isomorphes JS“ zwischen Server und Client. Dies ist eine gute Option, wenn Ihre Bibliothek auf dem Server (Node) nicht funktioniert.

Pre-Rendering-Tools

Die Node.com-Community hat eine Vielzahl von Tools für die Arbeit mit SSR-JS-Anwendungen entwickelt. Keine Überraschungen! Ich persönlich habe festgestellt, dass YMMV mit einigen dieser Tools verwendet werden kann. Mach also auf jeden Fall deine Hausaufgaben, bevor du dich auf eines festlegst. So sind beispielsweise einige SSR-Tools älter und verwenden weder die monitorlose Chrome-Version noch einen monitorlosen Browser. Stattdessen wird PhantomJS (das alte Safari) verwendet, was bedeutet, dass Ihre Seiten mit neueren Funktionen nicht richtig gerendert werden.

Eine der Ausnahmen ist das Pre-Rendering. Das Pre-Rendering ist insofern interessant, als es eine monitorlose 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();

Beachte, dass beim Pre-Rendering keine Details zum Herunterladen und Installieren von Chrome auf verschiedenen Plattformen erforderlich sind. Oft ist es ziemlich schwierig, den richtigen Weg zu finden. Das ist einer der Gründe, warum Puppeteer für Sie arbeitet. Ich hatte auch Probleme mit dem Onlinedienst, der einige meiner Apps rendert:

Chromestatus wird in einem Browser gerendert
Website wird in einem Browser gerendert
Chromestatus wird durch Pre-Rendering gerendert
Von prerender.io gerenderte Website