一文解决前端跨域

lxf2023-05-21 01:56:20

简单跨域请求

  • method:
    • HEAD
    • GET
    • POST
  • content-type
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  • 不能超过所列请求头信息:
    • Accept
    • Accept-Language
    • Content-Language

如果完全满足上面的条件,就是简单请求,只需要设置返回头Access-Control-Allow-Origin为“*”或者是对应请求头origin 就能实现跨域请求。

在node koa可以通过这么做

ctx.set('Access-Control-Allow-Origin', ctx.request.headers.origin);

复杂跨域请求

只要不符合上面任何一项条件,就是复杂的跨域请求

复杂的跨域请求情况:

  • 除了简单的跨域请求的方法,比如PUT、DELETE
  • 除了简单的跨域请求的Content-type类型。 比如application/json
  • 自定义的header头

复杂请求会先进行options预请求, 对后台设置对应的允许返回头

Access-Control-Allow-Methods

Access-Control-Allow-Headers

Access-Control-Allow-Credentials

进行验证,通过了才发起复杂请求的实际请求。

请注意:如果前端ajax请求是不带有withCredentials=true的复杂请求,那么Access-Control-Allow-Methods,Access-Control-Allow-Origin 或 Access-Control-Allow-Headers可以设置为“*”

如果是简单跨域请求,那么前端发送请求,后台接受请求是有返回数据的,只是被浏览器拦截掉了,并且浏览器抛出错误; 如果是复杂跨域请求,那么在options预请求的时候,浏览器会跟上面的一样报错,然后不发送后面的实际请求;

cookie跨域

如果前端请求需要跨域携带cookie,那么需要设置xhr.withCredentials=true,而上面三个返回头都要设置对应的具体值,否则是不生效的,并且只有同时设置返回头Access-Control-Allow-Credentials:true, 那么cookie跨域传输才会成功。

cookie跨域也讲究同源

无论是前端设置cookie:document.cookie='name=jgchen;'(以分号+空格 进行分割) 还是后端设置返回头set-cookie,都要客户端跟服务端配合, 两者缺一不可,浏览器请求设置withCredentials=true,同时服务端设置返回头 Access-Control-Allow-Credentials:true

提高复杂请求的效率

为了避免每次都进行复杂请求都有options请求,从而造成不必要的性能问题,返回头设置 Access-Control-Max-Age:秒数, 表示 preflight request (预检请求)的返回结果缓存时间,如Access-Control-Allow-Methods 和Access-Control-Allow-Headers能被浏览器缓存多久,验证过第一次之后,在这个时间内不再发起options预请求

前端如何获取返回头信息

前端如何拿到返回头信息呢?可以通过xhr.getResponseHeader方法进行获取。 但是此方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。

其他自定义返回头属性,如node设置的:ctx.response.setHeader(name,'jgchen'),

需要同时设置 ctx.response.setHeader('Access-Control-Expose-Headers', 'name') 后,前端才能拿到name这样的自定义属性,否则就会出现

Refused to get unsafe header "name" // 拒绝获取不安全的头信息“name”

简单跨域通过什么进行拦截?

请求头中的Origin:request请求头中的Origin指示了请求来自于哪个站点,并且只有在CORS的情况下origin才会在请求头中出现

请求头中的host:Host 请求头指明了请求的服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个HTTP的URL会自动使用80端口)

HTTP/1.1 的所有请求报文中必须包含一个Host头字段。如果一个 HTTP/1.1 请求缺少Host 头字段或者设置了超过一个的 Host 头字段,一个400(Bad Request)状态码会被返回。

响应头中的Access-Control-Allow-Origin:该响应头字段指定了该响应的资源是否被允许与前台请求头给定的origin共享。

所以简单跨域请求返回浏览器之后,虽然数据会返回,但是,浏览器会比对请求头中的Origin与响应头中的Access-Control-Allow-Origin是否是共享匹配。如果不匹配,浏览器的xhr会捕获错误并且在浏览器端控制台抛出错误,并不能拿到期望的数据。

复杂跨域请求如何检测跨域的?

预请求实现机制

对于复杂的请求跨域, 浏览器一旦检测此发送的请求头存属于复杂的跨域请求时, 首先会发送一个预请求options, 请求头中包函着以下重要的内容:

