前端跨域jsonp的细节,挡住面试官的连环提问

632 阅读9分钟

1.前言

在前端面试中,想必每一个人都会被问到跨域相关的问题,背过八股文的小伙伴肯定对跨域的解决对答如流,常见的跨域解决方案在网上有很多整理,但是如果问到实现的细节,你是否能够手写实现或者深入解读呢?其实很多情况下,面试官不仅仅会考察第一层的概念,还会追问第二层、第三层内容,所以对于实现原理的掌握是必要的,今天笔者就整理一下跨域的基本方式,重点介绍jsonp的实现~

2.跨域方案

常见的跨域解决方案有8种左右,在面试中能答出4-5种就可以了~

2.1 如果只是想要实现主域名下的不同子域名的跨域操作,我们可以使用设置document.domain 来解决

document.domain 设置为主域名,来实现相同子域名的跨域操作,这个时候主域名下的 cookie 就能够被子域名所访问。同时如果文档中含有主域名相同,子域名不同的 iframe 的话,我们也可以对这个 iframe 进行操作。

2.2 使用 location.hash 的方法

我们可以在主页面动态的修改 iframe 窗口的 hash 值,然后在 iframe 窗口里实现监听函数来实现这样一个单向的通信。因为在 iframe 是没有办法访问到不同源的父级窗口的,所以我们不能直接修改父级窗口的 hash 值来实现通信,我们可以在 iframe 中再加入一个 iframe ,这个 iframe 的内容是和父级页面同源的,所以我们可以 window.parent.parent 来修改最顶级页面的 src,以此来实现双向通信。

2.3 使用 window.name 的方法

主要是基于同一个窗口中设置了 window.name 后不同源的页面也可以访问,所以不同源的子页面可以首先在 window.name 中写入数据,然后跳转到一个和父级同源的页面。这个时候父级页面就可以访问同源的子页面中 window.name 中的数据了,这种方式的好处是可以传输的数据量大。

2.4 使用 postMessage 来解决的方法

这是一个 h5 中新增的一个 api。通过它我们可以实现多窗口间的信息传递,通过获取到指定窗口的引用,然后调用 postMessage 来发送信息,在窗口中我们通过对 message 信息的监听来接收信息,以此来实现不同源间的信息交换。如果是像解决 ajax 无法提交跨域请求的问题,我们可以使用 jsonpcorswebsocket 协议、服务器代理来解决问题。

2.5 使用 jsonp 来实现跨域请求

它的主要原理是通过动态构建 script 标签来实现跨域请求,因为浏览器对 script 标签的引入没有跨域的访问限制 。通过在请求的 url 后指定一个回调函数,然后服务器在返回数据的时候,构建一个 json 数据的包装,这个包装就是回调函数,然后返回给前端,前端接收到数据后,因为请求的是脚本文件,所以会直接执行,这样我们先前定义好的回调函数就可以被调用,从而实现了跨域请求的处理。这种方式只能用于 get 请求。

2.6 使用 CORS 的方式

CORS 是一个 W3C 标准,全称是"跨域资源共享"。CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,因此我们只需要在服务器端配置就行。浏览器将 CORS 请求分成两类:简单请求和非简单请求。对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是会在头信息之中,增加一个 Origin 字段。Origin 字段用来说明本次请求来自哪个源。服务器根据这个值,决定是否同意这次请求。对于如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段,就知道出错了,从而抛出一个错误,ajax 不会收到响应信息。如果成功的话会包含一些以 Access-Control- 开头的字段。非简单请求,浏览器会先发出一次预检请求,来判断该域名是否在服务器的白名单中,如果收到肯定回复后才会发起请求。

2.7 使用 websocket 协议,这个协议没有同源限制

2.6 使用服务器来代理跨域的访问请求

就是有跨域的请求操作时发送请求给后端,让后端代为请求,然后最后将获取的结果发返回。

3.jsonp详解

3.1 基本原理

Jsonp(JSON with Padding) json 的一种"使用模式",可以让网页从别的域名(网站)那获取资料,即跨域读取数据。在上文中已经说明,jsonp 的基本原理,主要就是利用了 script 标签的 src 没有跨域限制来完成的。

let a = 123;
this.document.getElementById('123')

