欢迎回到跨域这个老生常谈的问题,作为一个前端切图仔的我们,遇到跨域问题怎么办?当然是“后端大哥哥,你这个接口出现跨域啦,能不能帮我加一个 Header 允许跨域呢,呜呜呜”

emmm,当然我在工作中肯定不会这么卑微(只会更加卑微的自己去加…Bushi)

难道我们前端就不能自己处理跨域问题吗?每次都要卑微的找后端大哥哥帮忙处理这多麻烦啊,更何况我们秉持着宁愿麻烦自己也不能麻烦别人的态度(其实有些项目是比较特殊,为了安全着想也确实是不能够直接将所有域名允许跨域)

对于跨域问题,我目前遇到有三种情况:

1、网站调用了一些其他域名的服务接口

2、在本地调试某个网站时,遇到接口服务是线上或者已部署的服务器请求导致出现跨域错误

3、Hybrid Application 客户端就是 Webview 套壳当请求服务器接口时由于本地 UI 启动服务 host 位于本地,导致请求服务器接口出现跨域问题。

首先是第一条,一般这种情况是出现在我自己写的后端接口为了提供给别人网站使用,或者多个域名之间使用其接口服务。当然这种情况最简单的处理方式还是在后端添加允许跨域的 Header。

至于第二条和第三条,我们除了通过后端放行跨域这种解决方案之外,还能够通过前端实现一个 http client proxy 服务对跨域流量进行转发以达到实现解决跨域错误。

其实在刚开始搞前端之前我都完全不知道一个请求还会出现跨域这种说法(那时候只会一点点 Android),后面接触到跨域问题都是遇到跨域错误就找后端大哥哥解决(反正当时也不知道后端大哥哥有啥魔法反正每次都能很快的处理好)。

当然,这一切都随着我前端搬砖越来越熟练以及自己也开始接触后端开发后,其实跨域问题说到底还是 Webview 或者说浏览器对于请求的限制,推荐阅读一下 跨源资源共享(CORS) 文档

服务端通过跨域预检请求允许发送跨域请求

浏览器是如何对于跨域资源实现识别的呢?打开你的控制台你就会发现,好像你的跨域请求都发送了两个请求第一次居然不是 POST / GET 而是一个 OPTIONS 请求,这个其实就是浏览器主动发起的跨域预检请求,当然我们在 JavaScript 层是感知不到这个请求的发起以及回复的,如果这跨域预检请求未通过的话浏览器将直接拒绝发送我们的实际请求并抛出跨域异常,于是就产生了跨域问题。请求流程如下图

NodeJS 实现 http client proxy 请求转发,客户端层解决前端请求跨域问题-天真的小窝

其实后端能够处理跨域问题就是通过回复跨域预检请求并按照要求返回跨域规则 Header 来解决或者说处理跨域请求问题的。

下面我简单从文档中摘抄服务器为访问控制请求返回的 HTTP 响应头记录一下

Access-Control-Allow-Origin

Access-Control-Allow-Origin 参数指定了单一的源,告诉浏览器允许该源访问资源。或者,对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符“*”,表示允许来自任意源的请求。

Access-Control-Allow-Origin: <origin> | *

Access-Control-Expose-Headers

在跨源访问时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到一些最基本的响应头,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他头,则需要服务器设置本响应头。

Access-Control-Expose-Headers: <header-name>[, <header-name>]*

Access-Control-Max-Age

Access-Control-Max-Age 头指定了 preflight(跨域预检请求) 请求的结果能够被缓存多久,delta-seconds 参数表示 preflight 预检请求的结果在多少秒内有效。

Access-Control-Max-Age: <delta-seconds>

Access-Control-Allow-Credentials

Access-Control-Allow-Credentials 头指定了当浏览器的 credentials 设置为 true 时是否允许浏览器读取 response 的内容。当用在对 preflight 预检测请求的响应中时,它指定了实际的请求是否可以使用 credentials。请注意:简单 GET 请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。

Access-Control-Allow-Credentials: true

Access-Control-Allow-Methods

Access-Control-Allow-Methods 标头字段指定了访问资源时允许使用的请求方法,用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法。

Access-Control-Allow-Methods: <method>[, <method>]*

Access-Control-Allow-Headers