Access-Control-Request-Headers(如果有自定义头或者content-type类形不属于简单请求的类型的情况下才会出来)

Access-Control-Request-Method(除了简单的请求方法才会出现)

此时服务端需要让这两个请求头中对应的信息通过允许。通过在响应返回的时候对响应头做出响应处理:设置 Access-Control-Allow-Methods和Access-Control-Allow-Headers 为对应的字段,这样预请求才会成功,并且存下来,在接下里的实际请求中带上这些字段作为请求头。

在预检请求时并不会把请求数据和cookie信息带入请求信息中。 只有预请求成功之后,才会进行实际请求,带入上cookie跟请求数据

预请求虽然好,但是如何避免性能问题

通过以上的所有对复杂的跨域请求的分析清楚的认识到,那些复杂跨域请求方式每次都会发送预检请求,然后通过后再来发送实际的请求。我们可以把第一次预检请求的成功结果缓存下来,这样在后面的指定时间内发送这个请求,都不会再触发预检请求来,可以通过设置Access-Control-Max-Age 【表示 preflight request (预检请求)的返回结果的返回头(即 Access-Control-Allow-Methods 和Access-Control-Allow-Headers 提供的信息) 可以被缓存多久】

如何对预检请求设置有效期

ctx.setHeader('Access-Control-Max-Age', 600)

对预检请求设置10分钟的过期时间(时间可以根据项目情况进行自定义)

在这次预检请求返回成功的10分钟内,发送这个复杂跨域请求都不会触发预检请求。 但是对于每个浏览器的缓存时间机制都不一样。在本地调试的时候,有时候你会发现设置了预检的过期时间并不生效。注意一下可能开启了浏览器的Disable cache导致了此原因

通过代理劫持解决跨域问题

一文解决前端跨域

在各大框架中都通过脚手架启动node服务承载着项目。例如vue-cli中就利用了http-proxy-middle进行一个请求的代理拦截,向目标服务器发送请求来解决跨域问题。

代理劫持示例

假如前端起了个locahost:3000的服务

当前的前端请求http://localhost:3000/api/getUser接口

相关代码如下

<script>
  let url = '/api/getUser';
  let xhr = new XMLHttpRequest();
  xhr.open('post', url, true);
  xhr.setRequestHeader('content-type', 'application/json');
  xhr.setRequestHeader('X-Customer-Header', 'value');
  xhr.send();
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        console.log(1)
        console.log(xhr.response)
      }
    }
  }
</script>

// 但是在前端使用的脚手架用到的http-proxy-middle模块,做了代理劫持
const proxyOption = {
    target: 'http://localhost:4000',
    pathRewrite: {
        '^/api/' : '/'  // 重写请求,api/解析为/
    },
    changeOrigin:true
};
app.use('/api', proxy(proxyOption))

// 后台启动4000端口服务
app.post('/getUser', (req, res, next) => {
  res.send({
    code: 1
  })
})

当3000端口的前端发送ajax请求‘/api/getUser’的时候,本身就是在一个域名下,由于不会造成任何跨域问题,同时http-proxy-middle 启的cli server服务,app.use('/api/')捕获拦截,改写url地址向服务端4000端进行请求发送数据,此时就是cli server端与 api server端的请求通信。当4000端口的api server接收到请求之后把数据返回给3000端口的cli server端,同时再返回给请求的ajax客户端

用原生node api 实现代理劫持

const http=require('http')
app.use('/api', (req, res) => {
  const reqHttp = http.request({
    host: '127.0.0.1',
    path: '/getUser',
    port: '4000',
    method: req.method,
    headers: req.headers
  }, (resHttp) => {
    //http模块请求回调
    let body = ''
    resHttp.on('data', (chunk) => {
      console.log(chunk.toString())
      body += chunk
    });
    resHttp.on('end', () => {
      res.end(body)
    });
  })
  reqHttp.end()
});

以上代码本质上是模拟了代理劫持的方式,当拦截到url开头以/api起始的请求之后,通过node原生http模块的request方法向对应的后台发送请求,同时把浏览器请求过来的一些请求体,请求头等数据一并传给server端。通过http模块监听的结束方法最后把数据再返回到client浏览器端。这样形成了二次转发方式解决跨域问题。整体就是利用了服务端向服务发送请求不会有跨域策略的限制,就是所谓的同源策略。

