0%

聊聊常见的跨域问题

跨域是前端工程中一个很常见的问题。今天就展开来聊聊跨域,以及常见的处理方式和原理。

什么是跨域?

跨域这个概念源于浏览器对 JavaScript 的一种安全限制,我们通常称之为同源策略。默认情况下,我们只能访问同一个协议、域名和端口下的资源。在现代的前后端分离的架构中,前端需要调用大量的后端接口,因此,解决跨域问题就变得尤为重要。

为什么会有跨域?

产生跨域的原因主要有以下两个原因

  1. 浏览器安全限制(不能读取不同域、端口、协议下的内容)
  2. 使用的是 XHR(XMLHttpRequest)请求

同源策略是一个众所周知的安全策略,它确实有效地预防了一些安全问题。然而,它同时也阻止了许多有用的跨域请求。而 XHR 受到同源策略的限制:浏览器不允许 JavaScript 查找跨域文档的内容。由于 XHR 使用 responseText 属性暴露文档内容,因此同源策略不允许 XHR 进行跨域请求。

解决跨域的方案

  1. JSONP
  2. 跨域资源共享(CORS)
  3. Nginx
  4. dev-server

JSONP

前文我们说过, 产生跨域的原因之一是 XHR 请求, 但是 script 发出的请求类型(type)并不是 xhr,因此可以解决跨域的问题。

JSONP 由回调函数和数据组成的, 实现方式就是动态创建一个<script>标签, 然后设置src属性指向的跨域的 URL(包含请求参数)来向服务端请求数据。

比如我们要查询小明的信息, 这时我们得知它的 userID 为 1150, 同时我们都知道 GET 请求可以通过 url 进行传参, 因此我们向服务器发起请求:

1
2
3
var script = document.createElement('script')
script.src = "https://www.example.com/users?user_id=1150"
document.body.appendChild(script)

插入<script>标签到<body>后, 浏览器立马就去请求服务器的资源. 值得注意的是, 使用jsonp也需要服务端的配合. 因此必须通过某种方式来告知服务端, 我们正在通过<script>标签调用请求, 必须返回一个 JSONP 响应, 而不应该是普通 JSON 响应.

至于什么叫jsonp响应呢? 这里其实很好理解:

假设后端发回来的是json格式的数据, 我们也用不了呀, 数据还是数据, 不会做任何变化.. 为了让浏览器可以在<script>标签里直接使用, 我们需要让服务端返回一段js代码 —— 用函数包装的json的形式(这也jsonp中”P(padding)”的含义). 这个函数名前后端可约定. 如下:

1
2
3
4
5
6
7
// 服务端返回 js 代码到 <script>里
userData({'naem':'小明','id':1150,'level':'中等'})

// 前端定义函数
function userData(data) {
// 当 jsonp 请求成功后, 将json传入函数并调用, 我们拿到 json 后就可以做一些其他的事
}

我们来拿B站为例. 打开chrome下的network, 上图就是jsonp的应用, 服务端返回的js脚本. 下图可以发现, 我们发出去的请求类型是 script, 验证了前文所说的 <script> 不受同源策略影响的.

目前主流的类库都对 jsonp 进行了封装, 如 JQuerygetJSONajax, 这里就不深入讲解了. 最后对 jsonp 总结一下:

jsonp 实际上是一个非正式传输协议, 或者说是一种”投机取巧”的方式. 我们可以利用<script>的特性从而进行数据交互解决跨域的问题. 相对来说, 它也有一定的局限性: 只能应用在 GET 请求上, 除此之外还有安全性的问题 —— 只能用在我们信任的服务端, 因为你不能保证对方未来会给你传些什么…

跨域资源共享(CORS)

概述

说完了”不正规”的jsonp, 紧接着我们再说说原生的CORS规范. 我们先来看看官方的定义:

