Headless Chrome: respuesta a los sitios JS de renderización del servidor

Aprende a usar las APIs de Puppeteer para agregar capacidades de renderización del servidor (SSR) a un servidor web de Express. La mejor parte es que tu app requiere cambios muy pequeños en el código. Headless se encarga del trabajo pesado.

Con un par de líneas de código, puedes realizar un SSR en cualquier página y obtener su lenguaje de marcado 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 qué usar Chrome sin interfaz gráfica?

Es posible que te interese Chrome sin interfaz gráfica en los siguientes casos:

Algunos frameworks, como Preact, se envían con herramientas que abordan la renderización del servidor. Si tu framework tiene una solución de renderización previa, no utilices Puppeteer y Headless Chrome en tu flujo de trabajo.

Rastreo en la Web moderna

Los rastreadores de los motores de búsqueda, las plataformas de uso compartido en redes sociales y incluso los navegadores siempre se basaron exclusivamente en el lenguaje de marcado HTML estático para indexar el contenido web y de superficie. La Web moderna ha evolucionado hacia algo muy diferente. Las aplicaciones basadas en JavaScript llegaron para quedarse, lo que significa que, en muchos casos, nuestro contenido puede ser invisible para las herramientas de rastreo.

Googlebot, nuestro rastreador de la Búsqueda, procesa JavaScript y, al mismo tiempo, se asegura de no perjudicar la experiencia de los usuarios que visitan el sitio. Existen algunas diferencias y limitaciones que debes tener en cuenta cuando diseñes tus páginas y aplicaciones para adaptar la manera en que los rastreadores acceden a tu contenido y lo procesan.

Renderizar previamente las páginas

Todos los rastreadores comprenden HTML. Para garantizar que los rastreadores puedan indexar JavaScript, necesitamos una herramienta que haga lo siguiente:

  • Sabe cómo ejecutar todos los tipos de JavaScript moderno y generar código HTML estático.
  • Se mantiene actualizado a medida que la Web agrega funciones.
  • Se ejecuta con pocas actualizaciones de código o ninguna en tu aplicación.

¿Te parece bien? Esa herramienta es el navegador. En Chrome sin interfaz gráfica, no importa qué biblioteca, framework o cadena de herramientas usas.

Por ejemplo, si tu aplicación se compiló con Node.js, Puppeteer es una manera fácil de trabajar con la versión 0.headless de Chrome.

Comencemos con una página dinámica que genera su HTML con 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>

Función SSR

A continuación, tomaremos la función ssr() anterior y la reforzaremos un poco:

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

Los cambios principales son los siguientes:

  • Se agregó el almacenamiento en caché. Almacenar en caché el HTML renderizado es la mayor ventaja a la hora de acelerar los tiempos de respuesta. Cuando se vuelve a solicitar la página, evitas ejecutar por completo Chrome sin interfaz gráfica. Más adelante, analizaremos otras optimizaciones.
  • Agrega el manejo básico de errores si se agota el tiempo de espera de la carga de la página.
  • Agrega una llamada a page.waitForSelector('#posts'). Esto garantiza que las publicaciones existan en el DOM antes de que volquemos la página serializada.
  • Agrega ciencia. Registra el tiempo que tarda el procesamiento sin interfaz gráfica de la página y mostrar el tiempo de renderización junto con el código HTML.
  • Pega el código en un módulo llamado ssr.mjs.

Ejemplo de servidor web

Por último, este es el pequeño servidor Express que reúne todo. El controlador principal renderiza previamente la URL http://localhost/index.html (la página principal) y entrega el resultado como su respuesta. Los usuarios ven las publicaciones de inmediato cuando llegan a la página, porque el lenguaje de marcado estático ahora forma parte de la respuesta.

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 ejecutar este ejemplo, instala las dependencias (npm i --save puppeteer express) y ejecuta el servidor con Node.js 8.5.0+ y la marca --experimental-modules:

Este es un ejemplo de la respuesta que envió 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>

Un caso de uso perfecto para la nueva API de Server-Timing

La API de Server-Timing comunica las métricas de rendimiento del servidor (como los tiempos de solicitud y respuesta o búsquedas en la base de datos) al navegador. El código de cliente puede usar esta información para realizar un seguimiento del rendimiento general de una app web.

Un caso de uso perfecto para Server-Timing es informar cuánto tarda Chrome sin interfaz gráfica en la renderización previa de una página. Para ello, solo agrega el encabezado Server-Timing a la respuesta del servidor:

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