3.2 执行过程

  • 前端定义一个解析函数(如: jsonpCallback = function (res) {})
  • 通过params的形式包装script标签的请求参数,并且声明执行函数(如cb=jsonpCallback)
  • 后端获取到前端声明的执行函数(jsonpCallback),并以带上参数且调用执行函数的方式传递给前端
  • 前端在script标签返回资源的时候就会去执行jsonpCallback并通过回调函数的方式拿到数据了。

3.3 优缺点

缺点:只能进行GET请求,而且需要后端配合进行函数逻辑书写。

优点:兼容性好,在一些古老的浏览器中都可以运行。

3.4 案例分析

先来看看我们要实现一个什么效果,在一个叫index.html的文件中有以下代码:

<script type='text/javascript'>
    window.jsonpCallback = function (res) {
        console.log(res)
    }
</script>
<script src='http://localhost:8080/api/jsonp?id=1&cb=jsonpCallback' type='text/javascript'></script>

然后我本地有一个文件server.js它会使用node提供一个服务,来模拟服务器,并且定义一个接口/api/jsonp来查询id对应的数据。

当我打开index.html的时候就会加载script标签,并执行了此次跨域请求。

前期准备

  • 我在本地新建一个文件夹node-cors
  • 并在此目录下npm init,初始化package.json
  • 安装koa(node的一个轻量级框架)
  • 新建文件夹jsonp,并新建index.htmlserver.js,一个写前端代码,一个写后端
mkdir node-cors && cd node-cors
npm init
cnpm i --save-dev koa
mkdir jsonp && cd jsonp
touch index.html
touch server.js

后端代码

由于JSONP的实现需要前后端配合,先来写一下后端的实现 (看不懂没关系,下面的前端简单实现会做解释):

const Koa = require('koa');
const app = new Koa();
const items = [{ id: 1, title: 'title1' }, { id: 2, title: 'title2' }]

app.use(async (ctx, next) => {
  if (ctx.path === '/api/jsonp') {
    const { cb, id } = ctx.query;
    const title = items.find(item => item.id == id)['title']
    ctx.body = `${cb}(${JSON.stringify({title})})`;
    return;
  }
})
console.log('listen 8080...')
app.listen(8080);

写完之后,保存。并在jsonp这个文件夹下执行:

node server.js

来启动服务,可以看到编辑器的控制台中会打印出"listen 8080..."

前端简单实现OK👌,后端已经实现了,现在让我们来看看前端最简单的一种实现方式,也就是写死一个script并发送请求:

index.html中:

<script type='text/javascript'>
    window.jsonpCallback = function (res) {
        console.log(res)
    }
</script>
<script src='http://localhost:8080/api/jsonp?id=1&cb=jsonpCallback' type='text/javascript'></script>

这两个script的意思是:

  • 第一个,创建一个jsonpCallback函数。但是它还没有被调用
  • 第二个,加载src中的资源,并等待请求的内容返回 整个过程就是:
  1. 当执行到第二个script的时候,由于请求了我们的8080端口,并且把idcb这两个参数放到URL里。那么后台就可以拿到URL里的这两个参数。

  2. 也就是在后端代码中的const { id, cb } = ctx.query这里获取到了。

  3. 那么后端在拿到这两个参数之后,可能就会根据id来进行一些查询,当然,我这里只是模拟的查询,用了一个简单的find来进行一个查找。查找到id1的那项并且取title

  4. 第二个参数cb,拿到的就是"jsonpCallback"了,这里也就是告诉后端,前端那里是会有一个叫做jsonpCallback的函数来接收后端想要返回的数据,而后端你只需要在返回体中写入jsonpCallback()就可以了。

  5. 前端在得到了后端返回的内容jsonpCallback({"title":"title1"}),发现里面是一段执行函数的语句,因此就会去执行第一个script中的jsonpCallback方法了,并且又是带了参数的,所以此时浏览器控制台会打印出{ title: 'title1' }

以此来达到一个简单的跨域的效果。

其实你想想,如果我们把第二个script标签换成以下代码,是不是也能达到同样的效果呢?

<!-- <script src='http://localhost:8080/api/jsonp?id=1&cb=jsonpCallback' type='text/javascript'></script> -->
<script type="text/javascript">
    jsonpCallback({ title: 'title1' })
