full

JSONP

JSON with padding (简称JSONP)是一种允许开发人员绕过(使用脚本元素的性质)浏览器强制执行的同源策略的技术。该政策禁止阅读任何来自不同网站的回复,这些网站的来源与目前使用的不同。

顺便说一句,该策略允许发送请求,但不允许读取请求的内容。

一个网站的来源由三个部分组成。首先, URI方案(https://), 主机名: 例如 www.baidu.com, 端口号: 80 或 443。

只要有其中任一部分不同,浏览器就认为是不同源。

如果想学习浏览器同源策略的更多内容,请点击这里

工作原理

假设我们在localhost:8000上,向JSON API的服务器发送请求。

1
https://www.server.com/api/person/1

服务端响应是这样的:

1
2
3
4
{
"firstName": "Maciej",
"lastName": "Cieslar"
}

由于众所周知的浏览器同源策略,请求被阻止,因为网站的起源和服务器不同。

通过将脚本src属性设置为API的URL,脚本将获取响应并在浏览器上下文中执行它。

1
<script src="https://www.server.com/api/person/1" async="true"></script>

但问题是,脚本元素自动解析并执行返回的代码。在本例中,返回的代码是上面显示的JSON片段。JSON将被解析为JavaScript代码,并因此抛出一个错误,因为它不是有效的JavaScript。

parsing-error

必须返回一段可运行的JavaScript脚本,以便浏览器能正确地解析和执行。如果我们将JSON代码分配给一个变量或将其作为参数传递给一个函数,JSON代码就可以正常工作—毕竟,JSON格式只是一个JavaScript对象。

因此,服务器可以返回JavaScript代码,而不是返回纯JSON格式。在返回的代码中,一个函数包装在JSON对象外面。函数名必须由客户机传递,因为代码将在浏览器中执行。通过查询参数中callback作为包裹函数名。

同时,我们在全局(window对象上)上下文中创建一个函数,一旦解析并执行响应,就会调用这个函数。

1
https://www.server.com/api/person/1?callback=callbackName
1
2
3
4
callbackName({
firstName: 'Maciej',
lastName: 'Cieslar',
});

等价于如下:

1
2
3
4
window.callbackName({
firstName: 'Maciej',
lastName: 'Cieslar',
});

代码在浏览器的上下文中执行。函数将在全局作用域从脚本中下载的代码中执行。

为了让JSONP工作,客户机和服务器都必须支持它。虽然定义函数名称的参数没有标准名称,但是客户端通常会通过 callback 参数来传递这个函数名。

动手实现JSONP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
let jsonpID = 0;

function jsonp(url, timeout = 7500) {
const head = document.querySelector('head');
jsonpID += 1;

return new Promise((resolve, reject) => {
let script = document.createElement('script');
const callbackName = `jsonpCallback${jsonpID}`;

script.src = encodeURI(`${url}?callback=${callbackName}`);
script.async = true;

const timeoutId = window.setTimeout(() => {
cleanUp();

return reject(new Error('Timeout'));
}, timeout);

window[callbackName] = (data) => {
cleanUp();

return resolve(data);
};

script.addEventListener('error', (error) => {
cleanUp();

return reject(error);
});

function cleanUp() {
window[callbackName] = undefined;
head.removeChild(script);
window.clearTimeout(timeoutId);
script = null;
}

head.appendChild(script);
});
}

上面代码中,声明了一个名为 jsonpID 的变量—它将用于确保每个请求都有惟一的函数名。

首先,我们将对对象的引用保存在一个名为 head 的变量中。然后增加 jsonpID 以确保函数名是惟一的。函数返回一个 Promise 实例,通过创建一个 <script> , jsonpCallbackjsonpID 组成的callbackName

然后,我们将脚本元素的 src 属性设置为指定的URL。在服务端,我们将 callback 参数设置为callbackName

请注意,这个简化的实现不支持具有预定义查询参数的url,因此它不适用于https://logrocket.com/?param=true,因为我们要追加?最后再一次。

我们还将script 的 async 属性设置为 true,确保不会阻塞页面的渲染。

最终会有三种可能的情况:

1、请求成功,并且执行window[callbackName],执行的 Promiseresolve 传入data.
2、script 标签发生异常,执行 Promisereject
3、请求花费的时间超过预期时间,直接抛出超时错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const timeoutId = window.setTimeout(() => {
cleanUp();

return reject(new Error('Timeout'));
}, timeout);

window[callbackName] = (data) => {
cleanUp();

return resolve(data);
};

script.addEventListener('error', (error) => {
cleanUp();

return reject(error);
});

回调函数必须注册在 window对象上,才能全局使用。在全局范围内执行一个名为callback()的函数相当于调用window.callback()

通过在cleanup函数中抽象清理过程,三个回调—超时、成功和错误侦听器—看起来完全相同。唯一的区别是他们是否解决或拒绝承诺。

1
2
3
4
5
6
function cleanUp() {
window[callbackName] = undefined;
head.removeChild(script);
window.clearTimeout(timeoutId);
script = null;
}

cleanUp函数是为了在请求完成之后清理请求过程中产生的对象:该函数首先删除注册在窗口上的回调,该回调在成功响应时调用。然后从head中删除脚本元素并清除定时器对象。另外,只是为了确定,它将脚本引用设置为null,以便对其进行垃圾收集。

最后,我们将 <script> 标签添加到 head, 会自动触发请求。

1
2
3
jsonp('https://gist.github.com/maciejcieslar/1c1f79d5778af4c2ee17927de769cea3.json')
.then(console.log)
.catch(console.error);

总结结论

JSONP的原理很简单,只有有创造力的程序员才能想出来这种绕过限制的方法。JSONP是历史的用法,使用上存在一些限制条件,比如只能发送get请求和许多安全问题。

我们应该依赖于跨源资源共享(CORS)机制来提供安全的跨源请求。