无头 Chrome:解决服务器端呈现 JavaScript 网站的问题

Addy Osmani
Addy Osmani

了解如何使用 Puppeteer API 向 Express Web 服务器添加服务器端渲染 (SSR) 功能。最棒的是,您的应用只需对代码进行非常细微的更改。无头设备会完成所有繁重工作。

只需几行代码,您就可以 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;
}

为何使用无头 Chrome?

如果您符合以下条件,则可能对无头 Chrome 感兴趣:

某些框架(例如 Preact)附带了用于处理服务器端渲染的工具。如果您的框架有预渲染解决方案,请坚持使用该解决方案,而不是将 Puppeteer 和无头 Chrome 引入您的工作流。

抓取现代网络

搜索引擎抓取工具、社交分享平台,甚至浏览器,一直以来都完全依赖于静态 HTML 标记来编制网页索引并显示内容。现代网络已演变为完全不同的形态。基于 JavaScript 的应用将会长期存在,这意味着在许多情况下,我们的内容可能对抓取工具不可见。

Googlebot 是我们的搜索抓取工具,它会处理 JavaScript,同时确保其处理操作不会导致网站的用户体验下降。在设计网页和应用时需要考虑一些差异和限制,以适应抓取工具访问和呈现您的内容的方式。

预渲染网页

所有抓取工具都支持 HTML。为确保抓取工具能够将 JavaScript 内容编入索引,我们需要一款能够:

  • 了解如何运行所有类型的新型 JavaScript 并生成静态 HTML。
  • 随着网站添加功能,及时了解最新动态。
  • 无需对应用进行代码更新即可运行。

听起来不错吧?那就是浏览器!无头 Chrome 不关心您使用哪些库、框架或工具链。

例如,如果您的应用是使用 Node.js 构建的,则可以通过 Puppeteer 轻松使用无头 Chrome。

首先,创建一个使用 JavaScript 生成 HTML 的动态网页:

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>

SSR 函数

接下来,使用之前的 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};

主要变更:

  • 添加了缓存。缓存呈现的 HTML 是缩短响应时间的最大优势。当系统重新请求该网页时,您可以完全避免运行无头 Chrome。我稍后会讨论其他优化
  • 添加了在加载页面超时时的基本错误处理。
  • 添加对 page.waitForSelector('#posts') 的调用。这样可确保在转储序列化页面之前,这些帖子已存在于 DOM 中。
  • 添加科学。记录无头浏览器渲染网页所需的时间,并返回渲染时间以及 HTML。
  • 将代码粘贴到名为 ssr.mjs 的模块中。

Web 服务器示例

最后,下面是将所有这些内容整合在一起的小型 Express 服务器。主处理程序会预渲染网址 http://localhost/index.html(首页),并将结果作为响应提供。由于静态标记现在是响应的一部分,因此用户访问该页面后会立即看到帖子。

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 标志运行服务器:

以下是此服务器发回的响应示例:

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

新 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 APIPerformanceObserver 来访问以下指标:

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)"
}

效果结果

以下结果包含稍后讨论的大多数性能优化

在示例应用中,无头 Chromium 大约需要 1 秒钟的时间才能在服务器上呈现网页。网页缓存后,DevTools 3G 慢速模拟会使 FCP 比客户端版本快了 8.37 秒

首次绘制 (FP)First Contentful Paint (FCP)
客户端应用4 秒 11 秒
SSR 版本2.3 秒大约 2.3 秒

这些结果令人鼓舞。由于服务器端呈现的网页不再依赖 JavaScript 来加载和显示帖子,因此用户可以更快地看到有意义的内容。

防止重新水合

还记得我说过“我们没有对客户端应用进行任何代码更改”吗?那是谎言。

我们的 Express 应用会接收请求,使用 Puppeteer 将网页加载到无头模式,并将结果作为响应提供。但这种设置存在问题。

当用户的浏览器在前端加载网页时,服务器上在无头 Chrome 中执行的同一 JavaScript再次运行。我们有两个位置会生成标记。#doublerender

要解决此问题,请告知网页其 HTML 已就位。一种解决方法是让网页 JavaScript 在加载时检查 <ul id="posts"> 是否已在 DOM 中。如果是,则表示该网页已使用 SSR,您可以避免再次添加帖子。👍

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>

优化

除了缓存渲染的结果之外,我们还可以对 ssr() 进行许多有趣的优化。有些方法可以快速取得成效,而有些方法可能更具投机性。您最终获得的性能优势可能取决于您预渲染的页面类型和应用的复杂程度。

终止非必需请求

目前,整个网页(以及它请求的所有资源)都会无条件地加载到无头 Chrome 中。不过,我们只关注以下两点:

  1. 呈现的标记。
  2. 生成该标记的 JS 请求。

