跨域——突破前后端技术的壁垒

211 阅读6分钟

引言

跨域问题曾经是前端面试的焦点,尽管近几年热度有所降低,但是由于我们在实践中仍会经常遇见,理清它背后的原理仍是相当有必要的。

当我仅有前端开发经验的时候,对跨域总是有一种雾里看花的感觉。绝大部分跨域相关的帖子也会提到一个几乎没有人用过的解决方案JSONP

那么跨域的本质究竟是什么?JSONP又是什么?

1. 跨域问题的本质

我们先来搞清楚一个问题。

跨域究竟是浏览器的限制还是服务器的限制?

MDN文档开宗明义地指出了,跨域本质是浏览器的限制

出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求。

详见developer.mozilla.org/zh-CN/docs/…

对于仅有服务端开发经验的人来说,遇到跨域的机会可能更少。毕竟服务端之间的调用从来不存在什么跨域的问题。

借着这个思路,目前绝大部分主流开发框架选择在本地起一个开发服务器(dev server),将前端项目托管在开发服务器中,既解决了项目调试的问题,也规避了跨域的问题。

简单请求

简单请求的条件极其苛刻,业务实践中很难同时满足。

  • 仅限三种请求类型 GET HEAD POST
  • 允许的headers
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
    • Range
  • Content-Type限制三种
    • text/plain
    • mutipart/form-data
    • application/x-www-form-urlencoded
  • XMLHttpRequest不允许为upload注册事件监听器
  • 没有使用ReadableStream

更多内容参考简单请求详情

预检请求(需要预检的请求)

2. 前端工程化解决跨域

跨域的本质上是浏览器的安全限制,因此,服务器之间的接口调用不存在跨域问题。

以Vue项目为例,本地调试前端项目时,会启动一个dev server。

dev server托管了前端工程的静态资源,并且作为一台代理服务器,请求服务端接口。

这样一来,前端工程只需要调用dev server的接口就可以了,也就不存在跨域的问题了。