因为浏览器会做options等预检的检测,跟跨域返回结果的拦截,而服务端并不会。

实战

前端示例

普通跨域,复杂跨域,cookie跨域,自定义请求头,获取自定义响应头,jsonp demo

function formateData(data) {
    var arr = [];
    //for(let val of [1,2,3]) 是对数组值进行遍历,只有数组可以用
    //for (let key in [1,2,3]或者{a:1,b:2}) 数组取的是索引,对象取的是属性
    for (let key in data) {
        arr.push(encodeURIComponent(key) + '=' + data[key])
    }
    return arr.join('&');
}

/*
  ajax 实现的大概思路是 取一个params的对象参数,对里面的data进行序列化处理,
  formatData函数序列化处理便于GET请求携带变量参数 ,或者POST等其他请求的请求头设置为Content-type:application/x-www-form-urlencoded,请求体内容进行序列化
  当POST方法时候 send(请求体内容)
  当GET方法时候  xhr.open(方法,地址+参数,异步boolean)
  设置onreadystatechange函数回调或者onload函数毁掉
  */
function ajax(params) {
    params = params || {};
    params.data = params.data || {};
    var xhr = XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP');
    params.method = (params.method || 'GET').toUpperCase();
    xhr.withCredentials = true;
    if (params.method === 'GET') {
        xhr.open(params.method, params.url + '?' + formateData(params.data), true)
        // document.cookie = 'name=jgchen; password=123; ';
        xhr.send();
    } else {
        xhr.open(params.method, params.url, true);
        // xhr.setRequestHeader("name", "jgchen")
        /* 设置 Content-Type 为 application/x-www-form-urlencoded
         以表单urlencoded的形式传递数据 
         使用 xhr.send('username=admin&password=root')这样的格式
        */

        // xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded")
        // xhr.send(formateData(params.data));
        /*
         设置Content-Type:为application/json,以json格式传递数据
         这种会触发复杂跨域,需要后台配置ctx.set('Access-Control-Allow-Header','content-type')
         对params.data不能调用formdata进行格式化,要进行JSON.stringfiy(params.data)
         使用xhr.send({"username":"admin","password":"root"})
         */
        xhr.setRequestHeader("Content-type", "application/json")
        xhr.setRequestHeader("request-header", "test-my-header")

        xhr.send(JSON.stringify(params.data));
    }
    // readyState有五种可能的值:
    // 0 (未初始化): (XMLHttpRequest)对象已经创建,但还没有调用open()方法。
    // 1 (载入):已经调用open() 方法,但尚未发送请求。
    // 2 (载入完成): 请求已经发送完成。
    // 3 (交互):可以接收到部分响应数据。
    // 4 (完成):已经接收到了全部数据,并且连接已经关闭。
    //当然我们可以用onload来代替onreadystatechange等于4的情况,因为onload只在状态为4的时候才被调用,代码如下:
    // 这里有两种写法,第一种写法:当xhr.readyState===4的时候,会触发onload事件,直接通过onload事件 进行回调函数处理
    //对于xhr.responseText 进行JSON。parse解析 并且 调用parms。success.call(xhr,res) 对于返回数据传递到成功回调函数
    xhr.onload = function () {
        if (xhr.status >= 200 && xhr.status <= 206 || xhr.status === 304) {
            var res;
            if (params.success && params.success instanceof Function) {
                res = JSON.parse(xhr.responseText);
                console.log(xhr.getResponseHeader('keyword'))
                params.success.call(xhr, res);
            }
        } else {
            if (params.error && params.error instanceof Function) {
                res = xhr.responseText;
                params.error.call(xhr, res);
            }
        }

    }
    //第二种写法,当xhr.readyState===4时候,说明请求成功返回了,进行成功回调
    // xhr.onreadystatechange = function () {
    //     // console.log(xhr.readyState)
    //     if (xhr.readyState === 4) {
    //         // 进行onload里面的处理函数
    //     }
    // }
}
//跨域jsonp请求
//jsonp请求的第一步需要取一个params对象的jsonp属性的值作为callbackname的值,callback参数的值为callbackname
//然后取到head节点,创建script标签,
function jsonp(params) {
    //先对params进行处理,防止为空
    params = params || {};
    params.data = params.data || {};
    //后台传递数据时调用的函数名
    var callbackName = params.jsonp;
    // 拿到dom元素head,先不进行操作
    var head = document.querySelector('head');
    //创建script元素,先不进行操作
    var script = document.createElement('script');
    //传递给后台的data数据中,需要包含回调参数callback。
    //callback的值是 一个回调函数的函数名,后台通过该回调函数名调用传递数据,这个参数名的key由双方约定,默认为callback
    params.data['callback'] = callbackName;
    //对data数据进行格式化
    var data = formateData(params.data);
    //设置script请求的url跟数据
    script.src = `${params.url}?${data}`;
    //全局函数 由script请求后台,被调用的函数,只有后台成功响应才会调用该函数
    window[callbackName] = function (jsonData) {
        //请求移除scipt标签
        head.removeChild(script);
        clearTimeout(script.timer);
        window[callbackName] = null;
        params.success && params.success(jsonData)
    }
    //请求超时的处理函数
    if (params.time) {
        script.timer = setTimeout(() => {
            //请求超时对window下的[callbackName]函数进行清除,由于有可能下次callbackName发生改变了
            window[callbackName] = null;
            //移除script元素,无论请求成不成功
            head.removeChild(script)
            //这里不需要清除定时器了,clearTimeout(script.timer); 因为定时器调用之后就被清除了

            //调用失败回调
            params.error && params.error({
                message: '超时'
            })
        }, time);
    }
    //往head元素插入script元素,这个时候,script就插入文档中了,请求并加载src
    head.appendChild(script);
    //无论是请求超时,还是请求成功,都要移除script元素,script元素只有在第一次插入页面文档的时候,才会请求src
    //无论请求失败还是成功,都还是要移除window[callbackName]避免增加没用的全局方法,因为每次请求的callbackName可能是不同的
    //之前有个无聊的问题:为啥jsonp只能是get请求呢?看了实现过程,知道其实是因为script的加载就是get方式的~
}

