Chrome bez interfejsu graficznego: odpowiedź na pytania o renderowanie po stronie serwera witryn JavaScript

Addy Osmani
Addy Osmani

Dowiedz się, jak za pomocą interfejsów API Puppeteer dodać do serwera WWW Express obsługę renderowania po stronie serwera (SSR). Najlepszą rzeczą jest to, że Twoja aplikacja wymaga niewielkich zmian w kodzie. Serwer bez głowy wykonuje całą ciężką pracę.

Za pomocą kilku linii kodu możesz przeprowadzić SSR dowolnej strony i uzyskać jej końcowy znacznik.

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?

Użycie przeglądarki bez ekranu może być przydatne, jeśli:

Niektóre frameworki, takie jak Preact, są dostarczane z narzędziami do renderowania po stronie serwera. Jeśli framework ma rozwiązanie do wstępnego renderowania, użyj go zamiast Puppeteer i Chrome w trybie bez głowy.

Skanowanie współczesnej sieci

Roboty wyszukiwarek, platformy do udostępniania treści w mediach społecznościowych, a nawet przeglądarki od zawsze indeksowały internet i wyświetlały treści wyłącznie na podstawie statycznego znacznika HTML. Nowoczesny internet ewoluował w coś zupełnie innego. Aplikacje oparte na JavaScript są tu na stałe, co oznacza, że w wielu przypadkach nasze treści mogą być niewidoczne dla narzędzi indeksujących.

Googlebot, nasz robot wyszukiwarki, przetwarza JavaScript, dbając jednocześnie o to, aby nie obniżać komfortu użytkowników witryny. Podczas projektowania stron i aplikacji musisz wziąć pod uwagę pewne różnice i ograniczenia, aby roboty mogły uzyskać dostęp do Twoich treści i je wyrenderować.

Wstępne renderowanie stron

Wszystkie roboty rozumieją HTML. Aby roboty mogły indeksować kod JavaScript, potrzebujemy narzędzia, które:

  • Umie uruchamiać wszystkie typy nowoczesnego JavaScriptu i generować statyczny kod HTML.
  • Jest aktualizowany wraz z dodawaniem nowych funkcji w internecie.
  • działa z niewielkimi lub żadnymi aktualizacjami kodu aplikacji;

Brzmi dobrze, prawda? Takim narzędziem jest przeglądarka. Chrome bez interfejsu graficznego nie interesuje się tym, jakiej biblioteki, frameworku czy zestawu narzędzi używasz.

Jeśli na przykład Twoja aplikacja została utworzona w Node.js, Puppeteer ułatwia pracę z Chrome bez interfejsu graficznego.

Zacznij od strony dynamicznej, która generuje 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 weź funkcję ssr() z poprzedniego przykładu i trochę ją rozbuduj:

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 pamięć podręczną. Zapisywanie w pamięci podręcznej wyrenderowanego kodu HTML to najskuteczniejsza metoda przyspieszania czasu odpowiedzi. Gdy strona zostanie ponownie przesłana, nie będziesz musiał uruchamiać przeglądarki Chromium bez interfejsu. Omówię inne optymalizacje w dalszej części artykułu.
  • Dodaj podstawową obsługę błędów, jeśli wczytywanie strony przekroczy limit czasu.
  • Dodaj do page.waitForSelector('#posts') wezwanie do działania. Dzięki temu wpisy istnieją w DOM, zanim zrzutujemy serializowaną stronę.
  • Dodaj naukę. Zapisz, ile czasu zajmuje renderowanie strony w przeglądarce bez interfejsu, i zwracaj czas renderowania wraz z kodem HTML.
  • Umieść kod w module o nazwie ssr.mjs.

Przykładowy serwer WWW

Na koniec przedstawiam mały serwer Express, który łączy wszystkie te elementy. Główny przetwarzacz wstępnie renderuje adres URL http://localhost/index.html (stronę główną) i wysyła wynik jako odpowiedź. Użytkownicy widzą posty natychmiast po otwarciu strony, ponieważ znaczniki statyczne są 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ć to przykładowe rozwiązanie, zainstaluj zależności (npm i --save puppeteer express) i uruchom serwer za pomocą Node 8.5.0 lub nowszej wersji i flagi --experimental-modules:

Oto przykład odpowiedzi przesłanej 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>

Idealny przypadek użycia nowego interfejsu Server-Timing API

