Chrome bez interfejsu graficznego: odpowiedź na potrzeby renderowania witryn JS po stronie serwera

Dowiedz się, jak za pomocą interfejsów API Puppeteer dodać możliwości renderowania po stronie serwera (SSR) do serwera WWW Express. Co najlepsze, aplikacja wymaga niewielkich zmian w kodzie. Bez głowy już ciężko się pracuje.

Wystarczy kilka wierszy kodu, aby utworzyć SSR na dowolnej stronie i uzyskać jej ostateczne znaczniki.

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

Dlaczego warto używać Chrome bez interfejsu graficznego?

Chrome bez interfejsu graficznego może Cię zainteresować, jeśli:

Niektóre platformy, takie jak Preact, używają narzędzi do renderowania po stronie serwera. Jeśli Twoja platforma zawiera rozwiązanie do wstępnego renderowania, trzymaj się go, zamiast wprowadzać w swoim przepływie pracy Puppeteer i Chrome bez interfejsu graficznego.

Przemierzanie współczesnego internetu

Roboty wyszukiwarek, platformy udostępniania treści społecznościowych, a nawet przeglądarki korzystały w przeszłości wyłącznie ze statycznych znaczników HTML do indeksowania sieci i wyświetlania treści. Współczesna sieć ewoluowała w coś zupełnie innego. Aplikacje oparte na JavaScripcie będą nadal obowiązywać, co oznacza, że w wielu przypadkach nasze treści mogą być niewidoczne dla narzędzi do indeksowania.

Nasz robot, czyli Googlebot, przetwarza kod JavaScript, dbając przy tym o to, aby nie obniżał on komfortu użytkowników witryny. Podczas projektowania stron i aplikacji musisz wziąć pod uwagę pewne różnice i ograniczenia, by roboty mogły uzyskiwać dostęp do Twoich treści i je renderować.

Wstępne renderowanie stron

Wszystkie roboty rozumieją kod HTML. Aby mieć pewność, że roboty mogą indeksować JavaScript, potrzebujemy narzędzia, które:

  • Wie, jak uruchamiać wszystkie typy nowoczesnego JavaScriptu i generować statyczny kod HTML.
  • Jest zawsze na bieżąco w miarę dodawania funkcji przez internet.
  • Działa z niewielką lub zerową aktualizacją kodu w aplikacji.

Brzmi nieźle, prawda? Tym narzędziem jest przeglądarka. Chrome bez interfejsu graficznego nie zależy od biblioteki, platformy czy łańcucha narzędzi.

Jeśli na przykład Twoja aplikacja została utworzona w Node.js, Puppeteer ułatwi Ci pracę w Chrome 0.headless.

Zacznijmy od strony dynamicznej, która generuje swój kod HTML za pomocą JavaScriptu:

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>

Funkcja SSR

Następnie użyjemy funkcji ssr() z wcześniejszej wersji i zwiększymy jej nieco:

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

Najważniejsze zmiany:

  • Dodano buforowanie. Przechowywanie wyrenderowanego kodu HTML w pamięci podręcznej jest największą zaletą, jeśli chodzi o skrócenie czasu odpowiedzi. Gdy strona zostanie ponownie zażądana, unikasz jednoczesnego używania Chrome bez interfejsu graficznego. Inne optymalizacje omówię później.
  • Dodaj podstawową obsługę błędów w przypadku przekroczenia limitu czasu wczytywania strony.
  • Dodaj połączenie z numerem page.waitForSelector('#posts'). Dzięki temu posty istnieją w DOM, zanim zrzucimy stronę zserializowaną.
  • Dodaj naukę. Rejestruje czas potrzebny na wyrenderowanie strony bez interfejsu graficznego i zwraca razem z kodem HTML czas renderowania.
  • Wklej kod w module o nazwie ssr.mjs.

Przykładowy serwer WWW

Na koniec przedstawiamy mały serwer Express, który łączy wszystko w jedną stronę. Główny moduł obsługi renderuje wstępnie adres URL http://localhost/index.html (stronę główną) i wyświetla wynik jako odpowiedź. Użytkownicy widzą posty od razu po wejściu na stronę, ponieważ znacznik statyczny jest teraz częścią odpowiedzi.

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

Aby uruchomić ten przykład, zainstaluj zależności (npm i --save puppeteer express) i uruchom serwer, używając węzła Node 8.5.0 lub nowszego oraz flagi --experimental-modules:

