解決跨域問題的幾種方案

前言

JSONP 請求本質上是利用了 「Ajax 請求會受到同源策略限制,而 script 標籤請求不會」 這一點來繞過同源策略。javascript

跨域資源共享(CORS) 是一種機制,它使用額外的 HTTP 頭來告訴瀏覽器 讓運行在一個 origin (domain) 上的 Web 應用被准許訪問來自不一樣源服務器上的指定的資源。 --- MDNhtml

跨域的解決方案

  • jsonp:只支持 GET,不支持 POST 請求,不安全 XSS
  • cors:須要後臺配合進行相關的設置
  • postMessage:配合使用 iframe,須要兼容 IE六、七、八、9
  • document.domain:僅限於同一域名下的子域
  • websocket:須要後臺配合修改協議,不兼容,須要使用 socket.io
  • proxy:使用代理去避開跨域請求,須要修改 nginx、apache 等的配置

同源策略

什麼是同源策略,其做用是什麼?前端

同源策略指的是:協議+域名+端口三者皆相同,能夠視爲在同一個域,不然爲不一樣域。同源策略限制了從同一個源加載的文檔或腳本如何與來自另外一個源的資源進行交互。java

做用是一個用於隔離潛在惡意文件的重要安全機制。nginx

所限制的跨域交互包括:git

  • Cookie、LocalStorage、IndexdDB 等存儲內容;
  • DOM 節點;
  • Ajax 請求。

Ajax 爲何不能跨域

Ajax 其實就是向服務器發送一個 GET 或 POST 請求,而後取得服務器響應結果,返回客戶端。Ajax 跨域請求,在服務器端不會有任何問題,只是服務端響應數據返回給瀏覽器的時候,瀏覽器根據響應頭的Access-Control-Allow-Origin字段的值來判斷是否有權限獲取數據。github

所以,服務端若是沒有設置跨域字段設置,跨域是沒有權限訪問,數據被瀏覽器給攔截了。web

因此,要解決的問題是:如何從客戶端拿到返回的數據ajax

其實,在同源策略的基礎上,選擇性地爲同源策略開放了一些後門。例如 img、script、style 等標籤,都容許跨域引用資源。apache

因此, JSONP 來了。

JSONP 實現

JSONP(JSON with Padding(填充))是 JSON 的一種「使用模式」,本質不是 Ajax 請求,是 script 標籤請求。

JSONP 請求本質上是利用了 「Ajax 請求會受到同源策略限制,而 script 標籤請求不會」 這一點來繞過同源策略。

簡單 JSONP 實現:

class Jsonp {
  constructor(req) {
    this.url = req.url;
    this.callbackName = req.callbackName;
  }
  create() {
    const script = document.createElement("script");
    const url = `${this.url}?callback=${this.callbackName}`;
    script.src = url;
    document.getElementsByTagName("head")[0].appendChild(script);
  }
}

new Jsonp({
  url: "http://127.0.0.1:8000/",
  callbackName: "getMsg"
}).create();

function getMsg(data) {
  data = JSON.parse(data);
  console.log(`My name is ${data.name}, and ${data.age} years old.`);
}
複製代碼

服務端(Node):

const http = require("http");
const querystring = require("querystring");

const server = http.createServer((req, res) => {
  const url = req.url;
  const query = querystring.parse(url.split("?")[1]);
  const { callback } = query;
  const data = {
    name: "Yang Min",
    age: "8"
  };
  res.end(`${callback}('${JSON.stringify(data)}')`);
});

server.listen(8000);
複製代碼

前端利用 http-server -p 8001 .,開啓一個服務,而後 Node 也開啓一個端口爲 8000 的服務,運行:

My name is Yang Min, and 8 years old.
複製代碼

一個 JSONP 的步驟實質

客戶端發送 script 請求,參數中帶着處理返回數據的回調函數的名字 (一般是 callback),如請求 script 的 url 是:

http://127.0.0.1:8000/?callback=getMsg
複製代碼

