Nginx 部署与 SPA 预渲染实战:上线这一年我踩过的坑

悦目图库

我到现在还记得上线第一天的场景。

那天我从下午就开始部署,前端在本地跑得好好的,路由、页面、动效,一个毛病没有。我一遍遍检查 npm run build 的输出,看着 52 个静态 HTML 生成完毕,sitemap 也更新了,觉得万事俱备。然后我把 dist/ 推上去,打开手机,在地址栏敲下 /zh/about——白屏,一行字:404 Not Found。

我以为是部署没成功,重新推了一遍,刷新,还是 404。我盯着手机屏幕看了半天,第一反应是骂服务器:这破 Nginx 是不是没配好?骂完冷静下来才想起来,哦,SPA 只有一个 index.html,路由是前端的事,服务器根本不知道 /zh/about 是个什么东西。它找不到文件,按规矩回个 404,一点毛病没有。

问题从来不在 Nginx,在我没告诉它"认不出的路径都交给 index.html"。

Nginx

第一个坑:刷新就 404

解决 404 的办法地球人都知道,try_files $uri /index.html。我配上,本地一测,哎,好了。当时我还挺得意,觉得部署也就这么回事。结果上线第二天,用户反馈:个人主页刷新还是 404。

我当场就懵了,明明配了。后来把配置翻来覆去看了半天才反应过来——/zh/about 这个路径,$uri 是整个 /zh/about,而我配的回退是 /index.html。匹配倒是匹配上了,可 /zh/about 既不是文件也不是目录,Nginx 一层层往下找,最后找到的 /index.html 是根入口——但根入口是默认语言的

真正解决的是给回退链加了一级:

# /zh/xxx 或 /en/xxx
location ~ ^/(zh|en)(/.*)?$ {
    try_files $uri $uri/ /$1/index.html /index.html;
}

$1 是正则里捕获的 zhen。意思是:/zh/about 不是文件也不是目录,那就去 /zh/index.html 看看——那是构建期生成的中文预渲染首页,标题、描述、H1 全是中文的。实在没有再兜到根 index.html。

当时我盯着这个 /$1/index.html 想了很久,越想越觉得这行是整份配置里最值钱的一行。它不是简单的"找不到就回首页",而是"找不到就回对的语言的首页"。百度蜘蛛来抓 /zh/picture/123,看到的是中文站点的完整描述,而不是一个默认语言的空壳。

那段时间我还踩了个相关的坑:/zh/monitor/zh/games 这些路径,一开始会落进 SPA 兜底,结果系统监控页面和游戏入口全被"前端路由"接管了,返回的全是 index.html。后来在回退之前加了更精确的规则单独拦截——监控重定向回无前缀路径,游戏 301 到独立子站。回退链前面,永远要留一层"特殊路径先处理"

SPA 回退链

第二个坑:缓存把新版本锁死了

404 解决了,我开始琢磨性能。给静态资源加了缓存,/assets/ 一年、图片三十天,一切正常。然后某天我发了个新版本,测试同学跟我说:还是旧版。

我一脸懵:部署日志显示成功了啊。我打开 DevTools 一看,HTML 被缓存了——我当初为了"性能"给 HTML 也加了 expires 1d。结果是用户拿着旧 HTML,引用旧 hash 的 JS,新版本死活加载不出来。测试同学看我的眼神,像看一个傻子。

那天我在代码里加了这么一段,从此再没碰过:

location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-store, no-cache, must-revalidate";
}

HTML 必须不缓存。Vite 打包出来的 JS/CSS 文件名都带内容 hash,SnakeGamePage-DR39kSHf.js 这种,hash 变文件名就变。所以 /assets/ 可以往死里缓存一年——浏览器永远用旧缓存,直到部署新版本,URL 变了自然加载新的。但 HTML 是入口,它必须每次请求都拿到最新的,才能引到最新的 hash 资源。

后来我把这套想明白,画了张图给自己看:

缓存分层

版本化资源一年、普通静态三十天、HTML 不缓存。不变的往死里缓存,变的一个字节都不留——就这么简单,但那次我交了一天的学费才懂。哦对,还有 robots.txtsitemap.xmlads.txt 这三个特殊文件,我单独给了 expires 1d3600——它们要能被搜索引擎及时刷新,又不能天天重新抓。每个文件的缓存时长,都得按"它多久变一次"来定,没有一刀切。

第三个坑:https 被 301 降级了

