JavaScript 處理二進位資料 TypedArray, ArrayBuffer, DataView

在早期的 JavaScript 中,主要是用來處理網頁上的文字內容,對於原始的二進位資料 (Binary Data) 並沒有很好的支援。但是隨著 HTML5 的發展,Web 應用程式變得越來越強大,我們開始需要在瀏覽器中處理音訊、影片、WebSockets 原始數據,甚至是 WebGL 的 3D 繪圖。

為了滿足這些高效能操作二進位資料的需求,JavaScript 引入了 TypedArray (具型別陣列) 的概念。

這篇文章將帶你深入了解 JavaScript 如何透過 ArrayBufferTypedArrayDataView 來操作二進位資料。

核心概念概述

在深入語法之前,我們需要先理解這三個核心角色的關係:

  1. ArrayBuffer (緩衝區):代表一段原始的、固定長度的二進位記憶體空間。你不能直接操作它裡面的內容。
  2. TypedArray (具型別陣列):是一個視圖 (View),它讓你用特定的格式(例如:每個元素都是 8 位元無號整數 Uint8)來讀寫 ArrayBuffer 的內容。
  3. DataView (資料視圖):也是一種視圖,但它更靈活。它允許你在同一個 Buffer 中混合讀寫不同類型的數據(例如:開頭讀一個 16 位元整數,接著讀一個 32 位元浮點數),並且可以控制位元組順序 (Endianness)。
簡單來說:ArrayBuffer 是記憶體中的「實際資料」,而 TypedArrayDataView 是用來「解讀」這些資料的「眼鏡」。

ArrayBuffer

ArrayBuffer 是用來分配一段連續記憶體空間的物件。

語法

// 分配 16 bytes (位元組) 的記憶體空間
const buffer = new ArrayBuffer(16);

console.log(buffer.byteLength); // 16

ArrayBuffer 實例只有一個屬性 byteLength,用來取得它的大小。特別注意的是,你無法直接讀寫 ArrayBuffer 的內容,必須透過 View (TypedArray 或 DataView) 來操作。

TypedArray (具型別陣列)

TypedArray 提供了一種類似一般陣列的操作介面,但它的每個元素都是固定型別且連續的。這使得它在效能上比一般的 JavaScript Array [] 快得多。

常見的 TypedArray 類型包括:

名稱每個元素大小 (Bytes)說明數值範圍
Uint8Array18 位元無號整數0 ~ 255
Uint16Array216 位元無號整數0 ~ 65535
Uint32Array432 位元無號整數0 ~ 4294967295
Int8Array18 位元有號整數-128 ~ 127
Float32Array432 位元浮點數1.2 x 10^-38 ~ 3.4 x 10^38
Float64Array864 位元浮點數5.0 x 10^-324 ~ 1.8 x 10^308
Uint8ClampedArray18 位元固定無號整數0 ~ 255 (特別用於 Canvas ImageData)

建立 TypedArray

有幾種方式可以建立 TypedArray:

1. 直接指定長度

// 建立一個有 8 個元素的 Uint8Array
// 這會自動建立一個 8 bytes 的 ArrayBuffer
const uint8 = new Uint8Array(8);

console.log(uint8.length); // 8 (元素個數)
console.log(uint8.byteLength); // 8 (總 Bytes 數)

// 預設都會初始化為 0
console.log(uint8[0]); // 0

2. 使用一般的 Array 或其他可迭代物件

// 將一般陣列轉換為 Uint8Array
const uint8 = new Uint8Array([10, 20, 30, 40]);

console.log(uint8[0]); // 10
console.log(uint8.length); // 4

3. 共用 ArrayBuffer (同一個記憶體,不同視圖)

這是一個非常強大的特性。我們可以創建多個 Views 指向同一個 ArrayBuffer

const buffer = new ArrayBuffer(4); // 4 bytes

// 用 Uint8Array 看這個 buffer (視為 4 個 8-bit 整數)
const uint8 = new Uint8Array(buffer);
uint8[0] = 1;
uint8[1] = 2;
uint8[2] = 3;
uint8[3] = 4;

console.log(uint8); // Uint8Array(4) [1, 2, 3, 4]

// 用 Uint16Array 看同一個 buffer (視為 2 個 16-bit 整數)
const uint16 = new Uint16Array(buffer);

// 結果會是什麼?這取決於系統的 Endianness (通常是 Little Endian)
// 記憶體中是: 01 02 03 04 (hex: 0x01, 0x02, 0x03, 0x04)
// Little Endian 下:
// uint16[0] 是 0x0201 (十進位 513)
// uint16[1] 是 0x0403 (十進位 1027)
console.log(uint16[0]); // 513

TypedArray 的特性與方法

TypedArray 擁有許多與一般 Array 相同的方法,例如: map(), filter(), reduce(), forEach(), find(), some(), slice() 等等。

但有幾個主要區別:

  1. 長度固定:一旦建立,長度就不能改變。所以沒有 push(), pop(), splice() 等會改變長度的方法。
  2. 型別限制:如果你嘗試存入超出範圍的值,它不會報錯,但會發生溢位 (Overflow) 或截斷。
const uint8 = new Uint8Array(1);

// 超出 255,會取餘數 (256 變 0, 257 變 1)
uint8[0] = 256;
console.log(uint8[0]); // 0

