node.js学习笔记: 搞定跨域问题

所谓跨域,就是浏览器对于JavaScript的同源策略的限制

浏览器认为跨域情况下的数据请求是不安全的。

那么同源策略是什么呢?

同源策略

同源保证指A网页设置的Cookie,B网页不能打开,除非这两个网页同源,同源指的是:

  • 协议相同(protocol)
  • 域名相同(host)
  • 端口相同(port)

这三个中任意一个不同,网站间的数据请求与传输就构成了跨域调用,会受到同源策略的限制。

举例来说,http://www.example.com,`http://`就是协议,`www.example.com`就是域名,端口是`80`(默认端口可以省略)

同源策略限制了从一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的关键安全机制。

解决跨域

虽然同源策略是出于安全考虑,但是在使用时会有很多麻烦,所以我们还是会想办法规避它的限制。

我们可以写一个简单的ajax请求作为请求失败的例子:

  • html部分就只有一个按钮,点击按钮会向http://localhost:9000发送一个ajax请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<input type="submit" id="submit" value="提交" />
<script>
let oBtn = document.getElementById("submit");
oBtn.onclick = function() {
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:9000", true);
xhr.send();
xhr.onreadystatechange = function() {
if(xhr.readyState === 4) {
if(xhr.status === 200) {
console.log("succeed: " + xhr.responseText);
} else {
console.log("failed: " + xhr.status);
}
}
}
}
</script>
  • 后端部分是一个简单的服务器,只要接收到请求,就回复一个字符串
1
2
3
4
5
6
7
const http = require("http");
let server = http.createServer((req, res) => {
res.end("hello world");
})
server.listen(9000, () => {
console.log("server running at 9000");
})

页面如下:

image-20180811151447726

点击后我们可以在控制台看到报错信息:

image-20180811151531666

那么解决方法有哪些呢?

JSONP

在HTML标签里,有一些标签比如img, script是不受跨域限制的,所以我们可以利用这一点绕过限制。

  • 前端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<button id='btn'>点我发送ajax请求</button>
<script type="text/javascript">
// 1. 创建一个script标签
var script = document.createElement('script');
// 2. 挂载一个全局函数,我们先给函数一个函数名
var callbackName = 'callback_' + Date.now();
// 3. 服务器会返回数据,这个数据会在函数中执行
window[callbackName] = function(data) {
console.log(data);
}
// 4. 把函数名传递给服务器
script.scr = 'http://localhost:9000/?callback=' + callbackName;
// 5. 把script标签插入到body中
document.body.appendChild(script);
</script>
  • 后端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 引入必要模块
const http = require("http");
const url = require('url');
// 2. 创建服务器
let server = http.createServer((req, res) => {
// 3. 获取请求的url,解析出函数名
let fnName = url.parse(req.url, true).query.callback;
// 4. 将函数数据返回给前端
res.end(fnName + '("hello world)');
})
// 5. 服务器监听
server.listen(9000, () => {
console.log("server running at 9000");
})

我们做了这样几件事:

  1. 我们在前端代码中写了一个script文件的内容,并且插入到body当中,这个文件会和其他script文件一样被执行,这里如果写成平时嵌入的script文件的形式,应该是这样的(我们假设callback函数名是callback_1234):

    1
    2
    3
    4
    5
    <script src="http://localhost:9000?calback=callback_1234">
    //现在已有一个定义好的全局函数,即callback_1234
    //现在要执行的内容就是返回回来的data
    ${data}
    </script>
  2. 前端的请求发送到了http://localhost:9000,后端拿到的完整req.urlhttp://localhost:9000/?callback=callback_1234,我们用url模块解析url,可以获取到query是一个json数据

    1
    {callback: callback_1234}

    我们将获取到的callback数据保存为fnName,这样方便我们加入我们想要返回给前端的数据,然后让前端依照这个函数执行

  3. 我们先尝试只返回给前端一个字符串,我们希望前端能输出这个字符串:

    1
    2
    res.end(fnName + "('hello world')");
    //实际返回给前端的内容就是callback_1234("hello world")
  4. 前端收到了这个字符串callback_1234("hello world"),前面提到前端已经有这个callback_1234的全局函数了,那么前端要执行的${data}就是:

    1
    2
    3
    <script src="http://localhost:9000?calback=callback_1234">
    callback_1234("hello world");
    </script>

    即:

    1
    console.log("hello world");

    我们再看一看浏览器的控制台

    image-20180811161805232

