云计算、AI、云原生、大数据等一站式技术学习平台

网站首页 > 教程文章 正文

AI 时代已来!不容忽视的 TypedArray 的底层力量?

jxf315 2025-07-23 15:40:46 教程文章 4 ℃

家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

1. 什么是类型数组

JavaScript 类型数组 (Typed Array) 是类似数组的对象,提供了一种在内存缓冲区中读写原始二进制数据的机制。

类型数组并非要取代数组来实现任何功能。相反,其为开发者提供了一个熟悉的二进制数据操作接口,在例如:音视频操作、 WebSocket 访问原始数据 等场景下非常有用。JavaScript 类型数组中的每个元素都是原始二进制值,支持多种格式,从 8 位整数到 64 位浮点数。

类型数组对象与具有相似语义的数组共享许多相同的方法,但也有例外,比如:push、pop 等。同时,不要将类型数组与普通数组混淆, 在类型数组上调用 Array.isArray() 总会返回 false。

为了最大化灵活性和效率,JavaScript 类型数组将实现分为 缓冲区 (Buffer) 和 视图 (View)。缓冲区是表示数据块的对象,没有具体的格式,也不提供访问其内容的机制。为了访问缓冲区中的内存,需要使用视图。视图提供上下文,即:数据类型、起始偏移量和元素数量。

2. 为何 JavaScript 中要设计诸多不同的类型数组

这是由于在 JavaScript 中,Number 是一个双精度浮点数(64 位),可以表示整数和小数,但有以下问题:

  • 占用空间大(8 字节)
  • 精度有限(不能准确表示所有整数)
  • 不适合处理原始的二进制数据,例如:图片像素、音频采样

而类型数组提供了对底层内存的直接访问能力,而且每种类型对应不同的数据需求:

试想一种场景,如果开发者要存储 1000 个整数,每个最大值不超过 255:

  • 用普通数组:[1, 2, ..., 255],每个数字是 Number,占 8 字节,总共 8000 字节
  • 用 Uint8Array:只占 1000 字节,节省 8 倍内存

又例如,很多浏览器 API 或原生模块都需要特定的数据格式:

  • Canvas.getImageData() 返回的是 Uint8ClampedArray
  • WebGL 使用 Float32Array 存储顶点坐标
  • AudioContext 使用 Float32Array 表示音频采样
  • fetch().arrayBuffer() 和 FileReader.readAsArrayBuffer() 返回原始二进制数据

而使用 TypedArray 可以无缝对接这些接口。同时,在 AI 时代,类型数组变得更加普及:

  • 模型权重数据(通常为 Float32Array 或 Int8Array)
  • 向量嵌入(如 Float32Array)
  • 音频采样点(如 Float32Array)
  • 图像像素值(如 Uint8Array)
  • 模型压缩 / 量化(如 Int8Array 来减少内存占用)

下面示例是在 Canvas 场景修改一张图片的每个像素的颜色值:

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Uint8ClampedArray,每个像素由 4 个字节组成:R, G, B, A
for (let i = 0; i < data.length; i += 4) {
  data[i] = 255; // R
  data[i + 1] = 0; // G
  data[i + 2] = 0; // B
  data[i + 3] = 255; // A
}
ctx.putImageData(imageData, 0, 0);

在上面例子中,Uint8ClampedArray 正好匹配图像每个像素的 0~255 范围。因此,区分不同类型数组的原因可以概括为以下几个:

  • 内存效率:更小的存储空间,适合大规模数据
  • 性能优化:更快的访问速度,适合高频计算
  • 精确控制:控制数值范围和溢出行为(如 clamped)
  • 对接原生:与浏览器 API、C/C++、WebAssembly 接口兼容
  • 二进制处理:解析和构造协议、文件、流等数据格式

3. 什么是缓冲区 ArrayBuffer 和 SharedArrayBuffer

缓冲区有两种类型:ArrayBuffer 和 SharedArrayBuffer,两者都是内存跨度 (Memory Span) 的底层表示。虽然名称中包含 “array”,但与数组没有太大关系,开发者无法直接读取或写入。相反,缓冲区是仅包含原始数据的通用对象,而为了访问缓冲区所表示的内存需要使用视图。

缓冲区支持以下操作:

  • 分配 (Allocate):一旦创建新缓冲区,就会分配一个新的内存跨度并将其初始化为 0
const buffer = new ArrayBuffer(16);
// 分配固定长度为 16 字节的缓冲区
if (buffer.byteLength === 16) {
  console.log("Yes, it's 16 bytes.");
} else {
  console.log("Oh no, it's the wrong size!");
}
  • 复制 (Copy):开发者可以使用 slice() 高效地复制部分内存,而无需创建视图来手动复制每个字节
