Headless Chrome: uma resposta para sites JS de renderização do lado do servidor

Saiba como usar as APIs do Puppeteer para adicionar recursos de renderização do lado do servidor (SSR, na sigla em inglês) a um servidor da Web Express. A melhor parte é que seu app exige mudanças muito pequenas no código. O headless faz todo o trabalho pesado.

Com algumas linhas de código, você pode usar a SSR em qualquer página e obter sua marcação final.

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 a Headless Chrome?

Talvez você se interesse pelo Headless Chrome se:

Alguns frameworks, como o Preact, são fornecidos com ferramentas que abordam a renderização do lado do servidor. Se o framework tiver uma solução de pré-renderização, use-a em vez de incluir o Puppeteer e o Headless Chrome no fluxo de trabalho.

Como rastrear a Web moderna

Rastreadores de mecanismos de pesquisa, plataformas de compartilhamento em redes sociais e até mesmo navegadores dependiam exclusivamente da marcação HTML estática para indexar a Web e exibir 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 da Pesquisa, processa o JavaScript sem afetar a experiência dos usuários que visitam o site. Há algumas diferenças e limitações que você precisa considerar ao criar páginas e aplicativos para acomodar a forma como os rastreadores acessam e renderizam seu conteúdo.

Pré-renderizar páginas

Todos os rastreadores entendem HTML. Para garantir que os rastreadores indexem JavaScript, precisamos de uma ferramenta que:

  • Sabe como executar todos os tipos de JavaScript moderno e gerar HTML estático.
  • Fica atualizado à medida que a Web adiciona recursos.
  • Executado com pouca ou nenhuma atualização de código no seu aplicativo.

Parece uma boa ideia, não é? Essa ferramenta é o navegador. A versão headless do Chrome não importa qual biblioteca, framework ou cadeia de ferramentas você usa.

Por exemplo, se o aplicativo for criado com Node.js, o Puppeteer será uma maneira fácil de trabalhar com o Chrome 0.headless.

Vamos começar com uma página dinâmica que gera 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, vamos usar a função ssr() de antes e aprimorá-la 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. O armazenamento em cache do HTML renderizado é a maior vitória para acelerar os tempos de resposta. Quando a página é solicitada novamente, você evita executar a versão headless do Chrome. Falarei sobre outras otimizações posteriormente.
  • Inclua um tratamento de erros básico se a página expirar.
  • Adicione uma chamada para page.waitForSelector('#posts'). Isso garante que as postagens existam no DOM antes de despejarmos a página serializada.
  • Adicionar ciência. Registre quanto tempo a visualização headless leva para renderizar a página e retornar 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 expresso que reúne tudo. O gerenciador principal pré-renderiza o URL http://localhost/index.html (a página inicial) e exibe o resultado como resposta. Os usuários veem imediatamente as postagens 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+ e a sinalização --experimental-modules:

Veja um exemplo de resposta enviada por este 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 Server-Timing API

A API Server-Timing comunica as métricas de desempenho do servidor (como tempos de solicitação e resposta ou pesquisas de banco de dados) de volta ao navegador. O código do cliente pode usar essas informações para acompanhar o desempenho geral de um app da Web.

Um caso de uso perfeito para o Server-Timing é informar quanto tempo leva para a versão headless do Chrome 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 posteriormente.

Em um dos meus apps (código), a versão headless do Chrome leva cerca de um segundo para renderizar a página no servidor. Quando a página é armazenada em cache, a Emulação lenta 3G do DevTools coloca a FCP em 8,37s mais rápido do que a versão do lado do cliente.

Primeira exibição (FP)First Contentful Paint (FCP)
Aplicativo do lado do cliente4 s 11s
Versão da SSR2,3 sCerca de 2,3 s

Esses resultados são promissores. Os usuários veem conteúdo significativo com muito mais rapidez, porque a página renderizada do lado do servidor não depende mais do JavaScript para carregar e mostrar postagens.

Como prevenir a reidratação

Você se lembra de quando eu disse "não fizemos mudanças de código no app do lado do cliente"? Isso era mentira.

Nosso app Express recebe uma solicitação, usa o Puppeteer para carregar a página na headless e exibe o resultado como uma resposta. Mas essa configuração tem um problema.

O mesmo JavaScript executado na versão headless do Chrome no servidor é executado novamente quando o navegador do usuário carrega a página no front-end. Temos dois lugares que geram marcações. #doublerender!

Vamos corrigir. Precisamos informar à página que o HTML já está no lugar. A solução que encontrei foi fazer com que o JS da página verificasse se <ul id="posts"> já estava no DOM no momento do carregamento. Se for, sabemos que a página recebeu uma SSR e podemos evitar adicionar 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 para 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 pré-renderizadas e da complexidade do app.

Cancelar solicitações não essenciais

No momento, a página inteira e todos os recursos solicitados por ela são carregados incondicionalmente na versão headless do Chrome. No entanto, só estamos interessados em duas coisas:

  1. A marcação renderizada.
  2. As solicitações do JS que produziram essa marcação.