Oto przykładowa odpowiedź odesłana przez ten serwer:

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

Idealne zastosowanie nowego interfejsu Server-Timing API

Interfejs API Server-Timing przekazuje do przeglądarki dane o wydajności serwera (np. czasy żądań i odpowiedzi czy wyszukiwania w bazie danych). Kod klienta może używać tych informacji do śledzenia ogólnej wydajności aplikacji internetowej.

Idealnym przypadku użycia funkcji Server-czas jest raportowanie czasu potrzebnego na wstępne wyrenderowanie strony w Chrome bez interfejsu graficznego. Aby to zrobić, dodaj do odpowiedzi serwera nagłówek Server-Timing:

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

Po stronie klienta można użyć interfejsów Performance API i PerformanceObserver, aby uzyskać dostęp do tych danych:

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

Wyniki skuteczności

Podane niżej wyniki uwzględniają większość omówionych w dalszej części optymalizacji skuteczności.

W jednej z moich aplikacji (kod) renderowanie strony na serwerze bez interfejsu graficznego Chrome zajmuje około sekundy. Gdy strona zostanie zapisana w pamięci podręcznej, emulacja 3G Slow w Narzędziach deweloperskich sprawi, że FCP będzie działać 8,37 s szybciej niż w przypadku wersji po stronie klienta.

Pierwsze renderowanie (FP)First Contentful Paint (FCP)
Aplikacja po stronie klienta4 s 11s
Wersja SSR2,3 s~2,3 s

Wyniki są obiecujące. Użytkownicy znacznie szybciej widzą istotne treści, ponieważ strona renderowana po stronie serwera nie wymaga już wczytywania JavaScriptu i wyświetla posty.

Zapobieganie nawodnieniu

Pamiętasz, jak powiedziałem, że nie wprowadziliśmy żadnych zmian w kodzie aplikacji po stronie klienta? To było kłamstwo.

Nasza aplikacja Express pobiera żądanie, używa Puppeteer do wczytania strony bez interfejsu graficznego i wyświetla wynik jako odpowiedź. Z tą konfiguracją jest jednak problem.

Ten sam skrypt JS, który jest wykonywany w Chrome bez interfejsu graficznego na serwerze, uruchamia się ponownie, gdy przeglądarka użytkownika wczytuje stronę w frontendzie. Znaczniki są generowane w dwóch miejscach. #Doublerender

Zajmijmy się tym. Musimy poinformować, że jej kod HTML znajduje się już na stronie. Rozwiązaniem, które znalazłem, było sprawdzenie przez JavaScript strony, czy <ul id="posts"> znajduje się już w DOM w momencie wczytywania. Jeśli tak, wiemy, że strona została zarezerwowana i możemy uniknąć ponownego dodawania postów. 👍

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>

Optymalizacje

Oprócz zapisywania renderowanych wyników w pamięci podręcznej można wprowadzić wiele ciekawych optymalizacji w usłudze ssr(). Niektóre z nich są łatwe do osiągnięcia, a inne spekulacyjne. Korzyści w zakresie wydajności zależą od typu wstępnie renderowanych stron i złożoności aplikacji.

Przerywanie mniej ważnych żądań

W tej chwili cała strona (i wszystkie żądane przez nią zasoby) jest bezwarunkowo wczytywana do Chrome bez interfejsu graficznego. Jednak interesują nas tylko 2 rzeczy:

  1. Renderowany znacznik.
  2. Żądania JS, które wygenerowały te znaczniki.

Żądania sieciowe, które nie tworzą modelu DOM, są niepotrzebne. Takie zasoby jak obrazy, czcionki, arkusze stylów i multimedia nie są uwzględniane przy tworzeniu kodu HTML strony. Dopasowują one styl strony i dopełniają jej strukturę, ale nie są jawnie skonstruowane. Powinniśmy poinformować przeglądarkę, by ignorowała te zasoby. Zmniejsza to obciążenie związane z Chrome bez interfejsu graficznego, oszczędza przepustowość i potencjalnie skraca czas renderowania wstępnego dużych stron.

Protokół DevTools obsługuje zaawansowaną funkcję o nazwie Przechwytywanie sieci która pozwala modyfikować żądania, zanim zostaną wysłane przez przeglądarkę. Puppeteer obsługuje przechwytywanie sieci, włączając metodę page.setRequestInterception(true) i nasłuchując zdarzenia request strony. Dzięki temu możemy przerywać żądania dotyczące pewnych zasobów i pozostawać w kontakcie.

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

