原创

Nginx 单页应用部署:让页面回退保留 API 和静态资源的 404

刷新前端的订单详情页,需要让服务器返回应用入口;请求不存在的 API,则应该得到接口自己的 404。这两个请求都没有对应的磁盘文件,但不能因此交给同一个首页回退规则。

最直接的失败表现是:fetch('/api/orders/99') 收到 200,正文却以 <!doctype html> 开始。浏览器随后报 JSON 读取错误,监控里的成功率仍很好看。问题发生在接口到达业务代码之前,或者在代理把业务错误改写成页面的时候。

先把站点分成三条明确路径:API 交给上游,静态构建产物缺失就报错,前端页面路径才回退到入口。这个实验使用 Nginx 1.27.4 与 Python 3,不需要任何前端框架。

做一个真的返回 JSON 404 的上游

在空目录创建 logs 与 site/assets,避免混用已有部署目录。保存 backend.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        data = b'{"error":"not_found"}\n'
        self.send_response(404)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

if __name__ == '__main__':
    ThreadingHTTPServer(('127.0.0.1', 18182), Handler).serve_forever()

这个服务故意对所有 GET 返回 404,用来判断代理有没有保留上游状态与正文。它只绑定回环地址,是一个实验替身,不是给生产 API 推荐 Python 标准库服务器。

准备入口和一个真实静态文件:

mkdir -p logs site/assets
cat > site/index.html <<'HTML'
<!doctype html>
<meta charset="utf-8">
<title>应用入口</title>
<div id="app">app shell</div>
HTML
cat > site/assets/app.js <<'JS'
console.log('app');
JS
python3 backend.py

最后一条命令在当前终端运行,另开终端进入同一目录启动 Nginx。

把回退范围写在配置里

保存完整的 nginx.conf。18082 与 18182 都应未被其他程序使用。

pid logs/nginx.pid;
error_log logs/error.log;
events {}
http {
    access_log logs/access.log;
    server {
        listen 127.0.0.1:18082;
        root site;
        types {
            text/html html;
            application/javascript js;
        }

        location = /api {
            return 308 /api/;
        }
        location ^~ /api/ {
            proxy_pass http://127.0.0.1:18182;
            proxy_intercept_errors off;
        }
        location ^~ /assets/ {
            try_files $uri =404;
        }
        location = /index.html {
            try_files $uri =404;
        }
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
}

/api/ 没有磁盘文件检查,也没有首页回退。这里的 proxy_pass 不附加 URI 部分,实验上游会收到原请求路径;它不需要知道前端构建目录在哪里。proxy_intercept_errors off 明确保留上游错误响应,不再由 Nginx 替接口选择 HTML 错误页。

/assets/ 只查真实构建文件。资源路径写错、部署漏了文件或旧页面引用了已删除哈希文件,都应保留可观察的 404。如果给这些请求返回首页,浏览器可能报脚本 MIME 类型不匹配,缓存层还可能保存一份带错误内容的“成功资源”。

/index.html 的精确位置不是多余装饰。入口本身不存在时,直接返回 404,避免再次回退到同一个入口形成内部重定向循环。先保住明确的失败,再考虑向用户显示维护页面。

最后的 location 才承担客户端路由。try_files 按当前 root 检查文件与目录,前面的候选都不存在时,对最后的 URI 发起内部跳转。它不是向上游发一次试探请求,也不理解前端路由表。

四次请求要核对不同结果

nginx -p "$PWD/" -c nginx.conf -t
nginx -p "$PWD/" -c nginx.conf
curl -i http://127.0.0.1:18082/orders/7
curl -i http://127.0.0.1:18082/api/missing
curl -i http://127.0.0.1:18082/assets/missing.js
curl -i http://127.0.0.1:18082/assets/app.js

本地实验得到:订单页面路径返回 200 与 text/html,正文是 app shell;API 返回 404 与 application/json,保留 not_found;缺失脚本返回 404;真实脚本返回 200 与 application/javascript。

不能只断言页面“都打开了”。这组测试的价值正是保留差异:页面回退成功,接口错误仍是接口错误,缺失资源仍然缺失。某个请求内容不对时,先看状态码与媒体类型,再看正文开头,通常比直接排查 JSON 业务模型更快。

还可以把 site/index.html 临时改名,再请求 /orders/7。应观察到明确失败,而不是一串不断内部跳转后的 500。改名只在实验目录做,完成后恢复入口。

如果 API 同时使用 /graphql、/auth/ 等其他入口,应分别声明其归属,不能只保护一个名叫 api 的前缀。前缀选择是站点 URL 协议的一部分,前端和后端需要共同遵守。

全局 error_page 会把已经分开的路径重新搅在一起

配置里另一处若还有 error_page 404 =200 /index.html,就要重新检查作用范围。首页回退只应该解释页面路径,并不应该把所有组件产生的 404 都改成 200。反向代理是否接管上游错误,还与 intercept 设置有关,不能只看某一个 location 的 try_files。

配置检查应面对展开后的结果。公共 include 可能在 server 层定义错误页,另外一个文件又增加了静态正则 location;只拿上面的片段粘进已有配置,不代表实际生效逻辑仍然相同。可以用本实例参数执行 nginx -T 查看展开配置,但输出可能含内部地址与敏感配置,不应原样发布到公开工单。

这里用前缀位置隔离 API 与 assets,避免常见扩展名正则再把它们带入静态处理。复杂站点还需要按自己的全部规则测试,不必为了结构统一把所有位置都写成正则。

前端路由自己的“页面不存在”又是另一层。服务器一律交付 app shell,通常会让未知页面路径也先返回 200。若站点需要搜索引擎看到准确页面状态,或者对路由存在性有服务端要求,应采用能在服务端判断路由的方案;仅靠 try_files 没有前端业务数据,不能替它判断订单是否存在。

发布时把入口与资源当成一套产物

正确的缺失资源 404 会暴露版本发布问题。新入口引用了新脚本,脚本还没上传完成,用户就会看到失败;旧入口还在浏览器或 CDN 中,旧脚本却已立即删除,同样会失败。把 404 改成首页只会掩盖这份不一致。

可以先上传新版本的带内容哈希资源,核对可访问,再切换入口,并保留旧入口仍可能引用的资源直到缓存窗口结束。具体保留时长由缓存策略与回滚窗口决定,不应在文章里写一个脱离部署条件的固定天数。

入口缓存与哈希资源缓存也应分别设置。给两者同样的超长有效期,可能让用户迟迟拿不到新入口;统一禁止缓存则牺牲了构建资源本来可以复用的优势。本文只验证路由归属,缓存规则应在这份结果正确之后再加入并回归。

完成实验,停止独立 Nginx 和 Python 进程:

nginx -p "$PWD/" -c nginx.conf -s quit

Python 所在终端按 Ctrl-C。

正文到此结束
评论插件初始化中...
Loading...
本文目录