这样我们就实现了用jsonp的方法来解决跨域。

需要注意的是,response.end()方法只能传输字符串或者Buffer,如果我们需要传输其他数据结构,还需要添加解析的步骤:

1
2
3
4
5
6
7
8
9
10
<button id="btn">点我发ajax请求</button>
<script type="text/javascript">
var script = document.createElement('script');
var callbackName = 'callback_' + Date.now();
window[callbackName] = function (data) {
console.log(JSON.parse(data)); //把字符串解析成data
}
script.src = 'http://localhost:9000?callback=' + callbackName;
document.body.appendChild(script);
</script>
1
2
3
4
5
6
7
8
9
10
11
12
const http = require('http');
const url = require('url');
let server = http.createServer((req,res)=>{
let fnName = url.parse(req.url,true).query.callback;
let data = JSON.stringify({msg:'hello world'}); //json解析成字符串
let str = fnName + `('${data}')`;
res.end(str);
});

server.listen(9000,()=>{
console.log("server running at 9000");
})

image-20180811162517851

jsonp总结:

  1. 前端代码
    • 创建script标签
    • 添加全局函数
    • 传递函数名到后端
  2. 后端代码
    • 获取响应数据
    • 返回函数名(“数据”)字符串给前端script执行

特征:

  • jsonp的请求方式是GET
  • 没有兼容性问题

CORS(跨域资源共享)

CORS,跨域资源共享(Cross-Origin Resource Sharing), 是一个W3C标准。它允许浏览器向跨源服务器发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

完成CORS通信需要浏览器和服务器同时支持,目前浏览器都支持该功能,IE浏览器不能低于IE10。服务器则需要实现CORS接口。

CORS的两种请求

浏览器将CORS请求分为两种:简单请求和非简单请求。

  1. 简单请求

    满足:

    • 方法: HEAD / GET / POST
    • HTTP头信息不超出以下几种字段
      • Accept
      • Accept-Language
      • Content-Language
      • Last-Event-ID
      • Content-Type(只接受以下3个值)
        • application/x-www-form-urlencoded
        • multipart/form-data
        • text/plain

image-20180811173426777

通信条件:

  • 服务器设置的Access-Control-Allow-Origin中包含CORS请求,这样有两种方式,一个是设置成CORS请求中的origin,还有一种是设置成*,即接受任意域名的请求;
  • Access-Control-Allow-Credentials设置一个布尔值,表示是否允许浏览器发送Cookie给服务器。设置成true表示服务器明确允许浏览器把Cookie放在请求中一起发给服务器。事实上,这个值也只能设为true,服务器如果不需要cookie,删除该字段即可;
  • CORS请求默认不发送Cookie和HTTP认证信息,所以服务器即使设置Access-Control-Allow-Credentials: true也不表示浏览器就会发送cookie,因为还需要开发者在AJAX请求中打开withCredentials属性,将其设置为true
  • 需要注意,如果要发送cookie,Access-Control-Allow-Origin就不能设置为*,必须指定请求中的域名(如果所有域名都可以获取cookie,那也是挺危险的)
  • 同样地,cookie也会遵循同源策略,只会将设置为服务器域名的cookie发送给服务器,其他域名下的cookie不会发送
  • Access-Control-Expose-Headers用来指定特定字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<button id="btn">点我发ajax请求</button>
