TypedArray 或 DataView:理解字节序

太长不看版

根据你访问 ArrayBuffer 的方式,你可能会在同一台机器上获得不同的字节序。所以长话短说:使用 TypedArray 还是使用 DataView 的设置器是有区别的。

ArrayBuffer 用于提供对二进制数据的有效且快速的访问,例如 WebGLCanvas 2DWeb Audio 所需的数据。在这些情况下,你通常希望以最有效的方式存储数据,以便你的硬件能够轻松地使用,或者以便更容易地通过网络进行流传输。

继续阅读,了解其详细工作原理。

TypedArray 和 ArrayBuffer 入门

ES6 为我们带来了三件不错的新事物

  1. ArrayBuffer,一种用于存储给定数量二进制数据的数据结构。
  2. TypedArrayArrayBuffer 的一种“视图”,其中每个项都具有相同的大小和类型。
  3. DataViewArrayBuffer 的另一种“视图”,但它允许在 ArrayBuffer 中使用不同大小和类型的项。

如果我们想要处理图像或各种文件等内容,那么拥有一个可以存储大量字节以处理二进制数据的数据结构是有意义的。

在不深入探讨二进制数据工作原理的情况下,让我们来看一个简单的例子

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var bytes = new Uint8Array(buffer) // views the buffer as an array of 8 bit integers

bytes[0] = 65 // ASCII for 'A'
bytes[1] = 66 // ASCII for 'B'

现在我们可以将其转换为 Blob
从中创建一个 Data URI,并将其作为新的文本文件打开

var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

这将在新的浏览器窗口中显示文本“AB”。

哪个方向是正确的?字节序,第一部分

因此,我们一个接一个地写入了两个字节(或 16 位),但由于 TypedArray 构造函数可以处理更大的数字,因此我们也可以使用单个 16 位数字写入这两个字符 - 使用单个指令写入两个字节。

Mozilla 开发者网络上的 类型化数组文章 中的这个有用的表格应该可以说明这个想法

TypedArrays group one or multiple bytes in an ArrayBuffer

你可以看到,在前面的示例中,我们首先写入了“A”的字节,然后写入了“B”的字节,但我们也可以使用 Uint16Array 同时写入两个字节,并将这两个字节放入一个 16 位数字中

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var word = new Uint16Array(buffer) // views the buffer as an array with a single 16 bit integer

var value = (65 << 8) + 66 // we shift the 'A' into the upper 8 bit and add the 'B' as the lower 8 bit.
word[0] = value // write the 16 bit (2 bytes) into the typed array

// Let's create a text file from them:
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

但是等等?我们看到的是“BA”而不是之前的“AB”!发生了什么事?

让我们仔细看看我们写入数组的值

65 decimal = 01 00 00 01 binary
66 decimal = 01 00 00 10 binary

// what we did when we wrote into the Uint8Array:
01 00 00 01 01 00 00 10
<bytes[0]-> <bytes[1]->

// what we did when we created the 16-bit number:
var value = (01 00 00 01 00 00 00 00) + 01 00 00 10
= 01 00 00 01 01 00 00 10

你可以看到我们写入 Uint8Array 的 16 位和我们写入 Uint16Array 的 16 位是相同的,那么为什么结果会不同呢?

答案是,大于一个字节的值中的字节顺序取决于系统的 字节序。让我们检查一下

var buffer = new ArrayBuffer(2)
// create two typed arrays that provide a view on the same ArrayBuffer
var word = new Uint16Array(buffer) // this one uses 16 bit numbers
var bytes = new Uint8Array(buffer) // this one uses 8 bit numbers

var value = (65 << 8) + 66
word[0] = (65 << 8) + 66
console.log(bytes) // will output [66, 65]
console.log(word[0] === value) // will output true

当查看单个字节时,我们看到“B”的值确实已被写入缓冲区的第一个字节,而不是“A”的值,但是当我们读回 16 位数字时,它是正确的!

这是因为浏览器默认使用小端数字。

这意味着什么?

