Узнайте, как использовать 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, если:
- Вы создали веб-приложение, которое не индексируется поисковыми системами.
- Вы надеетесь на быструю победу, оптимизируя производительность JavaScript и улучшая первую осмысленную отрисовку .
Некоторые фреймворки, такие как Preact, поставляются с инструментами , предназначенными для рендеринга на стороне сервера. Если в вашей платформе есть решение для предварительного рендеринга, придерживайтесь его, а не добавляйте Puppeteer и Headless Chrome в свой рабочий процесс.
Сканирование современной сети
Сканеры поисковых систем, платформы социального обмена и даже браузеры исторически полагались исключительно на статическую разметку HTML для индексации веб-сайтов и поверхностного контента. Современная сеть превратилась в нечто совершенно иное. Приложения на основе JavaScript никуда не денутся, а это означает, что во многих случаях наш контент может быть невидим для инструментов сканирования.
Робот Googlebot, наш поисковый робот, обрабатывает JavaScript , следя за тем, чтобы он не ухудшал качество обслуживания пользователей, посещающих сайт. Существуют некоторые различия и ограничения , которые необходимо учитывать при разработке страниц и приложений, чтобы обеспечить доступ сканеров к вашему контенту и его обработку.
Страницы пререндера
Все сканеры понимают HTML. Чтобы сканеры могли индексировать JavaScript, нам нужен инструмент, который:
- Умеет запускать все типы современного JavaScript и генерировать статический HTML.
- Постоянно обновляется по мере добавления новых функций в Интернет.
- Работает практически без обновлений кода вашего приложения.
Звучит хорошо, правда? Этот инструмент — браузер ! Безголовому Chrome не важно, какую библиотеку, фреймворк или цепочку инструментов вы используете.
Например, если ваше приложение создано с использованием Node.js, Puppeteer – это простой способ работать с 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()
и немного улучшите ее:
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. Однако нас интересуют только две вещи:
- Отрисованная разметка.
- Запросы 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:
- Use a
page.on('response')
handler to listen for network responses. - Stashes the responses of local stylesheets.
- Finds all
<link rel="stylesheet">
in the DOM and replaces them with an equivalent<style>
. Seepage.$$eval
API docs. Thestyle.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 для периодического предварительного рендеринга
ДЛЯ одновременной визуализации нескольких страниц вы можете использовать общий экземпляр браузера.
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 устарели и не используют безголовый 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 вам подходит .