<script type="text/javascript">
let oBtn = document.getElementById("btn");
oBtn.onclick = function() {
let xhr = new XMLHttpRequest();
xhr.open("get", "http://localhost:9000", true);
xhr.send();
xhr.onreadystatechange = function() {
if(xhr.readyState === 4 && xhr.status === 200) {
console.log("succeed: " + xhr.responseText);
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
const http = require('http');

let server = http.createServer((req,res)=>{
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', true);
res.end("hello world");
});

server.listen(9000,()=>{
console.log("server running at 9000");
})

image-20180811203127014

  1. 非简单请求

    非简单请求即不满足上面简单请求的两个要求,一般有三种情况:

    • 请求方法是GETPOST之外的方法,如PUTDELETE
    • 请求头里包含自定义头
    • Content-Typeapplication/json

    非简单请求会在正式通信前请示服务器,发送一个OPTIONS预检请求(preflight),询问服务器当前页面所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有服务器许可之后,浏览器才会发出正式的XMLHttpRequest请求,否则就会报错。

    预检请求的请求方法是OPTIONS,表明这个请求是用来询问的。

    image-20180811211959958

    我们还是用上面的例子做演示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <button id="btn">点我发ajax请求</button>
    <script type="text/javascript">
    let oBtn = document.getElementById("btn");
    oBtn.onclick = function() {
    let xhr = new XMLHttpRequest();
    xhr.open("get", "http://localhost:9000", true);
    xhr.send();
    xhr.onreadystatechange = function() {
    if(xhr.readyState === 4 && xhr.status === 200) {
    console.log("succeed: " + xhr.responseText);
    }
    }
    }
    </script>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    const http = require('http');
    let server = http.createServer((req,res)=>{
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Credentials', true);
    res.end("hello world");
    });
    server.listen(9000,()=>{
    console.log("server running at 9000");
    })
    1. 第一个触发点(get/post方法之外)

      1
      2
      //xhr.open("get", "http://localhost:9000", true);
      xhr.open("put", "http://localhost:9000", true);

      结果:

      image-20180811211333327

    2. 第二个触发点(加入自定义头)

      1
      2
      3
      xhr.open("get", "http://localhost:9000", true);
      xhr.setRequestHeader('my-token', 'abc');
      xhr.send();

      结果:

      image-20180811211543137

    3. 第三个触发点(Content-Typeapplication/json)

      1
      2
      3
      xhr.open("get", "http://localhost:9000", true);
      xhr.setRequestHeader('Content-Type', 'application/json');
      xhr.send();

      结果:

      image-20180811211734111

    我们可以从报错的信息里看到,预检请求的头信息中有两个重要的特殊字段:

    • Access-Control-Request-Methods:用来列出CORS请求用到哪些HTTP方法
    • Access-Control-Request-Headers:指定CORS请求会额外发送的头字段信息

    我们在例子中加入这两个设置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    let oBtn = document.getElementById("btn");
    oBtn.onclick = function() {
    let xhr = new XMLHttpRequest();
    xhr.open("get", "http://localhost:9000", true);
    xhr.setRequestHeader('my-token', 'abc')
    xhr.setRequestHeader('Content-Type', 'application/json')
    xhr.send();
    xhr.onreadystatechange = function() {
    if(xhr.readyState === 4 && xhr.status === 200) {
    console.log("succeed: " + xhr.responseText);
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const http = require('http');
    let server = http.createServer((req,res)=>{
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Credentials', true);
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'my-token, content-type')
    res.end("hello world");
    });
    server.listen(9000,()=>{
    console.log("server running at 9000");

    结果:

    image-20180811212142934

    这时我们可以在NETWORK里看到两次请求:

    • 预检请求

      image-20180811212451993

    • XMLHttpRequest请求

      image-20180811212600259

总结:

  • CORS需要在服务器端编写代码,浏览器端正常发送ajax请求
  • 和JSONP相比,CORS支持所有类型的HTTP请求,JSONP的优势则在于支持老式浏览器,以及可以向不支持CORS的网站请求数据

代理

服务器本身不受浏览器同源策略的影响,所以我们可以通过代理来实现跨域数据请求。

核心思路:

跨域的本质是浏览器向服务器请求数据,浏览器和服务器不同源,导致请求被拒。那么如果我们能让服务器代为发送请求,再由服务器把数据返回给浏览器的话,就可以规避限制了。所以我们实现代理要做的就是:

  1. 浏览器将需要发送的请求告诉服务器(而不是自己发送请求)
  2. 服务器接收到浏览器告诉自己的请求要求并替浏览器发送请求
  3. 服务器获取到请求返回的数据,再回传给浏览器
配置nginx

我们首先需要配置nginx。

  1. 下载

    1
    brew install nginx
  2. 启动

    1
    nginx

nginx安装后,我们可以在浏览器访问,默认端口是8080,在http://localhost:8080我们会看到这样的界面:

image-20180811231751868

接着需要配置,我在mac上的配置文件路径是/usr/local/etc/nginx,找到nginx.config文件。

1
2
3
4
5
6
7
8
9
location /apis {
rewrite ^/apis/(.*)$ /$1 break; #重写URL
include uwsgi_params; # 携带请求参数
proxy_pass http://localhost:8888;
# 服务器回写的cookie的domain是 代理服务器,
# 如此操作可以修改cookie的domain为 浏览器
# 浏览器cookie -> 代理服务器cookie -> 自动到目标服务器
proxy_cookie_domain domino_server nginx_server;
}

配置好之后,我们可以开始写代理服务器的代码。这里我们拿豆瓣api来做例子。

我们请求正在热映的电影信息就是输入以上两部分地址,直接在地址输入https://api.douban.com/v2/movie/in_theaters,可以看到:

image-20180811232724024

(有可能会是写成一堆的json数据,我安装了插件,所以信息美化了)

确认好地址无误后,我们在html页面里用XMLHttpRequest将地址发送给服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<button id="btn">点我发本服务器的node代理</button>
<script type="text/javascript">
let oBtn = document.getElementById("btn");
oBtn.onclick = function () {
// 发请求
var xhr = new XMLHttpRequest();
// 将请求地址放在url中发送给服务器
// 因为要区分登录和发送请求,这里我们用'/proxy'来区分
xhr.open('get','/proxy?url=http://api.douban.com/v2/movie/in_theaters');
xhr.send();
xhr.onreadystatechange = function () {
if(xhr.readyState === 4 && xhr.status === 200) {
alert(xhr.responseText);
}
}
}
</script>
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
// 1. 引入模块
const http = require('http');
const url = require('url');
const fs = require('fs');
// 这里需要用到一个新的插件request
const request = require('request');

// 2. 创建服务器
let server = http.createServer();
// 3. 为服务器接受请求绑定事件
server.on('request', (req, res) => {
// 判断请求和登录
if(req.url === '/') {
// 如果是访问localhost:8888就返回html页面
// 注意这里要用同步,否则会直接读取nginx的index页面
let data = fs.readFileSync('./proxy.html');
res.end(data);
} else if(req.url.startsWith('/proxy')) {
// 如果发送的是请求(以'/proxy‘开头)
// 解析出请求地址 req.query是{url: 豆瓣api地址}
let obj = url.parse(req.url, true);
// 新建对象x 它是一个请求
let x = request(obj.query.url);
// 将req中的数据通过“管道”流给x
req.pipe(x);
// 返回给x的数据通过“管道”流给res用于返回给浏览器
x.pipe(res);
}
})
server.listen(8888, () => {
console.log("server running at 8888");
})

image-20180812000903565

点击按钮会返回豆瓣数据:

image-20180812001003822

我们可以解析一下这些步骤

image-20180812001606364

登录的时候,直接到localhost:8888页面,服务器解析到url是/,于是把proxy.html页面的代码返回给浏览器,浏览器解析出来,呈现在页面上。

image-20180812002132380

请求数据则是浏览器只把请求内容告诉服务器,服务器代为请求。这里可以把前端代码中的豆瓣api地址部分替换成任何其他我们想要请求的数据地址。

以上就是常见的解决跨域的三种方法:

  • JSONP
  • CORS
  • 代理