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

Узнайте, как использовать 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 — это простой способ работать с 0.headless 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 требуется около 1 секунды для рендеринга страницы на сервере. После кэширования страницы эмуляция DevTools 3G Slow увеличивает скорость FCP на 8,37 с по сравнению с версией на стороне клиента.

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

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

Предотвращение повторной гидратации

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

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

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

Давайте исправим это. Нам нужно сообщить странице, что ее HTML-код уже существует. Решение, которое я нашел, заключалось в том, чтобы JS-страница проверяла, находится ли <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 страницы . Это позволяет нам прерывать запросы на определенные ресурсы и позволять другим продолжать работу.

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

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

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

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

В этом примере показано, как перехватить ответы для локальных таблиц стилей и встроить эти ресурсы на страницу в виде тегов <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};
}

Повторное использование одного экземпляра 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);
});

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

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

В своем приложении панели управления App Engine я настроил обработчик cron для периодического повторного рендеринга верхних страниц сайта. Это помогает посетителям всегда видеть быстрый и свежий контент и избегать его, а также помогает им не видеть «затраты на запуск» нового предварительного рендеринга. Создание нескольких экземпляров Chrome в этом случае было бы расточительством. Вместо этого я использую общий экземпляр браузера для одновременного отображения нескольких страниц:

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-адресу рендеринга — это простой способ привязать страницу к хуку:

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

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

общественный/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 вам подходит . У меня также были проблемы с онлайн-сервисом , отображающим некоторые из моих приложений:

chromestatus отображается в браузере
Сайт отображается в браузере
chromestatus, визуализируемый с помощью prerender
Тот же сайт, созданный prerender.io