Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

第三方Javascript开发系列之前后端接口协议 #25

Open
zmmbreeze opened this issue May 10, 2017 · 0 comments
Open

第三方Javascript开发系列之前后端接口协议 #25

zmmbreeze opened this issue May 10, 2017 · 0 comments

Comments

@zmmbreeze
Copy link
Owner

zmmbreeze commented May 10, 2017

在Web网页开发中有一个有意思的分支,既第三方Javascript脚本的开发。所谓第三方Javascript脚本,就是第三方服务商将自己的服务通过“HTML投放代码”的形式提供给网站使用。由于Javascript的动态特性,一般的第三方服务都会直接或间接的提供Javascript文件给网站页面加载。

tmp1

之前我们已经讨论过第三方Javascript的投放代码设计相关要点了。这次我们进入下一步,来确定下前后端接口协议。下图是一般的第三方Javascript脚本使用流程:

javascript sdk 1

可以看到第三方Javascript是运行在开发者(即客户)所提供的网页中,而所在网页的域名往往和我们的接口服务器地址所在的域名不同。所以在设计前后端接口时需要使用支持跨域的前后端接口协议。

之所以出现跨域问题,是因为浏览器为了安全启用了一种同源策略(same-origin policy)。

在讨论同源策略的影响之前先要知道什么是『源』。从同源策略的英文same-origin policy,我们就已经知道源的英文origin。大家可以在浏览器的console里面运行一下console.log(location.origin)打印出当前网页的源是什么。就大概知道它其实是包含了网页地址的一些部分。

可以看到『源』其实包含了三个部分:协议、域名和端口。只要这三者不同就会受到同源策略的影响。IE是一个例外:端口号并未加入到同源策略的组成部分之中。所以http://example.com:8080/http://example.com属于同源并且不受任何限制。

再说回同源策略的影响:它会阻止网页上的Javascript向不同源的服务器发起XMLHttpRequest请求,如下简称XHR。除此之外还会阻止两个不同源的网页互相访问。这不是这篇文章要讨论的点,这里主要讨论的是网页Javascript与服务器之间的跨域请求。