uint8[0] = 257;
console.log(uint8[0]); // 1

set() 方法

用於從另一個陣列複製數值到 TypedArray。

const uint8 = new Uint8Array(5);
uint8.set([1, 2], 1); // 從 index 1 開始寫入

console.log(uint8); // Uint8Array(5) [0, 1, 2, 0, 0]

subarray() 方法

回傳一個新的 TypedArray,但共用相同的 ArrayBuffer記憶體空間。這跟一般陣列的 slice() 不同(slice 會複製一份新的)。

const ui8 = new Uint8Array([10, 20, 30, 40]);
const sub = ui8.subarray(1, 3); // 取 index 1 到 3 (不含 3)

console.log(sub); // Uint8Array(2) [20, 30]

sub[0] = 99; // 修改子視圖

console.log(ui8); // 原本的陣列也被修改了: Uint8Array(4) [10, 99, 30, 40]

DataView

TypedArray 適合處理「全部都是同一種類型」的資料。但如果我們面對的是一個結構化的二進位檔(例如檔頭 Header 有 2 bytes ID,接著 4 bytes 長度,再接著字串內容...),使用 DataView 會更方便。

DataView 提供了 getset 方法來讀寫不同型態的數值,並且可以指定 Byte Order (Endianness)

語法

const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);

// 寫入資料
view.setInt8(0, 127); // 在 byte offset 0 寫入 int8
view.setUint16(1, 5000); // 在 byte offset 1 寫入 uint16

// 讀取資料
console.log(view.getInt8(0)); // 127
console.log(view.getUint16(1)); // 5000

Endianness (位元組順序)

在多字節數字(例如 16-bit, 32-bit)中,位元組的排列順序有兩種:

  • Big-endian (大端序):高位元組在前(人類讀習慣的順序)。
  • Little-endian (小端序):低位元組在前(Intel x86 架構常用)。

DataView 的方法預設使用 Big-endian。若要使用 Little-endian,需要在方法的最後一個參數傳入 true

const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);

// 寫入 258 (Hex: 0x0102)
// Big-endian: [01, 02]
// Little-endian: [02, 01]

view.setUint16(0, 258); // 預設 Big-endian
console.log(new Uint8Array(buffer)); // [1, 2]

view.setUint16(0, 258, true); // 使用 Little-endian
console.log(new Uint8Array(buffer)); // [2, 1]

實務應用範例

Canvas 影像處理

HTML5 Canvas 的 ImageData 其實底層就是一個 Uint8ClampedArray。每個像素佔用 4 個 bytes (R, G, B, A)。

// 假設 ctx 是一個 Canvas 2D Context
const imageData = ctx.getImageData(0, 0, 100, 100);
const data = imageData.data; // 這是一個 Uint8ClampedArray

// data 裡的排列是 [R, G, B, A, R, G, B, A, ...]
// 讓我們把整張圖變成紅色
for (let i = 0; i < data.length; i += 4) {
  data[i] = 255; // Red
  data[i + 1] = 0; // Green
  data[i + 2] = 0; // Blue
  // data[i + 3] 是 Alpha (透明度),保持不變
}

ctx.putImageData(imageData, 0, 0);

為什麼這裡用 Uint8ClampedArray 因為如果我們計算出的顏色是 300,一般 Uint8Array 會變成 300 % 256 = 44(顏色跑掉)。而 Uint8ClampedArray 會將其「鉗制 (Clamp)」在 255(最大值),確保顏色運算是正確的(0 ~ 255)。

Fetch API 讀取二進位檔案

當我們下載一張圖片或一個二進位檔案時,可以使用 ArrayBuffer 來處理。

fetch('image.png')
  .then((response) => response.arrayBuffer()) // 告訴 fetch 我們要 ArrayBuffer
  .then((buffer) => {
    console.log(`下載了 ${buffer.byteLength} bytes 的資料`);

    // 做一些處理,例如轉成 Blob 顯示
    const blob = new Blob([buffer], { type: 'image/png' });
    const url = URL.createObjectURL(blob);
    // document.getElementById('img').src = url;
  });

合併多個 ArrayBuffer

有時候我們需要將多個二進位片段合併成一個,這必須透過 TypedArray 來建立一個夠大的新 Buffer 並複製內容。

function concatenate(buffer1, buffer2) {
  // 建立一個新的 TypedArray,大小是兩者之和
  const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);

  // 複製第一個 buffer
  tmp.set(new Uint8Array(buffer1), 0);

  // 複製第二個 buffer,位置接在第一個後面
  tmp.set(new Uint8Array(buffer2), buffer1.byteLength);

  return tmp.buffer; // 回傳底層的 ArrayBuffer
}

總結

  • ArrayBuffer 是原始的二進位資料容器。
  • TypedArray (如 Uint8Array) 是用來操作同一類型數據的高效能視圖,操作類似 Array 但長度固定。
  • DataView 是用來操作混合類型數據的靈活視圖,支援 Endianness 設定。
  • 這些 API 在 WebGL、Canvas 影像處理、檔案操作 (File API) 以及網路傳輸 (Fetch/WebSocket) 中扮演著關鍵角色。

掌握這些工具,能讓你更有效地處理 JavaScript 中的底層資料!