【译】无头 Chrome:服务端渲染 JS 页面的一个解决方案

TL;DR

无头 Chrome 是一个将动态 JS 页面转成静态 HTML 页面的即插即用的解决方案。将其运行于 web 服务器之上,你可以预渲染任何现代 JS 特性,从而提速内容加载,并且是可被搜索引擎索引的

本篇文章介绍的技术,旨在教大家如何使用 Puppeteer 的 API,给一个 Express 服务器添加服务端渲染(SSR)能力。最棒的地方是,应用本身几乎不需要修改任何代码。无头 Chrome 做了所有的重活。三两行代码,SSR 页面带回家。

大餐之前先来点甜点:

import puppeteer from 'puppeteer';

async function ssr(url) {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();
  return html;
}

注意: 我会在文章中使用 ES 模块(import),这要求 Node 8.5.0+,并在运行时加上 --experimental-modules 标志。觉得麻烦的话可以自行使用 require() 语句。关于 Node 上的 ES 模块支持可以读读这篇文章。


导论


如果我对 SEO 理解没有偏差的话,你读到这篇文章可能因为下面两个原因之一。首先,你已经搭建了一个 web 应用,并且它没有被搜索引擎索引!你的应用可能是 SPA,PWA,使用了 vanilla JS,或者使用了其他更复杂的框架或类库。老实说,你使用何种技术并不重要。重要的是,你花费了大量时间搭建出优秀的 web 页面,然而用户却搜不到它。你读这篇文章的另一个理由可能是因为,网上一些文章说了服务端渲染可以提升性能。你希望快速减少 JavaScript 启动时间,提升首次有效绘制速度。

一些框架,比如 Preact 使用了工具来实现服务端渲染。如果你使用的框架具备预渲染的解决方案,请继续使用。没有任何理由引入另一个工具(无头 Chrome / Puppeteer)。

爬取现代网站

搜索引擎爬虫,社交平台,甚至浏览器自诞生至今就唯一依赖于静态 HTML 标记,来索引 web 页面和表层内容。现代 web 页面已经演变的大为不同。基于 JavaScript 的应用,在很多时候,需要保持网站内容是对于爬取工具是可见的。

一些爬虫,比如 Google 搜索,已经变得更智能了!Google 的爬虫使用 Chrome 41 执行 JavaScript,并渲染出最终的页面。但是这个方案才刚出来,还不完美。举个例子,使用了新特性的页面,比如 ES6 Class,模块,箭头函数等,将会在这个比较老的浏览器上报错,使得页面不能正确渲染。至于其他搜索引擎,鬼知道它们在干嘛!?¯_(ツ)_/¯

使用无头 Chrome 预渲染页面


所有的爬虫程序都能够理解 HTML。我们要“解决”索引问题的话需要一个工具,它来执行 JS 生成 HTML。我不会告诉你现在已经有这样一个工具了!

  1. 该工具可以运行所有类型的现代 JavaScript,并吐出静态 HTML。
  2. 出现新特性时,该工具可以保持更新
  3. 已有应用上只需少量代码就可以运行这个工具

听起来很不错吧?这个工具就是浏览器

无头 Chrome 不在乎你使用什么库、框架或者工具。它将 JavaScript 作为早餐,在午饭前吐出静态 HTML。可能会更快一点 :) -Eric

如果你用的 Node,Puppeteer 容易上手。它的 API 提供了预渲染客户端应用的能力。下面用个例子演示下。

1. JS 应用示例

我们以一个 JavaScript 生成 HTML 的动态页面为例:

public/index.html



  

2. 服务端渲染函数

接下来,我们会使用之前提到的 ssr() 函数,并充实它的内容。

ssr.mjs

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();

async function ssr(url) {
  if (RENDER_CACHE.has(url)) {
    return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  }

  const start = Date.now();

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  try {
    // networkidle0 waits for the network to be idle (no requests for 500ms).
    // The page's JS has likely produced markup by this point, but wait longer
    // if your site lazy loads, etc.
    await page.goto(url, {waitUntil: 'networkidle0'});
    await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
  } catch (err) {
    console.error(err);
    throw new Error('page.goto/waitForSelector timed out.');
  }

  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  const ttRenderMs = Date.now() - start;
  console.info(`Headless rendered page in: ${ttRenderMs}ms`);

  RENDER_CACHE.set(url, html); // cache rendered page.

  return {html, ttRenderMs};
}

export {ssr as default};

主要的变化:

  1. 添加了缓存。缓存已渲染的 HTML 对于加速响应时间居功至伟。当页面再次有请求过来,避免了无头 Chrome 的重复执行。我随后会讨论其他的优化 。
  2. 添加加载页面超时时的基本错误处理。
  3. 添加了 page.waitForSelector('#posts') 这行代码。确保在丢弃这个序列化页面之前,posts 节点存在于 DOM 之中。
  4. 记录无头浏览器渲染页面所用时间。
  5. 代码都被封装进名为 ssr.mjs 的模块中。