Wbudowane zasoby krytyczne

Do przetwarzania aplikacji oraz do umieszczenia na stronie kluczowych kodu CSS i JS w czasie kompilacji często używa się osobnych narzędzi do kompilacji (np. gulp). Może to przyspieszyć pierwsze wyrenderowanie, ponieważ podczas wstępnego wczytywania strony przeglądarka wysyła mniej żądań.

Zamiast osobnego narzędzia do kompilacji, używaj przeglądarki jako narzędzia. Za pomocą Puppeteer możemy modyfikować DOM strony, style w treści, JavaScript lub inne elementy, które chcesz umieścić na stronie przed jej wstępnym wyrenderowaniem.

Ten przykład pokazuje, jak przechwycić odpowiedzi z lokalnych arkuszy stylów i wbudować te zasoby na stronie jako tagi <style>:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  const stylesheetContents = {};

  // 1. Stash the responses of local stylesheets.
  page.on('response', async resp => {
    const responseUrl = resp.url();
    const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
    const isStylesheet = resp.request().resourceType() === 'stylesheet';
    if (sameOrigin && isStylesheet) {
      stylesheetContents[responseUrl] = await resp.text();
    }
  });

  // 2. Load page as normal, waiting for network requests to be idle.
  await page.goto(url, {waitUntil: 'networkidle0'});

  // 3. Inline the CSS.
  // Replace stylesheets in the page with their equivalent <style>.
  await page.$$eval('link[rel="stylesheet"]', (links, content) => {
    links.forEach(link => {
      const cssText = content[link.href];
      if (cssText) {
        const style = document.createElement('style');
        style.textContent = cssText;
        link.replaceWith(style);
      }
    });
  }, stylesheetContents);

  // 4. Get updated serialized HTML of page.
  const html = await page.content();
  await browser.close();

  return {html};
}

This code:

  1. Use a page.on('response') handler to listen for network responses.
  2. Stashes the responses of local stylesheets.
  3. Finds all <link rel="stylesheet"> in the DOM and replaces them with an equivalent <style>. See page.$$eval API docs. The style.textContent is set to the stylesheet response.

Auto-minify resources

Another trick you can do with network interception is to modify the responses returned by a request.

As an example, say you want to minify the CSS in your app but also want to keep the convenience having it unminified when developing. Assuming you've setup another tool to pre-minify styles.css, one can use Request.respond() to rewrite the response of styles.css to be the content of styles.min.css.

ssr.mjs

import fs from 'fs';

async function ssr(url) {
  ...

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. If request is for styles.css, respond with the minified version.
    if (req.url().endsWith('styles.css')) {
      return req.respond({
        status: 200,
        contentType: 'text/css',
        body: fs.readFileSync('./public/styles.min.css', 'utf-8')
      });
    }
    ...

    req.continue();
  });
  ...

  const html = await page.content();
  await browser.close();

  return {html};
}

Ponowne wykorzystywanie pojedynczej instancji Chrome podczas renderowania

Uruchomienie nowej przeglądarki dla każdego renderowania wstępnego wiąże się z dużym nakładem pracy. Zastanów się, czy nie lepiej uruchomić pojedynczą instancję i wykorzystać ją do renderowania wielu stron.

Puppeteer może ponownie połączyć się z istniejącą instancją Chrome, wywołując metodę puppeteer.connect() i przekazując jej adres URL zdalnego debugowania instancji. Aby instancja przeglądarki działała długo, możemy przenieść kod uruchamiający Chrome z funkcji ssr() na serwer ekspresowy:

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

Przykład: zadanie cron do okresowego renderowania wstępnego

W aplikacji z panelem App Engine konfiguruję moduł obsługi cron, aby okresowo ponownie renderować najpopularniejsze strony w witrynie. Dzięki temu użytkownicy zawsze widzą szybkie i aktualne treści, unikają i uniemożliwiają sprawdzanie „kosztu początkowego” nowego renderowania wstępnego. W takim przypadku utworzenie kilku instancji Chrome nie byłoby marnotrawstwem. Zamiast tego używam współdzielonej instancji przeglądarki do renderowania kilku stron naraz:

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

Dodałem też eksport clearCache() do pliku ssr.js:

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

export {ssr, clearCache};

