一文讀懂跨域,不在稀裡糊塗

toringo發表於2019-04-27

文章列出解決方案以及對應的demo, 不在稀裡糊塗

什麼情況出現跨域?

  • 協議不同
  • 域名不同
  • 埠不同

跨域解決方案

1.同一個主域下不同子域之間的跨域請求 - document.domain+iframe

同一個 origin 下,父頁面可以通過 iframe.contentWindow 直接訪問 iframe 的全域性變數、DOM 樹等,iframe 可以也通過 parent/top 對父頁面做同樣的事情。

domain.html

<body>
  <iframe id="ifr" src="http://b.tblog.com:3004/domain2.html"></iframe>
  <script>
    document.domain = 'tblog.com';
    function aa(str) {
      console.log(str);
    }

    window.onload = function () {
      document.querySelector('#ifr').contentWindow.bb('aaa');

    }

  </script>
複製程式碼

domain2.html

<body>
  2222222222
  <script>
    document.domain = 'tblog.com';
    function bb(str) {
      console.log(str);
    }

    parent.aa('bbb');
  </script>

</body>
複製程式碼

完整demo

2. 完全不同源 - postMessage

html5新增API, 支援IE8+。

otherWindow.postMessage(message, targetOrigin, [transfer]);
複製程式碼
  • otherWindow 其他視窗的一個引用,比如iframe的contentWindow屬性、執行window.open返回的視窗物件、或者是命名過或數值索引的window.frames。
  • message 將要傳送到其他 window的資料
  • targetOrigin 通過視窗的origin屬性來指定哪些視窗能接收到訊息事件,其值可以是字串""(表示無限制)或者一個URI。如果你明確的知道訊息應該傳送到哪個視窗,那麼請始終提供一個有確切值的targetOrigin,而不是。不提供確切的目標將導致資料洩露到任何對資料感興趣的惡意站點。
  • transfer 可選 是一串和message 同時傳遞的 Transferable 物件. 這些物件的所有權將被轉移給訊息的接收方,而傳送一方將不再保有所有權。

傳遞過來的message的屬性有:

  • data 從其他 window 中傳遞過來的物件。
  • origin 呼叫 postMessage 時訊息傳送方視窗的 origin . 這個字串由 協議、“://“、域名、“ : 埠號”拼接而成
  • source 對傳送訊息的視窗物件的引用; 您可以使用此來在具有不同origin的兩個視窗之間建立雙向通訊

下面index.html和index2.html通訊
index.html

<body>
  <input type="text" placeholder="http://b.tblog.com:3004/index2.html">
  <iframe src="http://192.168.101.5: 3004/index2.html" frameborder="0"></iframe>

  <script>
    const input = document.querySelector('input');

    input.addEventListener('input', function () {
      window.frames[0].postMessage(this.value, '*');
      // window.frames[0].postMessage(this.value, 'http://192.168.101.5');
      // window.frames[0].postMessage(this.value, 'http://192.168.101.5:3004');

    });
    // 接收訊息
    window.addEventListener('message', function (e) {
      input.value = e.data;
      console.log('父視窗', e.data);
      console.log('父視窗', e.source);
      console.log('父視窗', e.origin);
    });
  </script>
</body>
複製程式碼

index2.html

<body>
  子視窗
  <input id="input" type="text" placeholder="http://a.tblog.com:3004/index.html">

  <script>
    const input = document.querySelector('#input');

    input.addEventListener('input', function () {
      window.parent.postMessage(this.value, '*');
    });

    // 接收訊息
    window.addEventListener('message', function (e) {
      input.value = e.data;
      console.log('子視窗', e.data);
      console.log('子視窗', e.source);
      console.log('子視窗', e.origin);
    });

  </script>
</body>
複製程式碼

完整demo

3. 完全不同源 - location.hash+iframe

原理是利用location.hash來進行傳值。改變hash並不會導致頁面重新整理,所以可以利用hash值來進行資料傳遞,當然資料容量是有限的。 例如:假設a.tblog.com:3004 和 192.168.101.5:3004/index2.html通訊
原理:a.tblog.com:3004中index.html以iframe將192.168.101.5:3004/index2.html頁面引入,在192.168.101.5:3004/index2.html中插入新的iframe, 此iframe引入的頁面和a.tblog.com:3004同源,就可將192.168.101.5:3004/index2.html的hash資料傳入a.tblog.com:3004頁面的hash值中。parent.parent.location.hash = self.location.hash.substring(1);
a.tblog.com:3004/index.html

 <script>
    var ifr = document.createElement('iframe');
    ifr.style.display = 'none';
    ifr.src = 'http://192.168.101.5:3004/ index2.html#paramdo';
    document.body.appendChild(ifr);
    function checkHash() {
      try {
        var data = location.hash ? location.hash.substring(1) : '';
        if (console.log) {
          console.log('Now the data is ' + data);
        }
      } catch (e) { };
    }
    setInterval(checkHash, 2000); 
  </script>