En el cliente, la API de Performance y PerformanceObserver se pueden usar para acceder a 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 del rendimiento

Los siguientes resultados incorporan la mayoría de las optimizaciones de rendimiento que se analizan más adelante.

En una de mis apps (código), Chrome sin interfaz gráfica tarda alrededor de 1 segundo en renderizar la página en el servidor. Una vez que la página se almacena en caché, la emulación lenta 3G de Herramientas para desarrolladores coloca el FCP en 8.37 s más rápido que la versión del cliente.

Primer procesamiento de imagen (FP)First Contentful Paint (FCP)
App del cliente4 s 11s
Versión de SSR2.3sAprox. 2.3 s

Estos resultados son prometedores. Los usuarios ven contenido significativo mucho más rápido porque la página renderizada del servidor ya no depende de JavaScript para cargar y mostrar publicaciones.

Evita la rehidratación

¿Recuerdas cuando dije “no hicimos ningún cambio de código en la app del cliente”? Eso fue una mentira.

Nuestra app de Express toma una solicitud, usa Puppeteer para cargar la página en un modo headless y entrega el resultado como respuesta. Pero esta configuración tiene un problema.

El mismo JS que se ejecuta en Chrome sin interfaz gráfica en el servidor vuelve a ejecutarse cuando el navegador del usuario carga la página en el frontend. Hay dos lugares que generan lenguaje de marcado. #Doublerender!

Vamos a solucionarlo. Debemos indicarle a la página que ya está implementado. La solución que encontré fue que el JS de la página verificara si <ul id="posts"> ya se encuentra en el DOM en el tiempo de carga. Si es así, sabemos que la página se creó con SSR y podemos evitar que se vuelvan a agregar publicaciones. 👍

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>

Optimizaciones

Además de almacenar en caché los resultados renderizados, hay muchas optimizaciones interesantes que podemos realizar en ssr(). Algunas son victorias rápidas, mientras que otras pueden ser más especulativas. En última instancia, los beneficios de rendimiento que obtienes pueden depender de los tipos de páginas que renderizas previamente y la complejidad de la app.

Anula solicitudes no esenciales

En este momento, toda la página (y todos los recursos que solicita) se cargan de forma incondicional en Chrome sin interfaz gráfica. Sin embargo, solo nos interesan dos cosas:

  1. El lenguaje de marcado renderizado
  2. Las solicitudes de JS que produjeron ese lenguaje de marcado.

Las solicitudes de red que no construyen un DOM son un desperdicio. Los recursos como las imágenes, las fuentes, las hojas de estilo y los medios no participan en la compilación del código HTML de una página. Diseñan y complementan la estructura de una página, pero no la crean explícitamente. Deberíamos indicarle al navegador que ignore estos recursos. De esta manera, se reduce la carga de trabajo en Chrome sin interfaz gráfica, se ahorra ancho de banda y se puede acelerar el tiempo de procesamiento previo para las páginas más grandes.

El protocolo de Herramientas para desarrolladores admite una función potente llamada intercepción de red, que se puede usar para modificar las solicitudes antes de que el navegador las emita. Puppeteer admite la intercepción de red activando page.setRequestInterception(true) y escuchando el evento request de la página. Eso nos permite anular las solicitudes de ciertos recursos y permitir que otros continúen.

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

Intercala los recursos críticos

Es común usar herramientas de compilación independientes (como gulp) para procesar una app e intercalar CSS y JS de carácter crítico en la página durante la compilación. Esto puede acelerar el primer procesamiento de imagen significativo, ya que el navegador realiza menos solicitudes durante la carga inicial de la página.

En lugar de una herramienta de compilación independiente, usa el navegador como herramienta de compilación. Podemos usar Puppeteer para manipular el DOM, los estilos de intercalación, JavaScript o cualquier otro elemento que desees guardar en la página antes de renderizarla previamente.

En este ejemplo, se muestra cómo interceptar respuestas para hojas de estilo locales y, luego, intercalar esos recursos en la página como etiquetas <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};
}

Reutilización de una sola instancia de Chrome en todos los renderizados

Si inicias un navegador nuevo para cada renderización previa, se generarán muchas sobrecargas. En su lugar, te recomendamos iniciar una sola instancia y reutilizarla para renderizar varias páginas.

