TL;DR:
无头模式的 Chrome(Headless Chrome) 可以成为将动态 JS 站点转化成为静态 HTML 页面的入门工具。在 Web 服务器上运行它能够让你预渲染任何具备任何现代 JS 特性的页面,使得页面内容加载更快而且可以被爬虫工具索引到。
这篇文章的技术方案为大家展示了如何使用 Puppeteer 的 API 来为一个 Express web 服务器添加服务端渲染(SSR)的功能。其中最棒的一点是 Express app 本身几乎不需要做代码修改。繁重的任务将交给 Headless Chrome。你只需要添加短短几行代码你就可以 SSR 任何一个页面,并且得到该页面的最终完整 HTML 。
可以参考下面的代码:
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 Modules(
import
),它需要使用 Node 版本高于 8.5.0 并且运行时开启了 —experimental-modules
参数。如果你觉得它困扰了你,你也可以自由地选择使用 require()
。点击这里查看 Node 的 ES Modules 的支持情况。简介
如果 SEO 做得好的话,那么你访问这篇文章的原因大概有两种。一种是你编写了一个 Web 应用,但是它不能被搜索引擎索引到!你的 app 有可能是一个单页应用(SPA),或者是渐进式 Web 应用(PWA),或者使用原生 JS 编写的,又或者是使用了某个复杂的库或者框架来编写的。说实话,你的技术栈具体用的是什么不是问题。问题是你花了很多时间来编写这个超赞的网页,但是你的用户却没办法发现它。你访问这篇文章的另外一个原因也有可能是因为互联网上的一些文章提到了服务端渲染对性能提升有很大帮助。你来到这里就是来寻找快速的解决方案来减少 JavaScript 启动的耗时以及改进页面的第一个有意义的渲染(First meaningful paint)。
现在有一些框架,像是 Preact ,本身提供了可以用来解决 SSR 的工具。如果你使用的框架本身提供了预渲染的方案,那么请你使用它。将其他工具(headless chrome/ Puppeteer)参杂进来没有任何意义。
爬取现代网页
搜索引擎爬虫,社交分享平台,甚至是浏览器历来都只是依赖静态 HTML 标签来索引页面,以及提取内容。然而现代的网页已经演变成完全不一样的东西。基于 JavaScript 编写的程序在现在很流行,这意味着在很多情况下,我们的内容在爬虫工具看来是空的。
不过一些爬虫工具已经变得更加聪明了,比如 Google Search。Google 的爬虫使用了 Chrome 41 来运行页面的 JavaScript 然后渲染最终的页面,但是这个功能由于刚推出不久,仍然不完美。比如说,如果页面使用了更新的语法特性,比如 ES6 的 classes ,Modules 以及箭头函数,那么就会导致 JS 在这个比较老的浏览器里面运行错误,最终导致页面渲染不正确。至于其他搜索引擎,谁知道他们会怎么做呢!?¯\(ツ)/¯
使用 Headless Chrome 预渲染页面
所有的爬虫都能够识别 HTML 。我们所需要解决的索引问题就是找到一个可以从运行 JS 然后输出 HTML 的工具。如果我说现在就有这样一个工具?
- 这个工具知道如何运行所有现代 JavaScript 然后输出静态 HTML
- 这个工具会随着 Web 添加新的功能而保持更新
- 你可以在现有的 app 里面快速地使用这个工具,并且几乎不需要修改代码
听起来棒棒哒对吧?这个工具就是浏览器!
Headless Chrome 并不关心你用的是什么库,框架或者工具链。它将 JavaScript 当作“早餐”吃进去,并且在“午餐”之前输出静态 HTML 。当然,希望它能够比这个更快一些:)- Eric
如果你使用 Node 的话,那么使用 headless chrome 的简单快速方案就是使用 Puppeteer 。它的 API 使得给定一个前端应用然后预渲染它的标签变成了可能。下面就是它的一个预渲染例子。
1. 示例的 JS 应用
我们从一个动态页面开始,它由 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: 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>
2. 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.err(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 缓存起来能够大大地改善响应时间。当页面被重复请求时,你不用再重新启动 headless chrome 去渲染一次 HTML 。我在后面将会讨论其他优化方案。
- 添加一个简单的页面加载超时错误处理
- 添加一行
page.waitForSelector('#post')
。这确保我们在存储序列化页面之前,文章存在于 DOM 上面。 - 添加响应时间。记录 headless chrome 渲染页面的耗时并且把渲染耗时和 HTML 一并返回。
- 将代码整合到一个叫
ssr.mjs
的模块
3. 示例的 Web 服务
最终,这是一个将所有代码整合到一起的 Express 服务器。主要的路由处理函数预渲染
http://localhost/index.html
URL(e.g 主要入口页面)并且将渲染结果作为服务器的响应。当用户访问页面时将会立即看到文章,因为这时候页面响应里面就包含了静态 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>
使用新 API Server-Timing 的完美案例
Server-Timing API 能够让你将 Web 服务器的性能指标(比如请求/响应时间,数据库查询时间等)传回给浏览器。客户端代码可以根据这个信息来跟踪这个 Web app 的整体性能。
Server-Timing 的一个完美使用案例就是报告 headless Chrome 预渲染页面所消耗的时间!为了实现这个功能,你只需要在服务器响应里面添加 Server-Timing 这个响应头就可以:
res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);
在客户端上,可以通过 Performance Timeline API 以及/或者 PerformaceObserver 来获取这些数据:
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)"
}
性能结果
那么性能数据怎样呢?我其中的一个 app(代码在这),在服务端上 headless Chrome 花了大概1秒的时间来渲染页面。一旦预渲染页面被缓存了,在通过 DevTools 开启 3G 缓慢模拟时,FCP 结果相对于客户端渲染版本快了 8.37 秒。
这个结果看起来很美好。用户可以更快地看到有意义的页面内容,这是因为服务端渲染的页面不再依赖 JavaScript 去加载和渲染文章。
避免多次执行
记得我之前说过“我们不需要修改任何客户端代码“吗?好吧,那是骗你的。
我们的 Express 服务器接受一个请求,使用 Puppeteer 去加载页面到 headless Chrome ,并且将结果作为响应返回给客户端。但是这个过程出现了一个问题。
那段运行在服务端 headless Chrome 的 JS 代码会在用户浏览器加载完页面的时候在前端再运行一次。我们有两个地方生成 HTML 标签。#重复渲染!
让我们来修复它。我们需要告诉页面,它的 HTML 已经生成出来了。我所想到的解决方案就是让页面的 JS 来判断当页面加载完后
<ul id="posts">
标签是否已经存在 DOM 上了。如果是,那么我们就可以知道当前页面时通过服务端渲染出来的,我们可以避免重复添加文章。👍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()
函数做许多有趣的优化。有些是可以快速得到优化结果的,而有些则可能更具投机性。你所看到的性能优势可能最终取决于你所要预渲染的网页类型以及它的复杂度。取消非必要的请求
现在,整个页面(包括它所请求的所有资源)会毫无条件地在 headless Chrome 上加载。但是,我们只对两个东西感兴趣:
- 最终渲染出来的 HTML 标签
- 会产出 HTML 标签的 JS 请求
不参与构建 DOM 树的网络请求是非常浪费的。像是图片,字体,CSS,以及媒体资源文件不会参与页面 HTML 的构建。它们为网页的结构添加样式以及做补充,但是不会显式地创建它。我们应该告诉浏览器忽略这些请求!这样做能够减少 headless Chrome 的工作负载,减少网络带宽请求,并且可能会加快一些较大的页面的预渲染时间。
Devtools 协议支持一个叫做 Network interception 的强大功能,它能够在浏览器真正发出请求之前修改这些请求。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)来处理 app 以及内联一些重要的 CSS/JS 到页面上。这样做可以加速第一次有意义的绘制(First meaningful paint),因为浏览器在初始加载页面的时候能够发出更少的请求。
与其使用不同的构建工具,我们也可以使用浏览器作为你的构建工具!我们可以在预渲染页面之前通过 Puppeteer 来操作页面的 DOM ,内联样式,JavaScript,以及任何你想要添加到页面的东西。
这个例子展示了如何拦截本地样式文件的响应,并且将这些资源以
<style>
标签内联进页面里面。ssr.mjs
import urlModule from 'url';
const URL = urlModule.URL;
async function ssr(url) {
...
const stylesheetContent = {};
// 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) {
stylesheetContent[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};
}
这段代码做了如下操作:
- 使用了 page.on('response') 监听器来监听网络响应
- 暂存本地样式表的响应
- 找到 DOM 上的所有
<link rel="stylesheet">
标签并且将它们替换为等价的<style>
标签。你可以查阅page.$$eval
的 API 文档。style.textContent
被设置为样式文件的响应。
自动压缩资源
通过网络拦截功能你还可以实现另外一个小技巧,那就是修改请求的响应。
打个比方,假如你想要在你的 app 上压缩 CSS ,但是也想保留未压缩的版本,方便开发阶段调试。假设你已经配置好另外一个工具来提前压缩
style.css
文件,那么你可以通过 Request.respond()
来用 style.min.css
的内容重写 style.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()
以及传递实例的远程调试 URL 给它来重新连接到现有的 Chrome 实例。为了能够保留一个长时间运行的浏览器实例,我们可以将启动 Chrome 的代码从 ssr()
函数里面移动到 Express server 里面。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};
}
例子:创建定时任务来周期性地预渲染
在我的 App Engine dashboard 这个 app 里面,我设置了一个定时任务处理函数来周期性地重新渲染站点最重要的页面。这让访客可以一直看到快速的,内容最新的页面,并且避免让他们看到创建新预渲染所带来的启动耗时。创建多个 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};
其他可取的方法
为页面添加一个标志:“你当前是通过 headless 模式渲染的”
当你的页面是通过服务端上的 headless Chrome 来渲染的,那么让客户端的逻辑知道这一个事情,对于客户端代码来说也许是有帮助的。在我的 app 里面,我使用这个 hook 来“关闭”部分页面逻辑,不要让它来参与渲染文章的 HTML 标签。比如,我禁用了会懒加载 firebase-auth.js 文件的代码。因为那根本没有用户可以登录(在服务端渲染里面)。
在需要渲染的 URL 上加上一个
?headless
参数是一个最简单的方式来给页面添加 hook: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>
贴心小提示:另外一个好用的方法是使用 Page.evaluateOnNewDocument() 。它让你能够注入代码到页面上,并且让 Puppeteer 在运行页面的其他运 JavaScript 代码之前先运行那段代码。
避免重复统计页面访问
如果你为你的站点使用了 Analytics(或者其他同类产品),你要小心。预渲染页面有可能会导致 pageview 数量变多。更加准确地说,你会看到两倍的 pageview ,一个是 headless Chrome 在预渲染页面,另外一个是当用户的浏览器渲染页面。
那么怎么修复呢?使用网络拦截功能来取消任何加载统计库的相关请求。
page.on('request', req => {
// Don't load Google Analytics lib requests so pageviews aren't 2x.
const blacklist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
if (blacklist.find(regex => req.url().match(regex))) {
return req.abort();
}
...
req.continue();
});
页面访问永远不会被记录到,只要相关代码不会被加载。
或者,继续加载 Analytics 统计库,以深入了解服务器执行的预渲染次数。
结论
Puppeteer 通过在你的 Web 服务器上使用 headless Chrome 作为配套服务,使得服务端渲染页面变得非常简单。我最喜欢的这种方法的“特点”是,在没有重大代码更改的情况下,您可以提高加载性能和页面的可索引性!
附录
现有技术的讨论
Isomorphic / Universal JavaScript
Universal JavaScript 的概念很简单:服务端运行的代码也运行在客户端上(浏览器)。在客户端和服务端上你共享同一套代码,大家仿佛在那一霎那感受到了禅意。
在实践当中,我发现 Universal JavaScript 很难脱颖而出。一个自己的故事:
我最近创建了一个新项目,并且想尝试使用一下 lit-html 。Lit 是一个很棒的库,它能够让你使用 JS 模板字符串来编写 HTML <template>,然后非常高效地渲染这些模板到 DOM 上。问题是他的核心功能(使用 <template> 元素)并不能在浏览器以外的环境上工作。这意味着它不能在 Node 服务上运行。我在Node和前端之间共享代码到 SSR 的希望被抛到天边。
最终我意识到我可以通过使用 headless Chrome 来服务端渲染这个 app 。无论 Chrome 是运行在用户的手上还是自动运行在服务端上都无关紧要。Chrome 非常乐意运行你给的任何 JS 。没有任何疑问。
Headless Chrome 使得客户端和服务端的同构JS变得可能。假如你使用的库不支持运行在服务端(Node)那么它会是一个很好的选择。
预渲染工具
Node 社区创造了非常多的工具来解决服务端渲染 JS app。这并不让我们感到惊喜!个人来说,我发现这些工具是因人而异的,所以一定要在使用某个工具之前先提前做好功课。比如,有些 SSR 工具太老了,不是使用 headless Chrome(或者其他任何无头模式的浏览器)。相反,它们使用 PhantomJS(也就是人们熟悉的旧Safari),这也意味着你的页面如果使用了新的语法特性之后它不会被正确地渲染。
Prerender 是一个值得让人注意的一个特例。Prerender 有趣的地方在于它使用 headless Chrome 并且它自带 express 的中间件:
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
同时也需要注意的是 Prerender 忽略了在不同平台上下载和安装 Chrome 的细节。很多时候,要正确地实现这个步骤非常麻烦,所以这也是为什么 Puppeteer 帮你做了这个步骤。我的一些 app 在使用在线服务的时候也遇到过问题:
浏览器渲染的页面
同样的站点使用 prerender.io 渲染