const buffer = new ArrayBuffer(16);
// 分配固定长度为 16 字节的缓冲区
const int32View = new Int32Array(buffer);
// 生成 Int32Array [0, 0, 0, 0],每个元素 4 个字节,所有总共 4 个元素
int32View[1] = 42;
const sliced = new Int32Array(buffer.slice(4, 12));
// 生成 Int32Array [42, 0],每个元素 4 个字节,总共 2 个元素
console.log(sliced[0]);
// 输出值为: 42
  • 传输 (Transfer):使用 transfer() 和 transferToFixedLength() 方法,开发者可以将内存跨度的所有权转移到新的缓冲区对象。这在不同的执行上下文之间实现零复制数据传输非常有用,传输后原始缓冲区不再可用。但是注意,SharedArrayBuffer 无法传输,因为该缓冲区已被所有执行上下文共享。
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view[1] = 2;
view[7] = 4;

// 实现缓冲区转移
const buffer2 = buffer.transfer();
console.log(buffer.detached); // true
console.log(buffer2.byteLength); // 8
const view2 = new Uint8Array(buffer2);
console.log(view2[1]); // 2
console.log(view2[7]); // 4

// 将缓冲区转移到较小的大小
const buffer3 = buffer2.transfer(4);
console.log(buffer3.byteLength); // 4
const view3 = new Uint8Array(buffer3);
console.log(view3[1]); // 2
console.log(view3[7]); // undefined

// 将缓冲区转移到大的缓冲区
const buffer4 = buffer3.transfer(8);
console.log(buffer4.byteLength); // 8
const view4 = new Uint8Array(buffer4);
console.log(view4[1]); // 2
console.log(view4[7]); // 0

// 如果已经 detached, 再次转移则抛出错误
buffer.transfer();
  • 调整大小 (Resize):使用 resize() 方法,开发者可以调整内存跨度的大小,只要不超过预设的 maxByteLength 限制。但是,SharedArrayBuffer 只能增大,不能缩小。
const buffer = new ArrayBuffer(8, { maxByteLength: 16});
console.log(buffer.byteLength);
// 输出: 8
buffer.resize(12);
console.log(buffer.byteLength);
// 输出: 12

值得注意的是,ArrayBuffer 和 SharedArrayBuffer 存在一定的区别:

ArrayBuffer 每次只由一个执行上下文拥有,如果要将 ArrayBuffer 传递给不同的执行上下文则会被转移,原 ArrayBuffer 不可用。而 SharedArrayBuffer 在传递给不同的执行上下文时不会被转移,因此其可以同时被多个执行上下文访问。当多个线程访问同一内存跨度时可能会引发竞争条件,此时诸如 Atomics 方法之类的操作就变得非常有用。

const sab = new SharedArrayBuffer(1024);
const ta = new Uint8Array(sab);
ta[0]; // 0
ta[0] = 5; // 5
Atomics.add(ta, 0, 12); // 5
Atomics.load(ta, 0); // 17

4. 什么是视图 Views

目前 JavaScript 有两种类型的视图: 类型数组视图 (Typed Array Views) 和 DataView。类型数组提供了工具方法,可让开发者方便地转换二进制数据。DataView 更偏底层,允许对数据访问方式进行精细控制,同时两种视图读取和写入数据的方式截然不同。

两种视图的 ArrayBuffer.isView() 方法都会返回 true,且都具有以下属性:

  • buffer:视图引用的底层缓冲区
  • byteOffset:视图相对于其缓冲区起始位置的偏移量,以字节为单位
  • byteLength:视图的长度

两种构造函数都接受上述三个参数,但 类型数组构造函数接受的长度是元素数量而非字节数。

4.1 如何使用类型数组视图访问 Buffer

类型数组视图提供了所有常见数字类型的视图,例如: Int8、Uint32、Float64 等。同时,还支持特殊类型数组视图 Uint8ClampedArray,其值在 0 到 255 之间,对于 Canvas 数据处理非常有用。

所有类型数组视图都具有相同的方法和属性,由 TypedArray 类定义,仅在底层数据类型和字节大小上有所不同。

类型数组原则上是固定长度的,因此无法使用可能改变数组长度的方法,包括: pop、push、shift、concat、splice(toSpliced) 和 unshift。此外,由于没有嵌套的类型数组,flat/flatMap 方法不可用。所有其他数组方法在 Array 和 TypedArray 之间共享。

同时,TypedArray 还具有额外的 set 和 subray 方法优化使用同一缓冲区的多个类型数组:

  • set() :允许使用来自另一个数组或类型数组的数据一次设置多个类型化数组索引。如果两个类型化数组共享相同的底层缓冲区,由于内存移动速度更快,操作效率可能会更高