複製程式碼

192.168.101.5:3004/ index2.html

<body>
  <script>
    //模擬一個簡單的引數處理操作 
    switch (location.hash) {
      case '#paramdo':
        callBack();
        break;
      case '#paramset':
        //do something…… 
        break;
    }

    function callBack() {
      try {
        parent.location.hash = 'somedata';
      } catch (e) {
        var ifrproxy = document.createElement('iframe');
        ifrproxy.style.display = 'none';
        ifrproxy.src = 'http://a.tblog.com:3004/index3.html#somedata'; // 注意該檔案在"a.com"域下 
        document.body.appendChild(ifrproxy);
      }
    } 
  </script>
</body>
複製程式碼

a.tblog.com:3004/index3.html

<body>
  <script>
    //因為parent.parent和自身屬於同一個域,所以可以改變其location.hash的值 
    parent.parent.location.hash = self.location.hash.substring(1); 
  </script>
</body>
複製程式碼

完整demo

4. window.name + iframe 跨域

window.name 獲取/設定視窗的名稱。 視窗的名字主要用於為超連結和表單設定目標(targets)。視窗不需要有名稱。 window.name屬性可設定或者返回存放視窗名稱的一個字串, name值在不同頁面或者不同域下載入後依舊存在,沒有修改就不會發生變化,並且可以儲存非常長的name(2MB)。
場景1 - 同源
a.html

 <body>
    <script type="text/javascript">
      const iframe = document.createElement('iframe');
      iframe.src = 'http://a.tblog.com:3004/b.html';
      iframe.style.display = 'none';
      document.body.appendChild(iframe);

      iframe.onload = function () {
        console.log(iframe.contentWindow.name)
      };
    </script>
  </body>
複製程式碼

b.html

<body>
  <script>
    window.name = '子頁面的資料';
  </script>
</body>
複製程式碼

場景2 - 不同源
利用iframe中window.name在不同頁面或者不同域下載入後依舊存在的特性。 a.tblog.com:3004/a.html中通過iframe新增192.168.0.103:3004/b.html(資料頁面, 指定window.name 的值),監聽iframe的load, 改變iframe的src與a.tblog.com:3004/a.html同源代理頁面a.tblog.com:3004/c.html(空頁面)。
a.tblog.com:3004/a.html

const iframe = document.createElement('iframe');
iframe.style.display = 'none';
let state = 0;

iframe.onload = function () {
  console.log('iframe.onload', state, iframe.contentWindow);
  if (state === 1) {
    const data = JSON.parse(iframe.contentWindow.name);
    console.log(data, state);
    iframe.contentWindow.document.write('');
    iframe.contentWindow.close();
    document.body.removeChild(iframe);
  } else if (state === 0) {
    state = 1;
    console.log('資料', window.name)
    iframe.contentWindow.location = 'http://a.tblog.com:3004/c.html';
  }
};

iframe.src = 'http://192.168.0.103:3004/b.html';
document.body.appendChild(iframe);
複製程式碼

完整demo

5. 跨域jsonp

jsonp原理:

  1. 首先是利用script標籤的src屬性來實現跨域。
  2. 客戶端註冊callback方法名,攜帶在URL上, 如'http://127.0.0.1:8080/getNews?callback=getData'
  3. 伺服器響應後生成json, 將json放在剛才接收到的callback的函式中,就生成一段getData(json)
  4. 客戶端瀏覽器將script 標籤插入 DOM,解析script標籤後,會執行getData(json)。 由於使用script標籤的src屬性,因此只支援get方法 客戶端程式碼
<body>
  <button class="get">get data</button>

  <script>
    const btn = document.querySelector('.get');

    btn.addEventListener('click', function () {
      const script = document.createElement('script');
      script.setAttribute('src', 'http://127.0.0.1:8080/getNews?callback=getData');
      document.head.appendChild(script);
      document.head.removeChild(script);
    })
    function getData(news) {
      console.log(news)
    }
  </script>
</body>
複製程式碼

服務端程式碼

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');

