非面試向跨域實踐詳解

PlutoPluto發表於2018-09-11

前言

筆者經常在前端開源群答疑,加上之前的招聘面試經歷。發現許多新手前端在問起跨域問題的解決方案,一套一套的,可是實際遇到跨域問題了就不知道怎麼解決了。這次寫這篇文章從實踐角度聊一聊跨域問題。

跨域基本概念

出於瀏覽器的同源策略限制,瀏覽器會拒絕跨域請求。 這就是跨域問題的產生原因,同源策略是用於隔離潛在惡意檔案的重要安全機制。

這句話的三個關鍵字:

  • 同源
  • 限制
  • 瀏覽器拒絕

什麼是同源

那麼第一個問題來了,什麼算是同源。解決這個問題需要先了解一下URL的完整結構:

URL結構
兩個URL的才算同源。反而言之,三者任何一個不相同都算跨域。 例如某個頁面地址為 www.domain.com/page1.html, 該頁面訪問以下API介面跨域關係表:

API 介面地址 是否跨域 原因
www.domain.com/api/users/1 協議、主機、埠全部都相同
www.domain.com:80/api/users/1 埠不同
www.baidu.com/api/users/1 協議不同
api.domain.com/v1/users/1 主機不同

有哪些限制

  1. XmlHttpRequest(即ajax請求)和Fetch兩種介面發出的HTTP請求進行限制。
  2. 對於嵌入資源標籤scriptimglinkvideo等標籤載入資源的請求(HTTP GET請求)不做限制。

具體的限制規則還有很多,這裡只說常見和本文用得上的。

瀏覽器拒絕

那麼那些環境算是瀏覽器?

  • PC端常見的 Chrome/Safari/Edge
  • 移動端的Chrome/Safari/各個App內嵌Webview 瀏覽器又是怎麼拒絕的 先來看一張圖,一個使用者點選了一個按鈕,發出了一個AJAX GET請求。那麼常見的流程如圖:
    非面試向跨域實踐詳解

那麼如果使用者發出的AJAX GET請求是一個跨域請求,那麼會在上圖中哪一個階段被阻止? 但是第3階段,也就是說使用者傳送的資訊可以到達服務端,伺服器是能夠接受處理並返回了。返回的瀏覽器發現這是一個跨域請求。就直接拒絕,同時把返回的資訊替換為報錯資訊,返回給JavaScript程式。 對於更復雜的POST/PUT等請求, MDN CORS文件裡面有更詳細的處理方法。這裡就不細說。

這一點很重要,但是總是被新人忽略。所以重要的事情說三遍,

  • 拒絕跨域請求是瀏覽器
  • 拒絕跨域請求是瀏覽器
  • 拒絕跨域請求是瀏覽器

反過來說,Nginx、Java/Nodejs等程式語言的HTTPClient以及手機App,他們發出的HTTP請求就完全沒有跨域問題,因為他們不是瀏覽器,沒有實現W3C規範。

跨域解決方案

JSONP

在瀏覽器中假設有以下一段程式碼會執行結果會是什麼樣?

<script>
window.callback = function (data) {
    console.log(data);
    delete window.callback;
}
</script>
<script>
callback({
    "code": 1,
    "data": [1,2,3]
});    
</script>
複製程式碼

毫無疑問,肯定是在控制檯輸出了一個物件資訊。 記得剛才在介紹跨域基本概念的時候說個瀏覽器不限制script標籤載入js檔案。那麼把這二者的特性相結合。第二個script標籤改為從網路載入. 就可以實現跨域. 例如 一個跨域APIhttp://api.domain.com/v1/users/1

  1. 在window物件上掛載一個函式callbackFun
  2. 建立一個script標籤: <script src="http://api.domain.com/v1/users/1?callback=callbackFun"></script>
  3. script就會向伺服器發出 GET http://api.domain.com/v1/users/1?callback=callbackFun的請求
  4. 讓後端返回如下內容 ContentType為application/javascript
callbackFun({/*需要的資料*/});
複製程式碼
  1. 資料返回成功以後處理資料,刪除script標籤

以上步驟就是JSONP的思想。實現一個完善的JSNOP請求庫還有細節要處理,比如超時取消、回撥函式防重名等。很多開源庫(jQuery, axios)都實現了JSNOP請求。想要程式碼的去Github閱讀原始碼,這裡就不給出程式碼。

優劣勢

JSONP雖然是一種實現跨域訪問的方法,前端想要使用JSONP進行跨域訪問卻不容易。

  1. 只支援GET方法
  2. 要後端的配合 GET http://api.domain.com/v1/users/1 返回
ContentType:	application/json
複製程式碼
{
    "code": 1,
    "data" : {"userid": 1}
}
複製程式碼

GET http://api.domain.com/v1/users/1?callback=callbackFun 返回