node 示例

const Koa = require('koa');
const Router = require('koa-router');
// const cors = require('koa2-cors');
const koaBody = require('koa-body');
const app = new Koa;
let home = Router();
/*

*/
app.use(async(ctx,next)=>{
    ctx.set('Access-Control-Allow-Origin', ctx.request.headers.origin);
    ctx.set('Access-Control-Allow-Methods', 'PUT');
    ctx.set('Access-Control-Expose-Headers','keyword')
    ctx.set('Access-Control-Allow-Credentials',true)
    ctx.set('keyword','jgchen')
    ctx.set('Access-Control-Allow-Headers','request-header,content-type');
    await next();
})
// app.use(cors());
app.use(koaBody())
home.get('/', async (ctx) => {
    return ctx.body = {
        code: 200,
        message: '这个是首页'
    }

})

home.get('/ajax', async (ctx) => {
    console.log(ctx.cookies.get('name'))
    ctx.cookies.set('password','qqqqqq')
    console.log('get called')
    return ctx.body = {
        code: 200,
        data: ctx.request.query
    }
})
home.post('/ajax', async (ctx) => {
    console.log('post called')
    return ctx.body = {
        code: 200,
        data: ctx.request.body
    }
})
home.put('/ajax', async (ctx) => {
    console.log('put called')
    return ctx.body = {
        code: 200,
        data: ctx.request.body
    }
})
home.get('/jsonp', async (ctx) => {
    console.log('jsonp called')
    let callbackName = ctx.request.query.callback;
    let data = {
        code: 200,
        data: ctx.request.query
    }
    //返回体直接是函数调用,调用的实参是要后台要传递的数据~由于data是对象,需要先进行json格式化
    // console.log(`${callbackName}(${JSON.stringify(data)})`)
    //script标签加载会解析json内容 默认进行了json.parse
    return ctx.body = `${callbackName}(${JSON.stringify(data)})`
})
app.use(home.routes());
app.use(home.allowedMethods())
app.listen(3000, () => {
    console.log('start');
})
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!