域名规范化是老生常谈:yuemutuku.comwww.yuemutuku.com 是俩站,权重也分两半。我配了 301:

server {
    listen 80 default_server;
    server_name yuemutuku.com _;
    return 301 https://www.yuemutuku.com$request_uri;
}

配完我拿手机试,yuemutuku.com 输进去,哎,跳到了 https://www.yuemutuku.com,一切正常。然后我就没管了。

直到某天我翻 CDN 日志,发现一堆奇怪的东西:明明全站 https,却有一批请求是从 http 进来的,而且状态码是 301 到 http 地址。我顺着查,才明白:这台 Nginx 只收 CDN 的 HTTP 回源(80 端口),而 Nginx 默认 absolute_redirect on——它生成绝对重定向地址时,用的是自己收到请求时的协议,也就是 http。浏览器在 https 页面上收到一个 http 的 301,就降级了。

修复就一行:

absolute_redirect off;
port_in_redirect off;

改成相对重定向之后,浏览器在 https 页面跟随 301,会保持 https。就这一行,我在注释里写了 TH01——"彻底避免降级"。这是给三个月后的自己看的,怕自己忘了当初为什么写它。

顺带一提,那段时间我还顺手给主站加了一组安全响应头,起因是安全扫描报告里全是黄灯:

add_header X-Content-Type-Options    "nosniff" always;
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header X-XSS-Protection          "1; mode=block" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Permissions-Policy        "camera=(), microphone=(), geolocation=()" always;

每个头都是被锤过才加上的:nosniff 防的是浏览器猜 MIME 类型(有人试过把恶意内容伪装成图片传上来);SAMEORIGIN 防的是页面被 iframe 套走(点击劫持);Referrer-Policy 防的是 URL 里的敏感参数跟着 Referrer 泄出去;Permissions-Policy 是一刀切,默认谁也别想碰摄像头和麦克风。扫描器黄灯变绿灯那天,我长舒了一口气。

第四个坑:搜索引擎收录了两套语言 URL

站点做了多语言,一开始图省事用 ?lang=en 切语言。后来改成路径式 /en/。改的时候我心想,反正前端能兼容老参数,没事。

结果三个月后看 Search Console,发现收录里 ?lang=en/en/ 两套 URL 并存,同一篇内容俩地址,权重分两半。我这才意识到:搜索引擎是有记性的,它收录过的 URL 不会因为你不用了就消失。

解决还是 301:

location = / {
    if ($arg_lang ~* ^(zh|en)$) {
        return 301 /$arg_lang/;
    }
}

?lang=en 永久跳转到 /en/,权重跟着走。这有个小讲究:用 location = / 只精确匹配首页——因为业务页面还有别的查询参数(?id=123 这种),一刀切会误伤。配完过了大概一个月,收录里 ?lang 的 URL 就慢慢消失了。

?lang 301 归一

前端我也做了兼容,resolvePreferredLocale() 先读 ?lang= 再读 localStorage,保证老链接即使不走 Nginx 也能显示对的语言。两头都堵上,才算真的收干净。这里还有个小插曲:某天我发现 /en/?lang=en 这种路径和参数一致的 URL 也在重复收录,又加了一层判断——参数和路径一致就不重定向,不一致才跳。多语言这摊子事,没有一处是省心的。

第五个坑:百度抓回来一堆空壳

404 修了,缓存改了,重定向理了,我以为万事大吉。然后有天我突发奇想,用百度的抓取工具看了眼自己的站点——页面里只有一个 <div id="app"></div>,一个标题一个描述都没有,一百个 URL 一百个一模一样的空壳。

那一刻的挫败感,跟第一天 404 差不多。

SPA 的问题在于,内容全是 JS 在浏览器里渲染的,而爬虫抓取阶段不执行 JS。你交付给搜索引擎的就是一个空壳加一个默认标题。这不能怪百度,是这架构天生如此。

我的解法是构建期预渲染:写了个脚本,在 npm run build 之后跑,读 locale 文件,把每个页面的标题、描述、canonical、hreflang、JSON-LD,甚至指南文章的全文字符串,直接写进静态 HTML。

// scripts/prerender.mjs —— 构建后执行
// 读 locale 文件 → 生成完整 <title>/<meta>/<link> + 文章正文 HTML
// 不执行 JS,百度/Google 抓取即得完整内容
const md = new MarkdownIt({ html: false, linkify: true })

配合 generate-locale-html.mjs 生成 dist/zh/index.htmldist/en/index.html——就是回退链第三级的那个文件,连 JS 都不用跑。于是生产的 dist/ 变成这样:

dist/
├── index.html              # SPA 入口(根兜底)
├── zh/
│   ├── index.html          # 中文预渲染首页(标题/描述/H1 全有)
│   ├── about/index.html    # 中文关于页
│   └── guides/a27/index.html  # 指南全文都注入进去了
├── en/                     # 英文同构
└── assets/                 # 版本化资源(一年缓存)

一次构建多花几秒,换来的是爬虫、无 JS 环境、弱网用户都能读到完整内容。对我来说这是内容型站点投入产出比最高的一条路——预渲染的本质,就是把"爬虫要执行 JS 才看得见的内容",提前在构建期写成静态 HTML

这中间还有个插曲:sitemap 生成脚本一开始没限速,把后端接口打得嗷嗷叫。后来给生成脚本加了请求间隔和每类型上限,才算消停。凡是会打后端的东西,都得记得限速,这条我记了很久。

第六个坑:WebSocket 代理被断

聊天功能上线后,用户反馈:聊着聊着就断线,过几秒又自动连上,循环往复。

我第一反应是前端 WebSocket 重连逻辑写得差。排查半天,前端没毛病。后来抓包才发现,问题出在 Nginx 的 WebSocket 代理——默认的 proxy_read_timeout 是 60 秒,WebSocket 连接超过 60 秒没消息,Nginx 就把连接掐了。客户端被迫重连,重连又要握手,体验就是一卡一卡的。

修复:

location /api/ws/ {
    proxy_pass http://yuemu-picture-backend:8080/ws/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 86400s;  # 长连接,一天
    proxy_send_timeout 86400s;
}

三个要点:proxy_http_version 1.1(WebSocket 需要 HTTP/1.1)、UpgradeConnection 头透传(升级协议)、超时拉长到一天(长连接不该被 60 秒的超时掐死)。这里还配合了一个 map:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

普通请求 Connection 用 close,WebSocket 请求才用 upgrade——不能无脑全设 upgrade,否则普通请求也会试图升级协议,白白增加开销。

第七个坑:敏感路径忘了屏蔽

上线后某天,我在日志里翻到一串诡异的请求:/SDK/xxx/admin/.env。一个个都在试探——有人在扫你的服务器,找管理后台、找配置文件、找没保护的接口。那段时间我浑身冷汗:要是这些路径真的存在且没保护,后果不敢想。

好在当时随手配了一段,后来证明是救命稻草:

# 敏感路径屏蔽
location ~ ^/SDK/  { return 404; }
location ~ ^/admin  { return 404; }
# 点开头文件一律拒绝(.env/.git 等)
location ~ /\. { deny all; access_log off; log_not_found off; }

/SDK//admin 直接 404(不是 403,403 会告诉扫描器"这里存在",404 让它以为不存在),点开头文件一律 deny(.env、.git、.htaccess 这些是信息泄露重灾区)。加完这些,扫描器的日志安静了不少。安全不是等出了事才做,是每天在日志里提前埋雷

第八个坑:Gzip 把图片压了个寂寞

性能优化做到后面,我打开了 Gzip。看着配置里一长串 gzip_types,心想这下静态资源都压缩了,美滋滋。然后一看流量报表,卧槽,图片的传输体积一点没变。

查了才知道:Gzip 压缩对文本类内容(HTML、CSS、JS、JSON、SVG)收益巨大,对图片(PNG、JPG、WebP)基本无效——图片本身就是高度压缩的二进制,再压也压不出多少,白耗 CPU。我把图片类型加进 gzip_types,等于让 Nginx 对每张图片做一次毫无意义的压缩尝试。

gzip on;
gzip_comp_level 5;      # 压缩比和 CPU 的平衡点
gzip_min_length 512;    # 小于 512 字节不压(压了反而更大)
gzip_types
    text/css
    text/javascript
    application/json
    image/svg+xml       # SVG 是文本,值得压
    ...;

SVG 虽然是图片格式,但内容是 XML 文本,压缩收益很大,所以留了 image/svg+xml。真正的位图(PNG/JPG/WebP)一个都不放进去。压缩要懂内容类型,不是文件后缀——这条也是交过学费的。

第九个坑:跨域把自己卡死了

图片社区嘛,免不了有人直接拿图片链接去外站用,也有人想从自己的站调我们接口。某天一个合作方跟我说:你们的接口我们调不通,跨域报错。

我开始还理直气壮:跨域不是你们后端要配吗?后来一想不对,我们的接口本来就要给外部用(开放 API),跨域得我们自己放开。于是给主站配了 CORS:

set $cors_origin  "*";
set $cors_methods "GET, POST, PUT, DELETE, OPTIONS";
set $cors_headers "Origin, X-Requested-With, Content-Type, Accept, Authorization";

# 预检请求直接放行
if ($request_method = OPTIONS) {
    return 204;
}
add_header Access-Control-Allow-Origin  $cors_origin always;
add_header Access-Control-Allow-Methods $cors_methods always;
add_header Access-Control-Allow-Headers $cors_headers always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "86400" always;

注意 OPTIONS 预检要单独处理——浏览器跨域复杂请求(带自定义头、非简单方法)会先发一个 OPTIONS 探路,Nginx 不响应它,浏览器就直接报跨域错。Access-Control-Max-Age 让预检结果缓存一天,减少预检次数。Access-Control-Allow-Credentials: true 是给带 cookie 的请求用的(我们的登录态走 cookie,跨子域共享靠的就是它)。这几个头配完,合作方的接口调通了,我也学会了"开放平台不是嘴上说说,是头文件里一条条配出来的"。

还有一个后手:爬虫代理和健康检查

预渲染覆盖了所有静态路由,但 /picture/123 这种带 ID 的动态路由没法全预生成。生产配置里我留了一招:按 User-Agent 区分,是百度/Google 蜘蛛就代理到渲染服务(无头浏览器现场跑 JS),普通用户正常走 SPA。这招更彻底,适合动态内容多的站。我现在靠预渲染 + locale 回退已经够用,这招算是给未来留的。

另外每个 server 块都配了 /health 健康检查端点和 /monitor 监控代理:

location = /health {
    access_log off;
    return 200 "OK";
    add_header Content-Type text/plain;
}

Spring Boot Admin 通过 /monitor 代理拉取后端指标,负载均衡器定时打 /health 确认服务活着。别小看这两行——线上出问题的时候,你第一个要看的就是它俩。没有健康检查的部署,等于在摸黑开车

最后:四个站共一份配置

写到最后发现,这个项目其实有四个站:主站、官方站、游戏站、工具站。它们共用一份 nginx.conf,每个站一个 server 块,结构长得一模一样——静态资源正则、SPA 回退、/api/ 反代、WebSocket、robots/sitemap/health。游戏站多一个"同源反代后端",因为它要复用主站的账号体系和排行榜;工具站最省,只放行 GET。

子站之间怎么共享登录态?靠 satoken cookie 跨子域(cookie.domain=yuemutuku.com)。用户在主站登录,去游戏站玩排行榜,同一个会话,不用再登一次。这套"多子站 + 共享账号"的形态,一份配置全搞定了。

哦对了,开头那段 worker_processes autokeepalive 也不是白写的。有段时间高峰期 Nginx CPU 飙到 90%,后来把 worker 数改成 auto(按 CPU 核数起)、加 multi_accept on(一次多收几个连接)、keepalive 拉长到 65 秒(复用连接,少握手),CPU 降到 30% 上下。性能优化的第一步永远是配置,不是代码——很多时候你以为要改代码,其实 Nginx 两行就解决了。

写到这里回头看,这份配置里每一个角落几乎都躺着一个故事:/$1/index.html 是 404 的夜晚,no-store 是缓存锁死的那天,absolute_redirect off 是 https 降级的困惑,location = / 的 301 是收录双 URL 的三个月,proxy_read_timeout 86400s 是聊天断线的那个下午,location ~ /\. { deny all } 是扫描器日志刷屏的那段日子。代码还是那些指令,但每一行下面都压着一次踩坑。

配置成型,收工

如果你也在部署 SPA,这份配置可以直接抄。抄的时候别嫌它"绕",那每一层都是真实问题换来的:版本化资源往死里缓存、HTML 坚决不缓存、回退链给 locale 留一级、正则顺序不能乱、重定向要防 https 降级、WebSocket 要记得拉长超时、敏感路径提前屏蔽、Gzip 别碰位图、跨域要会配预检、部署必须带健康检查。配置这东西,整齐是给别人看的,乱里的每一笔都是给自己擦的屁股。