http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);
  switch(pathObj.pathname){        
    case '/getNews':            
      const news = [{id: 678}];
      res.setHeader('Content-type', 'text/json; charset=utf-8');           
      if(pathObj.query.callback){
          res.end(pathObj.query.callback + '(' + JSON.stringify(news) + ')');
      }else {
          res.end(JSON.stringify(news));
      }            
    break; 
    default:
      res.writeHead(404, 'not found');
    }
}).listen(8080);
複製程式碼

完整demo

6. CORS跨域

原理
跨域資源共享(CORS) 是一種機制,它使用額外的 HTTP 頭來告訴瀏覽器 讓執行在一個 origin (domain) 上的Web應用被准許訪問來自不同源伺服器上的指定的資源。跨域資源共享( CORS )機制允許 Web 應用伺服器進行跨域訪問控制,從而使跨域資料傳輸得以安全進行。
什麼情況下需要CORS

  • 前文提到的由 XMLHttpRequest 或 Fetch 發起的跨域 HTTP 請求。
  • Web 字型 (CSS 中通過 @font-face 使用跨域字型資源), 因此,網站就可以釋出 TrueType 字型資源,並只允許已授權網站進行跨站呼叫。
  • WebGL 貼圖
  • 使用 drawImage 將 Images/video 畫面繪製到 canvas
  • 樣式表(使用 CSSOM)

功能概述
跨域資源共享標準新增了一組 HTTP 首部欄位,允許伺服器宣告哪些源站通過瀏覽器有許可權訪問哪些資源。允許伺服器宣告哪些源站通過瀏覽器有許可權訪問哪些資源。對於get以外的請求,瀏覽器必須首先使用 OPTIONS 方法發起一個預檢請求(preflight request),從而獲知服務端是否允許該跨域請求。伺服器確認允許之後,才發起實際的 HTTP 請求。 真個過程瀏覽器自動完成,伺服器會新增一些附加的頭資訊, 因此,實現CORS通訊的關鍵是伺服器。只要伺服器實現了CORS介面,就可以跨源通訊。
簡單請求
某些請求不會觸發 CORS 預檢請求。本文稱這樣的請求為“簡單請求”,請注意,該術語並不屬於 Fetch (其中定義了 CORS)規範。只要同時滿足以下兩大條件,就屬於簡單請求:

(1) 請求方法是以下三種方法之一:
  HEAD
  GET
  POST
  
(2)HTTP的頭資訊不超出以下幾種欄位:
  Accept
  Accept-Language
  Content-Language
  Last-Event-ID
  Content-Type:只限於三個值application/x-www-form-urlencoded、multipart/form-data、text/plain
複製程式碼

請求響應結果多出的欄位:

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
複製程式碼
  • Access-Control-Allow-Origin
    Access-Control-Allow-Origin: <origin> | *; 該欄位是必須的。它的值要麼是請求時Origin欄位的值,要麼是一個*,表示接受任意域名的請求, 有次響應頭欄位就可以跨域
  • Access-Control-Allow-Credentials
    Access-Control-Allow-Credentials: true; 當瀏覽器的credentials設定為true時, 此響應頭表示是否允許瀏覽器讀取response的內容,返回true則可以,其他值均不可以,Credentials可以是 cookies, authorization headers 或 TLS client certificates。
    Access-Control-Allow-Credentials 頭 工作中與XMLHttpRequest.withCredentials 或Fetch API中的Request() 構造器中的credentials 選項結合使用。Credentials必須在前後端都被配置(即the Access-Control-Allow-Credentials header 和 XHR 或Fetch request中都要配置)才能使帶credentials的CORS請求成功。 如果withCredentials 為false,伺服器同意傳送Cookie,瀏覽器也不會傳送,或者,伺服器要求設定Cookie,瀏覽器也不會處理。
    需要注意的是,如果要傳送Cookie,Access-Control-Allow-Origin就不能設為星號,必須指定明確的、與請求網頁一致的域名。同時,Cookie依然遵循同源政策,只有用伺服器域名設定的Cookie才會上傳,其他域名的Cookie並不會上傳,且(跨源)原網頁程式碼中的document.cookie也無法讀取伺服器域名下的Cookie。
// 允許credentials:  
Access-Control-Allow-Credentials: true

// 使用帶credentials的 XHR :
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true); 
xhr.withCredentials = true; 
xhr.send(null);

// 使用帶credentials的 Fetch :
fetch(url, {
  credentials: 'include'  
})
複製程式碼
  • Access-Control-Expose-Headers
    在跨域訪問時,XMLHttpRequest物件的getResponseHeader()方法只能拿到一些最基本的響應頭,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma, 如果想拿到其他欄位,就必須在Access-Control-Expose-Headers裡面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar欄位的值。

