本文探讨了Vue/React单页应用动态预渲染的实现和问题排查。项目通过Nginx进行爬虫识别,将请求代理至预渲染服务,但在实际部署中遇到了Nginx递归死循环、旁路穿透、Puppeteer等待超时等问题。文章详细分析了这些问题,并提供了相应的解决方案。
项目是一套跑了两年的 Vue3 + Vite 单页后台兼产品展示站。原来流量全靠销售在群里发链接,后来运营提出要把知识库、方案页和更新日志放出去做搜索排名。
我和前端评估过直接迁移 Nuxt3 的方案。当时项目里有十几个大页面,代码里散落着大量的 window 全局引用、第三方图表库以及没有做生命周期隔离的客户端代码。真要重构成服务端渲染,状态水合跟组件拆分至少得耗掉两个星期,手头还有当期的需求排期,根本抽不出这么大块时间。
折中之下我们选了动态渲染路线。架构听起来很顺理成章:在 Nginx 层面加一层判断,如果是普通用户访问,依然返回打包好的纯静态 index.html,由浏览器去跑 JS 渲染;如果是百度或者谷歌等搜索引擎蜘蛛,就把请求反向代理到一台跑着 Node.js + Headless Chrome 的预渲染服务,等页面脚本跑完把生成好的 HTML 吐给蜘蛛。
代码写完在本地测了几个页面,抓取结果看都很正常。结果把这套机制切到生产环境的第一周,站长平台和监控日志就接连报警。
Nginx 识别爬虫时的递归死循环与旁路穿透
最开始在 Nginx 配置爬虫转发时,我直接从网上找了一段匹配 User-Agent 的配置,把常见的蜘蛛关键词放在一个正则里匹配。
# 踩坑的简写配置
if ($http_user_agent ~* "Baiduspider|Googlebot|bingbot|Bytespider") {
set $spider 1;
}
if ($spider = 1) {
rewrite ^/(.*)$ /prerender/$1 last;
}
这段逻辑很快出了问题。我们在本地跑的 Puppeteer 预渲染服务在收到爬虫请求后,也是通过无头浏览器向我们自己的域名发起 HTTP 请求获取原始 HTML 和 JS 脚本。因为无头浏览器默认也是走标准网络栈,它的请求如果也被 Nginx 截获,并重新判定为需要渲染,请求就会在 Nginx 和预渲染服务之间打转,最终直接抛出连接超时。
更麻烦的是大小写匹配和不同爬虫的变种识别。用 if 指令嵌套容易踩到 Nginx 的执行上下文陷阱,改用 map 指令把爬虫判断和内部请求过滤拆开,逻辑会稳当很多。
# 在 http 块定义爬虫与内部请求过滤
map $http_user_agent $is_spider {
default 0;
~*Baiduspider 1;
~*Googlebot 1;
~*bingbot 1;
~*Bytespider 1;
~*360Spider 1;
~*Sogou 1;
}
map $http_x_prerender $final_spider {
"1" 0;
default $is_spider;
}
预渲染服务自身向业务站发起请求时,必须在请求头里带上自定义字段 X-Prerender: 1。这样 Nginx 看到这个标记就知道是渲染进程回源拉取资源,直接放行静态文件,避开了循环代理的问题。
Puppeteer 等待 networkidle0 导致的蜘蛛抓取 504 超时
服务上线第二天,百度蜘蛛抓取频次增加,Nginx 访问日志里开始冒出大量的 504 状态码。
排查 Node 预渲染脚本,当时为了保证所有动态数据都渲染完成,页面跳转写的是等待 networkidle0:
// 产生超时的写法
await page.goto(targetUrl, {
waitUntil: 'networkidle0',
timeout: 30000
});
这个参数要求页面在连续 500 毫秒内没有任何网络连接。然而我们页面里为了做未读消息提醒,前端挂了一个长轮询心跳请求,同时页面底部还加载了第三方埋点统计脚本。这些脚本一直在定时向外发包,页面的网络活动从头到尾就没停过。
Puppeteer 每次都要硬生生耗满 30 秒超时时间,然后抛出异常。外层的 Nginx 等不到上游响应,直接给爬虫返回 504。
针对这个问题我们做了两处改动。在 Node 服务端拦截静态资源和分析统计的请求,纯粹展示文字内容的页面根本不需要加载图片、字体和埋点分析脚本:
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
const url = req.url();
if (['image', 'media', 'font'].includes(resourceType) || url.includes('analytics') || url.includes('hm.baidu.com')) {
req.abort();
} else {
req.continue();
}
});
在前端配合加上渲染完成的状态通知。在 Vue3 页面拉取完接口并在 DOM 挂载完毕后,主动给全局对象赋值:
// Vue3 页面数据就绪钩子
onMounted(async () => {
await fetchArticleData();
nextTick(() => {
window.__PRERENDER_STATUS__ = { ready: true };
});
});
Puppeteer 只要监听这个全局变量,一旦为 true 就立即取 HTML 返回,不用再傻等网络空闲。渲染单个页面的平均耗时从原来十几秒压到了 800 毫秒以内。
前端 404 路由向爬虫返回 200 OK 导致的软 404 降权
这是单页应用做预渲染最容易被忽略但杀伤力最大的问题。
当爬虫访问一个已经下架的文章链接时,由于所有请求在物理服务器上落地的都是同一个 index.html,Nginx 最初返回给无头浏览器的 HTTP 响应码是 200 OK。浏览器加载完 JS 后,Vue Router 判定路由不存在,在页面中渲染了一个"抱歉,页面不存在"的提示组件。
人眼在屏幕上看到的是 404 页面,但 Puppeteer 读取这层 DOM 吐给爬虫时,整个 HTTP 响应头依然是 200 OK。
搜索引擎把这种现象称为软 404(Soft 404)。爬虫发现几百个不同 URL 对应的内容全是那几行"页面不存在"的模板文字,状态码却全是 200,很快就会判定网站充斥大量低质页面,进而削减整站的抓取配额。
解决办法是在前端找不到路由时,把真实的状态码写进 HTML 头部:
<!-- Vue Router 捕获到 404 页面时动态插入 -->
<meta name="prerender-status-code" content="404">
Puppeteer 在准备输出 HTML 之前,先扫描页面是否存在这个特定的 meta 标签:
const statusCode = await page.evaluate(() => {
const meta = document.querySelector('meta[name="prerender-status-code"]');
return meta ? parseInt(meta.getAttribute('content'), 10) : 200;
});
const content = await page.content();
res.status(statusCode).send(content);
这样遇到下架或者错误的链接,预渲染服务会老老实实给蜘蛛返回 404 或者 410 状态码,避免垃圾页面被当成有效内容索引。
爬虫集中扫描导致 Chromium 内存耗尽与缓存雪崩
预渲染本身是非常吃 CPU 和内存的重度任务。百度蜘蛛有时候会突然发起几百个并发请求,如果每个请求都实时唤起 Chromium 新建标签页,服务器的内存会在几十秒内被吃光。
上线第三天我就看到服务器内核触发了 OOM Killer,直接把 Node 进程干掉,接着整个预渲染节点挂掉,蜘蛛抓取成功率直线跌到 20%。
单纯靠限制 Puppeteer 的并发数还不够,必须在 Nginx 这一层给爬虫请求建立快照缓存,让同一个页面的重复抓取直接命中磁盘。
# Nginx 预渲染快照缓存
proxy_cache_path /var/cache/nginx/prerender
levels=1:2
keys_zone=prerender_cache:20m
max_size=2g
inactive=3d
use_temp_path=off;
server {
listen 80;
server_name www.example.com;
location / {
if ($final_spider = 1) {
proxy_pass http://127.0.0.1:3000;
break;
}
try_files $uri $uri/ /index.html;
}
# 预渲染代理与缓存策略
location @prerender {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Prerender 1;
proxy_set_header Host $host;
proxy_cache prerender_cache;
proxy_cache_valid 200 301 302 2d;
proxy_cache_valid 404 1h;
proxy_cache_key $scheme$host$request_uri;
# 遇到后端故障时使用旧缓存兜底
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
}
}
开启 proxy_cache_lock on 能够防止同一时间多个相同的爬虫请求穿透到后端。当一个 URL 正在被渲染时,后续相同的抓取请求会排队等待第一个请求写入缓存,直接拿缓存结果。
对于 Node 端的 Puppeteer 进程,使用单一的长连接浏览器实例容易出现内存泄露。推荐使用轻量连接池,每个浏览器实例在处理完 50 次渲染任务之后就主动销毁重建,配合 puppeteer-cluster 限制最大并发数为 2,把内存占用死死控制在 500MB 以内。
这几处调整跑完之后,整个站点的爬虫抓取曲线趋于平稳,百度和谷歌的收录量也在两周后逐步恢复,既保住了前端不用推翻重构的开发节奏,也达到了让搜索引擎顺畅收录的效果。
文章标题:Vue/React 单页应用不想重构 SSR 搞 Nginx 爬虫动态预渲染?排查 Headless Chrome 超时 504、软 404 穿透与缓存雪崩我踩过的几个坑
文章链接:https://www.llbbs.cn/jishujaocheng/251.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?