搭建服务器亲自体验跨域

背景

跨域这两个字就像狗皮膏药一样儿粘在每一个前端 er 身上 我遇见了很多开发者一般都是为了应付面试 随便背几个方案 知道概念 但是不知道为什么要这么干
到了真正的工作 开发环境有 webpack-dev-server 搞定 线上有运维大哥会配好,配什么我不管 反正不会跨域就是了
但是.. 这样儿混日子 你的良心不会痛吗?

痛定思痛 决心不定时更新 不要再问我 XX 的问题系列 之 不要再问我跨域的问题了

其实团队的小伙伴分享过类似的 但是不动手试一下 跟你面试前的死记硬背本质上没有任何区别

你需要了解的几个概念

  • 什么是跨域?

官方解释
跨域资源共享 (CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

比如,站点 http://domain-a.com 的某 HTML 页面通过 的 src 请求 http://domain-b.com/image.jpg。网络上的许多页面都会加载来自不同域的 CSS 样式表,图像和脚本等资源。

  • 为什么会产生跨域?

出于安全原因,浏览器限制从脚本内发起的跨源 HTTP 请求(也可能跨站请求可以正常发起,但是返回结果被浏览器拦截了)

跨域的产生来源于现代浏览器所通用的同源策略,所谓同源是指 "协议+域名+端口" 三者相同的情况下,才允许访问相同的 cookielocalStorage 或是发送 Ajax 请求等等

常见的跨域场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
URL                                      说明                    是否允许通信
http://www.domain.com/a.js
http://www.domain.com/b.js 同一域名,不同文件或路径 允许
http://www.domain.com/lab/c.js

http://www.domain.com:8000/a.js
http://www.domain.com/b.js 同一域名,不同端口 不允许

http://www.domain.com/a.js
https://www.domain.com/b.js 同一域名,不同协议 不允许

http://www.domain.com/a.js
http://192.168.4.12/b.js 域名和域名对应相同ip 不允许

http://www.domain.com/a.js
http://x.domain.com/b.js 主域相同,子域不同 不允许
http://domain.com/c.js

http://www.domain1.com/a.js
http://www.domain2.com/b.js 不同域名 不允许
  • 现代的跨域解决方案
  1. 通过 jsonp 跨域
  2. document.domain + iframe 跨域
  3. location.hash + iframe
  4. window.name + iframe 跨域
  5. postMessage 跨域
  6. 跨域资源共享(CORS)
  7. nginx 代理跨域
  8. nodejs 中间件代理跨域
  9. WebSocket 协议跨域

搭建服务尝试还原跨域过程

通过 koa 搭建两个本地 server 两个 server 都定义了一个 GET 请求接口 /ajax。除监听 port 不同外,app.js 还设置了静态服务。

koa1

app.js port:8000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const Koa = require('koa');
const app = new Koa();
const index = require('./routes/index')
const views = require('koa-views')
const serve = require('koa-static');
const path = require('path');

// 引入静态资源
const staticPath = path.resolve(__dirname, '/public');

// 设置静态服务
const staticServe = serve(staticPath, {
setHeaders: (res, path, stats) => {
if (path.indexOf('jpg') > -1) {
res.setHeader('Cache-Control', ['private', 'max-age=60']);
}
}
});

app.use(staticServe);


// 增加模版引擎 默认直接渲染html文件
app.use(views(__dirname + '/views'));
// 引入路由配置文件
app.use(index.routes(), index.allowedMethods())


router.get('/ajax', async (ctx, next) => {
console.log('get request', ctx.request.header.referer);
ctx.body = 'received';
});

app.listen(8000,()=>{
console.log('app1 server is listening port 8000');
});
console.log('demo in run.....')

// route.js

router.get('/ajax', async (ctx, next) => {
console.log('get request', ctx.request.header.referer);
ctx.body = 'received';
});

app2.js port:3000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const koa = require('koa');
const app = new koa();
const app2Route = require('./routes/app2Route')
const cors = require('koa2-cors');

app.use(app2Route.routes(), app2Route.allowedMethods())


const main = async function(ctx,next) {
ctx.response.body = '3000端口';
await next();
}

app.use(main)

app.listen(3000);
console.log('app2 server is listening port 3000');

// route.js

router.get('/ajax', async (ctx, next) => {
console.log('get request', ctx.request.header.referer);
ctx.body = 'received';
});

前端模版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>cross-origin test</title>
</head>
<body style="width: 600px; margin: 200px auto; text-align: center">
<button onclick="getAjax()">GET 简单请求</button>
<button onclick="getJsonP()">JSONP</button>
<button onclick="corsWithJson()">POST 非简单请求</button>
</body>
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<script type="text/javascript">

var baseUrl = 'http://localhost:3000';
function getAjax() {
var xhr = new XMLHttpRequest();
xhr.open('GET', baseUrl + '/ajax', true);
xhr.onreadystatechange = function() {
// readyState == 4说明请求已完成
if (xhr.readyState == 4 && xhr.status == 200 || xhr.status == 304) {
// 从服务器获得数据
alert(xhr.responseText);
} else {
console.log(xhr.status);
}
};
xhr.send();
}
</script>
</html>

很简单 大概长这样儿

koa2

AJAX

测试 case

  1. 同域下请求 ajax 不涉及跨域

    请求接口:baseUrl = 'http://localhost:8000';
    测试结果👇
    koa3

  1. 跨域 ajax 请求
    请求接口:baseUrl = 'http://localhost:3000';
    测试结果👇
    koa4
    很明显 跨域了

针对浏览器的 Ajax 请求跨域的主要解决方案有:JSONP、CORS。

  • JSONP

    原理

    虽然浏览器同源策略限制了 XMLHttpRequest 请求不同域上的数据。但是,在页面上引入不同域的 js 脚本是可以的,而且 script 元素请求的脚本会被浏览器直接运行

    测试

    origin.html 添加

1
2
3
4
5
6
7
8
9
  function getJsonP() {
var script = document.createElement('script');
script.src = baseUrl + '/jsonp?type=json&callback=onBack';
document.head.appendChild(script);
}

function onBack(res) {
alert('JSONP CALLBACK: ' + JSON.stringify(res) + '');
}

getJsonP 方法会在当前页面添加一个 script,src 属性指向跨域的 GET 请求
通过 query 格式带上请求的参数。callback 是关键,用于定义跨域请求回调的函数名称,这个值必须后台和脚本保持一致

app2.js 添加路由

1
2
3
4
5
6
7
8
9
10
11
router.get('/jsonp', async (ctx, next) => {
const req = ctx.request.query;
console.log(req);
const data = {
data: req.type
}
ctx.body = req.callback + '('+ JSON.stringify(data) +')';
})

app.use(router.routes());

针对 jsonp 请求,后台要做的是:

获取请求参数中的 callback 值,如本例中的 onBack
将 callback 的值以 function (args) 的格式作为 response。

重启服务 触发页面的 JSONP🔘
koa5

优点
JSONP 方案的兼容性好,IE 浏览器也支持。

缺点

1
2
因为是利用的<script>元素,所以只支持GET请求。
缺乏错误处理机制
  • CORS

CORS 即跨域资源分享,是 W3C 制定的标准。

  1. 特性
    CORS 需要浏览器和服务器同时支持。
1
2
大多主流浏览器都支持,IE 10以下不支持。
只要服务器端实现了CORS接口,浏览器就能自动实现基于CORS的跨域请求。
  1. 两种请求

浏览器将 CORS 请求分成两类:简单请求和非简单请求。

  • 简单请求
    满足条件:请求类型为 HEAD,GET,POST之一
    请求头信息不超出以下几种:
1
2
3
4
5
Accept
Accept-Language
Content-Language
Last-Event-ID
Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

对于简单请求,浏览器会直接发出,同时在请求头中添加 Origin 字段。

Origin 用来说明请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段(详见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequest 的 onerror 回调函数捕获。注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是 200。

回顾下直接 Ajax 测试跨域的请求报文:

koa6
浏览器为这个简单的 GET 请求添加了 Origin,而响应头信息中没有 Access-Control-Allow-Origin,浏览器判断请求跨域,给出错误提示。

  • 非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是 PUT 或 DELETE,或者 Content-Type 字段的类型是 application/json。

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为” 预检” 请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。

origin.html 中添加一个 post 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function corsWithJson() {
$.ajax({
url: baseUrl + '/cors',
type: 'post',
contentType: 'application/json',
data: {
type: 'json',
},
success: function(data) {
console.log(data);
}
})
}

通过设置 Content-Type 为 appliaction/json 使其成为非简单请求:

启动服务
koa7
“预检” 请求的方法为 OPTIONS,服务器判断 Origin 为跨域

除了 Origin 字段,” 预检” 请求的头信息包括两个特殊字段。

(1)Access-Control-Request-Method
该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是 PUT。
(2)Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上例是 content-type。

服务端设置 CORS

在 app2.js 引入 koa2-cors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.use(cors({
origin: function (ctx) {
if (ctx.url === '/cors') {
return "*"; // 允许来自所有域名请求
}
return 'http://localhost:3201';
},
exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
maxAge: 5,
credentials: true,
allowMethods: ['GET', 'POST', 'DELETE'], //设置允许的HTTP请求类型
allowHeaders: ['Content-Type', 'Authorization', 'Accept'],
}));

重启服务后,浏览器重新发送 POST 请求。可以看到浏览器发送了两次请求。

koa8

(1)Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次” 预检” 请求。
(2)Access-Control-Allow-Headers
如果浏览器请求包括 Access-Control-Request-Headers 字段,则 Access-Control-Allow-Headers 字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在” 预检” 中请求的字段。
(3)Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true,如果服务器不要浏览器发送 Cookie,删除该字段即可。
(4)Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 20 天(1728000 秒),即允许缓存该条回应 1728000 秒(即 20 天),在此期间,不用发出另一条预检请求。

现在为止 默认你已经完全理解跨域了哦

示例中的源代码