CORS(Cross-origin resource Sharing, 跨资源共享), 定义了访问跨域资源时, 浏览器和服务器应该如何沟通. 其背后主要思想就是使用自定义的HTTP头部来让浏览器与服务器进行沟通, 从而决定请求或相应是否成功, 还是应该失败.

目前主流的浏览器都已经对CORS有着良好的支持, 而IE8 ~ 9则还需要使用专用的XDomainRequest这里我们抛开不谈.

这个功能实际上是由浏览器自动完成的, 我们并不需要做什么额外的工作. 对于开发者来说, 也就需要了解一些安全细节的问题, 这一点我们放在后面讲.

两种请求

浏览器发送CORS请求时, 会将请求分为简单请求非简单请求.

在我们日常工作中, 常用的简单请求可以将其归为以下几点:

  • 使用的方法(Methods)为HEADGETPOST
  • 请求头无自定义头
  • Content-Type只能是以下几种
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

非简单请求:

  • PUT, Delete 方法的 ajax 请求
  • 发送 JSON 格式的 ajax 请求(比如post数据)
  • 带自定义头的 ajax 请求

如果是简单请求, 则会先执行, 后判断。执行的过程大致如下:

浏览器发起请求检测到是 CORS 请求, 然后添加一个origin字段(其中包含页面源信息: 协议、域名、端口) => 服务端收到后作相应的处理(对比origin, 服务端判断这个源是否接受)返回结果给浏览器 => 浏览器检查响应头是否允许跨域信息 => 允许, 那就当做没事发生. 不允许, 浏览器抛出相应的错误信息(值得一提的是, 这时状态码也还有可能是200).

非简单请求执行顺序又有些不同. 在发生 CORS 请求时, 浏览器预先发送一个option请求. 浏览器这种行为被称之为预检请求(Preflighted request). 其中包含如下的请求头:

  • origin: 同上,包含页面源信息.
  • Access-Control-Request-Methods: 请求方法
  • Access-Control-Request-Header: 自定义头部信息, 多个头部以逗号分隔(可选, 看请求时有没有定义请求头)

举个栗子, 我们用JQuery发送一段JSON格式的请求做演示:

1
2
3
4
5
6
7
8
9
10
11
var result;

$.ajax({
type : "post",
url: "https://www.example/api/rank",
contentType : "application/json;charset=utf-8",
data: JSON.stringify({name: "something"}),
success: function(json){
result = json;
}
});

这时请求头(Request Headers)信息如下:

1
2
3
4
5
6
7
8
9
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Access-Control-Request-Header: content-type
Access-Control-Request-Methods: POST
Connection:keep-alive
Host: https://www.example.com
origin: localhost:8080
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1

服务端接收到预检请求后, 判断是否允许这种类型的请求. 在响应头(Response Header)上返回如下头部与浏览器进行交流:

  • Access-Control-Allow-Origin: 服务端允许的源信息
  • Access-Control-Allow-Methods: 服务端允许的方法, 多个方法可以使用顿号分隔
  • Access-Control-Allow-Headrs: 服务端允许的头部, 多个头部可以使用顿号分隔

当预检请求被通过后, 我们原本想要发送的请求才会发送出去.

另外, 细心的你或许已经注意到了, 非简单请求这一来一回需要发送两次请求, 如果频率高的情况下岂不是很费性能又影响效率? 所幸的是HTTP协议新增(IE10+)了一个响应头用于缓存预检请求. 服务端在响应头添加如下字段:

1
Access-Control-Max-Age: 3600

这个响应头表示这个预检请求可以缓存多长时间, 单位为秒. 这里3600s = 1h, 也就是说一个小时内可以不用再发预检命令了.

默认情况下, 跨域请求是不带上 cookie 的. 前端需要将withCredentials属性设置为true, 同时还需要服务端设置Access-Control-Allow-Credentialstrue启动 cookie. 如果在发送 cookie 的时候, 浏览器检测到服务端响应头没有这个头部, 那么就会在控制台抛出一个错误.

