Aprenda a usar as APIs do Puppeteer para adicionar recursos de renderização no lado do servidor (SSR, na sigla em inglês) a um servidor da Web Express. A melhor parte é que o app exige mudanças muito pequenas no código. O headless faz todo o trabalho pesado.
Com algumas linhas de código, você pode fazer SSR em qualquer página e receber a marcação final dela.
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;
}
Por que usar o Chrome Headless?
Talvez você se interesse pelo Chrome sem cabeça se:
- Você criou um app da Web que não está sendo indexado pelos mecanismos de pesquisa.
- Você espera uma vitória rápida para otimizar a performance do JavaScript e melhorar a primeira pintura significativa.
Alguns frameworks, como o Preact, vem com ferramentas que abordam a renderização no servidor. Se o framework tiver uma solução de pré-renderização, use-a em vez de trazer o Puppeteer e o Chrome Headless para o fluxo de trabalho.
Como rastrear a Web moderna
Rastreadores de mecanismos de pesquisa, plataformas de compartilhamento social e até mesmo navegadores historicamente dependem exclusivamente da marcação HTML estática para indexar a Web e mostrar o conteúdo. A Web moderna evoluiu para algo muito diferente. Os aplicativos baseados em JavaScript vieram para ficar, o que significa que, em muitos casos, nosso conteúdo pode ficar invisível para as ferramentas de rastreamento.
O Googlebot, nosso rastreador de pesquisa, processa JavaScript sem afetar a experiência dos usuários que acessam o site. Há algumas diferenças e limitações que precisam ser consideradas na criação de páginas e aplicativos para acomodar o modo como os rastreadores acessam e renderizam seu conteúdo.
Pré-renderizar páginas
Todos os rastreadores entendem HTML. Para garantir que os rastreadores possam indexar JavaScript, precisamos de uma ferramenta que:
- Sabe executar todos os tipos de JavaScript moderno e gerar HTML estático.
- Fica atualizado conforme a Web adiciona recursos.
- Funciona com poucas ou nenhuma atualização de código no aplicativo.
Parece bom, certo? Essa ferramenta é o navegador. O Chrome sem cabeça não se importa com a biblioteca, o framework ou a cadeia de ferramentas que você usa.
Por exemplo, se o aplicativo for criado com Node.js, o Puppeteer é uma maneira fácil de trabalhar com a versão headless do Chrome.
Comece com uma página dinâmica que gera o HTML com JavaScript:
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>
Função SSR
Em seguida, use a função ssr()
anterior para melhorar um pouco:
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};
As principais mudanças:
- Armazenamento em cache adicionado. Armazenar em cache o HTML renderizado é a maior vantagem para acelerar os tempos de resposta. Quando a página é solicitada novamente, você evita executar o Chrome headless por completo. Vou falar sobre outras otimizações mais adiante.
- Adicionar o tratamento de erros básico se o tempo limite do carregamento da página for excedido.
- Adicione uma chamada para
page.waitForSelector('#posts')
. Isso garante que as postagens existam no DOM antes de despejar a página serializada. - Adicione ciência. Registre quanto tempo o headless leva para renderizar a página e retorne o tempo de renderização com o HTML.
- Cole o código em um módulo chamado
ssr.mjs
.
Exemplo de servidor da Web
Por fim, aqui está o pequeno servidor Express que reúne tudo. O gerenciador
principal renderiza o URL http://localhost/index.html
(a página inicial)
e serve o resultado como resposta. Os usuários veem as postagens imediatamente
quando acessam a página porque a marcação estática agora faz parte da resposta.
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'));
Para executar este exemplo, instale as dependências (npm i --save puppeteer express
)
e execute o servidor usando o Node 8.5.0 ou mais recente e a flag --experimental-modules
:
Confira um exemplo da resposta enviada por esse servidor:
<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>
Um caso de uso perfeito para a nova API Server Timing
A API Server-Timing comunica métricas de desempenho do servidor (como tempos de solicitação e resposta ou pesquisas no banco de dados) de volta ao navegador. O código do cliente pode usar essas informações para acompanhar a performance geral de um app da Web.
Um caso de uso perfeito para Server-Timing é informar quanto tempo o Chrome
headless leva para pré-renderizar uma página. Para fazer isso, basta adicionar o cabeçalho Server-Timing
à
resposta do servidor:
res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);
No cliente, a API Performance e o PerformanceObserver podem ser usados para acessar estas métricas:
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)"
}
Resultados de desempenho
Os resultados a seguir incorporam a maioria das otimizações de desempenho discutidas mais adiante.
Em um app de exemplo, o Chrome headless leva cerca de um segundo para renderizar a página no servidor. Depois que a página é armazenada em cache, a emulação lenta de 3G do DevTools coloca o FCP em 8,37 segundos mais rápido do que a versão do lado do cliente.
First Paint (FP) | First Contentful Paint (FCP) | |
---|---|---|
App do lado do cliente | 4 s | 11s |
Versão do SSR | 2,3s | ~2,3s |
Esses resultados são promissores. Os usuários encontram conteúdo relevante muito mais rápido porque a página renderizada no servidor não depende mais do JavaScript para carregar e mostra postagens.
Evitar a reidratação
Lembra quando eu disse "não fizemos nenhuma alteração no código do app do lado do cliente"? Isso foi uma mentira.
Nosso app Express recebe uma solicitação, usa o Puppeteer para carregar a página em modo headless e serve o resultado como uma resposta. No entanto, essa configuração tem um problema.
O mesmo JavaScript que é executado no Chrome sem cabeça no servidor é executado novamente quando o navegador do usuário carrega a página no front-end. Temos dois lugares gerando markup. #doublerender!
Para corrigir isso, informe à página que o HTML já está em vigor.
Uma solução é fazer com que o JavaScript da página verifique se <ul id="posts">
já está no DOM no momento do carregamento. Se for, você sabe que a página foi SSR e pode
evitar adicionar as postagens novamente. 👍
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>
Otimizações
Além de armazenar os resultados renderizados em cache, há muitas otimizações
interessantes que podemos fazer em ssr()
. Algumas são vitórias rápidas, enquanto outras podem ser
mais especulativas. Os benefícios de desempenho que você vê podem depender dos
tipos de páginas que você pré-renderiza e da complexidade do app.
Interromper solicitações não essenciais
No momento, a página inteira (e todos os recursos solicitados) é carregada incondicionalmente no Chrome sem cabeça. No entanto, só nos interessam duas coisas:
- A marcação renderizada.
- As solicitações JS que produziram essa marcação.
As solicitações de rede que não constroem o DOM são ineficientes. Recursos como imagens, fontes, folhas de estilo e mídia não participam da criação do HTML de uma página. Eles estilizam e complementam a estrutura de uma página, mas não a criam explicitamente. Precisamos dizer ao navegador para ignorar esses recursos. Isso reduz a carga de trabalho do Chrome sem cabeça, economiza largura de banda e potencialmente acelera o tempo de pré-renderização de páginas maiores.
O protocolo DevTools
oferece suporte a um recurso poderoso chamado
Interceptação de rede,
que pode ser usado para modificar solicitações antes que elas sejam emitidas pelo navegador.
O Puppeteer oferece suporte à interceptação de rede ativando
page.setRequestInterception(true)
e detectando o
evento request
da página.
Isso nos permite abortar solicitações de determinados recursos e permitir que outros continuem.
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};
}
Recursos críticos inline
É comum usar ferramentas de build separadas (como gulp
) para processar um app e
inlinear CSS e JS essenciais na página no momento do build. Isso pode acelerar
a primeira pintura significativa porque o navegador faz menos solicitações durante
o carregamento inicial da página.
Em vez de uma ferramenta de build separada, use o navegador como sua ferramenta de build. Podemos usar o Puppeteer para manipular o DOM da página, estilizar, usar JavaScript ou qualquer outra coisa que você queira fixar na página antes da pré-renderização.
Este exemplo mostra como interceptar respostas para folhas de estilo locais
e incorporar esses recursos na página como tags <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:
- 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};
}
Reutilizar uma única instância do Chrome em renderizações
O lançamento de um novo navegador para cada pré-renderização gera muita sobrecarga. Em vez disso, talvez você queira iniciar uma única instância e reutilizá-la para renderizar várias páginas.
O Puppeteer pode se reconectar a uma instância existente do Chrome chamando
puppeteer.connect()
e transmitindo o URL de depuração remota da instância. Para manter uma instância de navegador
de longa duração, podemos mover o código que inicia o Chrome da função ssr()
para o servidor 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};
}
Exemplo: job cron para pré-renderizar periodicamente
Para renderizar várias páginas de uma só vez, use uma instância de navegador compartilhada.
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!');
});
Além disso, adicione uma exportação clearCache()
ao ssr.js:
...
function clearCache() {
RENDER_CACHE.clear();
}
export {ssr, clearCache};
Outras considerações
Crie um indicador para a página: "Você está sendo renderizado em modo headless"
Quando a página é renderizada pelo Chrome sem cabeçalho no servidor, pode ser útil para a lógica do lado do cliente saber disso. No meu app, usei esse hook para "desativar" partes da minha página que não desempenham um papel na renderização da marcação de posts. Por exemplo, desativei o código que carrega de forma lazy firebase-auth.js. Não há usuário para fazer login.
Adicionar um parâmetro ?headless
ao URL de renderização é uma maneira simples
de criar um gancho para a página:
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};
}
E na página, podemos procurar esse parâmetro:
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>
Evite inflar as visualizações de página do Google Analytics
Tenha cuidado se estiver usando o Google Analytics no seu site. A pré-renderização de páginas pode resultar em visualizações de página infladas. Especificamente, você vai encontrar o dobro do número de hits, um quando o Chrome sem cabeça renderizar a página e outro quando o navegador do usuário a renderizar.
Qual é a correção? Use a interceptação de rede para interromper solicitações que tentam carregar a biblioteca do Google 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();
});
Os hits da página nunca são registrados se o código não for carregado. Boom 💥.
Como alternativa, continue carregando as bibliotecas do Google Analytics para saber quantas prerenderizações seu servidor está executando.
Conclusão
O Puppeteer facilita a renderização de páginas do lado do servidor executando o Chrome sem cabeça, como um complemento, no servidor da Web. O "recurso" favorito dessa abordagem é que você melhore o desempenho de carregamento e a indexabilidade do app sem mudanças significativas no código.
Se você quiser conferir um app funcional que usa as técnicas descritas aqui, confira o app devwebfeed.
Apêndice
Discussão da anterioridade
A renderização de apps do lado do cliente no servidor é difícil. Qual a dificuldade? Basta conferir quantos pacotes npm foram criados e dedicados ao tema. Há inúmeros padrões, ferramentas e serviços disponíveis para ajudar com apps JS de SSR.
JavaScript universal / isomorfo
O conceito de JavaScript universal significa que o mesmo código executado no servidor também é executado no cliente (o navegador). Você compartilha o código entre o servidor e o cliente, e todos sentem um momento de paz.
O Chrome sem cabeça ativa o "JS isomórfico" entre o servidor e o cliente. É uma ótima opção se a biblioteca não funcionar no servidor (Node).
Ferramentas de pré-renderização
A comunidade do Node criou várias ferramentas para lidar com apps JS de SSR. Nada de surpresas! Pessoalmente, descobri que YMMV com algumas dessas ferramentas. Por isso, faça sua lição de casa antes de se comprometer com uma delas. Por exemplo, algumas ferramentas de SSR são mais antigas e não usam o Chrome sem cabeça (ou qualquer navegador sem cabeça). Em vez disso, eles usam o PhantomJS (também conhecido como o antigo Safari), o que significa que suas páginas não serão renderizadas corretamente se estiverem usando recursos mais recentes.
Uma das exceções mais notáveis é a Prerender. O Prerender é interessante porque usa o Chrome sem servidor e vem com o middleware para Express:
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
Vale a pena observar que o Prerender não inclui os detalhes de download e instalação do Chrome em diferentes plataformas. Muitas vezes, isso é bastante complicado de fazer corretamente, o que é uma das razões pelas quais o Puppeteer faz isso por você.