//////////// 实例 1 //////////////
const buffer = new ArrayBuffer(8);
const uint8 = new Uint8Array(buffer);
// 手动写入数据
uint8.set([228, 189, 160, 229, 165, 189]);
const text = new TextDecoder().decode(uint8);
console.log(text); // "你好"

////////////// 实例 2 ///////
const buffer = new ArrayBuffer(8);
const uint16 = new Uint16Array(buffer);
uint16.set([0x4f60, 0x597d]);
const text = String.fromCharCode(...uint16);
console.log(text); // "你好"
  • subarray():创建一个新的类型数组视图,该视图引用与原始类型化数组相同的缓冲区,但跨度更窄
const uint8 = new Uint8Array([10, 20, 30, 40, 50]);
console.log(uint8.subarray(1, 3));
// 输出  Uint8Array [20, 30]
console.log(uint8.subarray(1));
// 输出 Uint8Array [20, 30, 40, 50]

无法在不更改底层缓冲区的情况下直接更改类型化数组的长度。但是,当类型化数组查看可调整大小的缓冲区且没有固定的字节长度时会进行长度跟踪,并会在可调整大小的缓冲区调整大小时自动调整大小以适应底层缓冲区。

与常规数组类似,开发者可以使用 [] 访问类型化数组元素,此时底层缓冲区中相应的字节将被检索并解释为数字。任何使用数字的属性访问都将由类型化数组代理,即永远不会与 Buffer 本身交互:

  • 越界索引始终返回 undefined,而不会实际访问对象的属性
  • 任何越界写入属性的操作均无效,虽然不会抛出错误但也不会更改缓冲区或类型化数组
  • 类型化数组索引看似可配置且可写,但任何更改其属性的尝试都将失败
const uint8 = new Uint8Array([1, 2, 3]);
console.log(uint8[0]); // 1
uint8[-1] = 0;
uint8[2.5] = 0;
uint8[NaN] = 0;
console.log(Object.keys(uint8)); // ["0", "1", "2"]
console.log(uint8[NaN]); // undefined

// 非数字访问也可以,不会报错
uint8[true] = 0;
console.log(uint8[true]); // 0
Object.freeze(uint8);
// TypeError: Cannot freeze array buffer views with elements

4.2 如何使用 DataView 访问 Buffer

DataView 是一个底层接口,其提供 getter、setter API 来读取和写入任意数据到缓冲区,这在处理不同类型的数据时非常有用。

类型数组视图采用平台的原生字节序列,而使用 DataView 可以自由控制字节序。默认情况下,是大端字节序列,即字节按从最高有效位到最低有效位的顺序排列。开发者可以使用 getter/setter 方法反转此顺序,即字节按从最低有效位到最高有效位的顺序排列(小端字节序)。

DataView 不需要对齐 (alignment),多字节读写可以从任何指定的偏移量开始,setter 方法的工作方式相同。以下示例使用 DataView 获取任意数字的二进制表示:

function toBinary(
  x,
  {type = "Float64", littleEndian = false, separator = " ", radix = 16} = {}
) {
  const bytesNeeded = globalThis[`${type}Array`].BYTES_PER_ELEMENT;
  // BYTES_PER_ELEMENT 表示类型数组中每个元素的字节大小
  // globalThis['Float64Array'].BYTES_PER_ELEMENT 返回 8 个字节
  // 每一种类型占用的字节数可以参考上文的图表
  const dv = new DataView(new ArrayBuffer(bytesNeeded));
  // 手动创建一个 DataView
  dv[`set${type}`](0, x, littleEndian);
  // 调用 DataView 的 setFloat64 等方法
  const bytes = Array.from({length: bytesNeeded}, (_, i) =>
    dv
      .getUint8(i)
       // 对于 toBinary(1.1),DataView 的 getUint8 方法
       // 读取 8 个字节中的每一个字节并解释为 8 位无符号整数
      .toString(radix)
      .padStart(8 / Math.log2(radix), "0")
  );
  return bytes.join(separator);
}
console.log(toBinary(1.1));
// 输出 3f f1 99 99 99 99 99 9a
console.log(toBinary(1.1, { littleEndian: true}));
// 输出  9a 99 99 99 99 99 f1 3f
console.log(toBinary(20, { type: "Int8", radix: 2}));
// 输出 00010100

下面是 setUint8 和 getUint8 方法的简单用法:

// 创建 16 个字节的缓冲区
const buffer = new ArrayBuffer(16);
const view = new DataView(buffer);
view.setUint8(1, 255);
// setUint8() 方法接受一个数字,并将其作为 8 位无符号整数
// 存储在该 DataView 指定字节偏移量的字节中
console.log(view.getUint8(1));
// getUint8() 从该 DataView 的指定字节偏移量读取 1 个字节并将其解释为 8 位无符号整数
// 预期输出 255

参考资料

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint8

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView/getUint8

Tags:

最近发表
标签列表