</script>

jQuery中的jsonp实现

上面👆我们介绍了用script标签来实现,在jQuery$.ajax()方法其实也提供了jsonp

让我们一起来看看:

<script src="https://cdn.bootcss.com/jquery/3.5.0/jquery.min.js"></script>
<script>
    $.ajax({
        url: "http://localhost:8080/api/jsonp",
        dataType: "jsonp",
        type: "get",
        data: {
            id: 1
        },
        jsonp: "cb",
        success: function (data) {
            console.log(data);
        }
    });
</script>

success回调中同样可以拿到数据。

3.5 完整jsonp封装实现

简易版

先看下我们要实现的功能

定义一个JSONP方法,它接收四个参数:

  • url
  • params
  • callbackKey:与后台约定的回调函数是用哪个字段(如cb)
  • callback:拿到数据之后执行的回调函数
<script>
    function JSONP({
        url,
        params = {},
        callbackKey = 'cb',
        callback
    }) {
        // 定义本地的一个callback的名称
        const callbackName = 'jsonpCallback';
        // 把这个名称加入到参数中: 'cb=jsonpCallback'
        params[callbackKey] = callbackName;
        //  把这个callback加入到window对象中,这样就能执行这个回调了
        window[callbackName] = callback;

        // 得到'id=1&cb=jsonpCallback'
        const paramString = Object.keys(params).map(key => {
            return `${key}=${params[key]}`
        }).join('&')
        // 创建 script 标签
        const script = document.createElement('script');
        script.setAttribute('src', `${url}?${paramString}`);
        document.body.appendChild(script);
    }
    JSONP({
        url: 'http://localhost:8080/api/jsonp',
        params: { id: 1 },
        callbackKey: 'cb',
        callback (res) {
            console.log(res)
        }
    })
</script>

这样写打开页面也可是可以看到效果的。

同时多个请求

上面我们虽然实现了JSONP,但有一个问题,那就是如果我同时多次调用JSONP

JSONP({
    url: 'http://localhost:8080/api/jsonp',
    params: { id: 1 },
    callbackKey: 'cb',
    callback (res) {
        console.log(res) // No.1
    }
})
JSONP({
    url: 'http://localhost:8080/api/jsonp',
    params: { id: 2 },
    callbackKey: 'cb',
    callback (res) {
        console.log(res) // No.2
    }
})

可以看到这里我调用了两次JSONP,只是传递的参数不同。但是并不会按我们预期的在No.1No.2中分别打印,而是都会在No.2中打印出结果。这是因为后面一个callbackJSONP里封装的第一个callback给覆盖了,它们都是共用的同一个callbackName,也就是jsonpCallback。如下所示:

image.png

两次结果都是从76行打印出来的。

所以我们得改造一下上面的JSONP方法:

callbackName是一个唯一的,可以使用递增 不要把回调定义在window中这样会污染全局变量,可以把它扔到JSON.xxx中 OK👌,来看看改造之后的代码:

<script>
    function JSONP({
        url,
        params = {},
        callbackKey = 'cb',
        callback
    }) {
        // 定义本地的唯一callbackId,若是没有的话则初始化为1
        JSONP.callbackId = JSONP.callbackId || 1;
        let callbackId = JSONP.callbackId;
        // 把要执行的回调加入到JSON对象中,避免污染window
        JSONP.callbacks = JSONP.callbacks || [];
        JSONP.callbacks[callbackId] = callback;
        // 把这个名称加入到参数中: 'cb=JSONP.callbacks[1]'
        params[callbackKey] = `JSONP.callbacks[${callbackId}]`;

        // 得到'id=1&cb=JSONP.callbacks[1]'
        const paramString = Object.keys(params).map(key => {
            return `${key}=${params[key]}`
        }).join('&')
        // 创建 script 标签
        const script = document.createElement('script');
        script.setAttribute('src', `${url}?${paramString}`);
        document.body.appendChild(script);
        // id自增,保证唯一
        JSONP.callbackId++;
    }
    JSONP({
        url: 'http://localhost:8080/api/jsonp',
        params: { id: 1 },
        callbackKey: 'cb',
        callback (res) {
            console.log(res)
        }
    })
    JSONP({
        url: 'http://localhost:8080/api/jsonp',
        params: { id: 2 },
        callbackKey: 'cb',
        callback (res) {
            console.log(res)
        }
    })
