Logo
Published on

实在不想再搞跨域的问题了

Authors

从 15 年开始工作用 jQuery,到后来 Vue 全家桶一把梭,好像很少实实在在地碰到跨域的问题,毕竟在 jQuery 里可以用 jsonp,基于 Vue 的项目有 webpack-dev-serve,就算上线了,后端的同事帮我们配置一下就搞定了。而为什么有跨域?跨域只存在于浏览器吗?怎么解决跨域?在浏览器里,发一个跨域请求的时候浏览器真的发了请求吗?

带着这几个问题,我决定把跨域这块的内容给整理一下。

为什么会有跨域?

要回答这个问题,其实应该先回答另一个问题:跨域只存在于浏览器吗?

emmm

是的,对于服务器之间来说并没有跨域一说(但并不是说服务端就不会做鉴权了)。只不过浏览器是面向各种格言的C端用户的使用门槛太低,黑客也可以直接通过浏览器做很多操作达到很多目的,所以提出了一个大家很熟悉的概念 -- 同源策略

那为什么会要搞一个同源策略,为什么又要跨域请求呢?

同源策略

在 MDN 上,对“源”给出了很明确的定义:

如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源。

因此,对于 http://www.myhost.com/path/target.html 这样一个url来说,以下给出了一些url,并且它们和上面这个url是否同源做出了检测:

同源策略

对应到我们平时的开发中就是,如果请求路径和当前站点的协议端口号域名有任意一项不同,那么这个请求是会失败的。

那么问题来了,如果没有同源策略会发生什么呢?我们可以看两个很简单的例子。

没有同源策略限制的接口请求

首先,在登录的场景下,我们经常会使用 cookie 来记录用户信息。在一次登录成功之后,服务端会通过 set-cookie 返回头写入 cookie 信息,下次打开页面的时候会自动带上 cookie,那么这时打开的就是有登录状态的页面了。

如果在某次登录银行的页面之后,用户打开了另一个不可描述的站点,正在看的过程中,这个站点的一些隐藏代码向用户登录过的银行发起了请求,可是由于没有同源策略的保护,这个请求带上了银行的 cookie,如果运气好 cookie 还没有过期的话,那么服务端就认为这是前端发来的正确请求,那自己的账户就完全暴露了。

没有同源策略限制的 DOM 操作

有一天用户突然收到了一封邮件,说用户的银行账户有问风险,要用户点击链接登录,结果用户没看清 url 地址,真实的 url 是 www.yinhang.com,而用户打开的却是 www.yinghang.com。但是后者打开之后显示的和真实的站点是一样的,因为后者直接套用 iframe 打开的真实的站点。在用户输入用户名和密码之后,如果没有同源策略限制 DOM 操作,那么钓鱼网站就可以通过以下代码获取用户信息:

// HTML
;<iframe name="yinhang" src="www.yinhang.com"></iframe>
// JS
// 由于没有同源策略的限制,钓鱼网站可以直接拿到别的网站的Dom
const iframe = window.frames['yinhang']
const node = iframe.document.getElementById('你输入账号密码的Input')
console.log(`拿到了这个${node},我还拿不到你刚刚输入的账号密码吗`)

上面两个小例子是最简单的两种因为没有同源策略导致的问题,当然不是有了同源策略我们就可以高枕无忧了,只不过同源策略会相对提高站点被黑的门槛。

解决接口跨域的N种方法

为了演示下面的 demo,我初始化了两个项目,一个是 Express 脚手架起的项目,地址是 http://localhost:3001,另一个是 Vue 脚手架起的项目,地址是 http://localhost:8080

JSONP

从 jQuery 时代过来的前端工程师应该是很熟悉 JSONP 了,其原理就是利用浏览器对于 script、img 标签在跨域上的“特殊处理”,它们并没有跨域限制,因此我们可以利用这一点达到跨域的目的。

后端代码:

// jsonp demo
router.get('/jsonp', function (req, res) {
  const result = {
    code: 0,
    message: 'jsonp success',
  }

  // cb是前端传来的query参数
  res.send(`${req.query.cb}(${JSON.stringify(result)})`)
})