Inne uwagi

Utwórz sygnał dla strony: „Jesteś renderowany w interfejsie bez interfejsu graficznego”.

Gdy strona jest renderowana przez Chrome bez interfejsu graficznego na serwerze, warto to wiedzieć na potrzeby logiki po stronie klienta. W mojej aplikacji użyłem tego haczyka, aby „wyłączyć” fragmenty strony, które nie odgrywają roli w renderowaniu znaczników postów. Wyłączyłem na przykład kod leniwego ładowania firebase-auth.js. Nie ma użytkownika, który mógłby się zalogować.

Dodanie parametru ?headless do URL-a renderowania to prosty sposób na przyciągnięcie uwagi strony:

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

Na stronie możemy poszukać tego parametru:

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>

Unikanie zawyżania liczby odsłon w Analytics

Zachowaj ostrożność, jeśli używasz Analytics w swojej witrynie. Wstępne renderowanie stron może zwiększyć liczbę odsłon. Konkretnie rzecz biorąc, będziesz mieć podwójną liczbę działań: jedno, gdy Chrome bez interfejsu graficznego wyrenderuje stronę, a drugie, gdy wyrenderuje ją przeglądarka użytkownika.

Co trzeba poprawić? Przechwytywanie sieci pozwala przerwać każde żądanie, które próbuje wczytać bibliotekę 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();
});

Jeśli kod nigdy się nie wczyta, wyświetlenia strony nie zostaną zarejestrowane. Bum 💥.

Możesz też nadal wczytywać biblioteki Analytics, aby mieć wgląd w liczbę wstępnego renderowania wykonywanego przez Twój serwer.

Podsumowanie

Puppeteer ułatwia renderowanie stron po stronie serwera przez uruchamianie na serwerze WWW Chrome bez interfejsu graficznego. Moją ulubioną „cechą” tego podejścia jest to, że poprawiasz wydajność wczytywania i łatwość indeksowania aplikacji bez znaczących zmian w kodzie.

Jeśli interesuje Cię działająca aplikacja wykorzystująca opisane tu techniki, wypróbuj aplikację devwebfeed.

Dodatek

Omówienie wcześniejszych technik

Renderowanie po stronie serwera aplikacji po stronie klienta jest trudne. Trudne? Zobacz, ile na dany temat napisało pakiety npm. Dostępnych jest niezliczone wzorce, tools i usługi pomocne w przypadku aplikacji SSRing JS.

JavaScript izomorficzny / uniwersalny

Koncepcja uniwersalnego JavaScriptu oznacza, że ten sam kod, który uruchamia się na serwerze, działa także na kliencie (czyli w przeglądarce). Współdzielisz kod między serwerem i klientem, a każdy może się zrelaksować.

Chrome bez interfejsu graficznego włącza „izomorficzny kod JavaScript” między serwerem a klientem. To świetne rozwiązanie, gdy Twoja biblioteka nie działa na serwerze (węźle).

Narzędzia do wstępnego renderowania

Społeczność Node zawiera mnóstwo narzędzi do obsługi aplikacji SSR JS. Bez niespodzianek! Osobiście uważam, że YMMV ma zastosowanie do niektórych z tych narzędzi, więc przed podjęciem decyzji o każdej z nich należy się zająć pracą domową. Na przykład niektóre narzędzia SSR są starsze i nie używają Chrome bez interfejsu graficznego (ani innej przeglądarki bez interfejsu graficznego). Zamiast tego wykorzystują PhantomJS (czyli starą wersję Safari), co oznacza, że strony nie będą się wyświetlać poprawnie, jeśli będą używać nowszych funkcji.

Jednym z ważnych wyjątków jest wstępne renderowanie. Renderowanie wstępne jest interesujące, ponieważ wykorzystuje Chrome bez interfejsu graficznego i ma zainstalowane oprogramowanie pośredniczące dla opcji Express:

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

Warto zauważyć, że wstępne renderowanie pomija szczegóły pobierania i instalowania Chrome na różnych platformach. Często jest to trudne, aby prawidłowo skończyć. To jeden z powodów, dla których Puppeteer robi to za Ciebie. Mam też problemy z renderowaniem niektórych moich aplikacji w usłudze online:

chromestatus renderowany w przeglądarce
Witryna wyrenderowana w przeglądarce
chromestatus renderowany przez wstępne renderowanie
Ta sama witryna renderowana przez prerender.io