2022 年 7 月 11 日

ArrayBuffer、二進制陣列

在網頁開發中,我們大多在處理檔案(建立、上傳、下載)時會遇到二進制資料。另一個典型的使用案例是影像處理。

JavaScript 中都可以做到,而且二進制運算效能很高。

不過,由於類別很多,因此會有些混淆。舉例來說

  • ArrayBufferUint8ArrayDataViewBlobFile 等。

與其他語言相比,JavaScript 中的二進制資料是以非標準的方式實作的。但是,當我們釐清後,一切都變得相當簡單。

基本的二進制物件是 ArrayBuffer,它是一個指向固定長度連續記憶體區域的參考。

我們這樣建立它

let buffer = new ArrayBuffer(16); // create a buffer of length 16
alert(buffer.byteLength); // 16

這會配置一個 16 位元組的連續記憶體區域,並預先填入零。

ArrayBuffer 不是某個陣列

讓我們消除一個可能的混淆來源。ArrayBufferArray 沒有任何共通點

  • 它有一個固定的長度,我們無法增加或減少它。
  • 它在記憶體中佔用的空間大小剛好就是這麼多。
  • 若要存取個別位元組,需要另一個「檢視」物件,而不是 buffer[index]

ArrayBuffer 是個記憶體區域。裡面儲存了什麼?它不知道。只是一個位元組的原始序列。

若要操作 ArrayBuffer,我們需要使用「檢視」物件。

檢視物件本身不儲存任何東西。它是「眼鏡」,提供對儲存在 ArrayBuffer 中的位元組的詮釋。

例如

  • Uint8Array – 將 ArrayBuffer 中的每個位元組視為一個獨立的數字,可能的數值範圍為 0 到 255(一個位元組是 8 位元,因此只能容納這麼多)。這樣的數值稱為「8 位元無符號整數」。
  • Uint16Array – 將每 2 個位元組視為一個整數,可能的數值範圍為 0 到 65535。這稱為「16 位元無符號整數」。
  • Uint32Array – 將每 4 個位元組視為一個整數,可能的數值範圍為 0 到 4294967295。這稱為「32 位元無符號整數」。
  • Float64Array – 將每 8 個位元組視為一個浮點數,可能的數值範圍為 5.0x10-3241.8x10308

因此,16 個位元組的 ArrayBuffer 中的二進位資料可以詮釋為 16 個「小數字」,或 8 個較大的數字(每個 2 個位元組),或 4 個更大的數字(每個 4 個位元組),或 2 個高精度的浮點值(每個 8 個位元組)。

ArrayBuffer 是核心物件,所有事物的根源,原始的二進位資料。

但是,如果我們要寫入它,或遍歷它,基本上對於幾乎任何操作,我們都必須使用檢視,例如

let buffer = new ArrayBuffer(16); // create a buffer of length 16

let view = new Uint32Array(buffer); // treat buffer as a sequence of 32-bit integers

alert(Uint32Array.BYTES_PER_ELEMENT); // 4 bytes per integer

alert(view.length); // 4, it stores that many integers
alert(view.byteLength); // 16, the size in bytes

// let's write a value
view[0] = 123456;

// iterate over values
for(let num of view) {
  alert(num); // 123456, then 0, 0, 0 (4 values total)
}

TypedArray

所有這些檢視(Uint8ArrayUint32Array 等)的通用術語是 TypedArray。它們共用同一組方法和屬性。

請注意,沒有稱為 TypedArray 的建構函式,它只是一個通用的「概括」術語,用於表示 ArrayBuffer 上的其中一個檢視:Int8ArrayUint8Array 等等,完整的清單將很快提供。

當您看到類似 new TypedArray 的內容時,表示為 new Int8Arraynew Uint8Array 等任何內容。

類型化陣列的行為類似一般陣列:具有索引且可迭代。

類型化陣列建構函式(無論是 Int8ArrayFloat64Array,並不重要)會根據引數類型而有不同的行為。

有 5 種引數變體

