跨域解決方案(總結篇)

喆星高照發表於2021-10-27

今天我們來聊一個老生常談的話題,跨域!又是跨域,煩不煩 ?網上跨域的文章那麼多,跨的我眼睛都疲勞了,不看了不看了 ? 別走...我儘量用最簡單的方式將常見的幾種跨域解決方案給大家闡釋清楚,相信認真看完本文,以後不管是作為受試者還是面試官,對於這塊的知識都能夠遊刃有餘。

什麼是“跨源”

**不是講跨域嗎 ?怎麼又來個“跨源” ?字都能打錯的 ?**?...稍安勿躁,其實我們平常說的跨域是一種狹義的請求場景,簡單來說,就是“跨“過瀏覽器的同源策略[1]去請求資“源”,所以我們叫它“跨源”也沒啥問題。那麼,跨源,源是什麼?瀏覽器的同源策略什麼是同源?協議,域名,埠都相同就是同源乾巴巴的,能不能舉個例子?栗子:),有的有的

const url = 'https://www.google.com:3000'

比如上面的這個 URL,協議是:https,域名是 **www.google.com\*\*,埠是[2] 3000。不同源了會怎麼樣?會有很多限制,比如

  • Cookie,LocalStorage,IndexDB 等儲存性內容無法讀取
  • DOM 節點無法訪問
  • Ajax 請求發出去了,但是響應被瀏覽器攔截了

我就想請求個東西,至於嗎,為什麼要搞個這麼個東西限制我?基於安全考慮,沒有它,你可能會遇到

  • Cookie劫持,被惡意網站竊取資料
  • 更容易受到 XSS,CSRF 攻擊
  • 無法隔離潛在惡意檔案
  • ... ...

所以,得有。正是因為瀏覽器同源策略的存在,你的 Ajax 請求有可能在發出去後就被攔截了,它還會給你報個錯:

✘ Access to XMLHttpRequest at 'xxx' from origin 'xxx' has been block by CORS,
  policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

這種發出去拿不到響應的感受,就像你在網上衝浪時,被一股神祕的東方力量限制了一樣:

 

 

 

非常難受,所以,我們接下來就來看看怎麼用科學的方法上網(啊呸,科學的方法解決跨域的問題)。

JSONP

這玩意兒就是利用了 <script> 標籤的 src 屬性沒有跨域限制的漏洞,讓我們可以得到從其他來源動態產生的 JSON 資料。為什麼叫 JSONP ?**JSONP 是 JSON with Padding 的縮寫,額,至於為什麼叫這個名字,我網上找了下也沒個標準的解釋,還望評論區的各位老哥知道的趕緊告訴我: )**怎麼實現 ?具體實現思路大致分為以下步驟

  • 本站的指令碼建立一個 元素,src 地址指向跨域請求資料的伺服器
  • 提供一個回撥函式來接受資料,函式名可以通過地址引數傳遞進行約定
  • 伺服器收到請求後,返回一個包裝了 JSON 資料的響應字串,類似這樣:callback({...})

瀏覽器接受響應後就會去執行回撥函式 callback,傳遞解析後的 JSON 物件作為引數,這樣我們就可以在 callback 裡處理資料了。實際開發中,會遇到回撥函式名相同的情況,可以簡單封裝一個 JSONP 函式:

function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    // 建立一個臨時的 script 標籤用於發起請求
    const script = document.createElement('script');
    // 將回撥函式臨時繫結到 window 物件,回撥函式執行完成後,移除 script 標籤
    window[callback] = data => {
      resolve(data);
      document.body.removeChild(script);
    };
    // 構造 GET 請求引數,key=value&callback=callback
    const formatParams = { ...params, callback };
    const requestParams = Object.keys(formatParams)
      .reduce((acc, cur) => {
        return acc.concat([`${cur}=${formatParams[cur]}`]);
      }, [])
   .join('&');
 // 構造 GET 請求的 url 地址
    const src = `${url}?${requestParams}`;
    script.setAttribute('src', src);
    document.body.appendChild(script);
  });
}

// 呼叫時
jsonp({
  url: 'https://xxx.xxx',
  params: {...},
  callback: 'func',
})

我們用 Promise 封裝了請求,使非同步回撥更加優雅,但是別看樓上的洋洋灑灑寫了一大段,其實本質上就是:

<script src='https://xxx.xxx.xx?key=value&callback=xxx'><script>