假设一个字节可以存储一位数字,那么数字 123 需要三个字节:123。小端意味着多字节数字的低位数字首先存储,因此在内存中它将存储为 321

还有大端格式,其中字节按我们预期的那样存储,从最高位数字开始,因此在内存中它将存储为 123
只要计算机知道数据的存储方式,它就可以为我们进行转换,并从内存中获取正确的数字。

这并不是一个真正的问题。当我们执行以下操作时

var word = new Uint16Array(buffer)
word[0] = value // If isLittleEndian is not present, set isLittleEndian to either true or false.

选择取决于实现。选择对实现最有效的替代方案。
实现必须在每次执行此步骤时使用相同的值,并且必须对 GetValueFromBuffer 抽象操作中的相应步骤使用相同的值。

好的,没问题:我们省略 isLittleEndian,浏览器决定一个值(在大多数情况下为 true,因为大多数系统都是小端),并坚持使用它。

这是一个相当合理的行为。正如 Dave Herman 在 他 2012 年的博文中 指出的那样,在规范中选择一种字节序时,要么是“快速模式”要么是“正确模式”。

如今大多数系统都是小端,因此选择小端是一个合理的假设。当数据采用系统使用的格式时,我们可以获得最佳性能,因为我们的数据无需在处理之前进行转换(例如通过 WebGL 通过 GPU 处理)。除非你明确需要支持某些罕见的硬件,否则你可以安全地假设小端并获得速度优势。

但是,如果我们想以块的形式通过网络传输此数据或写入结构化二进制文件怎么办?

最好让数据能够按顺序写入字节,就像数据从网络传入一样。为此,我们更喜欢大端,因为字节可以按顺序写入。

幸运的是,平台为我们提供了支持!

另一种写入 ArrayBuffer 的方法:DataView

正如我在开头提到的,有时将不同类型的数据写入 ArrayBuffer 会很有用。

假设你想要编写一个二进制文件,该文件需要如下所示的文件头

大小(字节) 描述
2 位图图像的标识符“BM”
4 图像大小(字节)
2 保留
2 保留
4 标头末尾与像素数据之间的偏移量(字节)

顺便说一句:这是 BMP 文件头 的结构。

与其处理一系列类型化数组,我们也可以使用 DataView

var buffer = new ArrayBuffer(14)
var view = new DataView(buffer)

view.setUint8(0, 66)     // Write one byte: 'B'
view.setUint8(1, 67)     // Write one byte: 'M'
view.setUint32(2, 1234)  // Write four byte: 1234 (rest filled with zeroes)
view.setUint16(6, 0)     // Write two bytes: reserved 1
view.setUint16(8, 0)     // Write two bytes: reserved 2
view.setUint32(10, 0)    // Write four bytes: offset

我们的 ArrayBuffer 现在包含以下数据

Byte  |    0   |    1   |    2   |    3   |    4   |    5   | ... |
Type  |   I8   |   I8   |                I32                | ... |    
Data  |    B   |    M   |00000000|00000000|00000100|11010010| ... |

在上面的示例中,我们使用 DataView 将两个 Uint8 写入前两个字节,然后是占用后续四个字节的 Uint32,依此类推。

不错。现在让我们回到我们的简单文本示例。

我们还可以使用 DataView 而不是之前使用的 Uint16Array 来写入一个 Uint16 以保存我们的双字符字符串 'AB'

var buffer = new ArrayBuffer(2) // array buffer for two bytes
var view = new DataView(buffer)

var value = (65 << 8) + 66 // we shift the 'A' into the upper 8 bit and add the 'B' as the lower 8 bit.
view.setUint16(0, value)

// Let's create a text file from them:
var blob = new Blob([buffer], {type: 'text/plain'})
var dataUri = window.URL.createObjectURL(blob)
window.open(dataUri)

等等,什么?我们得到了正确的字符串“AB”,而不是上次写入 Uint16 时得到的“BA”!也许 setUint16 默认使用大端?

