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 应用,可以排除。