前言 同源策略是浏览器 为了确保资源请求安全而遵循的一种策略,该策略对访问资源进行了一些限制。比较常见的就是跨域请求资源的问题,由于存在同源策略,浏览器会不允许接收得到的请求数据,需要通过一定的策略解决跨域请求的问题。
详细的可参见同源策略 - 网络安全 (w3.org)
源 概念 源的概念由协议、域名和端口三部分组成。严格来说只要三部分出现任意一部分的不相等 ,那么就是出现了跨域,也称为非同源或异源。相反的,如果三部分均相等,那么就为同源。
如何算跨域 跨域即发生了非同源请求,请看以下例子
下图清晰展示了同源与非同源的区别
跨域会受到哪些限制 一旦出现了跨域的请求,一般会受到浏览器的三种限制:
限制DOM访问
限制cookie访问
限制Ajax的响应(Ajax请求是可以发送成功的)
限制DOM访问 创建index.html
,index2.html
两个html文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > Document</title > </head > <body > <h2 > 页面1</h2 > <button onclick ="getDomObject()" > 点我获取DOM对象</button > <br /> <br /> <iframe id ="iframePage" src ="./index2.html" frameborder ="10" > </iframe > <script > function getDomObject ( ) { const iframePage = document .getElementById ("iframePage" ); console .log (iframePage.contentWindow .document ); } </script > </body > </html >
1 2 3 4 5 6 7 8 9 10 11 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <body > <h2 > 页面2</h2 > </body > </html >
使用iframe
嵌入index2.html
,尝试获取page.html
的DOM对象,可以成功获取,因为这两个页面都属于同一源http://127.0.0.1:5500
。
如果将http://127.0.0.1:5500/index2.html
换成https://www.baidu.com
,点击按钮获取DOM对象就会发生报错。
报错的详细信息为:
index.html:17 Uncaught DOMException: Failed to read a named property ‘document’ from ‘Window’: Blocked a frame with origin “http://127.0.0.1:5500 “ from accessing a cross-origin frame.
index.html:17 未捕获的 DOMException:无法从“Window”读取命名属性“document”:阻止源为“http://127.0.0.1:5500”的帧访问跨源帧。
限制cookie访问 同理,如果是获取http://127.0.0.1:5500/index2.html
的cookie,那么会请求成功,但是如果访问https://www.baidu.com/
的cookie则一定会失败(因为cookie是在document对象中的)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > Document</title > </head > <body > <h2 > 页面1</h2 > <button onclick ="getDomObject()" > 点我获取cookie</button > <br /> <br /> <iframe id ="iframePage" src ="https://www.baidu.com/" frameborder ="10" > </iframe > <script > function getDomObject ( ) { const iframePage = document .getElementById ("iframePage" ); console .log (iframePage.contentWindow .document .cookie ); } </script > </body > </html >
得到的报错原因也完全相同。
限制Ajax获取数据 尝试获取今日头条的数据(明显跨域)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 获取新闻数据</title > </head > <body > <button onclick ="getNews()" > 获取头条数据</button > <script > async function getNews ( ) { const url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" ; const res = await fetch (url); const data = await res.json (); console .log (data); } </script > </body > </html >
在控制台中出现了新的报错
index.html:1 Access to fetch at ‘https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc ‘ from origin ‘http://127.0.0.1:5500 ‘ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
index.html:1 CORS 策略已阻止从源“http://127.0.0.1:5500”在“https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc”处提取:请求的资源上不存在“Access-Control-Allow-Origin”标头。如果不透明响应满足您的需求,请将请求的模式设置为“no-cors”,以在禁用 CORS 的情况下获取资源。
但是实际上可以看到服务器的响应实际上是成功的,但是被浏览器拦截了。
浏览器抛出的错误为 CORS 错误
,这代表跨域请求失败。
在上述限制中,浏览器对Ajax获取数据的限制是影响最大的,且开发中经常遇到。
注意事项
实际上跨域问题仅限于浏览器端,而服务端不存在这样的问题。
Ajax的请求是可以发出的(如上文中的截图所示),但是相应的数据获取不到(被浏览器拦截),具体流程可参考下图。
<script>
、<img>
、<link>
等标签发出的请求也可能出现跨域(常见于使用cdn导入js),但是浏览器不会对标签跨域出现严格的限制,对实际开发无影响。
CORS解决Ajax跨域问题 CORS概述 CORS 全称:Cross-Origin Resource Sharing(跨域资源共享),是用于控制浏览器校验跨域请求的一套规范,服务器依照 CORS 规范,添加特定响应头来控制浏览器校验。有如下规则
使用 CORS 解决跨域是最正统的方式,且要求服务端进行处理。
CORS解决简单跨域 使用koa创建一个简单的服务器,对http://127.0.0.1:8081/student
进行监听。
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 import Koa from "koa" ;import Router from "@koa/router" ;const host = "127.0.0.1" ;const port = 8081 ;const router = new Router ();const app = new Koa ();const student = [ { id : 1 , name : "张三" , age : 18 }, { id : 2 , name : "李四" , age : 19 }, { id : 3 , name : "王五" , age : 20 }, ]; router.get ("/student" , (ctx ) => { ctx.status = 200 ; ctx.body = student; }); app.use (router.routes ()); app.listen (port, host, () => { console .log (`服务已启动:http://${host} :${port} ` ); });
创建一个页面进行模拟请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 获取学生数据</title > </head > <body > <button onclick ="getNews()" > 获取学生数据</button > <script > async function getNews ( ) { const url = "http://127.0.0.1:8081/students" ; const res = await fetch (url); const data = await res.json (); console .log (data); } </script > </body > </html >
当对服务器进行请求的时候,出现了一样的跨域请求报错
Access to fetch at ‘http://127.0.0.1:8081/student ‘ from origin ‘http://127.0.0.1:5500 ‘ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
CORS 策略已阻止从源“http://127.0.0.1:5500”在“http://127.0.0.1:8081/student”处提取的访问:请求的资源上不存在“Access-Control-Allow-Origin”标头。如果不透明响应满足您的需求,请将请求的模式设置为“no-cors”,以在禁用 CORS 的情况下获取资源。
其实从报错中就可以看出,浏览器要求我们添加Access-Control-Allow-Origin
这个标头。
对于简单请求,只需在请求头中将Access-Control-Allow-Origin
的值设置为源即可解决跨域请求问题。
1 2 3 4 5 6 router.get ("/student" , (ctx ) => { ctx.status = 200 ; ctx.set ("Access-Control-Allow-Origin" ,"http://127.0.0.1:5500" ) ctx.body = student; });
这样即可正常得到请求的数据
具体的原理图如下所示,关注请求头的Origin
和响应头的Access-Control-Allow-Origin
。当发生跨域请求时,只有服务端在响应中说明允许Origin
的访问请求时,浏览器才会接受这个响应,具体来说,就是在响应头中添加属性值为Origin
的Access-Control-Allow-Origin
另外要注意,Access-Control-Allow-Origin
的值设置要严格按照Origin
的值来设置。比如127.0.0.1
和localhost
在Access-Control-Allow-Origine
的含义是不一样的。
另外如果是复杂请求,那么仅仅设置Access-Control-Allow-Origin
就无效了。
简单请求和复杂请求 CORS会把请求分为简单请求和复杂请求两类。
简单请求
复杂请求
请求方法(method)为POST,GET,HEAD
不是简单请求就是复杂请求
请求头字段要符合CORS安全规范 简记:只要不动手修改请求头,一般都能符合该规范
复杂请求会自动发送预检请求(method为OPTIONS)
请求头的Content-Type
只能是以下的三种: 1. text/plain 2. multipart/form-data 3. application/x-www-form-urlencoded
可以看出简单请求的要求很严格,我们可以因此得出一些常见的复杂请求
如果是复杂请求,那么需要考虑的因素就会变多。且对于复杂请求,每次发送请求之前浏览器都会发送一个预检请求,来获得服务端的同意。如下图所示,如果预检请求没通过,那么跨域请求就不会发送。
关于预检请求:
发送的时机:预检请求由浏览器在跨域请求之前发出
主要作用:用于向服务器确实是否允许接下来的跨域请求
基本流程:先发起OPTIONS请求,如果通过预检,继续发起实际跨域请求
请求头内容:一个OPTIONS预检请求,通常会包含如下请求头
请求头
含义
Origin
发起请求的源
Access-Control-Request-Method
实际请求的 HTTP 方法
Access-Control-Request-Headers
实际请求中使用的自定义头(如果有的话)
继续上述解决简单请求的案例,将fetch请求修改为复杂请求
1 2 3 4 5 6 7 8 9 10 11 12 13 async function getNews ( ) { const url = "http://127.0.0.1:8081/student" ; const res = await fetch (url, { method : "POST" , headers : { "city" :"beijing" , "name" :"gcnanmu" }, }); const data = await res.json (); console .log (data); }
打开调试工具查看预检请求的请求头,即可以看到Origin
、Access-Control-Request-Method
、Access-Control-Request-Headers
三个属性。
CORS解决复杂跨域问题 既然预检请求的请求头中有着三个特殊的属性,那么服务器必然要对三个属性进行一定的回应。具体的原理如下图所示(示例)
服务端根据预检请求发送的请求头中的Origin
、Access-Control-Request-Method
、Access-Control-Request-Headers
信息,设置对应的响应头属性表示同意。
响应头
含义
Access-Control-Allow-Origin
允许的源
Access-Control-Allow-Methods
允许的方法
Access-Control-Allow-Headers
允许的自定义头
Access-Control-Max-Age
预检请求的结果的缓存时间(可选)
将服务端的代码按照图示进行修改。为了和一开始的例子做区分,改为在页面中发送post请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 router.options ("/student" , (ctx ) => { ctx.set ("Access-Control-Allow-Origin" , "http://127.0.0.1:5500" ); ctx.set ("Access-Control-Allow-Methods" , "POST" ); ctx.set ("Access-Control-Allow-Headers" , "city,name" ); ctx.body = "" ; }); router.post ("/student" , (ctx ) => { ctx.status = 200 ; ctx.set ("Access-Control-Allow-Origin" , "http://127.0.0.1:5500" ); ctx.body = student; });
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 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 获取学生数据</title > </head > <body > <button onclick ="getNews()" > 获取学生数据</button > <script > async function getNews ( ) { const url = "http://127.0.0.1:8081/student" ; const res = await fetch (url, { method : "POST" , headers : { "city" :"beijing" , "name" :"gcnanmu" }, }); const data = await res.json (); console .log (data); } </script > </body > </html >
设置之后能够通过预检且获得正确的响应数据
一定不要忘记ctx.set("Access-Control-Allow-Origin", "http://127.0.0.1:5500")
,否则跨域还是无法正常接收响应。
如果设置了Access-Control-Max-Age
,可以在第一次预检后停止一段时间的预检,在此期间默认通过预检。
借助CORS库快速完成配置 为了省去每次都要配置请求头的麻烦,对于koa,可以直接使用@koa/cors
库
安装
如果要简单允许所有的请求,可以使用app.use(Cors())
的方式。
1 2 3 4 5 6 7 8 9 10 11 import Koa from "koa" ;import Router from "@koa/router" ;import Cors from "@koa/cors" ;const host = "127.0.0.1" ;const port = 8081 ;const router = new Router ();const app = new Koa ();app.use (Cors ());
支持的参数如下所示(来自官网),默认情况下几乎是全开放的,没有做任何限制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 /** * CORS middleware * * @param {Object} [options] * - {String|Function(ctx)} origin `Access-Control-Allow-Origin`, default is '*' * If `credentials` set and return `true, the `origin` default value will set to the request `Origin` header * - {String|Array} allowMethods `Access-Control-Allow-Methods`, default is 'GET,HEAD,PUT,POST,DELETE,PATCH' * - {String|Array} exposeHeaders `Access-Control-Expose-Headers` * - {String|Array} allowHeaders `Access-Control-Allow-Headers` * - {String|Number} maxAge `Access-Control-Max-Age` in seconds * - {Boolean|Function(ctx)} credentials `Access-Control-Allow-Credentials`, default is false. * - {Boolean} keepHeadersOnError Add set headers to `err.header` if an error is thrown * - {Boolean} secureContext `Cross-Origin-Opener-Policy` & `Cross-Origin-Embedder-Policy` headers.', default is false * - {Boolean} privateNetworkAccess handle `Access-Control-Request-Private-Network` request by return `Access-Control-Allow-Private-Network`, default to false * @return {Function} cors middleware * @api public */
如果想要根据需求设置,可以设定Options
1 2 3 4 5 6 7 8 9 const corsOptions = { origin : "http://127.0.0.1:5500" , allowMethods : ["GET" ], allowHeaders : ["name" , "city" ], exposeHeaders : ["abc" ], }; app.use (Cors (corsOptions));
暴露的属性等在调试工具中可以看到
实测发现cors
的allowMethods
参数不起作用
后端完整代码备份
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 46 47 48 49 50 51 52 import Koa from "koa" ;import Router from "@koa/router" ;import Cors from "@koa/cors" ;const host = "127.0.0.1" ;const port = 8081 ;const router = new Router ();const app = new Koa ();const corsOptions = { origin : "http://127.0.0.1:5500" , allowMethods : ["GET" ], allowHeaders : ["name" , "city" ], exposeHeaders : ["abc" ], }; app.use (Cors (corsOptions)); const student = [ { id : 1 , name : "张三" , age : 18 }, { id : 2 , name : "李四" , age : 19 }, { id : 3 , name : "王五" , age : 20 }, ]; router.post ("/student" , (ctx ) => { ctx.status = 200 ; ctx.set ("Access-Control-Allow-Origin" , "http://127.0.0.1:5500" ); ctx.body = student; }); router.get ("/student" , (ctx ) => { ctx.status = 200 ; ctx.set ("Access-Control-Allow-Origin" , "http://127.0.0.1:5500" ); ctx.body = student; }); app.use (router.routes ()); app.listen (port, host, () => { console .log (`服务已启动:http://${host} :${port} ` ); });
JSONP解决跨域问题 除了使用CORS,早期一些浏览器不支持CORS
,人们还想到了一个巧妙的办法,可以靠JSONP
解决跨域。
在前文的“跨域会受到哪些限制中”的“注意事项”中我们提到,浏览器是不会严格限制标签的跨域请求的,因此就可以通过script
进行js预加载完成数据的传递,JSONP的基础原理就是通过script
标签完成跨域请求。
基本流程
客户端创建一个<script>
标签,并将其src
属性设置为包含跨域请求的 URL,同时准备一个回调函数,这个回调函数用于处理返回的数据。
第二步:服务端接收到请求后,将数据封装在回调函数中并返回。
第三步:客户端的回调函数被调用,数据以参数的形势传入回调函数。
原理图如下
从图中可以看出,服务器返回了一个js函数其中携带着数据,浏览器端先定义好对应的js函数,当响应内容到来时,就相当于运行设定好的function
,从而获得传递过来的数据。
先在服务端创建响应逻辑
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 import Koa from "koa" ;import Router from "@koa/router" ;const host = "127.0.0.1" ;const port = 8081 ;const router = new Router ();const app = new Koa ();const student = [ { id : 1 , name : "张三" , age : 18 }, { id : 2 , name : "李四" , age : 19 }, { id : 3 , name : "王五" , age : 20 }, ]; router.get ("/student" , (ctx ) => { ctx.body = `callback(${student} )` ; }); app.use (router.routes ()); app.listen (port, host, () => { console .log (`服务已启动:http://${host} :${port} ` ); });
创建一个页面获取数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > 获取学生数据</title > </head > <body > <script > function callback (data ) { console .log (data); } </script > <script src ="http://127.0.0.1:8081/student" > </script > </body > </html >
一定要注意script定义的先后顺序 。如果颠倒上文html中script
标签的顺序,那么就会出现ReferenceError: callback is not defined
的报错,这是因为script
的默认加载顺序为从上至下,因此一定要先定义好callback
函数,然后再发送请求,具体到代码来说就是script
标签的顺序问题。
打开本地服务器,得到如下的报错Uncaught SyntaxError: Unexpected identifier 'Object'
,意思是未捕获的 SyntaxError:意外的标识符“Object”
,通过观察发现返回的数据全变成了[object,Object]
修改后端的代码
1 2 3 4 router.get ("/student" , (ctx ) => { ctx.body = `callback(${JSON .stringify(student)} )` ; });
这样之后即可正确获取数据
完善案例,希望通过点击按钮来获取数据,且不想让后端频繁根据事先定义的函数名称修改返回字符串中的函数名。
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 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > 获取学生数据</title > </head > <body > <button onclick ="showData()" > 获取学生数据</button > <script > function getData (data ) { console .log (data); } function showData ( ) { const script = document .createElement ("script" ); script.src = "http://localhost:8081/student?callback=getData" ; document .body .appendChild (script); script.onload = () => { document .body .removeChild (script); } } </script > </body > </html >
1 2 3 4 router.get ("/student" , (ctx ) => { const { callback } = ctx.query ; ctx.body = `${callback} (${JSON .stringify(student)} )` ; });
在页面部分,跟之前一样,要先定义请求函数getData
,为了后端的函数名可以同步,通过查询字符串的方式传递当前定义好的请求函数名(callback=getData
),后端可以通过获取callback中的参数来调整返回字符串中的函数名。
注意点:由于是使用js添加script
到页面,加载完成后一定要记得删除script
标签,不然每点击一次就会生成一个script
。
使用jQuery jQuery有封装好的JSONP请求函数,可以更简单实现JSONP请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > 获取学生数据</title > <script src ="https://cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js" > </script > </head > <body > <button onclick ="showData()" > 获取学生数据</button > <script > function showData ( ) { $.getJSON ('http://localhost:8081/student?callback=?' ,(data )=> { console .log (data); }); } </script > </body > </html >
配置代理解决跨域问题 上文中提到,服务器与服务器之间是不存在跨域问题的,因此我们可以通过自己的服务器间接解决跨域请求的问题。
创建代理需要使用到koa-server-http-proxy
。下面的例子使用koa-server-http-proxy
进行展示。
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 import Koa from "koa" ;import Router from "@koa/router" ;import { readFile } from "fs/promises" ;import koaServerHttpProxy from "koa-server-http-proxy" ;const host = "127.0.0.1" ;const port = 8081 ;const router = new Router ();const app = new Koa ();router.get ("/" , async (ctx) => { ctx.type = "html" ; ctx.body = await readFile ("./index.html" , "utf8" ); }); app.use ( koaServerHttpProxy ("/api/" , { target : "https://www.toutiao.com" , pathRewrite : { "^/api" : "" }, changeOrigin : true , }) ); app.use (router.routes ()); app.listen (port, host, () => { console .log (`服务已启动:http://${host} :${port} ` ); });
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > 获取学生数据</title > </head > <body > <button onclick ="showData()" > 获取学生数据</button > <script > async function showData ( ) { const url = 'http://127.0.0.1:8081/api/hot-event/hot-board/?origin=toutiao_pc' ; const res = await fetch (url); const data = await res.json (); console .log (data); } </script > </body > </html >
上述代码尝试对头条数据进行请求。要理解这个过程,首先要理解这个代理部分做了什么
1 2 3 4 5 6 7 8 app.use ( koaServerHttpProxy ("/api/" , { target : "https://www.toutiao.com" , pathRewrite : { "^/api" : "" }, changeOrigin : true , }) );
根据上图所示,浏览器首先向代理服务器发出同源请求,此请求的协议域名部分与代理服务器保持一致,当代理服务器收到请求后,将请求地址转化为发送给今日头条的请求地址,由于服务器不存在跨域问题,代理服务器可以获得头条服务器所发回的数据,再将发回的数据发送回到同源的浏览器。这样就绕开了跨域的限制。
使用koa2-proxy-middleware
也是可以,但是koa2-proxy-middleware
不支持ES6模块化语法,需要使用commonjs
的导入规则。koa2-proxy-middleware
的代码如下
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 const Koa = require ("koa" );const proxy = require ("koa2-proxy-middleware" ); const host = "127.0.0.1" ;const port = 8081 ;const app = new Koa ();const options = { targets : { "/api/(.*)" : { target : "https://www.toutiao.com" , changeOrigin : true , }, }, }; app.use (proxy (options)); app.use (async (ctx, next) => { console .log (`Process ${ctx.request.method} ${ctx.request.url} ...` ); await next (); }); app.listen (port, host, () => { console .log (`服务已启动:http://${host} :${port} ` ); });