前端代码:

<script type="text/javascript">
  window.jsonpCallback = function (res) {
    console.log('res', res)
  }
</script>
<script src="http://localhost:3001/jsonp?cb=jsonpCallback"></script>

当然,如果跨域接口过多的话,可以稍微封装一下这个过程:

function requestJSONP(url, data, jsonp = 'jsonpCallback') {
  return new Promise((resolve, reject) => {
    let arr = []

    Object.keys(data).forEach((key) => {
      arr.push(`${key}=${data[key]}`)
    })

    let query = arr.join('&')
    const script = document.createElement('script')

    window[jsonp] = function (response) {
      document.body.removeChild(script)
      delete window[jsonp]
      resolve(response)
    }

    script.src = `${url}?${query}&cb=${jsonp}`
    document.body.appendChild(script)
  })
}

// 业务代码中
requestJSONP(
  'http://localhost:3001/jsonp',
  {
    data: 'Hello World',
  },
  'testJSONP'
).then((response) => {
  console.log('JSONP', response)
})

iframe + form

很容易发现,JSONP 只能处理 GET 请求,好在我们可以用 iframe + form 来处理 POST 请求。我们使用 iframe 发送数据,使用 form 执行表单提交:

function requestPOST(url, data, cb) {
  const iframe = document.createElement('iframe')

  iframe.name = 'iframePost'
  iframe.style.display = 'none'
  document.body.appendChild(iframe)

  const form = document.createElement('form')
  // input用来存放post提交的数据
  const input = document.createElement('input')

  iframe.addEventListener('load', function () {
    if (cb && typeof cb == 'function') {
      cb()
    }
  })

  form.action = url
  form.target = iframe.name
  form.method = 'post'

  Object.keys(data).forEach((key) => {
    input.name = key
    input.value = data[key].toString()
    form.appendChild(input.cloneNode())
  })

  form.style.display = 'none'
  document.body.appendChild(form)
  form.submit()

  document.body.removeChild(form)
}

// 业务场景调用
requestPOST(
  'http://localhost:3001/iframe_form',
  {
    data: 'Hello World',
  },
  function (response) {
    console.log('response', response)
  }
)

CORS

CORS 的全称是 Cross-Origin Resource Sharing (跨域资源共享),是处理跨域问题的标准做法。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

来个demo:

// 后端接口
// simple cors demo
router.get('/simple_cors', function (req, res) {
  const result = {
    code: 0,
    message: 'simple cors',
    data: req.query,
  }

  res.header('Access-Control-Allow-Origin', '*')
  res.send(result)
})

前端正常请求即可:

fetch('http://127.0.0.1:3001/simple_cors?is_simple=true').then((response) => {
  console.log('simple cors', response)
})

非简单请求会先发一个预检测请求(preflight),只有当预检测请求通过之后,才会发出真正的请求:

服务端代码:

// 处理options请求
router.options('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', 'http://127.0.0.1:8080')
  res.header('Access-Control-Allow-Credentials', true)
  res.header('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept, token'
  )

  next()
})

