在思否答题遇到几个关于图片上传的问题,中间都涉及到 ArrayBuffer
的概念,心心念念想整理下这方面的知识,也希望让更多人能有所收获。
各位看官,一起开始吧。
1. 如何上传文件
前端中上传一般使用 FormData
创建请求数据,示例如下:
var formData = new FormData();
formData.append("username", "Groucho");
// HTML 文件类型 input,由用户选择
formData.append("userfile", fileInputElement.files[0]);
// JavaScript file-like 对象
var content = '<a id="a"><b id="b">hey!</b></a>'; // 新文件的正文...
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);
FormData 对象的字段类型可以是 Blob, File, 或者 string,如果它的字段类型不是 Blob 也不是 File,则会被转换成字符串。
我们通过 <input type="input"/>
选择图片,把获取到的 file
放到FormData
,再提交到服务器。
如果上传多个文件,就追加到同一个字段中。
fileInputElement.files.forEach(file => {formData.append('userfile', file);
})
其中的 file-like
和new Blob
的示例说明我们可以构造一个新的文件直接上传。
场景 1:剪辑图片上传
我们通过裁剪库可以得到 data url
或者canvas
。
以 cropperjs
举例,使用 getCroppedCanvas
获取到 canvas
,然后利用自身的toBlob
获取到 file
数据,再通过 FormData
上传。
转换的核心代码可以参考下面:
canvas = cropper.getCroppedCanvas({
width: 160,
height: 160,
});
initialAvatarURL = avatar.src;
avatar.src = canvas.toDataURL();
// 从 canvs 获取 blob 数据
canvas.toBlob(function (blob) {var formData = new FormData();
formData.append('avatar', blob, 'avatar.jpg');
// 接下来可以发起请求了
makeRequest(formData)
})
场景 2:base64 图片上传
获取到 base64
形式的图片后,我们通过下面函数转为 blob
形式:
function btof(base64Data, fileName) {const dataArr = base64Data.split(",");
const byteString = atob(dataArr[1]);
const options = {
type: "image/jpeg",
endings: "native"
};
const u8Arr = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {u8Arr[i] = byteString.charCodeAt(i);
}
return new File([u8Arr], fileName + ".jpg", options);
}
这样我们拿到了文件file
,然后就可以继续上传了。
场景 3:URL 图片上传
想要直接用图片 URL 上传,我们可以分成两部来做:
- 获取
base64
- 然后转为
file
其中关键代码是如何从 URL
中创建 canvas
,这里通过创建Image
对象,在图片挂载之后,填充到到 canvas
中。
var img =
"https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=508387608,2848974022&fm=26&gp=0.jpg"; //imgurl 就是你的图片路径
var image = new Image();
image.src = img;
image.setAttribute("crossOrigin", "Anonymous");
image.onload = function() {
// 第 1 步:获取 base64 形式的图片
var base64 = getBase64Image(image);
var formData = new FormData();
// 第 2 步:转换 base64 到 file
var file = btof(base64, "test");
formData.append("imageName", file);
};
function getBase64Image(img) {var canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
var ext = img.src.substring(img.src.lastIndexOf(".") + 1).toLowerCase();
var dataURL = canvas.toDataURL("image/" + ext);
return dataURL;
}
<p class=”codepen” data-height=”355″ data-theme-id=”0″ data-default-tab=”js,result” data-user=”ineo6″ data-slug-hash=”MWgpGQZ” data-preview=”true” style=”height: 355px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;” data-pen-title=”url image 转为 base64″>
<span>See the Pen
url image 转为 base64 by neo (@ineo6)
on CodePen.</span>
</p>
<script async src=”https://static.codepen.io/ass…;></script>
2. 思考
虽然前文提到的场景我们解决了,但是里面包含了这些关键词,不得不让人思考:
- Blob
- File
- Uint8Array
- ArrayBuffer
- TypedArray
- Base64
- atob,btoa
这些关键词都指向 ” 文件 ”、” 二进制 ”、” 编码 ”,也是我们平时不太会注意的点。
之前使用到 File
、Blob
时心里也一直有疑惑。
到底这些有什么作用呢?接下来可以看看我整理的这些知识。
3. 概念
3.1 Blob
Blob 对象表示一个不可变、原始数据的类文件对象。
File
接口也是基于 Blob
对象,并且进行扩展支持用户系统的文件格式。
3.1.1 创建 Blob 对象
要从其他非 blob
对象和数据构造 Blob
,就要使用Blob()
构造函数:
var debug = {hello: "world"};
var blob = new Blob([JSON.stringify(debug, null, 2)], {type : 'application/json'});
3.1.1 读取 Blob 对象
使用 FileReader
可以读取 Blob
对象中的内容。
var reader = new FileReader();
reader.addEventListener("loadend", function() {
//reader.result 就是内容
console.log(reader.result)
});
reader.readAsArrayBuffer(blob);
3.1.1 Object URLs
Object URLs
指的是以 blob:
开头的地址,可以用来展示图片、文本信息。
这里就有点类似 base64
图片的展示,所以我们同样可以用来预览图片。
下面代码片段就是把选中的图片转为 Object URLs
形式。
function handleFiles(files) {if (!files.length) {fileList.innerHTML = "<p>No file!</p>";} else {
fileList.innerHTML = "";
var list = document.createElement("ul");
fileList.appendChild(list);
for (var i = 0; i < files.length; i++) {var li = document.createElement("li");
list.appendChild(li);
var img = document.createElement("img");
// 从文件中创建 object url
img.src = window.URL.createObjectURL(files[i]);
img.height = 60;
img.onload = function() {
// 加载完成后记得释放 object url
window.URL.revokeObjectURL(this.src);
}
li.appendChild(img);
var info = document.createElement("span");
info.innerHTML = files[i].name + ":" + files[i].size + "bytes";
li.appendChild(info);
}
}
}
demo
3.2 Typed Arrays – 类型化数组
类型化数组是一种类似数组的对象,提供了访问原始二进制数据的功能。但是类型化数组和正常数组并不是一类的,
Array.isArray()
调用会返回false
。
Typed Arrays
有两块内容:
- 缓冲(ArrayBuffer)
- 视图(TypedArray 和 DataView)
3.2.1 ArrayBuffer
ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。
ArrayBuffer 不能直接操作,而是要通过TypedArray
或DataView
对象来操作,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。
ArrayBuffer
主要用来高效快速的访问二进制数据,比如 WebGL
, Canvas 2D
或者 Web Audio
所使用的数据。
接下来我们结合 TypedArray
一起理解下。
3.2.2 TypedArray
TypedArray
可以在 ArrayBuffer
对象之上,根据不同的数据类型建立视图。
// 创建一个 8 字节的 ArrayBuffer
const b = new ArrayBuffer(8);
// 创建一个指向 b 的 Int32 视图,开始于字节 0,直到缓冲区的末尾
const v1 = new Int32Array(b);
// 创建一个指向 b 的 Uint8 视图,开始于字节 2,直到缓冲区的末尾
const v2 = new Uint8Array(b, 2);
// 创建一个指向 b 的 Int16 视图,开始于字节 2,长度为 2
const v3 = new Int16Array(b, 2, 2);
Int32Array
,Uint8Array
之类指的就是 TypedArray
,TypedArray
对象描述的是底层二进制数据缓存区的一个类似数组 (array-like) 的视图。
它有着众多的成员:
Int8Array();
Uint8Array();
Uint8ClampedArray();
Int16Array();
Uint16Array();
Int32Array();
Uint32Array();
Float32Array();
Float64Array();
再来看一个小栗子:
var buffer = new ArrayBuffer(2)
var bytes = new Uint8Array(buffer)
bytes[0] = 65 // ASCII for 'A'
bytes[1] = 66 // ASCII for 'B'
// 查看 buffer 内容
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri) // AB
字节序
上面的例子中,我们先写入 ’A’,再写入 ’B’,当然我们也可以通过 Uint16Array
一下写入两个字节。
var buffer = new ArrayBuffer(2) // 两个字节的缓冲
var word = new Uint16Array(buffer) // 以 16 位整型访问缓冲
// 添加 'A' 到高位,添加 'B' 到低位
var value = (65 << 8) + 66
word[0] = value
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri) // BA
执行这段代码你会发现,为什么看到的是 ”BA” 而不是 ”AB”?
这是因为还有 ” 字节序 ” 的存在,分别是小端字节序和大端字节序。
比如,一个占据四个字节的 16 进制数0x12345678
,决定其大小的最重要的字节是“12”,最不重要的是“78”。小端字节序将最不重要的字节排在前面,储存顺序就是 78563412;大端字节序则完全相反,将最重要的字节排在前面,储存顺序就是 12345678。
因为浏览器使用的是小端字节序,就导致我们看到的是 ”BA”。为了解决字节序不统一的问题,我们可以使用 DataView
设定字节序。
TypedArray.prototype.buffer
TypedArray
实例的 buffer
属性,返回整段内存区域对应的 ArrayBuffer
对象。该属性为只读属性。
const a = new Float32Array(64);
const b = new Uint8Array(a.buffer);
上面代码的 a 视图对象和 b 视图对象,对应同一个 ArrayBuffer 对象,即同一段内存。
TypedArray.prototype.byteLength,TypedArray.prototype.byteOffset
byteLength
属性返回 TypedArray
数组占据的内存长度,单位为字节。byteOffset
属性返回 TypedArray
数组从底层 ArrayBuffer
对象的哪个字节开始。这两个属性都是只读属性。
const b = new ArrayBuffer(8);
const v1 = new Int32Array(b);
const v2 = new Uint8Array(b, 2);
const v3 = new Int16Array(b, 2, 2);
v1.byteLength // 8
v2.byteLength // 6
v3.byteLength // 4
v1.byteOffset // 0
v2.byteOffset // 2
v3.byteOffset // 2
TypedArray.prototype.length
length
属性表示 TypedArray
数组含有多少个成员。注意将 length
属性和 byteLength
属性区分,前者是成员长度,后者是字节长度。
const a = new Int16Array(8);
a.length // 8
a.byteLength // 16
TypedArray.prototype.set()
TypedArray
数组的 set
方法用于复制数组(普通数组或 TypedArray
数组),也就是将一段内容完全复制到另一段内存。
const a = new Uint8Array(8);
const b = new Uint8Array(8);
b.set(a);
set
方法还可以接受第二个参数,表示从 b
对象的哪一个成员开始复制 a
对象。
TypedArray.prototype.subarray()
subarray
方法是对于 TypedArray
数组的一部分,再建立一个新的视图。
const a = new Uint16Array(8);
const b = a.subarray(2,3);
a.byteLength // 16
b.byteLength // 2
TypedArray.prototype.slice()
TypeArray
实例的 slice
方法,可以返回一个指定位置的新的 TypedArray
实例。
let ui8 = Uint8Array.of(0, 1, 2);
ui8.slice(-1)
// Uint8Array [2]
TypedArray.of()
TypedArray
数组的所有构造函数,都有一个静态方法 of
,用于将参数转为一个TypedArray
实例。
Float32Array.of(0.151, -8, 3.7)
// Float32Array [0.151, -8, 3.7]
下面三种方法都会生成同样一个 TypedArray 数组。
// 方法一
let tarr = new Uint8Array([1,2,3]);
// 方法二
let tarr = Uint8Array.of(1,2,3);
// 方法三
let tarr = new Uint8Array(3);
tarr[0] = 1;
tarr[1] = 2;
tarr[2] = 3;
TypedArray.from()
静态方法 from
接受一个可遍历的数据结构(比如数组)作为参数,返回一个基于这个结构的 TypedArray
实例。
Uint16Array.from([0, 1, 2])
// Uint16Array [0, 1, 2]
这个方法还可以将一种 TypedArray 实例,转为另一种。
const ui16 = Uint16Array.from(Uint8Array.of(0, 1, 2));
ui16 instanceof Uint16Array // true
from
方法还可以接受一个函数,作为第二个参数,用来对每个元素进行遍历,功能类似 map
方法。
Int8Array.of(127, 126, 125).map(x => 2 * x)
// Int8Array [-2, -4, -6]
Int16Array.from(Int8Array.of(127, 126, 125), x => 2 * x)
// Int16Array [254, 252, 250]
上面的例子中,from
方法没有发生溢出,这说明遍历不是针对原来的 8 位整数数组。也就是说,from 会将第一个参数指定的 TypedArray
数组,拷贝到另一段内存之中,处理之后再将结果转成指定的数组格式。
复合视图
由于视图的构造函数可以指定起始位置和长度,所以在同一段内存之中,可以依次存放不同类型的数据,这叫做“复合视图”。
const buffer = new ArrayBuffer(24);
const idView = new Uint32Array(buffer, 0, 1);
const usernameView = new Uint8Array(buffer, 4, 16);
const amountDueView = new Float32Array(buffer, 20, 1);
上面代码将一个 24 字节长度的 ArrayBuffer 对象,分成三个部分:
- 字节 0 到字节 3:1 个 32 位无符号整数
- 字节 4 到字节 19:16 个 8 位整数
- 字节 20 到字节 23:1 个 32 位浮点数
3.2.3 DataView – 视图
如果一段数据包含多种类型,我们还可以使用 DataView
视图进行操作。
DataView 视图提供 8 个方法写入内存。
dataview.setXXX(byteOffset, value [, littleEndian])
- byteOffset 偏移量,单位为字节
- value 设置的数值
- littleEndian 传入 false 或 undefined 表示使用大端字节序
setInt8:写入 1 个字节的 8 位整数。setUint8:写入 1 个字节的 8 位无符号整数。setInt16:写入 2 个字节的 16 位整数。setUint16:写入 2 个字节的 16 位无符号整数。setInt32:写入 4 个字节的 32 位整数。setUint32:写入 4 个字节的 32 位无符号整数。setFloat32:写入 4 个字节的 32 位浮点数。setFloat64:写入 8 个字节的 64 位浮点数。
相应也有 8 个方法读取内存:
getInt8:读取 1 个字节,返回一个 8 位整数。getUint8:读取 1 个字节,返回一个无符号的 8 位整数。getInt16:读取 2 个字节,返回一个 16 位整数。getUint16:读取 2 个字节,返回一个无符号的 16 位整数。getInt32:读取 4 个字节,返回一个 32 位整数。getUint32:读取 4 个字节,返回一个无符号的 32 位整数。getFloat32:读取 4 个字节,返回一个 32 位浮点数。getFloat64:读取 8 个字节,返回一个 64 位浮点数。
下面是表格里是 BMP 文件的头信息:
Byte | 描述 |
---|---|
2 | “BM” 标记 |
4 | 文件大小 |
2 | 保留 |
2 | 保留 |
4 | 文件头和位图数据之间的偏移量 |
我们使用 DataView
可以这样简单实现:
var buffer = new ArrayBuffer(14)
var view = new DataView(buffer)
view.setUint8(0, 66) // 写入 1 字节: 'B'
view.setUint8(1, 67) // 写入 1 字节: 'M'
view.setUint32(2, 1234) // 写入 4 字节的大小: 1234
view.setUint16(6, 0) // 写入 2 字节保留位
view.setUint16(8, 0) // 写入 2 字节保留位
view.setUint32(10, 0) // 写入 4 字节偏移量
里面对应的结构应该是这样的:
Byte | 0 | 1 | 2 | 3 | 4 | 5 | ... |
Type | I8 | I8 | I32 | ... |
Data | B | M |00000000|00000000|00000100|11010010| ... |
回到前面遇到的 ”BA” 问题,我们用 DataView
重新执行下:
var buffer = new ArrayBuffer(2)
var view = new DataView(buffer)
var value = (65 << 8) + 66
view.setUint16(0, value)
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri) // AB
这下我们得到了正确结果 ”AB”,这个也说明 DataView
默认使用大端字节序。
参考文章
- Blob – MDN
- ArrayBuffer – MDN
- ECMAScript 6 入门 - ArrayBuffer
- BMP 文件读写笔记
- TypedArray or DataView: Understanding byte order
- TypedArray 还是 DataView: 理解字节序
本文同步发表于作者博客: 图片上传姿势以及你不知道的 Typed Arrays