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