服務端收到請求,以回調函數名和返回數據組成當即執行函數的字符串,好比:其中 callback 的值是客戶端發來的回調函數的名字,假設回調函數的名字是 getMsg,返回腳本的內容就是:

getMsg("{name: 'Yang Min', age: '8'}");
複製代碼

客戶端收到 JavaScript 腳本內容後,當即執行腳本,這樣就實現了獲取跨域服務器數據的目的。

很明顯,因爲 JSONP 技術本質上利用了 script 腳本請求,因此只能實現 GET 跨域請求,這也是 JSONP 跨域的最大限制。

因爲 server 產生的響應爲 json 數據的包裝(故稱之爲 jsonp,即 json padding),形如:getMsg("{name: 'Yang Min', age: '8'}")

JSONP 封裝

客戶端:

const jsonp = ({ url, params, callbackName }) => {
  const generateURL = () => {
    let dataStr = "";
    for (let key in params) {
      dataStr += `${key}=${params[key]}&`;
    }
    dataStr += `callback=${callbackName}`;
    return `${url}?${dataStr}`;
  };
  return new Promise((resolve, reject) => {
    // 初始化回調函數名稱
    callbackName =
      callbackName ||
      "cb" +
        Math.random()
          .toString()
          .replace(".", "");
    let scriptEle = document.createElement("script");
    scriptEle.src = generateURL();
    document.body.appendChild(scriptEle);

    // 綁定到 window 上,爲了後面調用
    window[callbackName] = data => {
      resolve(data);
      // script 執行完了,成爲無用元素,須要清除
      document.body.removeChild(scriptEle);
    };
  });
};

jsonp({
  url: "http://127.0.0.1:8000/",
  params: {
    name: "Yang Min",
    age: "8"
  },
  callbackName: "getData"
})
  .then(data => JSON.parse(data))
  .then(data => {
    console.log(data); // {name: "Yang Min", age: "8"}
  });
複製代碼

Node 端:

const http = require("http");
const querystring = require("querystring");

const server = http.createServer((req, res) => {
  const url = req.url;
  const query = querystring.parse(url.split("?")[1]);
  const { name, age, callback } = query;
  const data = {
    name,
    age
  }
  res.end(`${callback}('${JSON.stringify(data)}')`);
});

server.listen(8000);
複製代碼

jQuery 中的 JSONP

Node 部分不變,使用 jQuery(3.4.1) 以下:

function getAjaxData() {
  $.ajax({
    type: "get",
    async: false,
    url: "http://127.0.0.1:8000/",
    dataType: "jsonp", //由 JSON 改成 JSONP
    jsonp: "callback", //傳遞給請求處理程序或頁面的,標識jsonp回調函數名(通常爲:callback)
    jsonpCallback: "getData", //callback的function名稱,成功就會直接走 success 方法
    success: function(data) {
      data = JSON.parse(data);
      console.log(`My name is ${data.name}, and ${data.age} years old.`);
    },
    error: function() {
      console.log("Error");
    }
  });
}
getAjaxData();
複製代碼

使用延遲對象從新寫下:

function getAjaxData() {
  const def = $.ajax({
    type: "get",
    async: false,
    url: "http://127.0.0.1:8000/",
    dataType: "jsonp",
    jsonp: "callback",
    jsonpCallback: "getData"
  });

  def
    .done(data => {
      data = JSON.parse(data);
      console.log(`My name is ${data.name}, and ${data.age} years old.`);
    })
    .fail(err => {
      console.log(err);
    });
}
複製代碼

JSONP 缺點

  • 只支持 GET 請求
  • 只支持跨域 HTTP 請求這種狀況,不能解決不一樣域的兩個頁面之間如何進行 JavaScript 調用的問題
  • 調用失敗的時候不會返回各類 HTTP 狀態碼。
  • 安全性,萬一假如提供 JSONP 的服務存在頁面注入漏洞,即它返回的 javascript 的內容被人控制的。

跨域資源共享 CORS