Access-Control-Allow-Headers 标头字段用于预检请求的响应。其指明了实际请求中允许携带的标头字段。这个标头是服务器端对浏览器端 Access-Control-Request-Headers 标头的响应。

Access-Control-Allow-Headers: <header-name>[, <header-name>]*

后端可以通过拦截跨域预检请求并回复以上请求头实现对跨域请求的允许规则,下面是我使用 Golang Gin 框架拦截并允许跨域中间件相关示例代码(建议不要学我设置 * 符号,还是设置为固定且合理的域名)

package middleware

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func Cors() gin.HandlerFunc {
	return func(c *gin.Context) {
		method := c.Request.Method
		origin := c.Request.Header.Get("Origin")
		if origin != "" {
			c.Header("Access-Control-Allow-Origin", "*") // 可将将 * 替换为指定的域名
			c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
			c.Header("Access-Control-Allow-Headers", "*")
			c.Header("Access-Control-Expose-Headers", "*")
			c.Header("Access-Control-Allow-Credentials", "true")
		}
		if method == "OPTIONS" {
			c.AbortWithStatus(http.StatusNoContent)
		}
		c.Next()
	}
}

客户端通过 Node 实现 http client proxy 流量转发解决跨域问题

终于到本篇博客的重点内容了,在讲具体代码之前其实我想说,如果在使用 Vite 等现代化前端编译工具的话,其实都有相关的配置非常方便使用了,相关文档: https://cn.vitejs.dev/config/server-options.html#server-proxy

当然,也可以使用一些反代工具实现类似的效果,其实说到底实现原理还是通过发送请求到本地,再由本地 Native 发起一个请求(由于本地的服务发起 http 请求并不会受到浏览器的跨域限制)再将请求结果转发到前端,就可以绕过浏览器跨域限制了,该手段通常用于调试(虽然说你也可以在线上部署一个代理服务,但是应该不会有人真在线上这么干吧,应该不会吧…)

由于前端项目嘛,想运行现代化前端项目那铁定是安装了 NodeJS 的,所以前端解决调试时跨域问题那通过编写一个 NodeJS 代理脚本,实现绕过跨域限制(再也不用求后端哥哥帮忙解决跨域问题啦,bushi)

import http from 'http';
import https from 'https';
import url from 'url';

var app = http.createServer(function (req, res) {

    const timeout = 800;

    var params = url.parse(req.url, true).query;
    if (!params.request) {
        res.statusCode = 501;
        res.end()
        return
    }
    console.log('[http client proxy]', params.request);
    var targetUrl = url.parse(params.request, true);

    const client = targetUrl.protocol === "http:" ? http : https
    var red_req = client.request({
        host: targetUrl.host,
        path: targetUrl.path,
        method: req.method,
        timeout,
    }, function (re_res) {
        re_res.setTimeout(timeout)
        re_res.pipe(res);
        re_res.on('end', function () {
            // console.log('done');
            res.end();
        });
    });

    red_req.on('error', function (e) {
        console.log(e);
        process.nextTick(() => {
            res.statusCode = 502;
            res.end();
        });
    });

    // 设置请求超时
    req.setTimeout(timeout)
    req.on('end', function () {
        // 请求结束,取消客户端请求
        red_req.emit('close');
        red_req.end();
    });

    if (/GET|POST|PUT/i.test(req.method)) {
        req.pipe(red_req);
    } else {
        red_req.end();
    }
});

app.listen(5175);
console.log('server started on http://127.0.0.1:5175');

然后,使用 node 运行 node http_proxy 启动代理服务

前端将请求发送到 127.0.0.1:5175 这个服务上就能够实现指定域名的请求转发了

http://127.0.0.1:5175?request=http://bin.zmide.com?p=250

当然,也可以通过配置 vite 的 server.proxy 将主域上 /api/ 路径下的请求全部转发到反代服务

// https://vitejs.dev/config/
export default defineConfig({
    server: {
        proxy: {
            '/api': {
                target: 'http://127.0.0.1:5175',
                changeOrigin: true,
            },
        },
    },
});

至于解决客户端中 UI 出现跨域问题,这就是另外一个故事啦,下回我会讲讲 Swift 在 IOS 中如何实现一个反代服务解决 Web UI 中出现跨域问题。