目前 HTML5 已经逐渐普及并成为主流,与之相关的 Single Page App 技术也逐渐被广泛应用起来,加上 Canvas 等等新的工具的支持,在前端可以做的事情可谓是非常多。
但是,不得不否认,各种原生的 HTML5 工具支持兼容性还并不是太好,本文的缘起就是基于在微信浏览器(QQ浏览器X5内核)下面开发表单提交上传附件的环节,出现了兼容性的问题(具体情况就是往 FormData 对象中置入 Blob 对象的时候产生 bug,提交数据为空。)
因此,本人基于 jQuery,深入底层研究了 jQuery AJAX 对请求的封装,以及在客户端的一些二进制流的处理,颇有心得,需要总结下来。
好文:https://hacks.mozilla.org/2011/01/how-to-develop-a-html5-image-uploader/
好文:http://www.zhangxinxu.com/wordpress/2013/10/understand-domstring-document-formdata-blob-file-arraybuffer/
FormData 是一个 HTML5 的原生对象,使用 FormData 可以将一个 Form 或者一系列的字段包装成一个对象,然后通过 jQuery 或者标准的 XHR 进行 Ajax 发送。
下面是一个简单的例子:
var formElement = document.querySelector("form");
var request = new XMLHttpRequest();
request.open("POST", "submitform.php");
request.send(new FormData(formElement));
var formData = new FormData();
formData.append("username", "Groucho");
formData.append("accountnum", 123456); // number 123456 is immediately converted to a string "123456"
// HTML file input, chosen by user
formData.append("userfile", fileInputElement.files[0]);
// JavaScript file-like object
var content = 'hey!'; // the body of the new file...
var blob = new Blob([content], { type: "text/xml"});
formData.append("webmasterfile", blob);
var request = new XMLHttpRequest();
request.open("POST", "http://foo.com/submitform.php");
request.send(formData);
如果使用 jQuery,产生含有数据的 FormData 对象之后,我们可以将其传进 $.ajax
的 data 参数里面。
$.ajax({
url: 'http://example.com/api',
method: 'post',
processData: false
contentType: false,
data: formdata
});
这种情况,只需要将 formdata 对象传入,并且制定 processData 和 contentType 为 false,就可以用 multipart/form-data
的方式通过 ajax post 一个请求出去,当然这里面就可以包含一般的二进制对象(File 或者 Blob)
但是,实际测试中发现腾讯QQ浏览器在将 Blob 传入 Formdata 中的时候就会出问题,肯定是内核对 FormData 的实现上面有 Bug。
于是为了兼容这个问题,我试图自己封装一个模拟出来的表单,即由 boundary 分割的multipart/form-data
请求体。
再重复一下,如果使用 multipart/form-data
的 Content-Type 去提交一个请求,实际上发出的 HTTP 请求是这样的:
请求头:
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryWwE7y8P3JK82rxsk
请求体:
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png
[binary stream]
------WebKitFormBoundaryWwE7y8P3JK82rxsk--
关键的格式就是这样,只要满足这个规范,后台就可以从(例如php) $_POST
和 $_FILE
获取提交的字段或者上传的文件。
因此,只要我们能够将这样的请求头和请求体按格式生成出来,就可以为所欲为了。
至于我们的调用方式,就是通过 $.ajax
的接口来给出。
其中,请求头很简单,首先随机一个 boundary 字符串,然后通过 ajax 的 contentType 参数输入即可:
var makeBoundary = function() {
return '----JQBoundary'+btoa(Math.random().toString()).substr(0,12);
};
var boundary = makeBoundary();
$.ajax({
contentType: 'multipart/form-data; boundary='+boundary,
// ...
});
如此即可在 ajax 请求中指定请求头。
难点在于请求体 ,请求体是在 $.ajax
方法的 data 部分给出的。
一般初学者来说,传进去的 data 是一个字典,还有我们刚刚上面提到的,给一个 FormData 对象也是可以的。
然后有一个关键点,processData 参数默认是 true,这时候 jQuery 在 ajax 之前会将我们传进去的字典串行化之后,放在 url 中(get 方式)或者放在 payload 请求体里面。
那么如果我们传进去 FormData 或者后面要讲的,传进去一个二进制流,就需要将这个 processData 设置为 false 了。
首先,我们并不知道,data 这个参数除了会吃字典和 FormData 还会吃些什么,我们先假设它会吃普通的 string。
所以我们试一下先不涉及二进制内容,将一个含有 unicode 内容的字符串传进去,看看能行不能行:
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: '--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="username"\r\n\r\n' +
'呆滞的慢板\r\n' +
'--' + boundary + '--\r\n'
});
上面这段是行得通的,因为是我从后台读取请求体之后一比一仿造出来的,肯定可以骗过后台。
只是我们要知道,前端还默默为我们做了一件事,就是将中文自动执行了编码,因为从前台看,’呆滞的慢板’在字符串中的长度是 5,但是在后台看,这五个字被编制成了 15 位的 utf 编码二进制串。
ok,一种方法行得通,那么如果涉及二进制内容呢(例如图片)。
首先,我们需要读取二进制流的内容。
对于二进制流,我们可以这样获取:
var file = document.getElementById('fileElement').files[0];
var reader = new FileReader();
reader.onload = function(e) {
var content = e.target.result;
console.log(content);
}
reader.readAsBinaryString();
这样得出来的 binaryString 格式,是一个字节流,与 atob (相当于base64decode) 一个 base64 串得到的输出是同样的格式。
同样,还有 readAsText,readAsDataUrl, readAsArrayBuffer 等方法,但是获取出来的e.target.result
是不一样的。
可以看到,如果这样,就可以异步获取文件的二进制内容,作为一个字符串,然后我们加上 boundary 拼接到其他字段的整体 formdata 中,然后就可以最终串接成一个完整的 payload 了。
然后我们将这样的 data ajax 出去,发现死翘翘了。
失败的原因是,由于文本类型(而且还是 unicode 文本)类型与直接的二进制流放在一起,产生了编码混乱,ajax 发出之间,由于这是一个字符串,因此 xhr 对象帮我们自动编码这个字符串,结果造成了二进制流的破坏,后台识别不出来了。
换个说法,我们遍历这个字符串,碰到一些中文的 unicode 字符,他的取值是超出一个字节的,因此作为流编码,应该按照 utf8 方式,编码成三个字节才对。
那怎么办?只有我们自己来做了。
经过了无尽的折腾撞墙试错,直接写出宝贵的结论:
可以通过 unicode 和二进制混编构造的字符串,在传递给 ajax 之前,将其一个一个字节编码到 Uint8Array 中,再获取其 buffer,作为 data 传给 ajax。
首先,我们要将中间所有涉及的 unicode 字符一个一个拆开:
关于这个问题,我在另一篇文章已经写得很详细了:
https://www.huangwenchao.com.cn/2015/09/javascript-utf8-encoding.html
于是我们可以得到一个确保每一个值都不会超过一个字节的字符串。
/**
* Encode a given string to utf8 encoded binary string.
* @param str:
* @returns string:
*/
var str2utf8 = window.TextEncoder ? function(str) {
var encoder = new TextEncoder('utf8');
var bytes = encoder.encode(str);
var result = '';
for(var i = 0; i < bytes.length; ++i) {
result += String.fromCharCode(bytes[i]);
}
return result;
} : function(str) {
return eval('\''+encodeURI(str).replace(/%/gm, '\\x')+'\'');
};
然后我们将其编码成 Uint8Array,过程省略,最终就是这个函数:
var str2Uint8Array = function(str) {
var arr = [], c;
for(var i = 0; i < str.length; ++i) {
c = str.charCodeAt(i);
if(c > 0xff) {
alert('Char code range out of 8 bit, parse error!');
return [];
}
arr.push(str.charCodeAt(i));
}
return new Uint8Array(arr);
};
那么最终我们可以这样来发送一个 ajax,就可以完全兼容二进制流和普通字段了:
var strctured_body = '...'; // 这是我们手工混编出来的,带有 unicode 字符的完整 request body
var encoded_body = str2utf8(structured_body);
var byte_array = str2Uint8Array(encoded_body);
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: byte_array.buffer
});
试了无数种方法,最后只有这样能够将自己编制的内容完整 post 出去,使用 ArrayBuffer 的格式。
最终,我还是耐不住寂寞,做了一个插件,自动做好这些封装,当然,中间还涉及到了接口的设计,如果再做此类工作,参考我的这个插件就可以了。
对象的使用