跨域資源共享(CORS) 是一種機制,它使用額外的 HTTP 頭來告訴瀏覽器 讓運行在一個 origin (domain) 上的 Web 應用被准許訪問來自不一樣源服務器上的指定的資源。 --- MDN

容許在下列場景中使用跨域 HTTP 請求:

  • XMLHttpRequestFetch 發起的跨域 HTTP 請求
  • Web 字體 (CSS 中經過 @font-face 使用跨域字體資源)
  • WebGL 貼圖
  • 使用 drawImage 將 Images/video 畫面繪製到 canvas

簡單請求、非簡單請求

瀏覽器將 CORS 請求分紅兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。

只要同時知足如下兩大條件,就屬於簡單請求(不會觸發 CORS 預檢請求)。

  • 請求方法是如下三種方法之一:HEADGETPOST

  • HTTP 的頭信息不超出如下幾種字段:

    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type(只限於三個值)
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

凡是不一樣時知足上面兩個條件,就屬於非簡單請求。

CORS 如何工做

首先,瀏覽器判斷請求是簡單請求仍是複雜請求(非簡單請求)。

若是是複雜請求,那麼在進行真正的請求以前,瀏覽器會先使用 OPTIONS 方法發送一個預檢請求 (preflight request),OPTIONS 是 HTTP/1.1 協議中定義的方法,用以從服務器獲取更多信息。

該方法不會對服務器資源產生影響,預檢請求中同時攜帶了下面兩個首部字段:

  • Access-Control-Request-Method: 這個字段代表了請求的方法;
  • Access-Control-Request-Headers: 這個字段代表了這個請求的 Headers;
  • Origin: 這個字段代表了請求發出的域。

服務端收到請求後,會以 Access-Control-* response headers 的形式對客戶端進行回覆:

  • Access-Control-Allow-Origin: 可以被容許發出這個請求的域名,也可使用*來代表容許全部域名;
  • Access-Control-Allow-Methods: 用逗號分隔的被容許的請求方法的列表;
  • Access-Control-Allow-Headers: 用逗號分隔的被容許的請求頭部字段的列表;
  • Access-Control-Max-Age: 這個預檢請求能被緩存的最長時間,在緩存時間內,同一個請求不會再次發出預檢請求。

簡單請求

對於簡單請求,瀏覽器直接發出 CORS 請求。具體來講,就是在頭信息之中,自動增長一個 Origin 字段,用來講明請求來自哪一個源。服務器拿到請求以後,在迴應時對應地添加Access-Control-Allow-Origin字段,若是 Origin 不在這個字段的範圍中,那麼瀏覽器就會將響應攔截。

Access-Control-Allow-Credentials。這個字段是一個布爾值,表示是否容許發送 Cookie,對於跨域請求,瀏覽器對這個字段默認值設爲 false,而若是須要拿到瀏覽器的 Cookie,須要添加這個響應頭並設爲 true, 而且在前端也須要設置withCredentials屬性:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
複製代碼

Access-Control-Expose-Headers。這個字段是給 XMLHttpRequest 對象賦能,讓它不只能夠拿到基本的 6 個響應頭字段(包括Cache-Control、Content-Language、Content-Type、Expires、Last-Modified和Pragma), 還能拿到這個字段聲明的響應頭字段。好比這樣設置:

Access-Control-Expose-Headers: aaa
複製代碼

那麼在前端能夠經過 XMLHttpRequest.getResponseHeader('aaa') 拿到 aaa 這個字段的值。

舉個栗子

好比下面開啓一個端口爲 8001 的服務,去請求端口爲 8000 的數據:

const url = "http://127.0.0.1:8000";
const data = { username: "example" };
const myHeaders = new Headers({
  "Content-Type": "text/plain"
});

fetch(url, {
  method: "POST",
  headers: myHeaders,
  body: JSON.stringify(data),
  mode: "cors"
})
  .then(res => res.json())
  .then(res => {
    console.log(JSON.parse(res.postData)); //{username: "example"}
  });
複製代碼

