SPA应用部署方便,直接扔到CDN即可。
直到有一天发现搜索引擎除了首页之外其他页面都没有收录~傻逼了吧

这可怎么办,SSR框架重写一遍?工程太大了吧。。

如果只是针对SEO,爬虫的访问量完全可以在响应的时候实时利用Headless browser去本地打开网页。
再把渲染过后的HTML返回给爬虫。

正常用户访问还是前端渲染。

缺点就是需要额外引入node作为后端webserver,需要自己保证这些服务的稳定性,没有直接扔CDN来的那么省事。
但是为了SEO的流量…

Puppeteer

自从Puppteteer发布后,PhantomJS就宣布停止维护了,前两年爬虫换上了puppeteer性能飞起。

Puppeteer的原理是通过dev protocol websokct协议去控制浏览器的各种操作。
我在15年写过一个类似的,利用chrome的扩展api,通过建立websock连接和node通讯来控制浏览器,当时是为了替代缓慢的phantomjs。

安装Puppeteer

1
cnpm install puppeteer --save

Centos依赖

1
2
3
4
5
# 依赖库
yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 -y

# 字体
yum install ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y

目录结构

1
2
3
4
5
6
7
8
9
10
project
│ server.js
| ssr.js

└───dist
│ index.html

└───static
│ xx.js
│ ...

依赖

  • isbot 爬虫uA判断库
  • connect-history-api-fallback express vue history兼容中间件
1
npm install express isbot connect-history-api-fallback --save
ssr.js

接受一个URL参数,返回渲染后的HTML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const puppeteer = require('puppeteer');

async function ssr(url) {
const start = Date.now();
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
// networkidle0 等待直到500ms内没有请求了
await page.goto(url, {waitUntil: 'networkidle0'});
// 或者等待直到某个dom插入了
await page.waitForSelector('#posts');
} catch (err) {
console.error(err);
throw new Error('page.goto/waitForSelector timed out.');
}

const html = await page.content(); // 获取HTML结构
await browser.close();

const ttRenderMs = Date.now() - start;
console.info(`Headless rendered page in: ${ttRenderMs}ms`);
return {html, ttRenderMs};
}

export {ssr as default};
server.js
  • 启动一个webserver
  • 每次请求判断客户端UA是否是爬虫,调用puppeteer获取渲染后的html
  • 利用expressstatic中间件来处理资源请求
  • 利用connect-history-api-fallback库来对请求重定向到index.html
  • 前端Vue路由改为history模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var express = require('express');
var app = express();
var server = require('http').createServer(app);
var history = require('connect-history-api-fallback');
var isBot = reuqire('isbot');
var SSR = require('./ssr.js');
var listenPort = 8088;

const staticFileMiddleware = express.static('dist');

app.use(function(req, res, next){
var UA = req.headers['user-agent'];
var isStaticDir = req.url.indexOf('static/') > -1;
// 判断是否是爬虫, 排除资源目录的请求
if(UA && isBot(UA) && !isStaticDir){
// 生成本地访问链接
var requestUrl = 'http://localhost:'+listenPort+req.url;
(async () => {
try{
var results = await SSR(requestUrl);
res.send(results.html);
}catch(e){
console.log('ssr failed', e);
res.status(500).send('Server error');
}
})();
return;
}
next();
});


// 先
app.use(staticFileMiddleware);

// 如果资源没命中会继续、经过history rewirte后
app.use(history({
disableDotRule: true,
verbose: true
}));

// 再次处理
app.use(staticFileMiddleware);
server.listen(listenPort);

性能优化

  • 浏览器单次启动
  • 减少不必要的资源请求如图片等等..
  • 缓存每个URL的HTML结构 生命周期1个小时

单次启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

let browserWSEndpoint = null;

async function ssr(renderUrl){

let browser = null;
if(browserWSEndpoint){
try{
browser = await puppeteer.connect({browserWSEndpoint});
}catch(e){
// 可能失败
browserWSEndpoint = null;
browser = null;
}
}

if(!browserWSEndpoint){
browser = await puppeteer.launch({
headless: true,
ignoreHTTPSErrors: true,
args: [
// Required for Docker version of Puppeteer
'--no-sandbox',
'--disable-setuid-sandbox'
]
});
browserWSEndpoint = await browser.wsEndpoint();
}
...

资源请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

let browserWSEndpoint = null;

async function SSR(renderUrl){

let browser = null;

if(browserWSEndpoint){
try{
browser = await puppeteer.connect({browserWSEndpoint});
}catch(e){
browserWSEndpoint = null;
browser = null;
}
}

if(!browserWSEndpoint){
browser = await puppeteer.launch({
headless: true,
ignoreHTTPSErrors: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox'
]
});
browserWSEndpoint = await browser.wsEndpoint();
}

const page = await browser.newPage();

// 1. 监听网络请求
await page.setRequestInterception(true);

page.on('request', req => {
// 2. 忽略不必要的请求,如图片,视频样式等等
const whitelist = ['document', 'script', 'xhr', 'fetch'];
if (!whitelist.includes(req.resourceType())) {
return req.abort();
}

// 3. 其它请求正常继续
req.continue();
});

await page.goto(renderUrl, {waitUntil: 'networkidle0'});

const html = await page.content();
let results = {
html
}
return results;
}

缓存

安装依赖

1
npm install cacheman cacheman-file --save
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

const FilecCache = new Cacheman('htmls', {
// 缓存3个小时
ttl: 60 * 60 * 3,
engine: 'file',
});

let browserWSEndpoint = null;

async function SSR(renderUrl){
let browser = null;
let urlMd5 = md5(renderUrl);

// 是否命中缓存
var hitByCache = await FilecCache.get(urlMd5);
if(hitByCache){
return hitByCache;
}

if(browserWSEndpoint){
try{
browser = await puppeteer.connect({browserWSEndpoint});
}catch(e){
browserWSEndpoint = null;
browser = null;
}
}

if(!browserWSEndpoint){
browser = await puppeteer.launch({
headless: true,
ignoreHTTPSErrors: true,
args: [
// Required for Docker version of Puppeteer
'--no-sandbox',
'--disable-setuid-sandbox',
]
});
browserWSEndpoint = await browser.wsEndpoint();
}

const page = await browser.newPage();

await page.setRequestInterception(true);

page.on('request', req => {
const whitelist = ['document', 'script', 'xhr', 'fetch'];
if (!whitelist.includes(req.resourceType())) {
return req.abort();
}
req.continue();
});

await page.goto(renderUrl, {waitUntil: 'networkidle0'});

const html = await page.content();
let results = {
html
}

// 写入缓存
await FilecCache.set(urlMd5, results);
return results;
}

统计代码排除

避免统计错误,可以把ga,百度统计之类的代码请求block掉