想要看例子 ?戳這裡[3]JSONP 的優點是簡單而且相容性很好,但是缺點也很明顯,需要伺服器支援而且只支援 GET 請求,下面我們來看第二種方案,也是目前主流的跨域解決方案,劃重點!?

CORS

CORS[4](Cross-Origin Resource Sharing)的全稱叫 跨域資源共享,名稱好高大上,別怕,這玩意兒其實就是一種機制。瀏覽器不是有同源策略吶,這東西好是好,但是對於開發人員來說就不怎麼友好了,因為我們可能經常需要發起一個 跨域 HTTP 請求。我們之前說過,跨域的請求其實是發出去了的,只不過被瀏覽器給攔截了,因為不安全,說直白點兒就是,你想要從伺服器哪兒拿個東西,但是沒有經過人家允許啊。所以怎麼樣才安全 ?伺服器允許了不就安全了,這就是 CORS 實現的原理:使用額外的 HTTP 頭來告訴瀏覽器,讓執行在某一個 origin 上的 Web 應用允許訪問來自不同源伺服器上的指定的資源

相容性

目前,所有的主流瀏覽器都支援 CORS,其中,IE 瀏覽器的版本不能低於 10,IE 8 和 9 需要通過 XDomainRequest 來實現。完整的相容性情況 ? 戳這裡[5]

實現原理

CORS 需要瀏覽器和伺服器同時支援,整個 CORS 的通訊過程,都是瀏覽器自動完成。怎麼個自動法 ?簡單來說,瀏覽器一旦發現請求是一個跨域請求,首先會判斷請求的型別,如果是簡單請求,會在請求頭中增加一個 Origin 欄位,表示這次請求是來自哪一個。而伺服器接受到請求後,會返回一個響應,響應頭中會包含一個叫 Access-Control-Allow-Origin 的欄位,它的值要麼包含由 Origin 首部欄位所指明的域名,要麼是一個 "*",表示接受任意域名的請求。如果響應頭中沒有這個欄位,就說明當前源不在伺服器的許可範圍內,瀏覽器就會報錯:

GET /cors HTTP/1.1
Origin: https://xxx.xx
Accept-Language: en-US
Connection: keep-alive
... ...

如果是非簡單請求,會在正式通訊之前,傳送一個預檢請求(preflight),目的在於詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些 HTTP 動詞和頭資訊欄位,只有得到肯定答覆,瀏覽器才會發出正式的請求,否則就報錯。你可能發現我們在日常的開發中,會看到很多使用 OPTION 方法發起的請求,它其實就是一個預檢請求:

OPTIONS /cors HTTP/1.1
Origin: http://xxx.xx
Access-Control-Request-Method: PUT
Accept-Language: en-US
... ...

那麼到底哪些是簡單請求,哪些是非簡單請求 ?

請求型別

不會觸發 CORS 預檢的,就是簡單請求。哪些請求不會觸發預檢 ?使用以下方法之一:GET, HEAD, POST,並且 Content-Type 的值僅限於下列三者之一:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

相反,不符合上述條件的就是非簡單請求啦。所以,實現 CORS 的關鍵是伺服器,只要伺服器實現了 CORS 的相關介面,就可以實現跨域。CORS 與 JSONP相比,優勢是支援所有的請求方法,缺點是相容性上較 JSONP 差。除了 JSONP 和 CORS 外,還有一種常用的跨域解決方案:PostMessage,它更多地用於視窗間的訊息傳遞。

PostMessage

PostMessage 是 Html5 XMLHttpRequest Level 2 中的 API,它可以實現跨文件通訊(Cross-document messaging)。相容性上,IE8+,Chrome,Firfox 等主流瀏覽器都支援,可以放心用?,如何理解跨文件通訊?你可以類比設計模式中的釋出-訂閱模式,在這裡,一個視窗傳送訊息,另一個視窗接受訊息,之所以說類似釋出-訂閱模式,而不是觀察者模式,是因為這裡兩個視窗間沒有直接通訊,而是通過瀏覽器這個第三方平臺。

window.postMessage(message, origin, [transfer])

postMessage 方法接收三個引數,要傳送的訊息,接收訊息的源和一個可選的 Transferable 物件,如何接收訊息 ?

window.addEventListener("message", function receiveMessage(event) {}, false); // 推薦,相容性更好

window.onmessage = function receiveMessage(event) {} // 不推薦,這是一個實驗性的功能,相容性不如上面的方法

接收到訊息後,訊息物件 event 中包含了三個屬性:source,origin,data,其中 data 就是我們傳送的 message。此外,除了實現視窗通訊,postMessage 還可以同 Web Worker 和 Service Work 進行通訊,有興趣的可以 戳這裡[6]

Websocket

Websocket 是 HTML5 的一個持久化的協議,它實現了瀏覽器與伺服器的全雙工通訊,同時也是跨域的一種解決方案。什麼是全雙工通訊 ?簡單來說,就是在建立連線之後,server 與 client 都能主動向對方傳送或接收資料。原生的 WebSocket API 使用起來不太方便,我們一般會選擇自己封裝一個 Websocket(嗯,我們團隊也自己封了一個 : ))或者使用已有的第三方庫,我們這裡以第三方庫 ws[7] 為例:

const WebSocket = require('ws');

const ws = new WebSocket('ws://www.host.com/path');

ws.on('open', function open() {
  ws.send('something');
});

ws.on('message', function incoming(data) {
  console.log(data);
});
... ...

需要注意的是,Websocket 屬於長連線,在一個頁面建立多個 Websocket 連線可能會導致效能問題。

Nginx 反向代理

我們知道同源策略限制的是:瀏覽器向伺服器傳送跨域請求需要遵循的標準,那如果是伺服器向伺服器傳送跨域請求呢?答案當然是,不受瀏覽器的同源策略限制。利用這個思路,我們就可以搭建一個代理伺服器,接受客戶端請求,然後將請求轉發給伺服器,拿到響應後,再將響應轉發給客戶端:

 

 

 

 

這就是 Nginx 反向代理的原理,只需要簡單配置就可以實現跨域:

# nginx.config
# ...
server {
  listen       80;
  server_name  www.domain1.com;
  location / {
    proxy_pass   http://www.domain2.com:8080;  #反向代理
    proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie裡域名
    index  index.html index.htm;

    # 當用 webpack-dev-server 等中介軟體代理介面訪問 nignx 時,此時無瀏覽器參與,故沒有同源限制,下面的跨域配置可不啟用
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Credentials true;
    # ...
  }
}

Node 中介軟體代理

實現的原理和我們前文提到的代理伺服器原理如出一轍,只不過這裡使用了 Node 中介軟體做為代理。需要注意的是,瀏覽器向代理伺服器請求時仍然遵循同源策略,別忘了在 Node 層通過 CORS 做跨域處理:

const https = require('https')
// 接受客戶端請求
const sever = https.createServer((req, res) => {
  ...
  const { method, headers } = req
  // 設定 CORS 允許跨域
  res.writeHead(200, {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': '*',
    'Access-Control-Allow-Headers': 'Content-Type',
    ...
  })
  // 請求伺服器
  const proxy = https.request({ host: 'xxx', method, headers, ...}, response => {
    let body = ''
    response.on('data', chunk => { body = body + chunk })
    response.on('end', () => {
      // 響應結果轉發給客戶端
      res.end(body)
    })
  })
  // 結束請求
  proxy.end()
})

document.domain

二級域名相同的情況下,設定 document.domain 就可以實現跨域。**什麼是二級域名 ?**a.test.com 和 b.test.com 就屬於二級域名,它們都是 test.com 的子域。如何實現跨域 ?

document.domain = 'test.com' // 設定 domain 相同

// 通過 iframe 嵌入跨域的頁面
const iframe = document.createElement('iframe')
iframe.setAttribute('src', 'b.test.com/xxx.html')
iframe.onload = function() {
  // 拿到 iframe 例項後就可以直接訪問 iframe 中的資料
  console.log(iframe.contentWindow.xxx)
}
document.appendChild(iframe)

總結

當然,除了上述的方案外,比較 Hack 的還有:window.name, location.hash,但是這些跨域的方式現在我們已經不推薦了,為什麼 ?因為相比之下有更加安全和強大的 PostMessage 作為替代。跨域的方案其實有很多,總結下來:

  • CORS 支援所有的 HTTP 請求,是跨域最主流的方案
  • JSONP 只支援 GET 請求,但是可以相容老式瀏覽器
  • Node 中介軟體和 Nginx 反向代理都是利用了伺服器對伺服器沒有同源策略限制
  • Websocket 也是一種跨域的解決方案
  • PostMessage 可以實現跨文件通訊,更多地用於視窗通訊
  • document.domain, window.name, location.hash 逐漸淡出歷史舞臺,作為替代 PostMessage 是一種不錯的方案

 

 

參考資料
https://juejin.cn/post/6844903992057659400

相關文章