端口爲 8000 的服務端設置:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/plain",
    "Access-Control-Allow-Origin": "*"
  });
  let resData = {};
  let postData = [];
  req.on("data", chunk => {
    postData.push(chunk);
  });

  req.on("end", () => {
    resData.postData = Buffer.concat(postData).toString();
    res.end(JSON.stringify(resData));
  });
});

server.listen(8000);
複製代碼

非簡單請求

非簡單請求相對而言會有些不一樣,體如今兩個方面: 預檢請求響應字段

預檢請求

好比使用 PUT 請求方法:

const url = "http://127.0.0.1:8000";
const data = { username: "example" };

const myHeaders = new Headers({
  "X-Custom-Header": "xxx"
});
fetch(url, {
  method: "PUT", // 改爲 PUT
  headers: myHeaders,
  body: JSON.stringify(data),
  mode: "cors"
})
  .then(res => res.json())
  .then(res => {
    console.log(JSON.parse(res.postData)); //{username: "example"}
  });
複製代碼

Node 部分:

res.writeHead(200, {
  "Content-Type": "text/json",
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "PUT, POST, GET",
  "Access-Control-Allow-Headers": "X-Custom-Header",
  "Access-Control-Max-Age": 2000,
  "Access-Control-Allow-Credentials": true
});
複製代碼

當這段代碼執行後,首先會發送預檢請求。這個預檢請求的請求行和請求體是下面這個格式:

OPTIONS / HTTP/1.1
Host: 127.0.0.1:8000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: x-custom-header
Origin: http://127.0.0.1:8001
複製代碼

預檢請求的方法是OPTIONS,同時會加上 Origin 源地址和 Host 目標地址,這很簡單。同時也會加上兩個關鍵的字段:

  • Access-Control-Request-Method, 列出 CORS 請求用到哪一個 HTTP 方法
  • Access-Control-Request-Headers,指定 CORS 請求將要加上什麼請求頭

這是預檢請求。接下來是響應字段

響應字段也分爲兩部分,一部分是對於預檢請求的響應,一部分是對於CORS 請求的響應

預檢請求的響應

HTTP/1.1 200 OK
Content-Type: text/json
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 2000
Access-Control-Allow-Credentials: true
Date: Fri, 27 Mar 2020 08:16:58 GMT
Connection: keep-alive
Transfer-Encoding: chunked
複製代碼

在預檢請求的響應返回後,若是請求不知足響應頭的條件,則觸發XMLHttpRequestonerror方法,固然後面真正的 CORS 請求也不會發出去了。

CORS 請求的響應:如今它和簡單請求的狀況是同樣的。瀏覽器自動加上 Origin 字段,服務端響應頭返回 Access-Control-Allow-Origin。在設置的Access-Control-Max-Age: 2000裏是不會再次發送預檢請求的,除非時間過時。

Nginx Proxy

Nginx 是一種高性能的反向代理服務器,能夠用來輕鬆解決跨域問題。

反向代理拿到客戶端的請求,將請求轉發給其餘的服務器,主要的場景是維持服務器集羣的負載均衡,換句話說,反向代理幫其它的服務器拿到請求,而後選擇一個合適的服務器,將請求轉交給它。

server {
  listen  80;
  server_name  client.com;
  location /api {
    proxy_pass server.com;
  }
}
複製代碼

Nginx 至關於起了一個跳板機,這個跳板機的域名也是client.com,讓客戶端首先訪問 client.com/api,這固然沒有跨域,而後 Nginx 服務器做爲反向代理,將請求轉發給server.com,當響應返回時又將響應給到客戶端,這就完成整個跨域請求的過程。

websocket

客戶端發送信息給服務端,若是想實現客戶端向客戶端通訊,只能經過 Client A -> Server -> Client B。關於 websocket,能夠學習阮一峯老師的這篇WebSocket 教程

WebSocket 最大特色就是,服務器能夠主動向客戶端推送信息,客戶端也能夠主動向服務器發送信息,是真正的雙向平等對話,屬於服務器推送技術的一種。