Puppeteer puede volver a conectarse a una instancia existente de Chrome llamando a puppeteer.connect() y pasándole la URL de depuración remota de la instancia. Para mantener una instancia de navegador de larga duración, podemos mover el código que inicia Chrome desde la función ssr() al 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};
}

Ejemplo: Trabajo cron para renderizar previamente de forma periódica

En mi app del panel de App Engine, configuro un controlador cron para volver a renderizar de forma periódica las páginas principales del sitio. Esto ayuda a los visitantes a ver siempre el contenido nuevo y rápido, a evitarlo y a que no vean el "costo de inicio" de una nueva renderización previa. En este caso, generar varias instancias de Chrome sería un desperdicio. En su lugar, uso una instancia compartida del navegador para procesar varias páginas a la 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!');
});

También agregué una exportación de clearCache() a ssr.js:

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

export {ssr, clearCache};

Otras consideraciones

Crea un indicador para la página: "Se te está renderizando sin interfaz gráfica".

Cuando Chrome sin interfaz gráfica en el servidor procesa tu página, puede resultar útil que la lógica del cliente de la página lo sepa. En mi app, utilicé este hook para "desactivar" las partes de mi página que no participan en la renderización del lenguaje de marcado de las publicaciones. Por ejemplo, inhabilité el código que realiza una carga diferida de firebase-auth.js. No hay ningún usuario para acceder.

Agregar un parámetro ?headless a la URL de renderización es una forma sencilla de atraer la atención a la 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};
}

En la página, podemos buscar ese 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>

No aumente las vistas de página de Analytics

Ten cuidado si usas Analytics en tu sitio. La renderización previa de las páginas puede generar un aumento excesivo de las vistas de página. Específicamente, verás el doble de hits: uno cuando Chrome procesa la página sin interfaz gráfica y otro cuando el navegador del usuario la procesa.

Entonces, ¿cuál es la solución? Usa la intercepción de red para anular las solicitudes que intenten cargar la biblioteca de 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();
});

Los hits de página nunca se registran si el código nunca se carga. ¡Bum! pub.

También puedes seguir cargando las bibliotecas de Analytics para obtener estadísticas sobre la cantidad de renderizaciones previas que realiza tu servidor.

Conclusión

Puppeteer facilita la renderización de páginas del servidor mediante la ejecución de Chrome sin interfaz gráfica, como complemento, en tu servidor web. Mi "función" favorita de este enfoque es que mejoras el rendimiento de carga y la indexabilidad de tu app sin cambios significativos en el código.

Si quieres ver una app funcional que use las técnicas que se describen aquí, consulta la app de devwebfeed.

Apéndice

Debate sobre el arte previo

Es difícil procesar las apps del cliente para procesar el servidor. ¿Qué tan difícil es? Solo considera cuántos paquetes de npm escribieron los usuarios y que están dedicados al tema. Hay una infinidad de patrones, tools y servicios disponibles para ayudarte con las apps de SSRing de JS.

JavaScript isomórfico / Universal

El concepto de JavaScript universal significa que el mismo código que se ejecuta en el servidor también se ejecuta en el cliente (el navegador). Compartes código entre el servidor y el cliente y todos sienten un momento zen.

Chrome sin interfaz gráfica habilita el “JS isomórfico” entre el servidor y el cliente. Es una gran opción si la biblioteca no funciona en el servidor (Node).

Herramientas de renderización previa

La comunidad de Node creó muchísimas herramientas para abordar las apps de SSR JS. ¡No hay sorpresas! Personalmente, descubrí que usar YMMV con algunas de estas herramientas, así que asegúrate de hacer tu tarea antes de comprometerte con una. Por ejemplo, algunas herramientas de SSR son más antiguas y no usan Chrome sin interfaz gráfica (ni ningún navegador sin interfaz gráfica). En su lugar, utilizan PhantomJS (también conocido como Safari antiguo), lo que significa que tus páginas no se renderizarán correctamente si usan funciones más nuevas.

Una de las excepciones notables es la de Renderización previa. La renderización previa es interesante porque usa Chrome sin interfaz gráfica y viene con middleware para Express directo:

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

Ten en cuenta que la renderización previa omite los detalles de descarga e instalación de Chrome en diferentes plataformas. A menudo, es bastante complicado hacerlo bien, que es una de las razones por las que Puppeteer lo hace por ti. También tuve problemas con el servicio en línea que renderiza algunas de mis apps:

chromestatus renderizado en un navegador
Sitio renderizado en un navegador
renderización previa de chromestatus
El mismo sitio renderizado por prerender.io