Interfejs Server-Timing API przekazuje do przeglądarki dane o wydajności serwera (takie jak 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 zastosowaniem parametru Server-Timing jest raportowanie czasu, jaki zajmuje przeglądarce Chromium pozbawionej interfejsu wstępne renderowanie strony. Aby to zrobić, dodaj nagłówek Server-Timing do odpowiedzi serwera:

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

Na kliencie możesz używać interfejsu Performance API i interfejsu PerformanceObserver, aby uzyskiwać 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

Podane niżej wyniki obejmują większość optymalizacji skuteczności omawianych w dalszej części artykułu.

W przykładowej aplikacji Chromium bez interfejsu graficznego renderowanie strony na serwerze zajmuje około sekundy. Gdy strona zostanie zapisana w pamięci podręcznej, emulacja 3G w DevTools powoduje, że FCP jest o 8,37 s szybszy niż wersja po stronie klienta.

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

Te wyniki są obiecujące. Użytkownicy szybciej widzą treści, ponieważ strona renderowana po stronie serwera nie wczytuje się już za pomocą JavaScriptu i pokazuje posty.

Zapobieganie ponownemu nawilżeniu

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

Nasza aplikacja Express odbiera żądanie, używa Puppeteer do wczytania strony w trybie bez okna i zwraca wynik jako odpowiedź. Takie rozwiązanie ma jednak pewien problem.

Ten sam kod JavaScript, który jest wykonywany w Chrome bez interfejsu graficznego na serwerze, zostanie ponownie uruchomiony, gdy przeglądarka użytkownika wczyta stronę na interfejsie. Mamy 2 miejsca, w których generowany jest znacznik. #doublerender

Aby to naprawić, powiedz stronie, że kod HTML jest już na miejscu. Jednym z rozwiązań jest sprawdzenie przez kod JavaScript na stronie, czy element <ul id="posts"> jest już w DOM w momencie wczytywania. Jeśli tak, wiesz, że strona została zarchiwizowana i możesz 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 buforowania wyrenderowanych wyników możemy w programie ssr() wprowadzić wiele ciekawych optymalizacji. Niektóre z nich przynoszą szybkie efekty, inne wymagają więcej czasu. Korzyści związane z wydajnością mogą zależeć od typu stron, które prerenderujesz, oraz złożoności aplikacji.

Przerywanie nieistotnych żądań

Obecnie cała strona (i wszystkie zasoby, których potrzebuje) jest bezwarunkowo wczytywana do przeglądarki Chrome bez interfejsu. Interesują nas jednak 2 rzeczy:

  1. Wyrenderowany znacznik.
  2. żądania JS, które wygenerowały ten znacznik;

Żądania sieciowe, które nie tworzą DOM, są nieefektywne. Zasoby takie jak obrazy, czcionki, arkusze stylów i multimedia nie biorą udziału w generowaniu kodu HTML strony. Nadają one styl i uzupełniają strukturę strony, ale nie tworzą jej wprost. Powinniśmy powiedzieć przeglądarce, aby zignorowała te zasoby. Zmniejsza to obciążenie Chrome bez interfejsu graficznego, oszczędza przepustowość i potencjalnie przyspiesza czas prerenderowania większych stron.

Protokół DevTools obsługuje potężną funkcję przechwytywania sieci, która umożliwia modyfikowanie żądań przed ich wysłaniem przez przeglądarkę. Puppeteer obsługuje przechwytywanie sieci przez włączenie page.setRequestInterception(true)i nasłuchiwanie zdarzenia request strony. Dzięki temu możemy przerwać żądania dotyczące niektórych zasobów i zezwolić na dalsze przesyłanie innych.

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

Zwykle do przetwarzania aplikacji i wbudowywania w stronę krytycznych elementów kodu CSS i JavaScriptu w momencie kompilacji używa się osobnych narzędzi do kompilacji (takich jak gulp). Może to przyspieszyć pierwsze wyrenderowanie elementu znaczącego, ponieważ przeglądarka wysyła mniej żądań podczas początkowego wczytywania strony.

Zamiast osobnego narzędzia do kompilacji użyj przeglądarki jako narzędzia do kompilacji. Puppeteer może służyć do manipulowania modelem DOM strony, wstawiania stylów, JavaScriptu lub innych elementów, które chcesz dodać do strony przed jej wstępną renderyzacją.

Ten przykład pokazuje, jak przechwytywać odpowiedzi dotyczące lokalnych arkuszy stylów i umieszczać 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 używanie jednego wystąpienia Chrome do różnych renderowań

Uruchamianie nowej przeglądarki na potrzeby każdego wstępnego renderowania powoduje duże obciążenie. Możesz też uruchomić pojedynczy egzemplarz i wykorzystać go do renderowania wielu stron.

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

server.mjs

import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';

let browserWSEndpoint = null;
const app = express();

app.get('/', async (req, res, next) => {
  if (!browserWSEndpoint) {
    const browser = await puppeteer.launch();
    browserWSEndpoint = await browser.wsEndpoint();
  }

  const url = `${req.protocol}://${req.get('host')}/index.html`;
  const {html} = await ssr(url, browserWSEndpoint);

  return res.status(200).send(html);
});

ssr.mjs

import puppeteer from 'puppeteer';

/**
 * @param {string} url URL to prerender.
 * @param {string} browserWSEndpoint Optional remote debugging URL. If
 *     provided, Puppeteer's reconnects to the browser instance. Otherwise,
 *     a new browser instance is launched.
 */
async function ssr(url, browserWSEndpoint) {
  ...
  console.info('Connecting to existing Chrome instance.');
  const browser = await puppeteer.connect({browserWSEndpoint});

  const page = await browser.newPage();
  ...
  await page.close(); // Close the page we opened here (not the browser).

  return {html};
}

Przykład: zadanie crona do okresowego prerenderowania

Aby wyrenderować wiele stron jednocześnie, możesz użyć współdzielonej instancji przeglądarki.

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

Dodaj też eksport clearCache() do pliku ssr.js:

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

export {ssr, clearCache};

Inne uwagi

Utwórz sygnał dla strony: „Renderowanie w trybie bez głowy”

Gdy strona jest renderowana przez Chrome bez głowy na serwerze, może być przydatne, aby logika strony po stronie klienta wiedziała o tym, że tak się dzieje. W swojej aplikacji użyłem tego elementu, aby „wyłączyć” fragmenty strony, które nie odgrywają roli w renderowaniu znaczników postów. Na przykład wyłączyłem kod, który ładuje z opóźnieniem plik firebase-auth.js. Nie ma użytkownika, który mógłby się zalogować.

Dodanie parametru ?headless do adresu URL renderowania to prosty sposób na dodanie do strony elementu wywołującego:

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 znaleźć ten parametr:

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>

Unikaj zawyżania liczby odsłon w Analytics

Jeśli używasz w swojej witrynie Analytics, zachowaj ostrożność. Wstępne renderowanie stron może powodować zawyżoną liczbę odsłon. Widoczna będzie podwójna liczba uderzeń – jedno, gdy przeglądarka bez głowy Chrome wyrenderuje stronę, a drugie, gdy zrobi to przeglądarka użytkownika.

Jaki jest sposób na rozwiązanie tego problemu? Użyj przechwytywania sieci, aby przerwać żądania, które próbują załadować 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();
});

