跨域,對(duì)于正在學(xué)習(xí)或者已經(jīng)就業(yè)的前端同學(xué)而言,就是老朋友。只要涉及“請(qǐng)求”“前后端交互”“開(kāi)發(fā)階段”等關(guān)鍵字,都避不開(kāi)跨域。同時(shí)它也是面試中最常出現(xiàn)的考點(diǎn)之一,面試官可以通過(guò)跨域,了解應(yīng)聘者對(duì)網(wǎng)絡(luò)協(xié)議、網(wǎng)絡(luò)安全等概念的理解。
跨域并不是阻礙前后端交互的障礙,什么是跨域,怎么避開(kāi)跨域帶來(lái)的不便,本文主要細(xì)解三種主流的解決方案:JSONP,CORS,代理服務(wù)器,細(xì)致地解開(kāi)跨域相關(guān)的迷惑。
一、同源策略
同源策略是一個(gè)重要的安全策略,它用于限制一個(gè)Origin的文檔或者它加載的腳本如何能與另一個(gè)源的資源進(jìn)行交互。它能幫助阻隔惡意文檔,減少可能被攻擊的媒介。
Origin:指web文檔的來(lái)源,Web 內(nèi)容的來(lái)源取決于訪問(wèn)的URL的方案 (協(xié)議),主機(jī) (域名) 和端口定義。只有當(dāng)方案,主機(jī)和端口都匹配時(shí),兩個(gè)對(duì)象具有相同的起源。
二、跨域
關(guān)于URL是否同源,根據(jù)上圖中的①②③進(jìn)行判斷即可,只要有一點(diǎn)不同,就達(dá)到跨域的條件。順帶一提,即便是向域名對(duì)應(yīng)的ip進(jìn)行資源請(qǐng)求,仍然會(huì)跨域。
IE的特殊性:Inte.NET Explorer 的同源策略有兩點(diǎn)差異,一是IE未將端口號(hào)納入同源策略的檢查,其次是兩個(gè)高度互信的域名也不受同源策略的檢查。
常見(jiàn)的跨域情景:
瀏覽器內(nèi)常見(jiàn)的跨域報(bào)錯(cuò):
跨域出現(xiàn)的場(chǎng)景:
一般常見(jiàn)于開(kāi)發(fā)階段,本地啟動(dòng)項(xiàng)目后,當(dāng)前頁(yè)面域名和后臺(tái)服務(wù)器域名不相同,導(dǎo)致跨域。在項(xiàng)目上線后,會(huì)通過(guò)統(tǒng)一域名、后端配置域名白名單等方式避免跨域。
下方的解決方案中,我們通過(guò)koa2框架搭建服務(wù)器,實(shí)現(xiàn)一系列的情景模擬。
三、跨域的解決方案
1.JSONP
原理:通過(guò)script標(biāo)簽沒(méi)有跨域限制的特性,進(jìn)行資源的請(qǐng)求和獲取。
限制:需要目標(biāo)服務(wù)器進(jìn)行配合,且僅支持get請(qǐng)求
我們直接通過(guò)代碼和注釋?zhuān)斫鈐sonp的使用前端代碼如下:
<script>
window.jsonp = function(res){
console.log(res);
}
</script>
<script src="http://localhost:9527/jsonp?val=123&cb=jsonp"></script>
后端代碼如下:
// 定義jsonp接口
router.get('/jsonp', async (ctx, next) => {
/*
1.后端通過(guò)query獲取前端傳來(lái)的請(qǐng)求參數(shù)
其中包括:
· 交予后端進(jìn)行功能邏輯操作的數(shù)據(jù),如val
· 交予后端進(jìn)行jsonp操作的函數(shù)名,如cb
*/
const {cb, val} = ctx.query
// 2.調(diào)用回調(diào)函數(shù),進(jìn)行傳參,將處理好的數(shù)據(jù)返回給前端
if(val === '123'){
const requestData = {
code: 10001,
data: '登陸成功'
}
//在響應(yīng)體中觸發(fā)目標(biāo)函數(shù),并將處理好的數(shù)據(jù)requestData作為實(shí)參傳入
ctx.body = `${cb}(${JSON.stringify(requestData)})`;
}
})
前端通過(guò)window對(duì)象,在全局掛載了一個(gè)待觸發(fā)的函數(shù)。
后端通過(guò)響應(yīng)體觸發(fā)這個(gè)函數(shù),并將數(shù)據(jù)作為入?yún)ⅲ瑐鹘o前端。
了解簡(jiǎn)單的實(shí)現(xiàn)后,前端可以對(duì)jsonp的功能再進(jìn)行一層封裝:
/*
1. 生成script標(biāo)簽,我們需要script標(biāo)簽進(jìn)行接口的調(diào)用
2. 處理參數(shù)數(shù)據(jù),分別整理好接口,接口參數(shù),函數(shù)名等數(shù)據(jù),并進(jìn)行填充
3. 寫(xiě)入生成好的script標(biāo)簽,實(shí)現(xiàn)接口的調(diào)用(返回promise對(duì)象,便于鏈?zhǔn)秸{(diào)用)
4. 清除script標(biāo)簽
*/
function jsonp(requestData) {
// 對(duì)傳入?yún)?shù)進(jìn)行處理
const { url, data, jsonp } = requestData;
let query = '';
for (let key in data) {
query += `${key}=${data[key]}&`;
}
const src = `${url}?${query}jsonp=${jsonp}`;
// 生成、填充script標(biāo)簽,在頁(yè)面中掛載調(diào)用接口
let scriptTag = document.('script');
scriptTag.src = src;
document.body.(scriptTag);
return new Promise((resolve, reject) => {
window[jsonp] = function(rest){
resolve(rest)
document.body.removeChild(scriptTag)
}
})
}
// 整理數(shù)據(jù)
const requestData = {
url: 'http://localhost:9527/jsonp',
data: {
val: 123,
},
jsonp: 'getMessage'
}
// 接口調(diào)用
btn.onclick = function () {
jsonp(requestData).then(function (response) {
console.log(response);
})
}
2.CORS
Cross-Origin Resource sharing(跨域資源共享),是一種基于HTTP頭的機(jī)制,該機(jī)制允許服務(wù)器標(biāo)示除了它自己以外其他origin(域名,協(xié)議和端口),既瀏覽器在跨域的情景下仍然能從目標(biāo)服務(wù)器請(qǐng)求并獲取資源。
而對(duì)服務(wù)器數(shù)據(jù)可能產(chǎn)生副作用的HTTP請(qǐng)求方法,都會(huì)觸發(fā)CORS中的預(yù)檢機(jī)制。
CORS中通過(guò)預(yù)檢機(jī)制(preflight request)檢查服務(wù)器是否允許瀏覽器發(fā)送真實(shí)請(qǐng)求,瀏覽器會(huì)先發(fā)送一個(gè)預(yù)檢請(qǐng)求(option請(qǐng)求),請(qǐng)求中會(huì)攜帶真實(shí)請(qǐng)求的請(qǐng)求信息:
origin:請(qǐng)求的來(lái)源
Access-Control-Request-Method:
通知服務(wù)器在真正的請(qǐng)求中會(huì)采用哪種HTTP方法(GET,POST,DELETE...)
Access-Control-Request-Headers:通知服務(wù)器在真正的請(qǐng)求中會(huì)采用哪些請(qǐng)求頭
服務(wù)器可以在預(yù)檢請(qǐng)求中,可以根據(jù)以上三條信息,確定預(yù)檢請(qǐng)求是否通過(guò):
//server.js
App.use(async (ctx, next) => {
// 允許跨域資源共享的白名單
const whiteList = ['http://127.0.0.1:5500']
// 判斷目標(biāo)源是否通行
const pass = whiteList.includes(ctx.header.origin)
// 對(duì)于預(yù)檢請(qǐng)求,如果沒(méi)有設(shè)置正確的響應(yīng)狀態(tài),瀏覽器會(huì)直接攔截真實(shí)請(qǐng)求,直接報(bào)錯(cuò)提示跨域
// 所以我們可以在這一部分,確定客戶端的請(qǐng)求是否符合我們的要求
if (ctx.method === "OPTIONS") {
if (!pass) return
// 預(yù)檢放行
ctx.status = 204
}
await next();
});
響應(yīng)的狀態(tài)碼是決定預(yù)檢請(qǐng)求是否通過(guò)的關(guān)鍵,返回正常的狀態(tài)碼(通常是204)就能通過(guò)預(yù)檢請(qǐng)求,讓瀏覽器發(fā)出真實(shí)的請(qǐng)求。
在代碼中也可以看出,pass是決定預(yù)檢請(qǐng)求的關(guān)鍵,那在實(shí)際的項(xiàng)目中,還得根據(jù)設(shè)計(jì)去決定通行的具體條件。當(dāng)通過(guò)預(yù)檢請(qǐng)求后,后臺(tái)可以設(shè)置對(duì)應(yīng)的響應(yīng)頭數(shù)據(jù),例如是否允許目標(biāo)源跨域資源共享:
//server.js
app.use(async (ctx, next) => {
console.log('middleware for cors');
// 允許跨域資源共享的白名單
const whiteList = ['http://127.0.0.1:5500']
// 判斷目標(biāo)源是否通行
const pass = whiteList.includes(ctx.header.origin)
// 對(duì)于預(yù)檢請(qǐng)求,如果沒(méi)有設(shè)置正確的響應(yīng)狀態(tài),瀏覽器會(huì)直接攔截真實(shí)請(qǐng)求,直接報(bào)錯(cuò)跨域
// 所以我們可以在這一部分,確定客戶端的請(qǐng)求是否符合我們的要求
if (ctx.method === "OPTIONS") {
if (!pass) return
// 預(yù)檢放行
ctx.status = 204
}
// 允許訪問(wèn)的origin
ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
// cookie是否允許攜帶
ctx.set("Access-Control-Allow-Credentials", true);
// 允許訪問(wèn)的HTTP方法
ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
// 哪些請(qǐng)求頭允許通行
ctx.set(
"Access-Control-Allow-Headers",
"X-Requested-With,Content-Type,Accept,Origin"
);
// 暴露給客戶端的響應(yīng)頭信息,在不設(shè)置的情況下,客戶端只能獲取默認(rèn)的響應(yīng)頭,如’content-type‘
ctx.set(
"Access-Control-Expose-Headers",
"With-Requested-Key"
);
// 設(shè)置對(duì)應(yīng)的響應(yīng)頭數(shù)據(jù)
ctx.set(
"With-Requested-Key",
"HW"
);
// 預(yù)檢結(jié)果的緩存時(shí)間,毫秒為單位,F(xiàn)irefox上限是86400-24小時(shí),Chromium(谷歌引擎)上限是7200-2小時(shí)
ctx.set("Access-Control-Max-Age", 0);
await next();
});
其中需要注意兩個(gè)點(diǎn):
關(guān)于Access-Control-Expose-Header
使用CORS時(shí),瀏覽器只允許獲取默認(rèn)的響應(yīng)頭,像上文代碼中的標(biāo)頭With-Requested-Key,即便我們可以通過(guò)瀏覽器的調(diào)試器查看,也無(wú)法通過(guò)代碼去獲取,這時(shí)候就需要后臺(tái)通過(guò)Access-Control-Expose-Header進(jìn)行暴露(后臺(tái)代碼在已在上方統(tǒng)一貼出)。
前端代碼
<body>
<button id="btn"> 請(qǐng)求資源 </button>
</body>
<script>
btn.onclick = function () {
axIOS.post('http://localhost:9527/getMessage', {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
// 可以在里面查找到暴露出來(lái)的響應(yīng)頭數(shù)據(jù),如’With-Requested-Key‘: "HW"
console.log(response.headers);
})
.catch(function (error) {
console.log(error);
});
}
</script>
關(guān)于Access-Control-Allow-Credentials
使用CORS時(shí),默認(rèn)不攜帶cookie,需要同時(shí)滿足三個(gè)條件,才能在使用CORS時(shí)進(jìn)行cookie的傳遞:
瀏覽器的請(qǐng)求中,設(shè)置withCredentials參數(shù)為true
服務(wù)端設(shè)置標(biāo)頭Access-Control-Allow-Credentials為true
服務(wù)端設(shè)置標(biāo)頭Access-Control-Allow-Origin不為*
我們可以在原生ajax請(qǐng)求中設(shè)置該參數(shù),或者在axios的默認(rèn)配置中設(shè)置該參數(shù):
// 原生ajax
const xhr = new ()
xhr.withCredentials = true
// axios
axios.defaults.withCredentials = true;
Ok,明白CORS的作用,以及明白CORS中的預(yù)檢機(jī)制后,接下來(lái)是了解什么時(shí)機(jī)下會(huì)觸發(fā)預(yù)檢機(jī)制。
CORS中歸納了一系列不會(huì)觸發(fā)預(yù)檢機(jī)制的請(qǐng)求場(chǎng)景,即滿足所有下述條件的情況下,統(tǒng)稱(chēng)為簡(jiǎn)單請(qǐng)求:
使用這三種方法之一:GET HEAD POST
不得人為設(shè)置此集合外的其他首部字段:Accept Accept-Language Content-Language Content-Type
Content-type的值僅限于這三者之一:
text/plain
multipart/form-data
application/x-www/form-urlencoded
請(qǐng)求中,實(shí)例沒(méi)有注冊(cè)任何事件監(jiān)聽(tīng)器,即實(shí)例對(duì)象可以使用.upload屬性進(jìn)行訪問(wèn)
請(qǐng)求中沒(méi)有使用ReadableStream對(duì)象
小結(jié):CORS中主要區(qū)分了簡(jiǎn)單請(qǐng)求和復(fù)雜請(qǐng)求兩種情況,復(fù)雜請(qǐng)求會(huì)觸發(fā)CORS的預(yù)檢機(jī)制。通過(guò)上方的案例,也可以清楚CORS的配置主要是在服務(wù)端,但客戶端也需要知道CORS的使用注意點(diǎn),例如響應(yīng)頭數(shù)據(jù)的獲取以及cookies的攜帶配置,這些知識(shí)應(yīng)該是前后端都需要掌握的技能點(diǎn)。
3.服務(wù)器代理
同源策略主要是限制瀏覽器和服務(wù)器之間的請(qǐng)求,服務(wù)器與服務(wù)器之間并不存在跨域。
我們可以通過(guò)koa2模擬和實(shí)現(xiàn)這種概念:
//前端代碼
<body>
<button id="btn"> 請(qǐng)求資源 </button>
<script>
btn.onclick = function () {
let url = checkUrlProxy('http://localhost:9527/api/getMessage','api')
axios.post(url, {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
console.log(response);
})
}
// 判斷接口是否攜帶api字段,若是,則更改為代理服務(wù)器對(duì)應(yīng)的域名
function checkUrlProxy(url, proxyFlag){
let proxyServer = 'http://localhost:1005'
let urlArr = [url.split('/')[1],url.split('/')[3]]
if(urlArr.includes(proxyFlag)) {
return `${proxyServer}/${proxyFlag}${url.split(proxyFlag)[1]}`
}
return url
}
//
</script>
</body>
前端的代碼部分,通過(guò)checkUrlProxy函數(shù)簡(jiǎn)單地確定本次請(qǐng)求是否要轉(zhuǎn)向代理服務(wù)器。
后端代碼如下:
//proxyServer.js
let requestFlag = false
let body = ''
app.use(async (ctx, next) => {
// 全放行
if (ctx.method === "OPTIONS") {
ctx.status = 204
requestFlag = false
} else {
requestFlag = true
}
ctx.set("Access-Control-Allow-Origin", "*");
ctx.set("Access-Control-Allow-Credentials", true);
ctx.set("Access-Control-Request-Method", "*");
ctx.set(
"Access-Control-Allow-Headers",
"X-Requested-With,Content-Type,Accept,Origin"
);
ctx.set("Access-Control-Max-Age", 86400);
// 根據(jù)具體情況進(jìn)行修改
ctx.set("Access-Control-Expose-Headers", "With-Requested-Key");
await next();
if(requestFlag) {
ctx.body = body
body = ''
}
});
app.use(async (ctx, next) => {
if (!requestFlag) return
await p4r(ctx)
});
function p4r(ctx) {
return new Promise((res, rej) => {
const proxyRequest = http.request({
host: '127.0.0.1',
port: 9527,
path: ctx.url,
method: ctx.method,
headers: ctx.header
},
serverResponse => {
serverResponse.on('data', chunk => {
body += chunk
})
serverResponse.on('end', () => {
res(body)
})
}
proxyRequest.end()
})
}
app.on('error', (err, ctx) => {
console.error('server error', err, ctx)
});
app.listen(1005, (err) => {
if (err) console.log('服務(wù)器啟動(dòng)失敗');
else console.log('proxy server 1005 running --> ???');
})
//targetServer.js
const data = {val : 123}
// 配合代理服務(wù)器的post路由
router.post('/api/getMessage', (ctx) => {
ctx.body = JSON.stringify(data)
})
// 定義好路由組件的內(nèi)容后進(jìn)行路由注冊(cè)
app.use(router.routes())
app.on('error', (err, ctx) => {
console.error('server error', err, ctx)
});
app.listen(9527, (err) => {
if (err) console.log('服務(wù)器啟動(dòng)失敗');
else console.log('服務(wù)器啟動(dòng)成功');
})
后端代碼主要分兩部分:
代理服務(wù)器(proxyServer),代理服務(wù)器設(shè)置CORS時(shí)不限制通行,在koa2框架中,通過(guò)中間件向目標(biāo)服務(wù)器發(fā)送請(qǐng)求,當(dāng)接收到對(duì)應(yīng)數(shù)據(jù)后,再響應(yīng)給瀏覽器
目標(biāo)服務(wù)器(targetServer),目標(biāo)服務(wù)器不需要做太復(fù)雜的配置,案例中只是將數(shù)據(jù)傳遞給請(qǐng)求方
Ok,我們通過(guò)這個(gè)案例,明確代理服務(wù)器的具體效果,瀏覽器向目標(biāo)服務(wù)器直接請(qǐng)求資源,仍然會(huì)受到同源策略的影響,但通過(guò)代理服務(wù)器向目標(biāo)服務(wù)器請(qǐng)求資源時(shí),卻沒(méi)這種限制。
那在實(shí)際項(xiàng)目中,我們可以通過(guò)腳手架或打包工具的配置文件,簡(jiǎn)潔方便地設(shè)置代理服務(wù)器,無(wú)需自己手寫(xiě)服務(wù)器代碼,拿vue的腳手架為例:
devServer:{
proxy:{
'api':{
target:'127.0.0.1:9527', //目標(biāo)服務(wù)器地址
changeOrigin: true, // 是否允許跨域
pathRewrite: { //是否重寫(xiě)接口
'api':'',
}
}
}
}
在配置的時(shí)候,可以通過(guò)框架的腳手架,或者打包工具確定配置文件,例如一些熟悉的字眼:vue.config.jswebpack.config.jspackage.json(react),更準(zhǔn)確的做法就是直接去對(duì)應(yīng)工具的官方文檔查閱代理服務(wù)器的配置介紹。
總結(jié)
對(duì)于跨域,許多同學(xué)都答得上來(lái)跨域是怎么產(chǎn)生的,以及解決跨域的方案。但在交流過(guò)程中,就總是一兩句就講完讓我覺(jué)得有點(diǎn)可惜。
前后端交互,或者應(yīng)該說(shuō)網(wǎng)絡(luò)協(xié)議,一直都是個(gè)大課題,是只要涉及這一塊的程序員,都應(yīng)該而且有必要學(xué)習(xí)的內(nèi)容。類(lèi)似上文中CORS配置時(shí)前后端要如何配合,以及使用CORS時(shí)前端的注意點(diǎn)都少有人提及。后端是主要的配置方,但不代表這一塊的知識(shí)限于只需后端理解。
了解知識(shí)點(diǎn)的本質(zhì),才能盡量保證在不同的項(xiàng)目場(chǎng)景實(shí)施對(duì)應(yīng)方案。