程式碼如下:

<!-- 服務端 -->
http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);
  switch(pathObj.pathname){        
    case '/user':            
      const news = [{id: 678}];
      res.setHeader('Content-type', 'text/json; charset=utf-8');    
      res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
      // res.setHeader('Access-Control-Allow-Origin', '*');

      // 需要cookie等憑證是必須
      res.setHeader('Access-Control-Allow-Credentials', true);

      res.end(JSON.stringify(news));           
    break; 
    default:
      res.writeHead(404, 'not found');
    }
}).listen(8080, (err) => {
  if (!err) {
    console.log('8080已啟動');
  }
});
<!-- 客戶端 -->
<body>
  <script>
    const xhr = new XMLHttpRequest();

    xhr.open('GET', 'http://localhost:8080/user', true);

    // 需要cookie等憑證是必須
    xhr.withCredentials = true;

    xhr.onreadystatechange = (e) => {
      console.log('onreadystatechange', e)
    }

    xhr.send();

  </script>
</body>
複製程式碼

完整demo

非簡單請求
非簡單請求的CORS請求,會在正式通訊之前,增加一次HTTP查詢請求,稱為"預檢"請求(preflight)。 以獲知伺服器是否允許該實際請求。"預檢請求“的使用,可以避免跨域請求對伺服器的使用者資料產生未預期的影響。
當請求滿足下述任一條件時,即應首先傳送預檢請求:

  • 使用了下面任一 HTTP 方法:
    • put
    • delete
    • connect
    • OPTIONS
    • trace
    • patch
  • 人為設定了對cors安全首部欄位集合外的其他首部欄位, 該集合為:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
    • DPR
    • Downlink
    • Save-data
    • Viewport-Width
    • Width
  • Content-Type的值不屬於下列之一:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 請求中的XMLHttpRequestUpload 物件註冊了任意多個事件監聽器。
  • 請求中使用了ReadableStream物件。

如下是一個需要執行預檢請求的 HTTP 請求:

<body>
  <script>
    const invocation = new XMLHttpRequest();
    const url = 'http://localhost:8080/user';
    const body = JSON.stringify({ name: 'toringo' });

    function callOtherDomain() {
      if (invocation) {
        invocation.open('POST', url, true);
        invocation.setRequestHeader('X-PINGOTHER', 'pingpong');
        invocation.setRequestHeader('Content-Type', 'application/json');
        invocation.onreadystatechange = (e) => {
          console.log('onreadystatechange', e)
        };
        invocation.send(body);
      }
    }
    callOtherDomain();
  </script>
</body>

<!-- 服務端 -->
http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);
  switch(pathObj.pathname){        
    case '/user':            
      const news = {id: 678};
      res.setHeader('Content-type', 'text/json; charset=utf-8');    
      res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
      // res.setHeader('Access-Control-Allow-Origin', '*');

      // 需要cookie等憑證是必須
      res.setHeader('Access-Control-Allow-Credentials', true);

      res.end(JSON.stringify(news));           
    break; 
    default:
      res.writeHead(404, 'not found');
    }
}).listen(8080, (err) => {
  if (!err) {
    console.log('8080已啟動');
  }
});
複製程式碼

瀏覽器請求結果

cors2.html:1 Access to XMLHttpRequest at 'http://localhost:8080/user' from origin 'http://127.0.0.1:3004' has been blocked by CORS policy: Request header field x-pingother is not allowed by Access-Control-Allow-Headers in preflight response.
複製程式碼

一文讀懂跨域,不在稀裡糊塗
如圖所示發起了預檢請求,請求頭部多了兩個欄位:

Access-Control-Request-Method: POST;  // 該欄位是必須的,用來列出瀏覽器的CORS請求會用到哪些HTTP方法.
Access-Control-Request-Headers: Content-Type, X-PINGOTHER;  告知伺服器,實際請求將攜帶兩個自定義請求首部欄位:X-PINGOTHER 與 Content-Type。伺服器據此決定,該實際請求是否被允許。
複製程式碼

上例需要成功響應資料,服務端需要同意

http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);
  switch(pathObj.pathname){        
    case '/user':            
      const news = {id: 678};
      res.setHeader('Content-type', 'text/json; charset=utf-8');    
      res.setHeader('Access-Control-Allow-Origin', req.headers.origin);

      // 新增的
      res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
      res.setHeader('Access-Control-Allow-Headers', 'X-PINGOTHER, Content-Type');
      res.setHeader('Access-Control-Max-Age', 86400);

      res.end(JSON.stringify(news));           
    break; 
    default:
      res.writeHead(404, 'not found');
    }
}).listen(8080, (err) => {
  if (!err) {
    console.log('8080已啟動');
  }
});
複製程式碼