new TypedArray(buffer, [byteOffset], [length]);
new TypedArray(object);
new TypedArray(typedArray);
new TypedArray(length);
new TypedArray();
  1. 如果提供 ArrayBuffer 引數,則會在其上建立檢視。我們已經使用過該語法。

    我們可以選擇性地提供 byteOffset 從頭開始(預設為 0)和 length(預設為緩衝區的結尾),然後檢視只會涵蓋 buffer 的一部分。

  2. 如果提供 Array 或任何類似陣列的物件,則會建立長度相同的類型化陣列並複製內容。

    我們可以使用它來預先填入資料陣列

    let arr = new Uint8Array([0, 1, 2, 3]);
    alert( arr.length ); // 4, created binary array of the same length
    alert( arr[1] ); // 1, filled with 4 bytes (unsigned 8-bit integers) with given values
  3. 如果提供另一個 TypedArray,則會執行相同的動作:建立長度相同的類型化陣列並複製值。如有需要,值會在過程中轉換為新的類型。

    let arr16 = new Uint16Array([1, 1000]);
    let arr8 = new Uint8Array(arr16);
    alert( arr8[0] ); // 1
    alert( arr8[1] ); // 232, tried to copy 1000, but can't fit 1000 into 8 bits (explanations below)
  4. 對於數字引數 length,會建立包含這麼多元素的類型化陣列。其位元組長度會是 length 乘以單一項目中的位元組數目 TypedArray.BYTES_PER_ELEMENT

    let arr = new Uint16Array(4); // create typed array for 4 integers
    alert( Uint16Array.BYTES_PER_ELEMENT ); // 2 bytes per integer
    alert( arr.byteLength ); // 8 (size in bytes)
  5. 如果沒有引數,則會建立長度為零的類型化陣列。

我們可以直接建立 TypedArray,而不用提到 ArrayBuffer。但檢視無法在沒有底層 ArrayBuffer 的情況下存在,因此在所有這些情況下都會自動建立,除了第一個情況(已提供時)。

若要存取底層 ArrayBufferTypedArray 中有下列屬性

  • buffer – 參照 ArrayBuffer
  • byteLengthArrayBuffer 的長度。

因此,我們可以隨時從一個檢視移動到另一個檢視

let arr8 = new Uint8Array([0, 1, 2, 3]);

// another view on the same data
let arr16 = new Uint16Array(arr8.buffer);

以下是類型化陣列的清單

  • Uint8ArrayUint16ArrayUint32Array – 針對 8、16 和 32 位元的整數。
    • Uint8ClampedArray – 針對 8 位元整數,在指定時會「限制」它們(請參閱下方)。
  • Int8ArrayInt16ArrayInt32Array – 針對有符號整數(可以為負數)。
  • Float32ArrayFloat64Array – 針對 32 和 64 位元的浮點數。
沒有 int8 或類似的單值類型

請注意,儘管有 Int8Array 等名稱,但 JavaScript 中沒有像 intint8 的單值類型。

這是合乎邏輯的,因為 Int8Array 不是這些個別值的陣列,而是在 ArrayBuffer 上的檢視。

超出界線的行為

如果我們嘗試將超出界線的值寫入類型化陣列中,會發生什麼事?不會有錯誤。但額外的位元會被切斷。

例如,我們嘗試將 256 放入 Uint8Array 中。在二進位形式中,256 是 100000000(9 位元),但 Uint8Array 每個值僅提供 8 位元,這使得可用範圍從 0 到 255。

對於較大的數字,只有最右邊(重要性較低)的 8 位元會被儲存,其餘的會被切斷

因此,我們將得到零。

對於 257,二進位形式是 100000001(9 位元),最右邊的 8 位元會被儲存,因此我們在陣列中會有 1

換句話說,數字模 28 會被儲存。

以下是範例

let uint8array = new Uint8Array(16);

let num = 256;
alert(num.toString(2)); // 100000000 (binary representation)

uint8array[0] = 256;
uint8array[1] = 257;

alert(uint8array[0]); // 0
alert(uint8array[1]); // 1

Uint8ClampedArray 在這方面很特別,它的行為不同。它會將大於 255 的任何數字儲存為 255,將任何負數儲存為 0。這種行為對於影像處理很有用。

類型化陣列方法

TypedArray 具有常規 Array 方法,但有顯著的例外。

我們可以進行反覆運算、mapslicefindreduce 等。

不過,有幾件事我們無法做

  • 沒有 splice – 我們無法「刪除」一個值,因為類型化陣列是緩衝區的檢視,而這些是固定的、連續的記憶體區域。我們能做的就是指定一個零。
  • 沒有 concat 方法。