ContentType:	application/javascript
複製程式碼
callbackFun({
    "code": 1,
    "data" : {"userid": 1}
});
複製程式碼

既然可以和後端商量配合你改造介面,那還有更好的方案可以解決。何必用這種方案。

JSONP有一個有點就是相容性好,IE678通通相容,所以一般JSNOP是後端同學如果主動需要開放API給他人使用,同時有需要極高的相容的一個妥協方案。一般情況下不推薦這個方案。

JSONP 開心一刻

真實經歷。之前開發專案需要呼叫另一個專案組的介面。 跨域造成介面掉不通,然後找Z君溝通, Z君說:"你用JSONP來掉介面就好了。這都不知道...." 然後我還在想大神這麼NB的麼,JSONP相容都提前做完了。我試了JSONP。坑爹呀,你後端根本就沒相容JSONP,我怎麼呼叫,呵呵... 呵呵呵呵....

非面試向跨域實踐詳解

請求代理

JSONP方案不推薦,那麼又需要訪問跨域介面,怎麼辦呢? 重要事情不在乎再多說一遍拒絕跨域請求是瀏覽器。 那麼如果有一個非W3C標準的HttpClient幫助我們轉發請求,不就可以了實現跨域訪問了。

App端

通常App對於webview都有很強的控制權,可以在Webview的JS環境中注入一些方法。 那麼移動端程式設計師可以在Webview中注入一個介面,執行在裡面的js程式碼可以通過這個方法把自己的請求地址、請求引數、請求體等資料交給App Native端,讓App Native代為收發請求。App Native不是瀏覽器,不受跨域限制。

非面試向跨域實踐詳解
具體實現方法可以搜 Hybird App開發或者請教移動端開發的同學。

Web端

Web端必然執行在瀏覽器環境中,那麼沒有App Native。還有伺服器上可以做反向代理。 所謂的反向代理,原理和App Native請求代理的原理差不多,就是我們請求非跨域下的反向代理服務,反向代理服務會把你的請求轉發給目標伺服器。 反向代理服務可以是Nginx也可以是java/Nodejs程式等等。這些程式也不受跨域限制,可以接受目標伺服器的請求,並返回給我們。

非面試向跨域實踐詳解

React/Vue 開發階段跨域處理

React/Vue 這種SPA開發施行的完全的前後端分離的模式,開發階段必然是需要跨域訪問介面的。 Vue開發可以這樣配置:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: '<url>',
        ws: true,
        changeOrigin: true
      },
      '/foo': {
        target: '<other_url>'
      }
    }
  }
}
複製程式碼

詳情見Vue CLI文件。React也有類似的配置,詳情見 create-react-app文件

那麼他們具體是怎麼實現的呢? 本地起一個服務端程式提供反向代理的能力。而React/Vue本地啟動的這個服務端程式就是Webpack-dev-server。來探索Webpack-dev-server原始碼,原始碼中啟動server的關鍵程式碼在lib/Server.js中,挑重點

/* 此處省略許多行程式碼 */

// 27行 引入express 作為服務端框架
const express = require('express');

/* 此處省略許多行程式碼 */


// 31行 引入 http-proxy-middleware 提供反向代理的能力
const httpProxyMiddleware = require('http-proxy-middleware');

/* 此處省略許多行程式碼 */

// 328行  獲取 proxyMiddleware 並載入到為express的中介軟體Middleware
app.use((req, res, next) = > {
    if (typeof proxyConfigOrCallback === 'function') {
        const newProxyConfig = proxyConfigOrCallback();

        if (newProxyConfig !== proxyConfig) {
            proxyConfig = newProxyConfig;
            // 334行 根據 proxyConfig 獲取 處理proxy請求的中介軟體proxyMiddleware
            proxyMiddleware = getProxyMiddleware(proxyConfig);
        }
    }

    const bypass = typeof proxyConfig.bypass === 'function';

    const bypassUrl = (bypass && proxyConfig.bypass(req, res, proxyConfig)) || false;

    if (bypassUrl) {
        req.url = bypassUrl;

        next();
    } else if (proxyMiddleware) {
        // 347行 最最關鍵一行 經過多次判定某個請求是需要代理轉發的請求,那麼把它交給proxyMiddleware進行處理, proxyMiddleware
        return proxyMiddleware(req, res, next);
    } else {
        next();
    }
});
複製程式碼

以上程式碼有點NodeJS服務端開發的同學基本能看明白,看不明白也沒關係。你知道React/Vue可以通過相應的配置項獲得介面跨域訪問的能力就可以了。其中最核心的就是依靠Express的網路請求能力充當反向代理伺服器。

非面試向跨域實踐詳解

React/Vue 線上部署階段跨域處理