3. web 服务器示例

最后,一个小的 express 服务器完成了所有的工作。它预渲染 URL http://localhost/index.html(主页),并在响应中返回渲染结果。由于响应中包含了静态 HTML, 当用户访问页面,posts 节点会立刻呈现。

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

要运行这个例子,需安装依赖 (npm i --save puppeteer express),然后使用 Node 8.5.0+ 并带有 --experimental-modules 标志来运行服务器。

这是一个该服务器返回的响应示例:



  
  • Title 1

    Summary 1

    post content 1

  • Title 2

    Summary 2

    post content 2

  • ...

Server-Timing API 的一个最佳用例

Server-Timing API 支持将服务器性能指标(比如请求/响应时间,数据库查询)返回给浏览器。客户端可以使用这些信息来追踪 web 应用的所有性能数据。

Server-Timing 的一个最佳用例是上报无头 Chrome 预渲染页面的时间!只需在响应上添加 Server-Timing 头,就可以实现这一点:

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

客户端上,Performance Timeline API 和 PerformanceObserver 可以获取这些指标:

  const entry = performance.getEntriesByType('navigation').find(
    e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());
{
  "name": "Prerender",
  "duration": 3808,
  "description": "Headless render time (ms)"
}

性能结果

注意: 这些数据体现了我随后讨论的大多数性能优化。

性能数据怎么样?在我的一个应用(代码)上,无头 Chrome 渲染页面大约需要 1s。页面被缓存后, 3G 低网速模拟下,FCP 要比客户端渲染版本的快 8.37s

  首次绘制 (FP) 首次内容绘制 (FCP)
客户端渲染 4s 11s
服务端渲染 2.3s ~2.3s

这些结果很有用。因为服务端渲染页面不再依赖于 JavaScript 的加载,用户看到有意义的内容比以前快得多。


Preventing re-hydration


还记得我说“我们无需在客户端应用上改任何代码”吗?那是骗你们的。

Express 应用接收请求,使用 Puppeteer 将页面加载进无头浏览器,然后在响应中返回结果。但这里有一个问题。

浏览器加载页面时,无头 Chrome 中相同的 JS 会在服务器上再次执行。有两处都在生成 HTML。

一起来修复这个问题。我们要告知页面,它的 HTML 早就名花有主了。我找到的解决方案是,在页面加载时判断

    是否已在 DOM 中,如果在,页面就已经在服务端渲染过了,这样就可以避免重新创建 DOM。

    public/index.html

    
    
      


    优化


    除了缓存渲染结果之外,还有一些有趣的优化技巧。有的优化可以快速见效,而有的可能带有猜测性的。

    中止不必要的请求

    现在,整个页面(以及它请求的所有资源)都无脑地加载进无头 Chrome。然而,我们只关注于两件事情:

    1. 渲染 HTML
    2. 生成 HTML 的 JS

    不构造 DOM 的网络请求是浪费的。一些资源,比如图片、字体、样式表和媒体内容,不参与页面的 HTML 构建。它们负责添加样式,补充页面的结构,但并不显式地创建页面。我们应该告诉浏览器去忽略掉这些资源!这样可以减少无头 Chrome 的工作负担,从而节省带宽,并且潜在地加速了大型页面的预渲染时间

    Protocol 开发者工具提供了一个强大的特性,叫做网络拦截。它可以用于在浏览器发出之前修改请求。Puppeteer 也支持网络拦截,它是通过打开 page.setRequestInterception(true),监听页面的 request 事件来实现的。这样我们可以中止某些资源请求。

    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 whitelist = ['document', 'script', 'xhr', 'fetch'];
        if (!whitelist.includes(req.resourceType())) {
          return req.abort();
        }
    
        // 3. Pass through all other requests.
        req.continue();
      });
    
      await page.goto(url, {waitUntil: 'networkidle0'});
      const html = await page.content(); // serialized HTML of page DOM.
      await browser.close();
    
      return {html};
    }
    

    注意: 安全起见,我使用了一个白名单,允许所有其他类型的请求能够继续正常发出。预先避免中止掉其他必要的请求。

    内联关键资源

    使用构建工具(比如 gulp)编译应用,并在构建时将关键 CSS/JS 内联到页面内,是一种很常见的做法。由于浏览器初始化页面加载时的请求数更少了,这样也就加速了首次有效绘制时间。

    别用构建工具了,浏览器就是你的构建工具!我们可以用 Puppeteer 管理页面 DOM,内联样式,JavaScript, 或者其他任何你想在预渲染之前加到页面中的东西。

    这个例子演示了如何拦截本地样式表的响应,并将这些资源内联进