devServer: {
    open: true,
    hot: true,
    host: '0.0.0.0',
    port: 8080,
    https: false,
    proxy: { // 配置跨域
    '/': {
      target: 'http://localhost:7250/', // 接口地址
      changOrigin: true, // 允许跨域
      pathRewrite: {
        '^/': ''// 请求的时候使用这个api就可以
      }
    },
}

3. 服务端解决跨域问题

3.1 Nodejs解决跨域问题

下文代码是express支持CORS的方案,引入了一个npm包cors

var express = require('express')
var cors = require('cors')
var app = express()
 
app.use(cors())

cors作为中间件,本质上就是为响应增加CORS相关的头字段。

以下截取了cors的部分源码。区分是实际请求还是预检请求,为请求附加上响应的头字段。

github地址 cors


function cors(options, req, res, next) {
    var headers = [],
    method = req.method && req.method.toUpperCase && req.method.toUpperCase();

    if (method === 'OPTIONS') {
      // preflight 预检请求
      headers.push(configureOrigin(options, req));
      headers.push(configureCredentials(options, req));
      headers.push(configureMethods(options, req));
      headers.push(configureAllowedHeaders(options, req));
      headers.push(configureMaxAge(options, req));
      headers.push(configureExposedHeaders(options, req));
      applyHeaders(headers, res);
      next();
    } else {
      // 实际请求
      headers.push(configureOrigin(options, req));
      headers.push(configureCredentials(options, req));
      headers.push(configureExposedHeaders(options, req));
      applyHeaders(headers, res);
      next();
    }
}

configureCredentials方法为例。

function configureCredentials(options) {
    if (options.credentials === true) {
        return {
            key: 'Access-Control-Allow-Credentials',
            value: 'true'
        };
    }
    return null;
}

4. 仅仅活在面试题中的JSONP

4.1 究竟什么是JSONP

script标签存在两大特性

  • script标签能够请求js地址

  • script标签请求后会立即执行代码

基于以上两大特性,

前端先定义好一个处理方法,动态创建script标签,然后拼接script标签的src属性,并为src增加带有预先定义的方法的查询参数(query string)。相当于发起了一个GET请求。

服务端接收到GET请求,解析查询参数,得到前端预定义好的方法。将要返回给前端的数据和前端预定义好的方法拼接成一个函数执行语句。以text/javascript类型相应给前端。

下面举一个具体的例子。

4.2 客户端代码。

jsonTest函数就是上文提到的,前端预定义好的方法。

<script>
    const body = document.getElementsByTagName('body')[0];
    function jsonpTest(data) {
        // 解析服务端返回的数据 
        console.log(JSON.parse(data));
    }
    // 动态创建script标签
    function createScript(functionName) {
        const script = document.createElement('script');
        script.src = `http://localhost:8005/json?callback=${functionName}`;
        return script;
    }
    // 执行script标签
    function executeScript(script) {
        body.appendChild(script);
    }
    
    executeScript(createScript('jsonTest'));
</script>

4.3 服务端代码

第一步,先创建一个服务。localhost:8005代码。

const express = require("express");
const app = express();

app.get("/json", (req, res) => {
  res.jsonp({ code: 0, msg: "这是8005端口返回的" });
});
app.listen("8005", () => {
  console.log("app5 running at port 8005");
});

第二步,改造"/json"接口

app.get('/json', function(req, res, next) {
    // 解析查询参数
    var _callback = req.query.callback;
    // 拼接返回给前端的数据
    var _data = { code: 0, msg: "这是8005端口返回的" };
    if (_callback) {
        // 以 text/javascript 类型返回给前端。
        res.type('text/javascript');
        // 返回前端后,前端直接执行这个方法 jsonTest(data);
        res.send(_callback + '(' + JSON.stringify(_data) + ')');
    } else {
        res.json(_data);
    }
});

5. 失传已久的二号人物——Image

var img = new Image();

// 通过 onload 及 onerror 事件可以知道响应是什么时候接收到的,但是不能获取响应文本
img.onload = img.onerror = function () {
  console.log("Done!");
}

// 请求数据通过查询字符串形式发送
img.src = 'https://metagraph.design/api/test/index';

推荐一篇大佬的文章 Cross-Origin Read Blocking (CORB)

4. 跨域传送cookie

浏览器向服务器发送Cookie遵守同源策略。

当浏览器发送跨域请求时,默认不会将Cookie信息放入请求头。

跨域服务器也不能将Cookie信息写入浏览器。

但是,跨域服务器和浏览器可以通过一些认证手段,彼此进行Cookie信息的交换。

4.1 同源服务器写入Cookie

假设静态资源部署在服务器localhost:8000上,地址是http://localhost:8000/index.html

在index.html页面中,存在向三个源发送的请求。

分别是localhost:8000 localhost:8001 localhost:8002

当页面初始化时,发起一个同源请求。

axios.get("http://localhost:8000/login", {}).then((res) => {
  console.log(res);
});

4.2 跨域请求,默认不发送Cookie

跨域访问时,浏览器不会在请求头中携带Cookie信息,跨域服务器不能向当前源写入Cookie信息。

下面是服务器localhost:8002代码。

const express = require("express");
const cors = require('cors')
const app = express();
app.use(cors());

// 定义一个接口,index.html页面请求这个接口就是跨域(因为端口不同)
app.get("/anotherService", (req, res) => {
  res.cookie("user2", "jay2", { maxAge: 2000000, httpOnly: true });
  res.json({ code: 0, msg: "这是8004端口返回的" });
});

localhost:8002服务器,实现了CORS,因此请求成功。

在Header中不存在Cookie信息。

在/anotherService接口中,试图向客户端写入Cookie,但是并不能写入。

4.3 跨域请求,发送Cookie的方法

如果希望跨域请求时带上Cookie,或者跨域服务器为当前源写入Cookie,必须指定withCredentials为true。

跨域服务器必须同时指定响应头Access-Control-Allow-Origin为当前源,否则会报跨域错误。

客户端代码如下。

axios({
  withCredentials: true, // ++ 新增
  method: "get",
  url: "http://localhost:8003/anotherService",
}).then((res) => {
  console.log(res);
});

服务端代码如下。

app.all("*", (req, res, next) => {
  // 客户端设置 withCredential 为 true,必须设置确定的 origin http://localhost:8000
  res.header("Access-Control-Allow-Origin", "http://localhost:8000");
  res.header("Access-Control-Allow-Credentials", "true"); // ++ 新增
  next();
});

// 定义一个接口,index.html页面请求这个接口就是跨域(因为端口不同)
app.get("/anotherService", (req, res) => {
  res.cookie("user1", "jay1", { maxAge: 2000000, httpOnly: true, sameSite: true });
  res.json({ code: 0, msg: "这是8003端口返回的" });
});

请求体中存在Cookie,服务器也成功地向浏览器写入了Cookie。