不构建 DOM 的网络请求会造成浪费。图片、字体、样式表和媒体等资源不会参与构建网页的 HTML。它们用于设置页面的样式并补充页面结构,但不会明确创建页面。我们应告知浏览器忽略这些资源。这可以减少无头 Chrome 的工作负载、节省带宽,并可能缩短较大网页的预渲染时间。

DevTools 协议支持一项名为网络拦截的强大功能,可用于在浏览器发出请求之前修改请求。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 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};
}

内嵌关键资源

通常,您可以使用单独的构建工具(例如 gulp)处理应用,并在构建时将关键 CSS 和 JS 内嵌到网页中。这可以加快首次有效绘制速度,因为浏览器在初始网页加载期间发出的请求更少。

使用浏览器作为构建工具,而不是单独的构建工具! 我们可以在预渲染页面之前使用 Puppeteer 操控页面的 DOM,内嵌样式、JavaScript 或您想在页面中保留的任何其他内容。

以下示例展示了如何拦截本地样式的响应,并将这些资源作为 <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};
}

在多次渲染中重复使用单个 Chrome 实例

为每次预渲染启动一个新浏览器会产生大量开销。 不过,您可能需要启动单个实例,并重复使用该实例来呈现多个网页。

Puppeteer 可以通过调用 puppeteer.connect() 并将实例的远程调试网址传递给它来重新连接到现有的 Chrome 实例。为了保持长时间运行的浏览器实例,我们可以将用于启动 Chrome 的代码从 ssr() 函数移至 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};
}

示例:用于定期预渲染的 Cron 作业

如需一次渲染多个网页,您可以使用共享的浏览器实例。

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

此外,还需要向 ssr.js 添加 clearCache() 导出:

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

export {ssr, clearCache};

其他注意事项

为页面创建信号:“您正在以无头模式呈现”

当您的网页由服务器上的无头 Chrome 呈现时,了解这一点可能对网页的客户端逻辑很有帮助。在我的应用中,我使用此钩子“关闭”了页面中不参与呈现帖子标记的部分。例如,我停用了延迟加载 firebase-auth.js 的代码。没有可登录的用户!

向呈现网址添加 ?headless 参数是一种简单的方法,可为网页添加钩子:

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

在该页面中,我们可以查找该参数:

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>

避免 Google Analytics 网页浏览量虚增

如果您在网站上使用 Google Analytics,请务必小心。预渲染网页可能会导致网页浏览量虚增。具体而言,您会看到命中次数的 2 倍,其中一次命中是在无头 Chrome 呈现页面时,另一次命中是在用户的浏览器呈现页面时。

那么,解决方法是什么?使用网络拦截来终止任何尝试加载 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();
});

如果代码从未加载,则系统永远不会记录网页命中。就是这样 💥?。

或者,继续加载 Google Analytics 库,以深入了解服务器执行了多少次预渲染。

总结

Puppeteer 可让您在 Web 服务器上作为配套程序运行无头 Chrome,从而轻松在服务器端呈现网页。这种方法最让我喜欢的“特性”是,您无需对代码进行重大更改,即可提升应用的加载性能可编入索引性!

如果您想查看使用此处介绍的技术的实际应用,请查看 devwebfeed 应用

附录

在先技术的讨论

服务器端呈现客户端应用并非易事。有多难?只需看看有多少人专门针对该主题编写了 npm 软件包即可。有无数的模式工具服务可帮助实现 JS 应用的 SSR。

同构 / 通用 JavaScript

通用 JavaScript 的概念意味着:在服务器上运行的代码也能在客户端(浏览器)上运行。您可以在服务器和客户端之间共享代码,所有人都会感到轻松自在。

无头 Chrome 支持在服务器和客户端之间实现“同构 JS”。如果您的库无法在服务器 (Node) 上运行,这是一个不错的选择。

预渲染工具

Node 社区构建了大量用于处理 SSR JS 应用的工具。这并不奇怪!我个人发现,对于其中一些工具,效果因人而异,因此在选择某款工具之前,请务必做好功课。例如,某些 SSR 工具较旧,不使用无头 Chrome(或任何无头浏览器)。而是使用 PhantomJS(也称为旧版 Safari),这意味着,如果您的网页使用较新功能,将无法正常呈现。

预渲染是一个值得注意的例外情况。预渲染很有意思,因为它使用无头 Chrome,并附带可直接替换的 Express 中间件

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

值得注意的是,预渲染省略了在不同平台上下载和安装 Chrome 的详细信息。通常,正确执行此操作相当棘手,这正是 Puppeteer 为您提供帮助的原因之一。