Solicitações de rede que não criam o DOM são desperdício. 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. O navegador deve ignorar esses recursos. Isso reduz a carga de trabalho da versão headless do Chrome, economiza largura de banda e possivelmente acelera o tempo de pré-renderização para páginas maiores.

O protocolo DevTools oferece suporte a um recurso eficiente chamado Interceptação de rede, que pode ser usado para modificar solicitações antes de serem emitidas pelo navegador. O Puppeteer oferece suporte à interceptação de rede ativando page.setRequestInterception(true) e detectando o evento request da página. Com isso, podemos cancelar solicitações para determinados recursos e deixar outros prosseguir.

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

É comum usar ferramentas de build separadas (como gulp) para processar um app e in-line do CSS e do JS essenciais na página no momento da criação. Isso pode acelerar a primeira exibição 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 ferramenta de build. Podemos usar o Puppeteer para manipular o DOM da página, estilos em linha, JavaScript ou qualquer outra coisa que você queira fixar na página antes de pré-renderizar.

Este exemplo mostra como interceptar respostas para folhas de estilo locais e inserir esses recursos inline 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:

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

Como reutilizar uma única instância do Chrome em renderizações

A inicialização de um novo navegador para cada pré-renderização cria muita sobrecarga. Em vez disso, convém 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 do 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: cron job para pré-renderizar periodicamente

No meu aplicativo de painel do App Engine, configuro um cron gerenciador para renderizar novamente as principais páginas do site periodicamente. Isso ajuda os visitantes a sempre ver conteúdo rápido e atualizado, bem como a evitar e a evitar o "custo de inicialização" de uma nova pré-renderização. Gerar várias instâncias do Chrome seria um desperdício para esse caso. Em vez disso, estou usando uma instância de navegador compartilhado para renderizar várias páginas de uma só vez:

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

Também adicionei uma exportação clearCache() para ssr.js:

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

export {ssr, clearCache};

Outras considerações

Criar um indicador para a página: "Você está sendo renderizado sem comando"

Quando sua página está sendo renderizada pelo Chrome headless no servidor, pode ser útil para a lógica do cliente da página saber disso. No meu app, usei esse gancho para "desativar" partes da minha página que não desempenham um papel na renderização da marcação de postagens. Por exemplo, eu desativei o código que carrega lentamente o 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 dar um gancho à 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};
}

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>

Evitar o aumento das exibições de página do Google Analytics

Tenha cuidado ao usar o Google Analytics no seu site. As páginas de pré-renderização podem resultar em visualizações de página infladas. Especificamente, você verá o dobro do número de hits: um quando a versão headless do Chrome renderiza a página e outro quando o navegador do usuário faz a renderização.

Qual é a correção? Use a interceptação de rede para cancelar qualquer solicitação que tente carregar a biblioteca do 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 de página nunca serão registrados se o código não for carregado. Ótimo! !

Como alternativa, continue carregando suas bibliotecas do Analytics para receber insights sobre quantas pré-renderizações o servidor está executando.

Conclusão

O Puppeteer facilita a renderização de páginas no lado do servidor executando a versão headless do Chrome, como um complemento, no seu servidor da Web. Meu "recurso" favorito dessa abordagem é que você melhora o desempenho do carregamento e a indexabilidade do app sem mudanças significativas no código.

Se você quiser ver um app funcional que use as técnicas descritas aqui, confira o app devwebfeed.

Apêndice

Discussão sobre arte anterior

A renderização de apps do lado do cliente no lado do servidor é difícil. Qual é a dificuldade? Observe quantos pacotes npm (em inglês) as pessoas escreveram dedicados ao tópico. Há inúmeros padrões, tools e serviços disponíveis para ajudar com apps SSRing para JS.

JavaScript isomórfico / universal

O conceito de JavaScript universal significa: o mesmo código executado no servidor também é executado no cliente (o navegador). Você compartilha código entre o servidor e o cliente, e todos se sentem zen.

A versão headless do Chrome ativa o "JS isolado" entre o servidor e o cliente. Essa é uma ótima opção se a biblioteca não funciona no servidor (Node).

Ferramentas de pré-renderização

A comunidade do Node criou muitas ferramentas para lidar com apps SSR JS. Sem surpresas. Pessoalmente, descobri que MMV com algumas dessas ferramentas, então faça sua lição de casa antes de se comprometer com uma. Por exemplo, algumas ferramentas de SSR são mais antigas e não usam a versão headless do Chrome (ou qualquer navegador headless nesse caso). Em vez disso, eles usam o PhantomJS (também conhecido como Safari antigo), o que significa que suas páginas não serão renderizadas corretamente se estiverem usando recursos mais recentes.

Uma das exceções notáveis é a Pré-renderização. A pré-renderização é interessante, porque usa a versão headless do Chrome e vem com middleware para Express:

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

A pré-renderização não inclui os detalhes de download e instalação do Chrome em diferentes plataformas. Muitas vezes, isso é bastante complicado acertar, e esse é um dos motivos pelo qual o Puppeteer faz por você (links em inglês). Também tive problemas com o serviço on-line que renderizava alguns dos meus apps:

chromestatus renderizado em um navegador
Site renderizado em um navegador
chromestatus renderizado pela pré-renderização
Mesmo site renderizado por prerender.io