</script>

可以看到现在调用了两次回调,但是会分别执行JSONP.callbacks[1]JSONP.callbacks[2]

image.png

继续改进

其实上面已经算比较完美了,但是还会有一个小问题,比如下面这种情况:

我改一下后端的代码

const Koa = require('koa');
const app = new Koa();
const items = [{ id: 1, title: 'title1' }, { id: 2, title: 'title2' }]

app.use(async (ctx, next) => {
  if (ctx.path === '/api/jsonp') {
    const { cb, id } = ctx.query;
    const title = items.find(item => item.id == id)['title']
    ctx.body = `${cb}(${JSON.stringify({title})})`;
    return;
  }
  if (ctx.path === '/api/jsonps') {
    const { cb, a, b } = ctx.query;
    ctx.body = `${cb}(${JSON.stringify({ a, b })})`;
    return;
  }
})
console.log('listen 8080...')
app.listen(8080);

增加了一个/api/jsonps的接口。

然后前端代码增加了一个这样的请求:

JSONP({
    url: 'http://localhost:8080/api/jsonps',
    params: {
        a: '2&b=3',
        b: '4'
    },
    callbackKey: 'cb',
    callback (res) {
        console.log(res)
    }
})

可以看到,参数的a中也会有b这个字符串,这样就导致我们获取到的数据不对了:

后台并不知道a的参数是一个字符串,它只会按照&来截取参数。

所以为了解决这个问题,可以使用URI编码。

也就是使用:

encodeURIComponent('2&b=3')

// 结果为"2%26b%3D3"

只需要改一下JSONP方法中参数的生成:

// 得到'id=1&cb=JSONP.callbacks[1]'
const paramString = Object.keys(params).map(key => {
    return `${key}=${encodeURIComponent(params[key])}`
}).join('&')

最终实现

来看一下完整版的JSONP方法:

<script>
    function JSONP({
        url,
        params = {},
        callbackKey = 'cb',
        callback
    }) {
        // 定义本地的唯一callbackId,若是没有的话则初始化为1
        JSONP.callbackId = JSONP.callbackId || 1;
        let callbackId = JSONP.callbackId;
        // 把要执行的回调加入到JSON对象中,避免污染window
        JSONP.callbacks = JSONP.callbacks || [];
        JSONP.callbacks[callbackId] = callback;
        // 把这个名称加入到参数中: 'cb=JSONP.callbacks[1]'
        params[callbackKey] = `JSONP.callbacks[${callbackId}]`;
        // 得到'id=1&cb=JSONP.callbacks[1]'
        const paramString = Object.keys(params).map(key => {
            return `${key}=${encodeURIComponent(params[key])}`
        }).join('&')
        // 创建 script 标签
        const script = document.createElement('script');
        script.setAttribute('src', `${url}?${paramString}`);
        document.body.appendChild(script);
        // id自增,保证唯一
        JSONP.callbackId++;

    }
    JSONP({
        url: 'http://localhost:8080/api/jsonps',
        params: {
            a: '2&b=3',
            b: '4'
        },
        callbackKey: 'cb',
        callback (res) {
            console.log(res)
        }
    })
    JSONP({
        url: 'http://localhost:8080/api/jsonp',
        params: {
            id: 1
        },
        callbackKey: 'cb',
        callback (res) {
            console.log(res)
        }
    })
</script>

注意⚠️:

encodeURIencodeURIComponent的区别:

encodeURI()不会对本身属于URI的特殊字符进行编码,例如冒号、正斜杠、问号和井字号; 而encodeURIComponent()则会对它发现的任何非标准字符进行编码 例如:

var url = 'https://lindaidai.wang'

encodeURI(url) // "https://lindaidai.wang"

encodeURIComponent(url) // "https%3A%2F%2Flindaidai.wang"

另外,可以使用decodeURIComponent来解码。

decodeURIComponent("https%3A%2F%2Flindaidai.wang")
// 'https://lindaidai.wang'