另外, 还有一个值得注意的是. 服务端响应头设置了Access-Control-Allow-Origin: *的话, 是不能满足带 cookie 的跨域请求的. 因此有这种场景不能使用通配符, 需要全匹配字段.

CORS 总结

简单总结一下 CORS. CORS的出现也是为了解决跨域的问题. 只不过和JSONP不同, 它是纳入规范的一部分, 它几乎支持所有的类型的HTTP请求(JSONP只能使用GET). 唯一美中不足的也就是兼容性的问题, 因此可以使用JSONP作向下的兼容

事实上前端在 CORS 上并没有多少可操作的余地, 主要的还是浏览器来处理、服务端在设置, 但是并不代表我们就不需要了解这些知识啦.

Nginx

Nginx 是一个高性能的 HTTP 和反向代理服务器。通过配置 Nginx, 我们可以实现跨域请求的代理转发。它可以将前端请求转发到不同的后端服务器,从而绕过浏览器的同源策略限制。

以下是一个配置 Nginx 反向代理的实例:

配置反向代理

假设我们有个前端应用,前端域名为 frontend.example.com,请求后端 API 服务器的地址为:https://api.example.com。我们在 Nginx 中配置一个反向代理,将前端的请求转发到后端服务器。编辑 Nginx 的配置文件(例如:/etc/nginx/nginx.conf 或 /etc/nginx/conf.d/default.conf):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
http {
...
server {
listen 80;
server_name frontend.example.com;

location /api/ {
proxy_pass https://api.example.com;
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;
}
}
...
}

这里,我们将所有 /api/ 开头的请求代理到 https://api.example.com

设置跨域响应头

为了让浏览器允许跨域请求,我们需要在 Nginx 配置文件中设置响应头。继续编辑配置文件,在 location /api/ 部分添加如下内容:

1
2
3
4
5
6
7
8
9
10
location /api/ {
...
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials 'true' always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always;
if ($request_method = OPTIONS) {
return 204;
}
}

这里我们设置了跨域请求所需的响应头。

  • Access-Control-Allow-Origin 允许指定来源的请求
  • Access-Control-Allow-Methods 允许哪些 HTTP 方法
  • Access-Control-Allow-Headers 允许哪些请求头。当请求方法为 OPTIONS 时,我们直接返回 204 状态码,表示预检请求成功。

重启 Nginx

配置完后记得重启 Nginx

1
2
sudo nginx -t # 检查配置文件语法是否正确
sudo nginx -s reload # 重新加载配置文件

现在就可以通过代理的方式来请求了。

代理服务器

本地开发一般有像 webpack、parcel 之类的脚手架,脚手架内一般都可以启动一个本地服务器。这个本地服务器是通过 Node.js 启动的,它可以将给定请求代理为本地的请求,从而绕开浏览器的跨域限制。

以常用的 webpack 为例:

webpack 使用 webpack-dev-server 实现代理请求、热更新、静态资源服务等功能。代理请求功能是指将特定的请求代理到其他服务器。通过将请求代理到后端服务器,开发人员可以绕过浏览器的同源策略限制。
以下是实现使用步骤:

配置 webpack config

Webpack 配置文件(默认为 webpack.config.js)中,我们可以设置一个代理规则,将特定的请求代理到其他服务器。这里是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
// ...
devServer: {
proxy: {
'/api': {
target: 'http://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
};

在这个例子中,我们将所有 /api 开头的请求代理到 http://api.example.comchangeOrigin 选项将更改请求头中的主机名以匹配目标服务器,pathRewrite 选项用于重写请求路径,删除了请求路径中的 /api

启动开发服务器

1
npx webpack serve

当浏览器向 webpack-dev-server 发送请求时,它会根据配置文件中的代理规则将请求转发到目标服务器。由于这些请求实际上是从开发服务器发出的,而不是直接从浏览器发出的,因此可以绕过浏览器的同源策略限制。

「请笔者喝杯奶茶鼓励一下」

欢迎关注我的其它发布渠道