为什么要阻止不同源的XHR请求呢?设想一下,你作为一个普通用户在浏览器里面刷着淘宝(https://taobao.com),这时候邮箱里面来了一封邮件。标题非常的吸引人,于是你不小心在同一个浏览器中点开了(https://attack.com)。这时候网页内包含的恶意代码就可以向淘宝的服务器去发起请求,获取你的隐私(例如商品浏览记录)。

因为你用了同一个浏览器,浏览器会记着你的登录session(即cookie)。幸好浏览器有同源策略,taobao.comattack.com不是同源所以不能发起请求。试想一下如果没有同源策略,那网站将会是一个非常不安全的地方。

CORS

其实大部分的主流浏览器的XHR接口都已经支持了Cross-Origin Resource Sharing(CORS)。通过CORS浏览器便可以轻松实现跨域的请求了。例子如下:

function xhr(url, params, callback, opts) {
    var XHR = XMLHttpRequest;
    var xhr = new XHR();
    xhr.open('POST', url, true);
    xhr.withCredentials = true;
    if (typeof params === 'string') {
        xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    }

    if (callback) {
        xhr.onreadystatechange = function () {
            if (xhr.readyState !== 4) {
                return;
            }

            callback(xhr.responseText || '');
        };
    }
    xhr.send(params);
}
xhr('http://test.com/xhr/users', 'id=0067ED');

你不需要担心安全问题,因为浏览器会判断请求返回的头部中是否包含Access-Control-Allow-Origin: http://example.com这样的头部信息。如果返回的Access-Control-Allow-Origin值包含当前网页的源,那么XHR才会拿到正确的返回值。而这个头部是否要返回完全是由服务器端来控制的。

这里面有两个细节:

  1. 如果XML发起的请求不是一个简单请求,那么浏览器会便会先发起一次 CORS 预检请求来检查自己是否有权限。所以为了避免多余的请求,一定要确保自己的请求是简单请求
  2. 如果想要在请求返回时由服务器种上第三方cookie,那么需要给XHR实例设置其withCredentials属性为true。在IE8-9中,微软给出过一个类似的标准实现:XDomainRequest接口(以下简称XDR)。这个接口虽然也能够实现跨域请求,但是它有一些限制。限制之一就是IE会剥离所有的cookie之后再发给服务器端,同时IE也会忽略所有的服务器端返回中的Set-Cookie指令。简而言之就是不支持cookie

CORS的支持程度来说其实它已经几乎被所有的主流浏览器支持了,除了IE<10。随着浏览器的更新换代,相信不久之后CORS将会成为跨域请求的终极选择。不过目前为止,如果你的第三方Javascript还需要支持IE<10,那么你需要考虑一些其他方案。

JSONP协议

提到跨域请求一般大家先想到的是JSONP,其本质是利用浏览器可以加载不同域名下的Javascript文件。服务器端根据URL上的参数动态的生成不同的Javascript文件返回。这样浏览器端便可以接收到服务端的返回结果了。

例如一个典型的JSONP请求接口地址如下:

http://test.com/jsonp?callback=callback&id=0067ED

服务器端则会返回如下Javascript文件,其中包含了数据:

callback && callback({
    id: '0067ED',
    username: 'mmzhou'
    // ......
});

浏览器在接收到返回之后会自动运行这段JS,然后调用全局的callback方法。这个callback方法是由调用方的JS事先事先准备好的,这样来接收到返回的参数。通过JSONP协议服务器端可以传给浏览器大量的数据。通常请求下一般都会使用JSONP来实现拉取数据、获取配置等等才做。但有的时候我们仅仅需要向后端发送数据,并且不关心返回结果是什么,甚至不在乎有没有成功。

ping

这种只需要发送数据且不在乎返回结果的请求类型,我们在这篇文章中称之为ping。也有很多人喜欢称之为beacon。

小图片

ping协议其实是一种很古老的跨域方法。它的本质是利用了浏览器可以获取不同域名下图片的原理,把请求参数放在了图片地址的URL参数中发给后端。因为是图片的请求,所以网页上的JS是不能得到返回图片是什么样的。它的大致JS代码如下:

function ping(url, callback) {
    var img = new Image();
    window[key] = img;
    var done = function () {
        img.onload = img.onerror = img.onabort = null;
        window[key] = null;
        img = null;
        if (callback) {
            callback();
        }
    };
    img.onload = img.onerror = img.onabort = done;
    img.src = url;
}
ping('http://test.com/ping?id=0067ED&type=pageview');

浏览器通过Image接口实例化之后的赋值其src属性为请求接口的URL地址,并把请求参数放在URL地址中。可以看到图片实例不需要插入到页面DOM树中,避免了返回图片展示出来被用户看到。

后端在接到请求之后返回204请求(表示执行成功,但是没有数据)或者是一张1x1像素的gif图片。204与gif图片的区别在于你是否想要知道请求响应是否成功。因为204返回会在部分浏览器下导致onerror的回调,这就会让JS分不清是请求发送失败还是成功。

页面关闭时发送数据

ping请求是很轻的,因为它只需要创建一个Image实例即可。但是它也有一个缺陷。我们先假设第三方Javascript需要实现一个功能:在页面关闭时发送请求给服务器端记录页面已经被关闭。一般的做法是在onload或者beforeOnload事件中发送ping请求。绝大多数浏览器会延迟卸载以保证图片的载入(数据发送成功),但并不是所有浏览器都是如此。而且浏览器会忽略在onload事件回调中产生的异步XHR请求 。所以要确保请求发送成功是很难的。

如果浏览器支持CORS,那么发送同步的XHR请求是可以 block 浏览器的UI主线程,同样也可以block浏览器关闭直到服务器端返回结果。但是如果服务器端响应慢,耗时超过250ms以上(普通人能够感知到了),这就会带来很差的用户体验。同时在浏览器UI主线程中的同步XHR请求已经在标准中被列为deprecated,之后浏览器将可能会不再支持(尽管这是一个漫长的过程)。

既然 block 住浏览器的UI主线程是可以延迟浏览器关闭的,那么可以想到另一个方法就是人为的设置一段时间的延迟。大概代码如下:

ping('http://test.com/ping?id=0067ED&type=pageclose');
// dead loop for 200ms to make sure imgPing success.
var end;
var delay = 200;
var now = +new Date();
for (end = now + delay; now < end;) {
    now = +new Date();
}

通过200ms的JS死循环来延迟浏览器关闭,为发送ping请求争取到更多的时间。200ms是一个很短的时间,用户一般不会察觉到。这其实在第三方Javascript开发中是一个常用的手段。

Navigator.sendBeacon

标准的制定者早已经看到了这个需求,所以已经提供了一个Navigator.sendBeacon接口来实现ping协议现在在做的事情。这个方法可以用来从浏览器向服务端异步地发送小的HTTP数据。它不像之前提到的两种方法,会延迟页面的onload影响下一个页面的加载。可惜的是至少目前为止(2017年5月8日)IE和Safari浏览器还没有支持它。

window.addEventListener('unload', function () {
    navigator.sendBeacon('/ping', data);
}, false);

智能ping协议

无论是小图片方式的ping请求还是JSONP请求本质上还是是一个GET请求,它通过URL地址上的参数来传输数据。因为URL地址的长度虽然HTTP协议中没有明确说明,但是很多浏览器都有一个自己的上限。例如IE8的长度限制是2083。所以URL的长度最好不要太长。这就导致了一次JSONP的请求可以携带的请求参数量有限。这也是它们的最大缺点。

这点在XHR CORS和sendBeacon方法中是不存在的,因为它们可以发起POST请求,把请求参数放在HTTP body中。避免了数据传输量小的问题。

图片、XHR CORS和sendBeacon接口各有优缺点,如下:

传输数据量 关闭时发送 浏览器支持程度
Image().src 少(2083字符) 半支持(死循200ms) 全平台
XHR CORS 不适合 非IE的主流浏览器
navigator.sendBeacon 标准支持 最差

我们可以根据传输数据在这三者之中智能地选择一种来传输。

普通情况下默认使用图片方式,因为它最轻量(GET方法)没有太多副作用。URL超过2083字符时优先使用navigator.sendBeacon方法,如果它不支持再使用XHR CORS方法。如果XHR CORS也不支持则再fallback到图片方式发送数据。能发则发部分浏览器会截断URL后发送。

如果是在onload事件回调中发送数据,则默认优先使用navigator.sendBeacon方法发送数据。不支持时再使用200ms延迟的小图片方式发送数据。

submit协议

之前已经提到过无论是小图片方式的ping协议还是JSONP协议,都存在一个发送数据量不能太大的问题。尤其是遇到图片上传之类的需求,使用它们是肯定做不到的。不过主流浏览器已经支持了FormData接口,有了它便可以通过XHR的方式发送文件了。例子如下:

// 用户在表单中选好文件
var formElement = document.getElementById('form');
formElement.onsubmit = function (e) {
    // 提交时改为XHR提交
    e.preventDefault();
    var formData = new FormData(formElement);
    xhr('http://test.com/xhr/file', formData);
};

虽然主流浏览器都支持了FormData接口,但是IE<=9仍旧不支持。为了解决这个问题我需要采用一些fallback手段。

form标签

HTML的form标签的提交是不受到同源策略限制的。换言之我们可以通过form标签来实现跨域请求。它的大致流程如下:

2017-05-10 10 35 59

首先通过JS来创建一个同源的iframe标签,并在其内部创建一个form标签。然后把需要发送的数据项创建成一个个input标签添加到form标签内部。最后调用form标签的submit方法触发异步提交(当前页面不需要刷新)。

服务端接收到数据之后,把返回数据封装成到一张HTML网页中。这个网页会在iframe标签中执行展示,并将服务端返回的结果(例如JSON数据)通过跨域通讯的方式传给第三方JS所在的运行环境(即window.parent)。

这之中有几个细节:

  1. 同源的iframe标签的src属性必须是about: blank或者javascript: URL(即javascript伪协议),iframe标签内的文档可以继承原始文档的源。这样就不会因为同源策略而导致外部的第三方JS不能在iframe标签内添加form标签。同时也可以不用走网络请求去加载一个空白页面
  2. 之所以没有使用form标签的target属性来指向提交目标的iframe标签,是因为要减少对已有站点的影响
  3. 因为返回的HTML网页的域名是我们的接口域名,与当前网页不是同源,故会受到同源策略影响。需要用postMessage接口来实现跨域的通讯,这块在稍后的文章中会提到
  4. 请确保接口返回的Content-Type头部是text/html,不然部分浏览器可能会有不正确的行为
  5. form表单是可以上传文件的。在IE9+下因为一些特殊的安全策略,必须由用户交互(例如点击)才能触发文件选择框去选文件。不能通过input.click()这样的JS方法来触发。所以开发者需要创建一个用户可见的form表单去让用户选文件然后异步提交

在支持CORS和FormData接口的浏览器中,就可以优先XHR去提交数据。不支持的浏览器再fallback到form+iframe的方式异步提交。至此我们大概可以得到一个完整submit协议,调用方式如下:

// 普通提交方式
submit('/api/submit', data, function (data) {
    console.log(data);
});
// 文件上传方式
var formElement = document.getElementById('form');
submit(formElement, function (data) {
    console.log(data);
});

总结

这篇文章中我们总结三种方式的前后端跨域接口协议:JSONP、ping以及submit协议。它们分别用于不同场景,主要的区别如下:

JSONP ping submit
提交数据量 少(2083字符) 少(2048字符)
接收数据 支持 不适合(图片宽高作为数据) 支持
支持文件上传 不支持 不支持 支持
页面关闭时发送 不支持 支持 不支持

从表中可以看出来:JSONP协议主要适合于拉取数据、获取配置等不需要大数据量提交的操作;ping协议则更适用于不要关心返回结果的少数据量提交,尤其适合在页面关闭是发送数据;submit协议则适用于大数据量的提交,尤其适合文件上传;

作为一个第三方Javascript脚本的开发者,我们需要在不同的场景下选择最合适的接口协议。希望这篇文章对你有所帮助~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant