Headless Chrome: ответ на серверный рендеринг JavaScript-сайтов

Адди Османи
Addy Osmani

Узнайте, как использовать API-интерфейсы Puppeteer для добавления возможностей серверного рендеринга (SSR) на веб-сервер Express. Самое приятное то, что ваше приложение требует очень небольших изменений в коде. Безголовый делает всю тяжелую работу.

За пару строк кода вы можете выполнить SSR любой страницы и получить ее окончательную разметку.

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

Зачем использовать Headless Chrome?

Вас может заинтересовать Headless Chrome, если:

Некоторые фреймворки, такие как Preact, поставляются с инструментами , предназначенными для рендеринга на стороне сервера. Если в вашей платформе есть решение для предварительного рендеринга, придерживайтесь его, а не добавляйте Puppeteer и Headless Chrome в свой рабочий процесс.

Сканирование современной сети

Сканеры поисковых систем, платформы социальных сетей и даже браузеры исторически полагались исключительно на статическую разметку HTML для индексации веб-сайтов и поверхностного контента. Современная сеть превратилась в нечто совершенно иное. Приложения на основе JavaScript никуда не денутся, а это означает, что во многих случаях наш контент может быть невидим для инструментов сканирования.

Робот Googlebot, наш поисковый робот, обрабатывает JavaScript, следя за тем, чтобы он не ухудшал качество обслуживания пользователей, посещающих сайт. Существуют некоторые различия и ограничения , которые необходимо учитывать при разработке страниц и приложений, чтобы обеспечить доступ сканеров к вашему контенту и его обработку.

Страницы пререндера

Все сканеры понимают HTML. Чтобы сканеры могли индексировать JavaScript, нам нужен инструмент, который:

  • Умеет запускать все типы современного JavaScript и генерировать статический HTML.
  • Остается в курсе событий, когда в Интернете добавляются новые функции.
  • Работает практически без обновлений кода вашего приложения.

Звучит хорошо, правда? Этот инструмент — браузер ! Безголовому Chrome не важно, какую библиотеку, фреймворк или цепочку инструментов вы используете.

Например, если ваше приложение создано с использованием Node.js, Puppeteer — это простой способ работать с Chrome без интерфейса.

Начните с динамической страницы, которая генерирует HTML-код с помощью JavaScript:

общественный/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() и немного улучшите ее:

сср.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};

Основные изменения:

  • Добавлено кэширование. Кэширование визуализированного HTML — самый большой выигрыш в сокращении времени ответа. Когда страница повторно запрашивается, вы вообще избегаете запуска Chrome без управления. О других оптимизациях я расскажу позже.
  • Добавьте базовую обработку ошибок, если время загрузки страницы истекло.
  • Добавьте вызов page.waitForSelector('#posts') . Это гарантирует, что сообщения существуют в DOM, прежде чем мы создадим дамп сериализованной страницы.
  • Добавьте науку. Регистрируйте, сколько времени требуется для рендеринга страницы в режиме headless, и возвращайте время рендеринга вместе с HTML.
  • Вставьте код в модуль с именем ssr.mjs .

Пример веб-сервера

Наконец, вот небольшой экспресс-сервер, который объединяет все это. Основной обработчик предварительно отображает URL-адрес http://localhost/index.html (домашнюю страницу) и передает результат в качестве ответа. Пользователи сразу видят сообщения, когда заходят на страницу, поскольку статическая разметка теперь является частью ответа.

сервер.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'));

Чтобы запустить этот пример, установите зависимости ( npm i --save puppeteer express ) и запустите сервер, используя Node 8.5.0+ и флаг --experimental-modules :

Вот пример ответа, отправленного этим сервером:

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

Идеальный вариант использования нового API синхронизации сервера

API синхронизации сервера передает показатели производительности сервера (такие как время запросов и ответов или поиск в базе данных) обратно в браузер. Клиентский код может использовать эту информацию для отслеживания общей производительности веб-приложения.

Идеальный вариант использования Server-Timing — сообщить, сколько времени требуется автономному Chrome для предварительной визуализации страницы. Для этого просто добавьте заголовок Server-Timing к ответу сервера:

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

На клиенте Performance API и PerformanceObserver можно использовать для доступа к этим метрикам:

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

Результаты производительности

Следующие результаты включают в себя большую часть оптимизации производительности, обсуждаемой позже.

В примере приложения автономному Chrome требуется около одной секунды для отображения страницы на сервере. После кэширования страницы эмуляция DevTools 3G Slow увеличивает скорость FCP на 8,37 с по сравнению с версией на стороне клиента.

Первая краска (FP) Первая содержательная краска (FCP)
Клиентское приложение 4 с 11 секунд
версия ССР 2,3 с ~2,3 с

Эти результаты являются многообещающими. Пользователи видят содержательный контент гораздо быстрее, поскольку страница, отображаемая на стороне сервера , больше не использует JavaScript для загрузки + отображения сообщений .

Предотвратить повторную гидратацию

Помните, я сказал: «Мы не вносили никаких изменений в код клиентского приложения»? Это была ложь.

Наше приложение Express принимает запрос, использует Puppeteer для загрузки страницы в безголовый режим и передает результат в качестве ответа. Но у этой установки есть проблема.

Тот же код JavaScript, который выполняется в автономном Chrome на сервере, запускается снова , когда браузер пользователя загружает страницу во внешнем интерфейсе. У нас есть два места, где генерируется разметка. #двойник !

Чтобы это исправить, сообщите странице, что ее HTML-код уже существует. Одним из решений является проверка JavaScript страницы, находится ли <ul id="posts"> уже в DOM во время загрузки. Если это так, вы знаете, что страница была защищена SSR, и можете избежать повторного добавления сообщений. 👍

общественный/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>

Оптимизации

Помимо кэширования результатов визуализации, мы можем выполнить множество интересных оптимизаций ssr() . Некоторые из них приносят быстрые победы, тогда как другие могут быть более спекулятивными. Видимый вами выигрыш в производительности в конечном итоге может зависеть от типов страниц, которые вы выполняете предварительную обработку, и сложности приложения.

Прерывать несущественные запросы

Прямо сейчас вся страница (и все запрашиваемые ею ресурсы) безоговорочно загружается в безголовый Chrome. Однако нас интересуют только две вещи:

  1. Отрисованная разметка.
  2. Запросы JS, создавшие эту разметку.

Сетевые запросы, которые не создают DOM, являются расточительными . Такие ресурсы, как изображения, шрифты, таблицы стилей и медиа, не участвуют в создании HTML-кода страницы. Они стилизуют и дополняют структуру страницы, но не создают ее явным образом. Мы должны сказать браузеру игнорировать эти ресурсы. Это снижает рабочую нагрузку на автономный Chrome, экономит пропускную способность и потенциально ускоряет время предварительной отрисовки для больших страниц.

Протокол DevTools поддерживает мощную функцию под названием «Сетевой перехват» , которую можно использовать для изменения запросов до того, как они будут отправлены браузером. Puppeteer поддерживает сетевой перехват, включив page.setRequestInterception(true) и прослушивая событие request страницы . Это позволяет нам прерывать запросы на определенные ресурсы и позволять другим продолжать работу.

сср.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};
}

Встроенные критически важные ресурсы

Обычно используются отдельные инструменты сборки (например, gulp ) для обработки приложения и встраивания критически важных CSS и JS в страницу во время сборки. Это может ускорить первую осмысленную отрисовку, поскольку браузер делает меньше запросов во время начальной загрузки страницы.

Вместо отдельного инструмента сборки используйте браузер в качестве инструмента сборки ! Мы можем использовать Puppeteer для управления DOM страницы, встроенными стилями, JavaScript и всем остальным, что вы хотите добавить на страницу перед ее предварительной отрисовкой.

В этом примере показано, как перехватить ответы для локальных таблиц стилей и встроить эти ресурсы на страницу в виде тегов <style> :

сср.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};
}

Повторное использование одного экземпляра Chrome при рендеринге

Запуск нового браузера для каждого предварительного рендеринга создает много накладных расходов. Вместо этого вы можете запустить один экземпляр и повторно использовать его для рендеринга нескольких страниц.

Puppeteer может повторно подключиться к существующему экземпляру Chrome, вызвав puppeteer.connect() и передав ему URL-адрес удаленной отладки экземпляра. Чтобы сохранить экземпляр браузера, работающий долго, мы можем переместить код, запускающий Chrome, из функции ssr() на сервер Express:

сервер.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);
});

сср.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};
}

Пример: задание cron для периодического предварительного рендеринга

ДЛЯ одновременной визуализации нескольких страниц вы можете использовать общий экземпляр браузера.

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

Также добавьте clearCache() в ssr.js :

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

export {ssr, clearCache};

Другие соображения

Создайте сигнал для страницы: «Вас визуализируют без головы».

Когда ваша страница отображается на сервере с помощью headless Chrome, для клиентской логики страницы может быть полезно знать это. В своем приложении я использовал этот крючок, чтобы «отключить» части моей страницы, которые не играют роли в отрисовке разметки сообщений. Например, я отключил код, который лениво загружает firebase-auth.js . Нет пользователя для входа!

Добавление параметра ?headless к URL-адресу рендеринга — это простой способ привязать страницу к хуку:

сср.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};
}

И на странице мы можем найти этот параметр:

общественный/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>

Не увеличивайте количество просмотров страниц Google Analytics.

Будьте осторожны, если вы используете Analytics на своем сайте. Предварительная обработка страниц может привести к увеличению количества просмотров. В частности, вы увидите в два раза больше обращений : одно попадание, когда Chrome отображает страницу в автономном режиме, а другое, когда браузер пользователя отображает ее.

Так что же исправить? Используйте сетевой перехват, чтобы прервать любые запросы, которые пытаются загрузить библиотеку 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();
});

Обращения к страницам никогда не записываются, если код никогда не загружается. Бум 💥.

Альтернативно, продолжайте загружать библиотеки аналитики, чтобы получить представление о том, сколько предварительных отрисовок выполняет ваш сервер.

Заключение

Puppeteer упрощает рендеринг страниц на стороне сервера, запуская на вашем веб-сервере Chrome в качестве сопутствующего приложения. Моя любимая «особенность» этого подхода — то, что вы улучшаете производительность загрузки и индексируемость вашего приложения без значительных изменений кода !

Если вам интересно увидеть работающее приложение, использующее описанные здесь методы, ознакомьтесь с приложением devwebfeed .

Приложение

Обсуждение предшествующего уровня техники

Рендеринг клиентских приложений на стороне сервера — это сложно. Насколько тяжело? Просто посмотрите, сколько пакетов npm , посвященных этой теме, написали люди. Существует бесчисленное множество шаблонов , инструментов и сервисов, которые помогут вам с приложениями SSRing JS.

Изоморфный/универсальный JavaScript

Концепция универсального JavaScript означает: тот же код, который работает на сервере, также работает и на клиенте (браузере). Вы делитесь кодом между сервером и клиентом, и каждый чувствует момент дзен.

Безголовый Chrome обеспечивает «изоморфный JS» между сервером и клиентом. Отличный вариант, если ваша библиотека не работает на сервере (Node).

Инструменты предварительной визуализации

Сообщество Node создало множество инструментов для работы с приложениями SSR JS. Никаких сюрпризов! Лично я нашел YMMV с помощью некоторых из этих инструментов, поэтому обязательно сделайте домашнюю работу, прежде чем приступать к использованию одного из них. Например, некоторые инструменты SSR устарели и не используют Headless Chrome (или любой другой автономный браузер, если уж на то пошло). Вместо этого они используют PhantomJS (также известный как старый Safari), а это означает, что ваши страницы не будут отображаться должным образом, если они используют новые функции.

Одним из заметных исключений является Prerender . Prerender интересен тем, что использует headless Chrome и поставляется со встроенным промежуточным программным обеспечением для Express :

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

Стоит отметить, что Prerender не учитывает детали загрузки и установки Chrome на разных платформах. Часто это довольно сложно сделать правильно, и это одна из причин, почему Puppeteer вам подходит .