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。我不会告诉你现在已经有这样一个工具了!
- 该工具可以运行所有类型的现代 JavaScript,并吐出静态 HTML。
- 出现新特性时,该工具可以保持更新
- 已有应用上只需少量代码就可以运行这个工具
听起来很不错吧?这个工具就是浏览器!
无头 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};
主要的变化:
- 添加了缓存。缓存已渲染的 HTML 对于加速响应时间居功至伟。当页面再次有请求过来,避免了无头 Chrome 的重复执行。我随后会讨论其他的优化 。
- 添加加载页面超时时的基本错误处理。
- 添加了
page.waitForSelector('#posts')
这行代码。确保在丢弃这个序列化页面之前,posts 节点存在于 DOM 之中。 - 记录无头浏览器渲染页面所用时间。
- 代码都被封装进名为
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。然而,我们只关注于两件事情:
- 渲染 HTML
- 生成 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, 或者其他任何你想在预渲染之前加到页面中的东西。
这个例子演示了如何拦截本地样式表的响应,并将这些资源内联进 标签中:
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