Odsłony strony nigdy nie są rejestrowane, jeśli kod nigdy się nie wczyta. Boom 💥.

Możesz też nadal wczytywać biblioteki Analytics, aby uzyskać informacje o tym, ile prerenderowań wykonuje Twój serwer.

Podsumowanie

Puppeteer ułatwia renderowanie stron po stronie serwera dzięki uruchamianiu przeglądarki Chrome bez interfejsu użytkownika na serwerze WWW. Największą zaletą tego podejścia jest to, że poprawia ono wydajność wczytywaniaułatwia indeksowanie aplikacji bez znaczących zmian kodu.

Jeśli chcesz zobaczyć działającą aplikację, która korzysta z omawianych tu technik, wypróbuj aplikację devwebfeed.

Dodatek

Omówienie stanu techniki

Renderowanie po stronie serwera aplikacji po stronie klienta jest trudne. Jak bardzo? Wystarczy spojrzeć, ile pakietów npm zostało napisanych na ten temat. Dostępne są niezliczone wzorce, narzędziausługi, które ułatwiają tworzenie aplikacji JS z serwerem osiowym.

Isomorphic / Universal JavaScript

Koncepcja uniwersalnego JavaScriptu oznacza, że ten sam kod, który działa na serwerze, działa też na kliencie (przeglądarce). Udostępniasz kod serwerowi i klientowi, a wszyscy czują się zrelaksowani.

Chrome bez interfejsu graficznego umożliwia „izomorficzny JS” między serwerem a klientem. Jest to świetna opcja, jeśli Twoja biblioteka nie działa na serwerze (Node).

Narzędzia do wstępnego renderowania

Społeczność Node stworzyła mnóstwo narzędzi do obsługi aplikacji JS SSR. Nic dziwnego! Osobiście uważam, że w przypadku niektórych z tych narzędzi wyniki mogą się różnić, dlatego warto się dobrze zastanowić, zanim zdecydujesz się na użycie danego narzędzia. Na przykład niektóre narzędzia SSR są starsze i nie korzystają z bezgłówej przeglądarki Chrome (ani z żadnej innej bezgłowej przeglądarki). Zamiast tego używają PhantomJS (czyli starego Safari), co oznacza, że strony nie będą prawidłowo renderowane, jeśli korzystają z nowszych funkcji.

Jednym z wyjątków jest renderowanie wstępne. Prerenderowanie jest interesujące, ponieważ korzysta z Chrome bez interfejsu graficznego i zawiera wtyczkę middleware dla Expressa:

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

Warto pamiętać, że funkcja Wstępna renderyzacja pomija szczegóły pobierania i instalowania Chrome na różnych platformach. Często jest to dość trudne, co jest jednym z powodów, dla których Puppeteer to dla Ciebie idealne rozwiązanie.