開發階段還可以通過本地啟動一個Express伺服器作為代理,幫助我們處理跨域問題,問題是生產環境是不推薦這麼做的。React/Vue 專案通常在build以後會生成以下檔案:

  • xxx.html 檔案1份
  • xxx.xxxxxx.js Javacript檔案若干
  • xx.xxxx.css 檔案若干
  • xxx.map 檔案若干,當然也可能沒有 而且裡面的js/css/圖片等檔案通常部署在cdn上,最為要緊的頁面入口index.html則需要小心部署,否則易遇到2個問題
  1. 頁面沒辦法訪問
  2. 介面跨域導致沒辦法訪問

1

對於index.html的部署,Vue-Router文件寫的很清楚。推薦通過nginx try-file命令來進行部署。同時nginx又是一個反向代理伺服器。假設 網頁需要在host http://www.domain.com/下, 真實API服務部署在http://api.domain.com/api。那麼通過反向代理把介面代理到 http://www.domain.com/api下。那麼跨域訪問就變成了同域名訪問。 那麼nginx的配置檔案可以這樣寫

server { 
    listen       80;
    server_name  www.domain.com ;
    root www; # 存放html檔案的資料夾
    location ^/api { # 介面代理到 8080
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_pass       http://api.domain.com/api;
    }
    location / { # 其他請求返回index.html
        try_files $uri $uri/ /index.html;
    }
  }
複製程式碼

這樣做完, 訪問API就會被代理轉發,訪問其他路徑就返回html。如下圖所示:

非面試向跨域實踐詳解

線上部署的方式可能根據系統架構選型而多種多樣。這只是其中一種比較通用且為官方推薦的方式。僅做參考。類似ngixn的服務端軟體還是Caddy、Envoy

這種方案的優點是不需要後端同學改動介面,只需要運維小哥幫助配置一下nginx即可完成相容。缺點是多一次轉發可能帶來效能損失。

CORS

實際情況多種多樣,有些時候沒辦法使用JSONP,也通過nginx轉發又會產生效能損失。那麼還有一個終極大招———— CORS.

W3C的同源策略出來以後造成了很多不便,無法應對某些跨域訪問的強需求。為此W3C增加了CORS相關的規範, 文件之前也提及過:MDN CORS文件

重要的事情再重複一遍:拒絕跨域請求是瀏覽器,那麼CORS的原理就是CORS相關的規範中制定了一些響應頭(Response Header),這些響應頭以Access-Control-Request-開頭。簡單列舉幾個,具體這些頭的含義和用法見MDN CORS文件.

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
複製程式碼

瀏覽器在接收到設定過CORS響應頭的返回以後,會根據CORS規範檢查合法性,檢查通過則不再阻止,放行通過。

非面試向跨域實踐詳解

簡而言之就是CORS響應頭就是用來告訴瀏覽器:"我是雖然是跨域請求,但是我是合法的,請不要拒絕我"。

CORS方案的優點是支援各種方法 GET、POST、PUT、DELTE等等。而且改動量比較小。可以在服務端程式比如Java或者NodeJS上做,也可通過前置代理伺服器nginx完成。

缺點就是

  1. 瀏覽器相容性差
  2. 降低了安全性,畢竟W3C之所以禁止跨域,是為了安全。現在推出CORS方案雖然已經在安全和靈活方面做到一個較好平衡。但是如果CORS響應頭設定不當,還是可能會產生安全問題。

其他

其他還有用與父頁面與子頁面(iframe)之間的通訊的跨域問題,window.name、postMessage等方法。這裡就不詳細說了。日常用的確實不多,有需要再查把。

要點總結

  • 跨域的基本概念
    • 跨域是W3C組織為了保證安全指定的規範
    • 協議、主機、埠全部都相同才是同源,否則就是跨域
    • 限制XHR與Fetch,不限制資源類標籤
    • 拒絕跨域請求是瀏覽器 拒絕跨域請求是瀏覽器 拒絕跨域請求是瀏覽器 重要事情說三遍
  • 常見跨域解決方案
    • JSONP 只能發出GET請求。一般不推薦,除非需要很強的介面相容性
    • 訪問代理
      • APP端可以通過Native端發請求
      • Web端可以通過架設反向代理伺服器
        • React/Vue日常開發就是通過Express伺服器做的反向代理
        • 生產環節部署可以使用nginx
    • CORS是W3C准許跨域規範,需要後端配合改程式
    • 其他略過

生產環境中建議選擇順序是 反向代理 > CORS >> JSONP。 因為反向代理相容性最好,程式改動少。 CORS適用於無法容忍反向代理的效能損失和第三方OpenAPI訪問。 JSONP 只有當後端需要相容性高,沒辦法部署反向代理伺服器的情況以及前端訪問第三方提供的JSONP介面。其他任何情況下不推薦。

相關文章