服務段新增的欄位:

Access-Control-Allow-Origin: req.headers.origin  
Access-Control-Allow-Methods: POST, GET, OPTIONS  // 表明伺服器允許客戶端使用 POST, GET 和 OPTIONS 方法發起請求。該欄位與 HTTP/1.1 Allow: response header 類似,但僅限於在需要訪問控制的場景中使用。這是為了避免多次"預檢"請求。
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type  // 如果瀏覽器請求包括Access-Control-Request-Headers欄位,則Access-Control-Allow-Headers欄位是必需的。它也是一個逗號分隔的字串,表明伺服器支援的所有頭資訊欄位,不限於瀏覽器在"預檢"中請求的欄位。
Access-Control-Max-Age: 86400  // 表明該響應的有效時間為 86400 秒,也就是 24 小時。在有效時間內,瀏覽器無須為同一請求再次發起預檢請求。請注意,瀏覽器自身維護了一個最大有效時間,如果該首部欄位的值超過了最大有效時間,將不會生效。
複製程式碼

7. nodejs代理跨域

node中介軟體實現跨域代理,是通過一個代理伺服器,實現資料的轉發,也可以通過設定cookieDomainRewrite引數修改響應頭中cookie中域名,實現當前域的cookie寫入,方便介面登陸認證。
原理:伺服器之間資料請求不存在跨域限制(同源策略是瀏覽器行為), 所以先將請求代理到大麗伺服器, 代理伺服器在內部請求真實的伺服器得到結果後end連線。

<!-- 服務 -->
http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);
  console.log('server', pathObj.pathname)  

  switch(pathObj.pathname){        
    case '/user':            
      const news = {id: 678};
      res.end(JSON.stringify(news));           
      break; 
    default:
      res.setHeader('Content-type', 'text/json; charset=utf-8');  
      res.end('未知錯誤');   
    }
}).listen(4000, (err) => {
  if (!err) {
    console.log('4000已啟動');
  }
});
<!-- 代理 -->
http.createServer(function(req, res){
  const pathObj = url.parse(req.url, true);

  switch(pathObj.pathname){        
    case '/user':            
      res.setHeader('Content-type', 'text/json; charset=utf-8');    
      res.writeHead(200, {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
        'Access-Control-Allow-Headers': 'X-PINGOTHER, Content-Type',
      });
      console.log('proxy', req.method, pathObj.pathname);
       // 請求真實伺服器
      const proxyRequest = http.request({
          host: '127.0.0.1',
          port: 4000,
          url: '/',
          path: pathObj.pathname,
          method: req.method,
          headers: req.headers
      }, (proxyRes) => {
          let body = '';
          proxyRes.on('data', (chunk) => {
            body += chunk;
          });
          proxyRes.on('end', () => {
              console.log('響應的資料 ' + body );
              res.end(body);
          })

      }).end();
         
      break; 
    default:
      res.writeHead(404, 'not found');
      res.end(body);
      break; 
    }
}).listen(8080, (err) => {
  if (!err) {
    console.log('8080已啟動');
  }
});
<!-- 客戶端 index.html -->
<body>
  <script>
    const invocation = new XMLHttpRequest();
    const url = 'http://localhost:8080/user';
    const body = JSON.stringify({ name: 'toringo' });

    function callOtherDomain() {
      if (invocation) {
        invocation.open('POST', url, true);
        // invocation.setRequestHeader('X-PINGOTHER', 'pingpong');
        invocation.setRequestHeader('Content-Type', 'application/json');
        invocation.onreadystatechange = (e) => {
          console.log('onreadystatechange', e)
        };
        invocation.send(body);
      }
    }
    callOtherDomain();
  </script>
</body>
複製程式碼

注意: 伺服器和瀏覽器資料互動也需要遵循同源策略

-- 持續更新 --

Tips:
部落格程式碼地址。~ github

WeChat

一文讀懂跨域,不在稀裡糊塗

參考文章
developer.mozilla.org/zh-CN/docs/…
vinc.top/2017/02/09/…
www.ruanyifeng.com/blog/2016/0…
segmentfault.com/a/119000000…
developer.mozilla.org/zh-CN/docs/…
developer.mozilla.org/zh-CN/docs/…
www.ruanyifeng.com/blog/2016/0…

相關文章