无头 Chrome:解决服务器端渲染 JS 网站的问题

了解如何使用 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;
}

为何使用 Headless Chrome?

在以下情况下,您可能会对无头 Chrome 感兴趣:

Preact 等一些框架附带工具,可用于解决服务器端渲染问题。如果您的框架有预渲染解决方案,请继续使用该解决方案,而不是将 Puppeteer 和 Headless Chrome 引入您的工作流中。

抓取现代网络

一直以来,搜索引擎抓取工具、社交分享平台甚至浏览器都完全依靠静态 HTML 标记将网页编入索引并显示内容。现代网络已演变为一种截然不同的事物基于 JavaScript 的应用将被保留下来,这意味着,在很多情况下,抓取工具看不到我们的内容。

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

预渲染页面

所有抓取工具都可以理解 HTML。为了确保抓取工具能够将 JavaScript 编入索引,我们需要一款能够满足下列要求的工具:

  • 知道如何运行所有类型的现代 JavaScript 并生成静态 HTML。
  • 随着网络功能不断更新,
  • 无需对应用进行代码更新即可运行。

听起来不错吧?该工具就是浏览器!无头 Chrome 不在意您使用什么库、框架或工具链。

例如,如果您的应用是使用 Node.js 构建的,Puppeteer 可让您轻松使用 0.headless 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)"
}

效果

以下结果融合了稍后讨论的大部分性能优化

在我的一个应用代码)上,无头 Chrome 需要大约 1 秒才能在服务器上呈现网页。页面缓存后,开发者工具的 3G 慢速模拟功能会将 FCP 速度比客户端版本提高 8.37 秒

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

这些成效十分可观。用户现在可以更快地看到有意义的内容,因为服务器端呈现的网页不再依赖 JavaScript 来加载并显示帖子

防止补水

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

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

当用户的浏览器在前端加载页面时,在服务器上的无头 Chrome 中执行的同一 JS再次运行。我们可以在两个地方生成标记#doubleRendering

让我们解决这个问题。我们需要告知网页,其 HTML 已就位。我找到的解决方案是,让网页 JS 在加载时检查 <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 作业

在我的 App Engine 信息中心应用中,我设置了 Cron 处理程序,定期重新呈现网站上的热门网页。这有助于访问者始终看到快速、新鲜的内容,并避免看到新的预渲染的“启动费用”。对于这种情况,生成多个 Chrome 实例会是浪费资源。我现在使用共享浏览器实例同时呈现多个页面:

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

如果代码根本未加载,网页命中就不会被记录。好棒 👏?。

或者,继续加载您的 Analytics(分析)库,以深入了解您的服务器执行的预渲染次数。

总结

Puppeteer 可在网络服务器上运行无头 Chrome 作为配套应用,从而轻松服务器端渲染页面。此方法我最喜欢的“特性”是,无需进行重大代码更改即可提高应用的加载性能可编入索引性

如果您想了解使用此处所述技术的实际应用,请查看 devwebfeed 应用

附录

先有技术的讨论

服务器端渲染客户端应用比较困难。有多难?只需看看有多少用户编写了专用于该主题的 npm 软件包即可。有无数的模式tools服务可以帮助您 SSRing JS 应用。

同构 / 通用 JavaScript

通用 JavaScript 的概念意味着:在服务器上运行的代码也会在客户端(浏览器)上运行。您在服务器和客户端之间共享代码,每个人都可以获得安宁。

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

预渲染工具

Node 社区构建了大量用于处理 SSR JS 应用的工具。没关系,就我个人而言,我发现 YMMV 使用了其中的一些工具,因此在提交之前,请务必先做足功课。例如,某些 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 能够为您代劳的原因之一。我在在线服务渲染部分应用时还遇到了一些问题:

浏览器中呈现的 chromestatus
在浏览器中呈现的网站
通过预渲染呈现的 chromestatus
由 prerender.io 呈现的同一网站