// 复杂跨域接口
router.post('/complex_cors', function (req, res) {
  const result = {
    code: 0,
    message: 'complex cors',
    data: req.query,
  }

  res.header('Access-Control-Allow-Origin', 'http://127.0.0.1:8080')
  res.header('Access-Control-Allow-Credentials', true)
  res.header('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
  // 随便写了个cookie,浏览器里可能会报错,可以忽略,仅供演示
  res.cookie('a', 1)

  res.send(result)
})

前端请求也很容易:

fetch('http://127.0.0.1:3001/complex_cors?is_complex=true', {
  method: 'POST',
  credentials: 'include',
  headers: { token: 'extra headers' },
})
  .then((response) => {
    return response.json()
  })
  .catch((err) => {
    console.log('complex err', err)
  })
  .then((response) => {
    console.log(response)
  })

此时我们在控制台可以看到两个请求:

options请求
post请求

当然,对于Express或者Koa这类基于NodeJS的服务端框架,有很多开源的中间件来处理跨域问题,就不需要每次都手写这么多代码了。

代理

而在前端工程化日益重要的今天,我们可以使用代理来处理跨域。例如 webpack-dev-serve 和 Nginx。

webpack-dev-serve 的原理主要也是服务器之间的请求是没有跨域的,它会用 Express 起一个服务,然后将所有请求透传到指定的 URI。不过这是开发环境下的方法,一旦放到线上,这个方法就不行了,这个时候我们可以使用 Nginx 来做转发。

众所周知,Nginx 是做反向代理的绝佳武器,当然,用来做接口转发也是小菜一碟了:

server {
  listen      80;
  server_name     www.myhost.com;
  location ^~ /api   {
      proxy_pass  http://127.0.0.1:3001;
  }
}

解决同源策略下DOM查询的方法

postMessage

window.postMessage 是 HTML5 提供的 API,可以实现安全地跨源通信。假设有两个不同源的页面:http://localhost:8080http://localhost:8081,且前者是父页面,后者是子页面,可以通过一个 demo 来看一下(两个域名对应的项目都是 Vue 脚手架起的)。

父页面:

<template>
  <div id="app">
    <button type="button" @click.stop="getMessage">测试按钮</button>
    <iframe name="child" src="http://localhost:8081" width="800" height="600"></iframe>
  </div>
</template>

<script>
  export default {
    name: 'app',
    methods: {
      getMessage() {
        window.frames.child.postMessage('从parent发来的消息', 'http://localhost:8081')
      },
    },
    mounted() {
      window.addEventListener('message', (e) => {
        if (e.origin === 'http://localhost:8081') {
          console.log(e.data)
        }
      })
    },
  }
</script>

子页面:

<template>
  <div id="app">我是子页面</div>
</template>

<script>
  export default {
    name: 'app',
    mounted() {
      window.addEventListener('message', (e) => {
        if (e.origin === 'http://localhost:8080') {
          console.log(e.data)
          e.source.postMessage('从child回复的消息', 'http://localhost:8080')
        }
      })
    },
  }
</script>

最终,在点击父页面上的测试按钮之后,可以在控制台看到如下内容:

父子页

document.domain

这种方式适合子域名不同的 iframe 跨域,就不做 demo 了

canvas处理跨域

张鑫旭大佬还写过用 canvas 处理跨域: 查看原文

到这就写完了如何处理跨域的各种方法了,但是还剩下最开始的最后一个问题,那就是在浏览器报跨域的问题时,到底浏览器有没有发请求呢?

浏览器处理跨域请求

答案是:发了。怎么证明呢?用抓包工具看一下就好了。

我们用之前的 demo 来发请求,用 charles 来抓包,前端发请求时,域名不能再用 http://127.0.0.1,而需要使用 charles 能抓到的域名:http://localhost.charlesproxy.com,端口还是之前的 3001,后端把 CORS 关掉。此时在浏览器控制台中能看到如下报错:

跨域报错

然后再看下 charles 的抓包情况:

charles 抓包

可以看到,charles 不仅抓到了一个请求,甚至连返回内容都有了。再看看开启 CORS 前后的返回头的区别。

开启前:

未开启 CORS

开启后:

开启 CORS

很明显,开启 CORS 之后 response headers 会多一个 Access-Control-Allow-Origin 的字段用于标识这个请求是可以跨域共享资源的。

那么就清楚了,同源策略的确是在浏览器中才有的,对于普通的网络请求,浏览器会先发请求,待收到请求的返回之后,校验请求的 response headers,再根据同源策略和 response headers 的内容判断能不能允许请求正常返回给 js 代码。

所以对于跨域这个问题来说,我们需要理解的是:

1.为什么有跨域以及出现的场景?

2.同源策略

3.解决跨域的方法及原理

4.对于一个跨域的请求,浏览器到底会不会发送请求?

参考资料:

https://segmentfault.com/a/1190000015597029

https://blog.csdn.net/wonking666/article/details/79159180

MDN的各种相关文档