DataView.prototype.setUint16 ( byteOffset, value [ , littleEndian ] )
1. 令 v 为 this 值。
2. 如果 littleEndian 不存在,则令 littleEndian 为 false
3. 返回 SetViewValue(v, byteOffset, littleEndian, “Uint16”, value)。

(我加粗了。)

明白了!规范规定省略的 littleEndian 应被视为 false,并且 SetViewValue 将将其传递给 SetValueInBuffer,但是 Uint16Array 上的操作被允许选择该值并选择 true

这种不匹配会导致不同的字节序,如果忽略它可能会导致一些麻烦。

Khronos Group 的 最初的规范提案(现已弃用)甚至明确说明了这一点

类型化数组视图类型使用主机计算机的字节序。

DataView 类型使用指定字节序(大端或小端)操作数据。

这听起来非常详尽,但存在一个明显的差距:如果类型化数组和 DataView 操作省略了所需的字节序会怎样?答案是

  • TypedArray 将使用系统的原生字节序。
  • DataView 将默认为大端。

结论

那么这是一个问题吗?其实不是。

浏览器可能选择小端,因为如今大多数系统在 CPU 和内存级别上都使用它,这对于性能非常有利。

那么,使用 TypedArray 设置器与 DataView 设置器时,为什么行为会有差异呢?

TypedArray 旨在提供一种方式来组合二进制数据以供同一系统使用 - 因此,临时选择字节序是一个不错的选择。

另一方面,DataView 用于序列化和反序列化二进制数据以传输这些二进制数据。这就是手动选择字节序的原因。大端的默认值正是因为大端通常用于网络传输(有时称为“网络字节序”)。如果数据是流传输的,则只需将传入的数据添加到下一个内存位置即可组装数据。

处理二进制数据的最简单方法是在创建的二进制数据离开浏览器时使用 DataView 设置器 - 无论是通过网络发送到其他系统还是以文件下载的形式发送给用户。

例如,在 2012 年的这篇文章 中一直建议这样做

通常,当你的应用程序从服务器读取二进制数据时,你需要扫描它一次以将其转换为应用程序内部使用的数据结构。

在此阶段应使用 DataView。

不建议直接将多字节类型化数组视图(Int16Array、Uint16Array 等)与通过 XMLHttpRequest、FileReader 或任何其他输入/输出 API 获取的数据一起使用,因为类型化数组视图使用 CPU 的原生字节序。

因此,总而言之,我们学到了以下内容

  • 可以安全地假设系统为小端。
  • TypedArray 非常适合创建二进制数据,例如传递给 Canvas2D ImageData 或 WebGL。
  • DataView 是一种安全的方式来处理从其他系统接收或发送到其他系统的二进制数据。

关于 Martin Splitt

Martin 非常擅长做人,也很擅长计算机,因此他决定利用他的计算机技能来改善他和别人的生活。他热爱开放的网络和开源,并通过(但不限于)代码帮助改进事物。

更多 Martin Splitt 的文章…


5 条评论

  1. Jona Stubbe

    我实际上听到大端被称为“网络字节序”的次数比“网络字节序”多,但我认为两者都可以理解。

    无论如何,我以前不知道 DataView,所以感谢这篇文章!

    2017年1月4日 11:54

  2. Noitidart

    很棒很棒的文章。感谢分享。这将帮助从未使用过此类内容的 JavaScript 开发人员理解它。

    2017年1月4日 11:59

  3. voracity

    很有趣,我以前不知道 DataView。

    现在,如果英特尔最初使用大端,那么本文 80% 的内容(以及多年来无数的头痛问题)将是不必要的。 :)

    2017年1月5日 22:58

  4. Gibran Malheiros

    很棒的文章,非常高兴能更多地了解这些内在细节。

    2017年1月13日 06:16

  5. Jarrod

    对于那些发现关于“GetValueFromBuffer”和 isLittleEndian 部分有点脱节的人,这是指访问 ArrayBuffer 的底层 ECMA 规范的一部分。请参阅此处,一切都会变得清晰:http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf

    很棒的文章。

    2017年1月31日 11:56

本文评论已关闭。