最近给系统做前端性能排查,盯着 Chrome DevTools 的 Network 面板直接无语了。所有的 API 请求在真正发送之前,浏览器都要莫名其妙先发一个 OPTIONS 请求。

关键是这笔过桥费还贼贵!我们的微服务网关部署在海外,普通的 GETPOST 接口响应时间大约在 150ms。但因为这个多出来的 OPTIONS 预检请求(Preflight Request),用户发个言、点个赞需要等上整整 350ms 以上。网络不好的时候,双倍的往返时间(RTT)能让前端卡得像 PPT。

开发环境本地跑的时候因为延迟低没感觉,一到生产环境,这种白痴问题就成了摧毁用户体验的隐形杀手。

为什么会平白无故多出 OPTIONS 请求?

简单来说,这是浏览器的 CORS(跨域资源共享)安全机制在作祟。当满足以下任一条件时,浏览器会认定该请求为「非简单请求」,在发送真实数据前,必须发送一次 OPTIONS 请求询问服务器是否允许此次跨域:

  1. 请求方法不是 GETPOSTHEAD
  2. Content-Type 设为了 application/json(现代 SPA 几乎全是这个)。
  3. 请求中包含了自定义的请求头(比如我们带在 Header 里的 Authorization token)。

既然我们的业务请求百分之九十都逃不过这些条件,那是不是每次发请求都要白白浪费一次 RTT?

当然不是。浏览器其实提供了一个缓存机制,允许我们把这个预检结果缓存起来,在一段时间内不用重复询问。

Access-Control-Max-Age 登场

要让浏览器记住跨域许可,后端需要在 OPTIONS 的响应头中加入:

Access-Control-Max-Age: 86400

这个值代表缓存的秒数。只要在有效期内,浏览器再次向该域名发送相同路径和方法的跨域请求时,就会直接略过 OPTIONS,秒发真实的业务请求。

然而,不同浏览器对这个最大缓存时间的限制是不一样的:

  • Firefox 的上限是 24 小时(86400 秒)。
  • Chrome / Chromium 系列 的上限只有 2 小时(7200 秒)。

如果你设置了 86400,Chrome 会自动降级成 7200。虽然只有 2 小时,但对于日常用户高频操作的页面来说,已经能干掉 90% 以上无意义的 OPTIONS 请求了。

生产级 Nginx 网关配置

在业务服务里去写跨域 Header 非常痛苦,而且增加了代码的心智负担。最科学的方案是在 Nginx 网关层一把梭搞定。

我们需要做到两点:

  1. 直接在 Nginx 拦截 OPTIONS 请求,不往后端的业务服务(Node.js/Go/Java)转发,直接返回 204 No Content,节省后端算力。
  2. 正确返回 CORS 相关的 Header,特别是 Access-Control-Max-Age

以下是亲测有效的生产环境 Nginx 配置:

server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        # 定义需要支持的跨域源,生产环境尽量不要写通配符 *
        set $cors_origin "";
        if ($http_origin ~* (https?://(?:.+\.)?yourdomain\.com)$) {
            set $cors_origin $http_origin;
        }

        # 如果是预检请求 OPTIONS,直接在 Nginx 层处理并返回
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
            
            # 核心:配置预检请求的缓存时间(设置为 2 小时,贴合 Chrome 的上限)
            add_header 'Access-Control-Max-Age' 7200 always;
            
            add_header 'Content-Type' 'text/plain; charset=utf-8' always;
            add_header 'Content-Length' 0 always;
            return 204;
        }

        # 针对真实的 GET/POST 等请求,也要带上跨域头
        if ($request_method ~* (GET|POST|PUT|DELETE)) {
            add_header 'Access-Control-Allow-Origin' $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
            add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
        }

        # 反向代理到后端业务集群
        proxy_pass http://backend_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

踩坑复盘:跨域安全与凭证

配置完后,一定要确保前端在发送带 Cookie/Credentials 的请求时,Access-Control-Allow-Origin 不能是 *,必须是具体的域名(这就是为什么我在 Nginx 里写了一段 $cors_origin 正则匹配)。

上线这个配置后,不仅接口响应平均时间下降了近一半,服务器的吞吐量也有了小幅上升,毕竟 OPTIONS 请求也是需要消耗系统连接数和 CPU 的。

你们在做跨域设计时,是倾向于在 Nginx 网关层一把梭配置,还是老老实实通过 BFF 做同源转发?