特色:

  • 創建在 TCP 協議之上,服務器端的實現比較容易。
  • 與 HTTP 協議有着良好的兼容性。默認端口也是 80 和 443,而且握手階段採用 HTTP 協議,所以握手時不容易屏蔽,能經過各類 HTTP 代理服務器。
  • 數據格式比較輕量,性能開銷小,通訊高效。
  • 能夠發送文本,也能夠發送二進制數據。
  • 沒有同源限制,客戶端能夠與任意服務器通訊。
  • 協議標識符是 ws(若是加密,則爲 wss),服務器網址就是 URL。

使用:

客戶端咱們使用http-server -p 8001 ./ 開啓一個服務訪問前端內容:

const socket = new WebSocket("ws://localhost:8080");

socket.addEventListener("open", function(event) {
  console.log("Connection open ...");
  socket.send("Hello Server!");
});

socket.addEventListener("message", function(event) {
  console.log("Message from server: ", event.data);
  socket.close();
});

socket.addEventListener("close", function(event) {
  console.log("Connection closed.");
});
複製代碼

服務端使用 Node 開啓一個 websocket 服務:

// 服務端
const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 8080 });

wss.on("connection", function connection(ws) {
  ws.on("message", function incoming(message) {
    console.log("received: %s", message);
  });

  ws.send("something");
});
複製代碼

客戶端輸出:

Connection open ...
Message from server:  something
Connection closed.
複製代碼

服務端輸出:

received: Hello Server!
複製代碼

document.domain

經常使用於處理 iframe 下跨域請求 DOM 資源(如提交表單等),該方式只能用於二級域名相同的狀況下,好比 a.test.comb.test.com 適用於該方式。

只須要給頁面添加 document.domain = 'test.com' 表示二級域名都相同就能夠實現跨域。

以下:訪問http://test.com:8001/a.html,若是不設置 document.domain = "test.com";,去訪問 http://www.test.com:8001/b.html DOM 資源,就會被阻斷。

注:可添加 host:127.0.0.1 test.com,方便測試。

a.html:

<body>
  <h1>Hi, this is A html.</h1>
  <iframe id="frame" src="http://www.test.com:8001/b.html" frameborder="0" onload="load()" ></iframe>
  <script> document.domain = "test.com"; //設置domain function load() { let frame = document.getElementById("frame"); console.log(frame.contentWindow.data); // This is b html content. } </script>
</body>
複製代碼

b.html:

<body>
  <h1>Hi, this is B html.</h1>
  <script> document.domain = "test.com"; //設置domain var data = "This is b html content."; </script>
</body>
複製代碼

postMessage

這種方式一般用於獲取嵌入頁面中的第三方頁面數據。一個頁面發送消息,另外一個頁面判斷來源並接收消息

// 發送消息端
window.parent.postMessage("message", "http://test.com");
// 接收消息端
var mc = new MessageChannel();
mc.addEventListener("message", event => {
  var origin = event.origin || event.originalEvent.origin;
  if (origin === "http://test.com") {
    console.log("驗證經過");
  }
});
複製代碼

舉個栗子: 發送方 a.html,端口號爲 8000:

<body>
  <h1>Hi, this is A html.</h1>
  <iframe id="frame" src="http://127.0.0.1:8001/b.html" frameborder="0" onload="load()" ></iframe>
  <script> function load() { let frame = document.getElementById("frame"); frame.contentWindow.postMessage("我很帥", "http://127.0.0.1:8001"); window.onmessage = function(event) { console.log("From b.html data: ", event.data); }; } </script>
</body>
複製代碼

接收方 b.html,端口號爲 8001:

<body>
  <h1>Hi, this is B html.</h1>
  <script> window.onmessage = function(event) { var origin = event.origin || event.originalEvent.origin; if (origin === "http://127.0.0.1:8000") { console.log("From a.html data: ", event.data); event.source.postMessage("不要臉", event.origin); } }; </script>
</body>
複製代碼

輸出:

From a.html data:  我很帥      b.html
From b.html data:  不要臉      a.html
複製代碼

參考資料

相關文章
相關標籤/搜索