有兩個額外的

  • arr.set(fromArr, [offset])fromArr 中的所有元素複製到 arr 中,從位置 offset 開始(預設為 0)。
  • arr.subarray([begin, end])beginend(不包含)建立同類型的新檢視。這類似於 slice 方法(也受支援),但不會複製任何內容 – 只建立一個新檢視,以操作給定的資料部分。

這些方法允許我們複製類型化陣列、將它們混合、從現有陣列建立新陣列,等等。

DataView

DataView 是一個特殊的超靈活「非類型化」檢視,用於 ArrayBuffer。它允許以任何格式存取任何偏移量中的資料。

  • 對於類型化陣列,建構函式會決定格式為何。整個陣列應該是一致的。第 i 個數字是 arr[i]
  • 使用 DataView,我們使用 .getUint8(i).getUint16(i) 等方法存取資料。我們在方法呼叫時間而不是建構時間選擇格式。

語法

new DataView(buffer, [byteOffset], [byteLength])
  • buffer – 底層 ArrayBuffer。與類型化陣列不同,DataView 不會自行建立緩衝區。我們需要準備好它。
  • byteOffset – 檢視的起始位元組位置(預設為 0)。
  • byteLength – 檢視的位元組長度(預設為 buffer 的尾端)。

例如,這裡我們從同一個緩衝區中以不同的格式提取數字

// binary array of 4 bytes, all have the maximal value 255
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

let dataView = new DataView(buffer);

// get 8-bit number at offset 0
alert( dataView.getUint8(0) ); // 255

// now get 16-bit number at offset 0, it consists of 2 bytes, together interpreted as 65535
alert( dataView.getUint16(0) ); // 65535 (biggest 16-bit unsigned int)

// get 32-bit number at offset 0
alert( dataView.getUint32(0) ); // 4294967295 (biggest 32-bit unsigned int)

dataView.setUint32(0, 0); // set 4-byte number to zero, thus setting all bytes to 0

當我們將混合格式資料儲存在同一個緩衝區時,DataView 非常有用。例如,當我們儲存一系列的配對(16 位元整數、32 位元浮點數)時,DataView 可以讓我們輕鬆地存取它們。

摘要

ArrayBuffer 是核心物件,它是一個指向固定長度連續記憶體區域的參考。

為了對 ArrayBuffer 執行幾乎任何操作,我們需要一個檢視。

  • 它可以是 TypedArray
    • Uint8ArrayUint16ArrayUint32Array – 分別用於 8、16 和 32 位元的無符號整數。
    • Uint8ClampedArray – 用於 8 位元整數,在指定時會將它們「固定」。
    • Int8ArrayInt16ArrayInt32Array – 針對有符號整數(可以為負數)。
    • Float32ArrayFloat64Array – 針對 32 和 64 位元的浮點數。
  • DataView – 使用方法來指定格式的檢視,例如 getUint8(offset)

在多數情況下,我們會直接建立和操作型別化陣列,讓 ArrayBuffer 隱藏起來,作為「公分母」。我們可以將其存取為 .buffer,並在需要時建立另一個檢視。

在描述操作二進位資料的方法時,還有兩個額外的術語

  • ArrayBufferView 是所有這些檢視類型的統稱。
  • BufferSourceArrayBufferArrayBufferView 的統稱。

我們將在後續章節中看到這些術語。BufferSource 是最常見的術語之一,因為它表示「任何類型的二進位資料」– ArrayBuffer 或其上的檢視。

以下是作弊清單

任務

給定一個 Uint8Array 陣列,撰寫一個函式 concat(arrays),將它們串接成一個單一陣列並回傳。

開啟一個包含測試的沙盒。

function concat(arrays) {
  // sum of individual array lengths
  let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);

  let result = new Uint8Array(totalLength);

  if (!arrays.length) return result;

  // for each array - copy it over result
  // next array is copied right after the previous one
  let length = 0;
  for(let array of arrays) {
    result.set(array, length);
    length += array.length;
  }

  return result;
}

在沙盒中開啟包含測試的解答。

教學課程地圖

留言

在留言前先閱讀這段文字…
  • 如果你有建議要如何改進 - 請 提交 GitHub 問題 或提出 pull request,而不是留言。
  • 如果你無法理解文章中的某些內容 – 請詳細說明。
  • 若要插入少量的程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將其包覆在 <pre> 標籤中,若要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)