Headless Chrome: respuesta a los sitios de JavaScript que se renderizan en el servidor

Addy Osmani
Addy Osmani

Descubre cómo puedes usar las APIs de Puppeteer para agregar capacidades de renderización del servidor (SSR) a un servidor web Express. La mejor parte es que tu app requiere cambios muy pequeños en el código. El modo sin cabeza hace todo el trabajo pesado.

Con un par de líneas de código, puedes renderizar cualquier página y obtener su 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?

Te puede interesar 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, utilízala en lugar de incorporar Puppeteer y Chrome sin interfaz gráfica a tu flujo de trabajo.

Cómo rastrear la Web moderna

Históricamente, los rastreadores de motores de búsqueda, las plataformas de redes sociales y hasta los navegadores se basaban exclusivamente en el lenguaje de marcado HTML estático para indexar la Web y mostrar contenido. La Web moderna evolucionó a algo mucho más 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 considerar cuando diseñes tus aplicaciones y páginas para ajustar la manera en que los rastreadores acceden a tu contenido y lo procesan.

Renderización previa de páginas

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

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

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

Por ejemplo, si tu aplicación se compiló con Node.js, Puppeteer es una manera fácil de trabajar con Chrome sin interfaz gráfica.

Comienza con una página dinámica que genere 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, toma la función ssr() de antes y mejorála 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};

Estos son los cambios más importantes:

  • Se agregó el almacenamiento en caché. Almacenar en caché el HTML renderizado es la mayor ventaja para acelerar los tiempos de respuesta. Cuando se vuelve a solicitar la página, evitas ejecutar Chrome sin interfaz gráfica. Más adelante, hablaré de otras optimizaciones.
  • Agrega un manejo de errores básico 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 volcar la página serializada.
  • Agrega ciencia. Registra cuánto tiempo tarda la versión sin interfaz gráfica en renderizar la página y muestra 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 lo une 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 ingresan a la página porque el 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 8.5.0 o una versión posterior y la marca --experimental-modules:

Este es un ejemplo de la respuesta que envía 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 las búsquedas en la base de datos) al navegador. El código del cliente puede usar esta información para hacer 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 renderizar previamente una página. Para ello, 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, se pueden usar la API de Performance y PerformanceObserver 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 analizarán más adelante.

En una app de ejemplo, Chromium sin interfaz gráfica tarda alrededor de un segundo en renderizar la página en el servidor. Una vez que la página se almacena en caché, la emulación lenta de 3G de DevTools establece 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 11 s
Versión de SSR2.3 sAprox. 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 que “no hicimos ningún cambio de código en la app del cliente”? Eso fue una mentira.

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

El mismo código JavaScript 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. Tenemos dos lugares que generan marcado. #doublerender.

Para solucionar este problema, dile a la página que su código HTML ya está en su lugar. Una solución es hacer que el código JavaScript de la página verifique si <ul id="posts"> ya está en el DOM en el momento de la carga. Si es así, significa que la página se procesó con SSR y puedes evitar volver 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. Los beneficios de rendimiento que veas pueden depender, en última instancia, de los tipos de páginas que renderices previamente y de la complejidad de la app.

Aborta las solicitudes no esenciales

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

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

Las solicitudes de red que no construyen el DOM son ineficientes. Los recursos, como las imágenes, las fuentes, las hojas de estilo y el contenido multimedia, no participan en la compilación del código HTML de una página. Le dan estilo y complementan la estructura de una página, pero no la crean de forma explícita. Debemos decirle al navegador que ignore estos recursos. Esto reduce la carga de trabajo de Chrome sin interfaz gráfica, ahorra ancho de banda y, posiblemente, acelera el tiempo de renderización previa de páginas más grandes.

El protocolo de DevTools admite una función potente llamada interceptación de red, que se puede usar para modificar las solicitudes antes de que las emita el navegador. Puppeteer admite la intercepción de red activando page.setRequestInterception(true) y escuchando el evento request de la página. Eso nos permite abortar 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};
}

Recursos críticos intercalados

Es común usar herramientas de compilación independientes (como gulp) para procesar una app y intercalar CSS y JS críticos en la página durante el tiempo de compilación. Esto puede acelerar la primera pintura significativa porque 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 de la página, intercalar estilos, JavaScript o cualquier otro elemento que quieras incluir en la página antes de renderizarla previamente.

En este ejemplo, se muestra cómo interceptar respuestas para hojas de estilo locales y 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};
}

Cómo volver a usar una sola instancia de Chrome en todas las renderizaciones

Iniciar un navegador nuevo para cada renderización previa genera mucha sobrecarga. En su lugar, te recomendamos que inicies una sola instancia y la vuelvas a usar para renderizar varias páginas.

Para volver a conectarse a una instancia existente de Chrome, Puppeteer llama a puppeteer.connect() y le pasa la URL de depuración remota de la instancia. Para mantener una instancia del 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 de forma previa de forma periódica

Para renderizar varias páginas a la vez, puedes usar una instancia de navegador compartida.

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

Además, agrega 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 renderiza sin interfaz gráfica"

Cuando Chrome sin cabeza renderiza tu página en el servidor, puede ser útil que la lógica del cliente de la página lo sepa. En mi app, usé este hook para “desactivar” 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 carga de forma diferida firebase-auth.js. No hay ningún usuario que pueda acceder.

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

Y 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>

Evita aumentar las vistas de página de Analytics

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

Entonces, ¿cuál es la solución? Usa la intercepción de red para abortar 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. Boom 💥.

Como alternativa, continúa cargando tus bibliotecas de Analytics para obtener información sobre la cantidad de renderizaciones previas que realiza tu servidor.

Conclusión

Puppeteer facilita la renderización de páginas del servidor, ya que ejecuta 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 en funcionamiento que use las técnicas que se describen aquí, consulta la app devwebfeed.

Apéndice

Análisis del estado de la técnica

La renderización del servidor de apps del cliente es difícil. ¿Qué tan difícil? Solo mira la cantidad de paquetes npm que se escribieron sobre el tema. Existen innumerables patrones, herramientas y servicios disponibles para ayudar con el SSR de apps de JS.

JavaScript isomórfico o 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 de paz.

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

Herramientas de renderización previa

La comunidad de Node creó toneladas de herramientas para trabajar con apps de JS de SSR. No hay sorpresas. En lo personal, he descubierto que los resultados pueden variar con algunas de estas herramientas, así que haz tu tarea antes de comprometerte con una. Por ejemplo, algunas herramientas de SSR son más antiguas y no usan Chrome sin cabeza (o cualquier navegador sin cabeza). En su lugar, usan 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 Prerender. Prerender es interesante porque usa Chrome sin interfaz gráfica y viene con un middleware para Express que se puede agregar fácilmente:

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

Es importante tener en cuenta que Prerender omite los detalles de la descarga y instalación de Chrome en diferentes plataformas. A menudo, es bastante complicado hacerlo bien, y ese es uno de los motivos por los que Puppeteer lo hace por ti.