Table of Contents
其实之前有些过两篇关于用 headless-chrome(puppeteer 是其封装) 做服务端渲染(ssr)的文章,但时隔太久,还是另写一篇清晰完整的好。
项目介绍
尚不成熟的 puppeteer 服务端渲染方案。
原理是当用户访问页面(xxx.xxx.xxx/xxx)的时候,nginx 代理到 puppeteer 渲染服务(127.0.0.1:8080),渲染服务根据配置文件向网站地址(比如:127.0.0.1:8081)发起请求,渲染好页面后返回给用户。
因为是通过浏览器渲染,QPS 当然好不到哪里去,但作为通用渲染方案,其适用性广,代码修改少。
目前这个 demo 还有点问题。比如:当访问某些网站的时候会报错,然后之后的所有请求都会报错。总之,服务是崩掉了。原因未知。
项目地址:https://github.com/tonyzhou1890/puppeteer-ssr
实现解析
思路
nginx 配置两个 server,一个是域名访问的,还有一个是端口访问。其中,域名访问的代理到 ssr 服务,ssr 服务再根据配置文件访问网站所在端口。
ssr 服务接到代理过来的页面地址,先检查是否有有效缓存,如果有,直接返回缓存,如果没有再交给 puppeteer 渲染。返回渲染结果的同时,根据配置的条件决定是否放入缓存。
项目结构
puppeteer-ssr: │ package.json │ ├─src │ config.js │ server.js │ ssr.js │ util.js
配置文件
// config.js
const config = {
hosts: { // 域名配置
'xxx.xxx.xxx': {
proxyHost: '127.0.0.1:8081', // 本地地址
expire: 10, // 全局页面有效期,单位为秒
waitForNetworkIdleTime: 300, // 网络空闲等待时间,单位毫秒
rules: [
{
path: '/',
expire: 0
},
{
path: /^\/collection$/,
expire: 0,
waitForNetworkIdleTime: 0
},
{
path: (url) => {
return /^\/poemList/.test(url)
},
expire: 3600
}
]
},
},
pages: 2, // 可同时渲染的页面
}
module.exports = config
上面的配置是一个示例。
- rules 部分是路径匹配规则,支持字符串(完全匹配)、正则、函数三种方式。
- expire 是缓存有效时间。如果是 0,则不缓存。
- waitForNetworkIdleTime 等待网络空闲时间。如果是 0,则不等待页面内部请求完成就返回页面。
工具函数
// util.js
/**
* 网络等待
* @param {*} page 页面引用
* @param {*} timeout 网络空闲时间
* @param {*} maxInflightRequests 忽略请求数量
*/
async function waitForNetworkIdle(page, timeout, maxInflightRequests = 0) {
page.on('request', onRequestStarted);
page.on('requestfinished', onRequestFinished);
page.on('requestfailed', onRequestFinished);
let inflight = 0;
let fulfill;
let promise = new Promise(x => fulfill = x);
let timeoutId = setTimeout(onTimeoutDone, timeout);
return promise;
function onTimeoutDone() {
page.removeListener('request', onRequestStarted);
page.removeListener('requestfinished', onRequestFinished);
page.removeListener('requestfailed', onRequestFinished);
fulfill();
}
function onRequestStarted() {
++inflight;
if (inflight > maxInflightRequests)
clearTimeout(timeoutId);
}
function onRequestFinished() {
if (inflight === 0)
return;
--inflight;
if (inflight === maxInflightRequests)
timeoutId = setTimeout(onTimeoutDone, timeout);
}
}
/**
* 获取页面配置
* @param {*} req
*/
function getPageConfig(req, config) {
const host = config.hosts[req.get('host')]
if (host && host.proxyHost) {
// 当前域名全局规则
const pageConfig = {
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
version: host.version || '',
expire: host.expire || 0,
proxyHost: host.proxyHost,
waitForNetworkIdleTime: host.waitForNetworkIdleTime === undefined ? 300 : host.waitForNetworkIdleTime, // 默认等待 300 毫秒
}
// 当前页面规则
if (Array.isArray(host.rules)) {
const rule = host.rules.find(item => {
// 字符串
if (typeof item.path === 'string' && item.path === req.originalUrl) {
return true
}
// 正则
if (typeof item.path === 'object' && item.path instanceof RegExp && item.path.test(req.originalUrl)) {
return true
}
// 函数
if (typeof item.path === 'function' && item.path(req.originalUrl)) {
return true
}
return false
})
if (rule) {
pageConfig.expire = rule.expire !== undefined ? rule.expire : pageConfig.expire
pageConfig.waitForNetworkIdleTime = rule.waitForNetworkIdleTime !== undefined ? rule.waitForNetworkIdleTime : pageConfig.waitForNetworkIdleTime
}
}
return pageConfig
} else {
return false
}
}
module.exports = {
waitForNetworkIdle,
getPageConfig
}
因为 headless-chrome 自带的网络空闲等待时间配置不够灵活(只能 500ms),所以这里实现了一个相关函数 waitForNetworkIdle。
请求处理
// server.js
const express = require('express')
const Cacheman = require('cacheman')
const CachemanFile = require('cacheman-file')
const router = express.Router()
const ssr = require("./ssr.js")
const config = require('./config')
const util = require('./util')
const factorytList = [] // 正在渲染的页面
let requestList = [] // 等待渲染的页面
const cache = new Cacheman('htmls', {
engine: 'file'
}) // 缓存
router.get("/*", async (req, res, next) => {
console.log(req.originalUrl)
req._pageConfig = util.getPageConfig(req, config)
// 如果没有相关配置返回错误
if (!req._pageConfig) {
endRequest(res, {
status: 404
})
return
}
// 先检查缓存
const html = await getCachePage(req, cache)
// 缓存里有就直接返回
if (html) {
endRequest(res, {
url: req._pageConfig.url,
html
})
} else { // 否则放入渲染等待队列
requestList.push({req, res, next})
assignPage()
}
})
module.exports = router
/**
* 分配页面渲染
*/
function assignPage() {
if (requestList.length && factorytList.length < config.pages) {
renderPage(requestList.shift(), factorytList)
console.info('render:' + requestList.length, 'factorytList:' + factorytList.length)
}
}
/**
* 检查缓存
* @param {*} req
* @param {*} cache
*/
async function getCachePage(req, cache) {
const pageConfig = req._pageConfig
let result = null
try {
result = await cache.get(pageConfig.url)
} catch (e) {
console.log(e)
}
// 过期或者版本不合,缓存失效
if (result
&& (result.createTime + result.expire * 1000 < Date.now()
|| (result.version !== pageConfig.version)
)) {
result = null
try {
await cache.del(pageConfig.url)
} catch (e) {
console.log(e)
}
}
return result ? result.html : null
}
/**
* 存到缓存
* @param {*} req
*/
async function setCachePage(req, cache, html) {
const pageConfig = req._pageConfig
try {
// 有期限才缓存
if (pageConfig.expire) {
await cache.set(pageConfig.url, {
createTime: Date.now(),
expire: pageConfig.expire,
version: pageConfig.version,
html
})
}
return true
} catch (e) {
console.log(e)
return false
}
}
/**
* 渲染页面
* @param {*} request
*/
async function renderPage(request) {
const pageConfig = request.req._pageConfig
let html = ''
let ttRenderMs = ''
let start = Date.now()
factorytList.push(start)
const result = await ssr(
`${request.req.protocol}://${pageConfig.proxyHost}${request.req.originalUrl}`,
pageConfig
)
factorytList.map((item, index) => {
if (item === start) {
ttRenderMs = Date.now() - start
factorytList.splice(index, 1)
}
})
html = result.html
// 结束本次请求
endRequest(request.res, {
version: pageConfig.version,
url: pageConfig.url,
html,
ttRenderMs
})
// 渲染没有错误,尝试缓存
if (result.renderError === false) {
setCachePage(request.req, cache, html)
}
// 队列中相同请求一并返回
requestList = requestList.filter(item => {
if (item.req._pageConfig.url === pageConfig.url) {
endRequest(item.res, {
url: pageConfig.url,
html,
ttRenderMs
})
return false
}
return true
})
// 检查是否还有需要渲染的页面
assignPage()
return
}
/**
* 结束请求
* @param {*} res
* @param {object} options
*/
function endRequest(res, options = {}) {
if (!options.status || options.status === 200) {
console.info(`Headless rendered page ${options.url} in: ${options.ttRenderMs || 0}ms`);
}
const defaultHtml = '<h1>error</h1>'
// Add Server-Timing! See https://w3c.github.io/server-timing/.
res.set(
"Server-Timing",
`Prerender;dur=${options.ttRenderMs || 0};desc="Headless render time (ms)"`
)
return res.status(options.status || 200).send(options.html || defaultHtml) // Serve prerendered page as response.
}
请求进来后先检查配置和缓存,如果命中,返回缓存,否则加入待渲染队列,通过 assignPage 分配渲染。
这里的缓存可以是内存缓存,也可以是磁盘缓存。鉴于页面比较多,所以使用磁盘缓存更合理。
渲染页面
// ssr.js
const puppeteer = require("puppeteer");
const util = require('./util')
// 存储browsweWSendpoint
let WS = null
/**
* 渲染页面
* @param {*} url
* @param {*} pageConfig
*/
async function ssr(url, pageConfig) {
let renderError = false // 渲染是否出错
let browser = null
let html = '' // 渲染结果
if (WS) {
browser = await puppeteer.connect({ browserWSEndpoint: WS })
} else {
browser = await puppeteer.launch({headless: true,
args: [
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-zygote',
'--single-process'
]});
WS = browser.wsEndpoint()
}
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();
});
try {
// util.waitForNetworkIdle 等待网络请求完成
await Promise.all([
page.goto(url, {
timeout: 5000
}),
util.waitForNetworkIdle(page, pageConfig.waitForNetworkIdleTime, 0)
])
} catch (err) {
console.error(err);
await page.close();
await browser.close()
ws = null
renderError = true
// throw new Error("page.goto/waitForSelector timed out.");
}
// 检查渲染是否出错
if (renderError) {
html = '<h1>发生了一些错误,请待会再试!</h1>'
} else {
html = await page.content(); // serialized HTML of page DOM.
await page.close();
await browser.disconnect()
// 解决闪屏
html = html.replace('id="app"', 'id="app" data-server-rendered="true"')
}
return { html, renderError }
}
module.exports = ssr;
这里解决闪屏代码是针对 vue 项目的。其他项目可以针对性的改造,比如通过配置文件指定。
性能
我的虚拟机配置如下:
- cup:i5-6300h——分配两核
- 内存:分配 1G
测试的性能如下(使用的简单页面:poem.tony93.top):
- 无缓存——QPS 大概为 5
- 磁盘缓存——QPS 大概为 25。这个波动比较大,和缓存命中有关。
使用的是谷歌浏览器,每十个页面为一组。因为浏览器并发限制,这里的结果仅供参考。
相比于 nuxt 和传统的后端模板方案,这个性能并不好(使用内存缓存应该会好很多,比如将访问频繁的页面放到内存,其他的仍然放磁盘),但是因为是 SPA 应用,只有用户打开网站的时候会走 ssr,后续的页面操作基本都在用户浏览器渲染,所以性能应该还可以满足一般网站。
后来我也测试了线上环境(阿里云 1 核 2G 1M),结果差不多。从后台资源使用率来看,cpu 压力很大。两个页面并行时,cpu 占用达到 80%。

注意事项
- 在渲染某些页面(我一个非根目录 hash 模式的项目)的时候,puppeteer 会报错,代码中的 try catch 并没有解决该问题。此后的所有请求都无法进行,也就是服务崩了。具体原因还不清楚。应该不是 hash 模式的问题。目前只能说确保项目是 history 模式很重要。
总结
puppeteer 作为通用渲染方案,还是值得一试的。并且其功能不仅仅是针对 SEO 的服务端渲染,还可以进行页面截图、后台海报渲染、爬虫页面抓取等。
不过,自己摸索肯定是要成本的。对于访问量不是特别大的项目,建议还是购买第三方服务。
此外,服务端渲染可以仅针对搜索引擎爬虫。google 的爬虫已经支持 SPA 应用,可以排除。