JavaScript與二進制數據的恩怨情仇


文章出自個人博客https://knightyun.github.io/2020/03/09/js-binary-data,轉載請申明


編程江湖,終日血雨腥風,論及二進制數據,又有多少豪傑談笑風生,風生水起,水起船高,高深莫測…

不扯遠了,想必談到二進制數據,大家聯想到的就會是 1010110110001 或者 00000000 11111111 00000101 這樣的數據流;而這武林之中,號稱三劍客之一的 JavaScript,在其行走江湖之際(日常開發),可能廝殺(處理)最多的類型就是直觀的數字、字符串或者對象等;那麼與極少露面的隱士(二進制)狹路相逢之時,它又將作何應對(描述與處理二進制數據)呢?是波瀾壯闊,還是全身而退,抑或是力挽狂瀾,且聽本文中分解。

ArrayBuffer

未曾識得英雄面,只緣身在此山中;先來了解第一個概念,ArrayBuffer 表示的是一個原始二進制數據緩衝區(buffer),長度固定,並且內容是隻讀的;如果需要執行寫操作,那麼需要使用 類型化數組TypedArray)或者 數據視圖DataView) 來實現;

知己知彼,方可百戰百勝;在 JavaScript 中與二進制數據接觸最緊密的可能就是 ArrayBuffer 了,之前講目的是要描述和操作二進制數據,那麼就要把這些數據先存放到某個地方,然後才能對其進行操作,這裏的 ArrayBuffer 緩衝區就可以被看成這麼一種地方;當然,可能最直觀的方式就是將其保存到字符串中,如 "101011011",又或者存入數組,如 [1,0,1,0,1,1,0,1],這樣確實是方便人類了,但是機器執行的效率也降低了,因爲畢竟字符和數組是另外兩種基本類型,並且也不是專門爲此設計的;所以,就出現了專門爲緩衝數據設計的 ArrayBuffer,並通過結合視圖來提供一個訪問和操作數據的接口;

語法

實例化 ArrayBuffer 構造函數時,只接受一個參數,就是要創建的 Arraybuffer 的大小,單位是字節,不指定的話默認爲 0;同時它也提供了一個實例屬性 byteLength(只讀),實現對當前 ArrayBuffer 字節值的訪問;

舉例:

var buffer = new ArrayBuffer(3);

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

另外,由於 ArrayBuffer 只是負責創建這麼一段數據區域,並沒有提供初始化賦值的接口,所以這 n 字節的數據都爲空,即都置 0;

方法

由於 ArrayBuffer 構造函數本身是用於創建數據緩衝區,並且數據只讀,所以提供的屬性和方法也只有少數幾個;

.slice()

用於返回一個新的緩衝區,語法爲 .slice(start, end),即以當前緩衝區爲母本,從索引爲 start 的位置開始,到 end 位置結束(end 位置不包含在內),然後複製並返回這一區段的數據;其用法大致與 Array.prototype.slice() 類似,舉例說明:

var buffer1 = new ArrayBuffer(5);
var buffer2 = buffer1.slice(0, 3);
var buffer3 = buffer1.slice(2);
var buffer4 = buffer1.slice(1, -1);

console.log(buffer2.byteLength); // 3
console.log(buffer3.byteLength); // 3
console.log(buffer4.byteLength); // 3

ArrayBuffer.isView()

該方法用來判斷所提供的參數值是否是一種 ArrayBuffer 視圖,比如類型化數組(TypedArray)和數據視圖(DataView),例如:

console.log(ArrayBuffer.isView());                   // false
console.log(ArrayBuffer.isView([1, 2, 3]));          // false
console.log(ArrayBuffer.isView(new ArrayBuffer(3))); // false

console.log(ArrayBuffer.isView(new Int8Array()));                 // true
console.log(ArrayBuffer.isView(new Uint32Array(3)));              // true
console.log(ArrayBuffer.isView(new DataView(new ArrayBuffer()))); // true

類型化數組(TypedArray)

概述

工欲善其事必先利其器;前面提到操作 ArrayBuffer 創建的數據緩衝區需要使用視圖(view)實現,類型化數組就是這麼一個描述二進制數據緩衝區(buffer)的視圖(view),這個視圖是一個 類數組。另外,不存在 TypedArray() 這個構造函數,它指的是一類數組,因此它有多種實現,即多個類型化數組構造器函數;可以姑且理解爲 水果 之於 蘋果香蕉,水果指的是一類食物,都知道並不存在名爲 水果 的一種具體食物,但是 蘋果香蕉 是具體存在的;

有效的類型如下:

Int8Array(); // 8 位二進制有符號整數
Uint8Array(); // 8 位無符號整數(超出範圍後從另一邊界循環)
Uint8ClampedArray(); // 8 位無符號整數(超出範圍後爲邊界值)
Int16Array(); // 16 位二進制有符號整數
Uint16Array(); // 16 位無符號整數signed
Int32Array(); // 32 位二進制有符號整數
Uint32Array(); // 32 位無符號整數
Float32Array(); // 32 位 IEEE 浮點數(7 位有效數字,如 1.1234567)
Float64Array(); // 64 位 IEEE 浮點數(16 有效數字,如 1.123...15)
BigInt64Array(); // 64 位二進制有符號整數
BigUint64Array(); // 64 位無符號整數

語法:

萬法不離其宗,一招一式都有跡可循;後面就都以 Int8Array() 爲例進行說明,以下代碼展示了可以傳入的參數類型:

new Int8Array(); // ES2017 中新增
new Int8Array(length); 
new Int8Array(typedArray); 
new Int8Array(object); 
new Int8Array(buffer [, byteOffset [, length]]); 

無參數

最好的招式是沒有招式;實例化構造函數時不傳入任何參數,則返回一個空的類型化數組:

var i8 = new Int8Array();

console.log(i8); // Int8Array []
console.log(i8.length); // 0
console.log(i8.byteLength); // 0
console.log(i8.byteOffset); // 0

length

一寸長一寸強;傳入一個數字類型的參數,表示申明類型化數組中元素的個數:

var i8 = new Int8Array(3);
var _i8 = new Int8Array('3'); // 字符串會先被轉換成數字

console.log(i8); // Int8Array(3) [0, 0, 0]
console.log(_i8); // Int8Array(3) [0, 0, 0]
console.log(i8.length); // 3
console.log(i8.byteLength); // 3
console.log(i8.byteOffset); // 0

typedArray

好招不怕效仿;當傳入的一個參數同樣是一個類型化數組時,則返回一個原類型數組的拷貝(不是引用):

var i8 = new Int8Array(3);
var _i8 = new Int8Array(i8);

console.log(i8 == _i8); // false
console.log(i8 === _i8); // false
console.log(_i8); // Int8Array(3) [0, 0, 0]

object

海納百川有容乃大;使用該參數時類似於用 TypedArray.prototype.from() 方法創建的類型數組,同時該方法也和 Array.from() 方法類似,即這個 object 參數是一個類數組的對象,或者是可迭代的對象;舉例:

// 數組
var i81 = new Int8Array([1, 2, 3]);
console.log(i81);
// Int8Array(3) [1, 2, 3]

// 等價的操作
var i81 = Int8Array.from([1, 2, 3]);
// Int8Array(3) [1, 2, 3]

// 類數組
var i82 = new Int8Array({
    0: 1,
    1: 2,
    2: 3,
    length: 3
});
console.log(i82);
// Int8Array(3) [1, 2, 3]

// 可迭代對象
var i83 = new Int8Array(new Set([1, 2, 3]));
console.log(i83);
// Int8Array(3) [1, 2, 3]

buffer, byteOffset, length

衆人拾柴火焰高;該構造函數也支持同時提供三個參數,第一個 buffer 指的是數組緩衝區,是 ArrayBuffer 的實例,同時也是 Int8Array.prototype.buffer 這個屬性的值;butyOffset 指的是元素的偏移值,表示從數組中第幾個元素開始讀取,默認是 0,也就是數組的第一個元素;length 指的是在設置了偏移值後,要讀取的元素長度,默認是整個數組的長度;舉例說明:

var buf = new Int8Array([1,2,3,4,5]).buffer;
var i8 = new Int8Array(buf, 1, 2);

console.log(i8);
// Int8Array(2) [2, 3]

也就是讓申明的類型化數組在提供的 buffer 的基礎上,從它的索引爲 1 的元素(第二個元素)開始讀取,然後向後讀取 2 個元素;該操作一般用於對緩衝區數據的截取;

類型差異

存在即合理;根據前面的介紹,TypedArray 定義了多種類型,如 Int8Array, Uint8Array, Int16Array 等,這樣做也是爲了適應不同的應用場景,接下來大致瞭解一下幾個典型的類型化數組之間的區別;

有無符號

Int8ArrayUint8Array 爲例,其實 有符號 的意思是數組中的元素可以存在符號,即可以是負數;因此 無符號 的意思就是元素只能是非負數,舉例:

var i8 = new Int8Array([1, 2, 3]);
var _i8 = new Int8Array([-1, -2, -3]);
var ui8 = new Uint8Array([1, 2, 3]);
var _ui8 = new Uint8Array([-1, -2, -3]);

console.log(i8);  // Int8Array(3) [1, 2, 3]
console.log(_i8); // Int8Array(3) [-1, -2, -3]
console.log(ui8); // Uint8Array(3) [1, 2, 3]
console.log(_ui8);// Uint8Array(3) [255, 254, 253]

可以發現有符號類型之處初始化負數元素,而無符號則會對負數進行轉換,具體轉換方式後面會提到;

元素範圍

有無符號的類型數組,除了元素的值的正負區別外,元素的取值範圍也有所不同;下面是一份具體的清單:

Type Range
Int8Array -128 ~ 127
Uint8Array 0 ~ 255
Uint8ClampedArray 0 ~ 255
Int16Array -32768 ~ 32767
Uint16Array 0 ~ 65535
Int32Array -2147483648 ~ 2147483647
Uint32Array 0 ~ 4294967295
Float32Array 1.2×10-38 ~ 3.4×1038
Float64Array 5.0×10-324 ~ 1.8×10308
BigInt64Array -263 ~ 263-1
BigUint64Array 0 ~ 264-1

可以看出,爲了顧及有無符號類型的單個元素取值範圍區間一樣,所以就調整了它們的取值上下限;

字節位數

以有符號類型爲例,可以發現有 Int8Array, Int16Array 等幾個不同的類型數組,唯一的區別就是他們構造函數名字中間的數字不同,其實這個數字指的是實例化後的類型化數組的單個元素的大小,即多少位,8 就是 8 位,即一字節,16 就是 2 字節,類推;其實,這個數字也反應了類型數組中 BYTES_PER_ELEMENT 這個屬性的值,從名字也可以看出代表的是每個元素的字節數;舉例說明:

var i8 = new Int8Array(3);
var i16 = new Int16Array(3);
var i32 = new Int32Array(3);

console.log(i8.BYTES_PER_ELEMENT); // 1
console.log(i16.BYTES_PER_ELEMENT); // 2
console.log(i32.BYTES_PER_ELEMENT); // 4

console.log(i8.length); // 3
console.log(i16.length); // 3
console.log(i32.length); // 3

console.log(i8.byteLength); // 3
console.log(i16.byteLength); // 6
console.log(i32.byteLength); // 12

另外 byteLength 這個屬性其實指的是類型數組總的字節大小,其值等於單個元素字節值乘以元素個數(byteLength = BYTES_PER_ELEMENT x length);

Clamped

鶴難隱於雞羣;從前面的清單中可以找到 Uint8ClampedArray 這個獨特的類型數組,區別就是中間多了 clamped 這個單詞,詞典解釋的意思是“夾緊,箝位”,具體功能是什麼,下面通過代碼來解釋:

var i8 = new Uint8Array([1, 2, 3]);
var _i8 = new Uint8Array([-1, -2, -3]);
var _i8_ = new Uint8Array([255, 256, 257]);

var uic8 = new Uint8ClampedArray([1, 2, 3])
var _uic8 = new Uint8ClampedArray([-1, -2, -3])
var _uic8_ = new Uint8ClampedArray([255, 256, 257])

console.log(i8);    // Int8Array(3) [1, 2, 3]
console.log(_i8);   // Int8Array(3) [255, 254, 253]
console.log(_i8_);  // Uint8Array(3) [255, 0, 1]

console.log(uic8);  // Uint8ClampedArray(3) [1, 2, 3]
console.log(_uic8); // Uint8ClampedArray(3) [0, 0, 0]
console.log(_uic8_);// Uint8Array(3) [255, 255, 255]

不知諸位可否查探出端倪,這裏也能解釋之前說的無符號類型數組實例化時轉換負值的問題;通過分析不難發現,轉換方式類似於素組循環取值,就是如果傳入的值超過了元素的取值範圍的上限或下限之一時,那麼超過的部分就會,從範圍的另一個界限開始依次向後計數;所以上例中 -1 會被轉換爲 255257 會被轉換成 1

而對於 Uint8ClampedArray 這個類型數組,其實差不多也是字面的意思,類似於一個 “夾住” 的操作:超出範圍不會發生循環轉換,無論超出多少都只會被置爲對應的邊界值,所以上例中 -1, -2, -3 都被轉換爲 0256, 257 則都被轉換成了 255

浮點數

論世間誰主浮沉;僅有的兩個浮點類型的類型數組,Float32ArrayFloat64Array,浮點的意思就是元素值可以是小數,因爲之前介紹過的都是 int(整數) 類型的;依然來舉例說明:

var f32 = new Float32Array([1.11, 2.12345678911, -3.33333333333333333333333333])
var f64 = new Float64Array([1.11, 2.12345678911, -3.33333333333333333333333333])

console.log(f32);
// Float32Array(3) [1.1100000143051147, 2.1234567165374756, -3.3333332538604736]
console.log(f64);
// Float64Array(3) [1.11, 2.12345678911, -3.3333333333333335]

從結果來看 32 位浮點類型數組每個元素都保留到小數點後 16 位,而 64 位是最多保留到 16 位,具體的細節就不深究了;

操作元素

欲與二進制數據一決高低,首先肯定是選幾樣趁手兵器;雖然類型化數組擁有普通數組的大部分方法,比如 every, forEach, slice 等等,但也有自己特有的方法值得說一下,比如 .set() 這個方法;

.set() 方法用於把指定數組的(所有)元素添加到當前數組的指定位置,接受的參數爲 .set(array[, ofset]),這裏的 array 可以是普通數組或類型化數組,offset 指的是偏移值,即從哪個位置開始寫入指定數組元素;舉例說明:

var i8 = new Int8Array(6);
var i81 = new Int8Array(6);
var i82 = new Int8Array(6);
var arr = [1, 2, 3];
var arr1 = [1, 2, 3, 4, 5, 6];

i8.set(arr, 2);
console.log(i8);
// Int8Array(6) [0, 0, 1, 2, 3, 0]

i81.set(arr1, 2);
console.log(i81);
// Uncaught RangeError: offset is out of bounds

i82.set(arr, 6);
console.log(i82);
// Uncaught RangeError: offset is out of bounds

證明無論是拷貝的數組大小超過原數組,還是偏移值過大使得拷貝結果超過原數組,都會報錯提示偏移超過邊界,因此使用時需計算準確;

操作緩衝區

箭在弦上,東風將至;前面將 TypedArray 描述爲操作 ArrayBuffer 中數據的視圖,下面就來看一下具體的操作方法;

數據讀取

數據轉換

敵不動我不動;使用類型化數組操作 ArrayBuffer 的數據前,需要先獲取其中的數據,也就是把 ArrayBuffer 轉換爲 TypedArray 類型;先來看一下這兩種類型互相轉換的方法:

ArrayBuffer 轉換爲 TypedArray

var buffer = new ArrayBuffer(5); // 先初始化 5 字節長的區域
var i8 = new Int8Array(buffer); // 再把數據傳遞進 TypedArray

console.log(i8); // Int8Array(5) [0, 0, 0, 0, 0]

這裏也可以驗證,ArrayBuffer 新創建的區域數據都被置 0 了;

TypedArray 轉化爲 ArrayBuffer

var i8 = new Int8Array(5);
var buffer = i8.buffer;

console.log(buffer); // ArrayBuffer(5) {}

讀取方式

前面講道,類型化數組有多種不同的實現,比如 1 字節有符號元素的 Int8Array,2 字節的 Int16Array 等;根據 ArrayBuffer 的定義,緩衝區是以 1 字節 爲單位進行創建的,所以我們通常讀取文本類數據使用 Uint8Array,因爲它也正好每個元素的大小爲 1 字節,當然,也可以選擇用 Uint16Array 來 2 字節地挨個讀,其他類型類推;

通過代碼來觀察一下具體的讀取方式:

var data = new Uint8Array([1, 2, 3, 4])
var buffer = data.buffer;

var ui8 = new Uint8Array(buffer);
var ui16 = new Uint16Array(buffer);

console.log(ui8);  // Uint8Array(4) [1, 2, 3, 4]
console.log(ui16); // Uint16Array(2) [513, 1027]

原始數據 data 是 4 字節大小,通過 Uint8Array 就是以 1 字節爲單位,所以得到的也是原始的數據 [1, 2, 3, 4],這裏由於數據小所以有無符號無影響;而通過 Uint16Array 則是以 2 字節爲單位進行讀取,所以總的元素長度爲 2(2 = 4 / 2),但是其中的單個元素 513, 1027 又分別是如何得到的呢?我們可以通過計算來探究一下:

首先看 1, 2 這兩個元素,根據結果它們被讀取成爲了 513,那麼就把這幾個元素的二進制數表示出來(緩衝區就是存儲的二進制數據):

"1":   00000001
"2":   00000010
"12":  00000001 00000010

"513": 00000010 00000001
"21":  00000010 00000001

規律顯而易見了,513 這個 2 字節的數據,其實是把 12 這兩個挨着的 1 字節的數據,以 倒序 方式拼接在一起的;

再來看一下 34 這兩個是否也是以同樣的方法得出 1027 這個數據的:

"3":    00000011
"4":    00000100
"34":   00000011 00000100

"1027": 00000100 00000011
"43":   00000100 00000011

結果不出所料,所以像 Uint32Array 等以多個字節讀取數據的類型數組,方法也可以類推;

字節序

另外值得一提的是,上面所說的 倒序 拼接方式,其實有個專業術語,叫做 字節序(Endian),對應這個英文單詞應該會感覺似曾相識,例如 Linux 中執行 lscpu 得到的結果中,就會發現它的存在:

Architecture:        x86_64
CPU op-mode(s):      32-bit, 64-bit
Byte Order:          Little Endian
Address sizes:       36 bits physical, 48 bits virtual

字節序,或字節順序(“Endian”、“endianness” 或 “byte-order”),描述了計算機如何組織字節,組成對應的數字。

這個字節序可以分爲:

Little Endia(低字節序):低位數據放入存儲地址的低位,高位數據放入高位地址;

這種順序就顯得和內存上的存儲地址順序(閱讀模式下低位在右,高位在左)保持一致,並且也是一種常見的方式,比如上面的英特爾處理器;只不過對於這種順序人類閱讀時就要反着讀了(從右至左),比如上面例子中的數據 12 就是以 21 的順序讀取的,也可以類比這種日期格式:"Sat 07 Mar 2020";

Big Endian(高字節序):低位數據存入高位地址,高位數據放入低位;

這種順序可能更符合人類的閱讀習慣(從左至右),它一般應用在互聯網標準的數據結構中,可以類比 "2020-03-07" 這種日期格式;

數據修改

下面通過類型化數組視圖來嘗試修改一下 ArrayBuffer 緩衝區中的內容:

var buffer = new ArrayBuffer(3);
var i8 = new Int8Array(buffer);

console.log(i8); // Int8Array(3) [0, 0, 0]

for (let i = 0; i < i8.length; i++) {
    i8[i] = 1;
}

var _i8 = new Int8Array(buffer); // 新建個視圖驗證是否修改成功

console.log(_i8); // Int8Array(3) [1, 1, 1]

數據拼接

用之前講過的 .set() 方法來嘗試將數據拼接進緩衝區:

var buffer = new ArrayBuffer(6);
var i80 = new Int8Array(buffer);

console.log(i80); // Int8Array(6) [0, 0, 0, 0, 0, 0]

var i81 = new Int8Array([1, 2, 3]);
var i82 = new Int8Array([4, 5, 6]);

i80.set(i81);
i80.set(i82, 3);

var _i80 = new Int8Array(buffer); // 驗證是否修改成功

console.log(_i80); // Int8Array(6) [1, 2, 3, 4, 5, 6]

注意:這裏不能使用數組的 .concat() 這個方法來進行元素拼接,因爲類型化數組中並沒有內置這個方法,不然會報錯,如下:

var arr1 = [1, 2, 3];
var arr2 = arr1.concat(4, 5, 6);

console.log(arr2); // [1, 2, 3, 4, 5, 6]

var i81 = new Int8Array([1, 2, 3]);
var i82 = i81.concat(4, 5, 6);

console.log(i82);
// Uncaught TypeError: i81.concat is not a function

同樣地,.splice() 這個可以替換元素的方法也不存在於類型化數組中;

數據視圖(DataView)

概述

一個好漢三個幫;DataView 是另外一個用於從 ArrayBuffer 緩衝區中讀寫數據的視圖接口,其特點就是考慮了 字節序 的問題,後面會講;

語法爲:

new DataView(buffer [, byteOffset [, byteLength]]);

其中 buffer 指傳入的數據緩衝區,如 ArrayBuffer;byteOffset 指偏移的字節量,默認第一個字節,byteLength 指要傳入的數據的字節長度,默認整個 buffer 的長度;並且這三個參數都可以在實例化後通過相應屬性(只讀)訪問到;

var buffer = new Int8Array([1, 2, 3, 4]).buffer;
var dv = new DataView(buffer, 1, 2);

console.log(dv); // DataView(2) {}
console.log(dv.buffer); // ArrayBuffer(4) {}
console.log(dv.byteOffset); // 1
console.log(dv.byteLength); // 2

操作數據

DataView 提供了一系列的方法用於操作緩衝區的數據,先簡單預覽一下:

Read Write
getInt8() setInt8()
getUint8() setUint8()
getInt16() setInt16()
getUint16() setUint16()
getInt32() setInt32()
getUint32() setUint32()
getFloat32() setFloat32()
getFloat64() setFloat64()

Read

getInt8() 方法爲例,可提供一個參數 byteOffset,表示偏移指定字節數,然後讀取 1 字節(8 位)數據,默認 爲 0(第一字節);而如果是 getInt16() 等用於獲取大於 1 字節值以及浮點值的方法,還接受第二個可選參數 littleEndian,就是是否使用 little endian(低字節序,上文有講)格式來讀取數據,傳入 true 就表示使用 little endian 格式,傳入 false 或者不填,就使用 big endian(高字節序) 格式;

var buffer = new Int8Array([1, 2, 3, 4]).buffer;
var dv = new DataView(buffer);

console.log(dv.getInt8(1)); // 2
console.log(dv.getInt16(0, true)); // 513
console.log(dv.getInt16(0, false)); // 258
console.log(dv.getInt16(0)); // 258

結果爲 513 的這一行代碼,使用的是 little endian 格式,並且 513 這個值也與之前 TypedArray 中關於 Int16Array 例子的結果一致,證明 TypedArray 默認使用的是 little endian 格式在操作數據緩衝區;

Write

setInt8() 爲例,接受兩個參數:setInt8(byteOffset, value),第一個表示偏移字節量,用於定位,第二個則是要設置的具體值,非數字類型會報錯;類似地,setInt16 等用於設置超過 1 字節的方法,也提供第三個可選參數 littleEndian,表示是否以 little endian 格式設置;

var buffer1 = new ArrayBuffer(2);
var buffer2 = new ArrayBuffer(4);
var dv1 = new DataView(buffer1);
var dv2 = new DataView(buffer2);

dv1.setInt8(0, 1);
dv1.setInt8(1, 2);
var i8 = new Int8Array(dv1.buffer);
console.log(i8); // Int8Array(2) [1, 2]

dv2.setUint16(0, 513, true);
dv2.setUint16(2, 513);
var i16 = new Uint16Array(dv2.buffer);
console.log(i16); // Int16Array(2) [513, 258]

需要注意的就是,因爲 byteOffset 這個參數的單位始終是 1 字節,所以當寫入超過一字節的數據時,相應的偏移值也需要增加,就像上例所以展示的一樣;

對比

與前文所講的 TyptedArray 視圖接口相比,DataView 視圖雖然兼容了不同平臺的字節序問題,但是也沒有了一些對整段數據進行修改拼接的功能,只能修改單個元素值;另外也不能用構造函數初始賦值,比如下面的情況:

console.log(new Int8Array([1, 2, 3]));
// Int8Array(3) [1, 2, 3]

console.log(new DataView([1, 2, 3]));
// Uncaught TypeError: First argument to DataView constructor must be an ArrayBuffer

所以,需要靈活地結合二者使用,以應對複雜的場景;兄弟齊心,其力斷金;

Blob

Blob 構造函數用於描述一個 blob(Binary Large OBject,二進制大對象),即保存原始數據的類文件對象,支持保存 多種類型 的數據(不像 TypedArray,只能使用數字類型),並且數據是隻讀的,不可修改;另一個基於 Blob 的構造函數 File,就是用來處理用戶上傳文件的(<input type="file">)數據。

語法:

new Blob(array, options);

array 指的是一系列類型的數據構成的數組或者類數組,這些數據可以是字符串、ArrayBuffer、DataView、TypedArray、Blob、DOMString 等等;options 則是一個對象,可以包含以下兩個屬性:

{
    type: "", // 傳入的數據的 MIMS 類型,比如 text/plain,默認爲空
    endings: "" // 如何處理數據中的換行符,比如 \n 和  \r\n,因操作系統而異
                // 值爲 transparent 或者 native,默認爲 transparent
                // native 表示替換爲當前系統的換行符
                // transparent 則表示不替換,保持數據內容
}

寫入數據

通過幾個例子來說明:

var blob1 = new Blob([1, 2, 3]);
var blob2 = new Blob(['a', 'bc', 'd e']);
var blob3 = new Blob(['hello'], {type: 'text/plain'});
var blob4 = new Blob(new Int8Array([4, 5, 6]));
var blob5 = new Blob([blob2]);

console.log(blob1); // Blob {size: 3, type: ""}
console.log(blob2); // Blob {size: 6, type: ""}
console.log(blob3); // Blob {size: 5, type: "text/plain"}
console.log(blob4); // Blob {size: 3, type: ""}
console.log(blob5); // Blob {size: 6, type: ""}

如果參入的參數不是類數組的類型,則會報錯:

var blob1 = new Blob(123);
var blob2 = new Blob('123');
var blob3 = new Blob({foo: 'bar'});
var blob4 = new Blob(true);
var blob5 = new Blob(blob1);

console.log(blob1);
// VM3497:1 Uncaught TypeError: Failed to construct 'Blob': 
// The provided value cannot be converted to a sequence.
console.log(blob2);
// VM3497:1 Uncaught TypeError: Failed to construct 'Blob': 
// The provided value cannot be converted to a sequence.
console.log(blob3);
// VM3497:1 Uncaught TypeError: Failed to construct 'Blob': 
// The provided value cannot be converted to a sequence.
console.log(blob4);
// VM3497:1 Uncaught TypeError: Failed to construct 'Blob': 
// The provided value cannot be converted to a sequence.
console.log(blob5);
// VM3497:1 Uncaught TypeError: Failed to construct 'Blob': 
// The provided value cannot be converted to a sequence.

讀取數據

寫入 Blob 實例中的數據雖然不能修改,但是還是可以讀取的,首先可以獲取數據總的大小和類型(只讀):

var blob = new Blob(['a', 'b', 'c'], {type: 'text/plain'});

console.log(blob.size); // 3
console.log(blob.type); // text/plain

.text() 方法用於獲取 Blob 中的文本數據,返回值是一個 promise 對象,包含一個 resolved 狀態的文本數據,無提供的參數;

var blob = new Blob([1, 2, 3]);

blob.text().then(data => {
    console.log(data, typeof data);
});
// 123 string

.arrayBuffer() 方法也用於獲取 Blob 中的數據,並且返回一個 promise,無參數提供,只不過返回的是數據的 ArrayBuffer,即二進制數據緩衝區;

var blob1 = new Blob([1, 2, 3]);
var blob2 = new Blob(['a', 'b', 'c']);

blob1.arrayBuffer().then(data => {
    console.log(new Uint8Array(data));
});
// Uint8Array(3) [49, 50, 51]

blob2.arrayBuffer().then(data => {
    console.log(new Uint8Array(data));
});
// Uint8Array(3) [97, 98, 99]

計算以下也可以驗證,類型數組中的數值確實是對應的原始數據的二進制值。

TextEncoder

臨陣磨槍,不快也光;這還是一個處於 實驗階段 的接口,當前的接口將來可能發生改變,並且目前 IE 系列瀏覽器都還不支持,這裏只作簡單介紹;

顧名思義,這個構造函數的作用就是負責編碼文本,其實就是以指定的編碼格式,將傳入的文本轉換成該數據對應的 類型化數組;實例化時可以提供一個參數,用於編碼格式,不過目前默認並且只使用 UTF-8 格式編碼,所以可以省略;

var encoder = new TextEncoder();
var arr = encoder.encode('abc');

console.log(encoder.encoding); // utf-8
console.log(arr); // Uint8Array(3) [97, 98, 99]

有編碼就自然有解碼,TextDecode 這個構造函數就與之對應,即將 ArrayBuffer 或者 ArrayBuffer View 類型的數據解碼爲相應的文本;

var ui8 = new Uint8Array([97, 98, 99]);
var buffer = ui8.buffer;
var decoder = new TextDecoder();

var text1 = decoder.decode(ui8);
var text2 = decoder.decode(buffer);

console.log(text1); // abc
console.log(text2); // abc

這樣,除了上面的 Blob,這裏的 TextEncoder 也可以用於將文本數據保存爲 JavaScript 中的二進制緩衝數據;

處理文件數據

人外有人,天外有天,跨過了這二進制,便是更廣闊的天地;說了一系列的關於二進制數據的保存和讀寫方法,也該談談其用武之地了;

要知道 JavaScript 中保存文本字符串什麼的用變量就行了,緩衝區、類型數組、Blob 這些接口其實多數是用於處理文件數據相關的,因爲它們有着不同的 MIME 類型,比如 .jpg .mp4 .bin 這些後綴的文件,JavaScript 並沒有內置一些直接處理這些數據類型的接口(例如 .txt 文檔就能可以處理),所以就需要以原生二進制數據的方式來保存或處理,方便用戶上傳、下載或預覽;下面就將介紹一些文件處理相關的接口;

File

前面講到,File 是基於 Blob 的,所以也就繼承了它的一些方法;File 用於提供有關文件的信息和內容,語法如下:

new File(content, name[, options]);

content 指要創建的文件內容,是 ArrayBuffer, View, Blob, DOMString 等類型構成的 數組 或者類數組;name 則是文件的名稱或者路徑;options 參數可選,包含 typelastModified 兩個屬性;

舉例:

var content = new TextEncoder().encode('hello world!');
var file = new File(content, 'test.txt', {
    type: 'text/plain', // 可選,默認爲空
    lastModified: Date.now() // 可選,後面是默認值
});

console.log(file.name); // test.txt
console.log(file.size); // 12
console.log(file.type); // text/plain
console.log(file.lastModified); // 1583638485180

File 構造函數自身並沒有自帶一些方法,而是繼承了 Blob 的方法,例如:

var file = new File(['hello world!'], 'text.txt'); 
// 初始化內容可以直接是字符串,只是需要放在數組中

file.text().then(data => {
    console.log(data); // hello world!
});

file.arrayBuffer().then(data => {
    var text = new TextDecoder().decode(data);
    console.log(text); // hello world!
});

其實,一般很少像這樣用 File 接口來直接創建一個文件對象,多數是用在用戶上傳文件等情況,比如在網頁中用 <input type="file" /> 標籤來上傳文檔,而用戶點擊上傳後,與文件相關的信息就被包含在了這個 input 標籤的節點引用的 files 屬性中,這個 files 屬性值是一個 FileList 接口的實例,就是包含所有上傳文件的數組,其中每個元素都是一個 File 接口的實例;

通過一個簡單的 demo 進行說明:

<!DOCTYPE HTML>
<html>
    <head></head>
    <body>
        <input type="file" class="upload" />
        <!--
        如果要上傳多個文件,則使用:
        <input type="file" class="upload" multiple />
        -->
        <input type="submit" value="Upload" onclick="doUpload()" />
        
        <script>
            var upload = document.querySelector('.upload');
            
            // 用戶點擊 Upload 按鈕後執行
            function doUpload() {
                var file = upload.files[0];
                
                console.log(file);
                // File {name: "test.txt", lastModified: 1583634142542, lastModifiedDate: 
                // Sun Mar 08 2020 10:22:22 GMT+0800 (中國標準時間), webkitRelativePath: "", size: 12, …}
            }
            
            // 也可以用這種方法獲取文件對象,
            // 這個函數中的代碼會在用戶完成上傳操作就執行,即使沒點上傳按鈕
            upload.onchange = function(el) {
                var file = el.files[0];
                // 執行的操作...
            }
        </script>
    </body>
</html>

FileReader

FileReader 是另一個用於讀取文件數據的接口,其實例化後的一些方法與 Blob 中的 .text().arrayBuffer 方法類似,只不過返回的不再是一個 promise 對象,而是一個基於 事件 的接口;FileReader 一般也用於讀取用戶上傳文件的數據;

語法:

new FileReader(); // 實例化無須提供任何參數

事件處理

既然是以基於事件,那麼就需要一系列處理不同事件的方法,列出如下:

  • .onabort():該事件在讀取操作被中斷時觸發。
  • .onerror():該事件在讀取操作發生錯誤時觸發。
  • .onload():該事件在讀取操作完成時觸發。
  • .onloadstart():該事件在讀取操作開始時觸發。
  • .onloadend():該事件在讀取操作結束時(要麼成功,要麼失敗)觸發。
  • .onprogress():該事件在讀取Blob時觸發。

以上事件也可以使用 addEventListener() 方法的相應格式來設置回調函數;

加載狀態

因爲是基於事件的接口,所以 FileReader 提供了 readyState 這個屬性,以不同值代表不同的數據加載狀態:

  • 0:數據尚未加載;
  • 1:數據正在加載中;
  • 2:數據加載完成;

數據加載完成後,可以使用 result 這個屬性來獲取文件內容;

數據加載

readAsText()

.readAsText(file[, encoding]) 以文本字符串的形式讀取 file (文件對象或者 Blob)中的數據,以 encoding 格式進行編碼(默認 utf-8);

var file = new File(['abc'], 'test.txt');
var reader = new FileReader();

reader.onloadstart = event => {
    console.log('loadstart state:', event.target.readyState);
}
reader.onload = event => {
    console.log('load state:', event.target.readyState);
    console.log('result:', event.target.result);
}
reader.onloadend = event => {
    console.log('loadend state:', event.target.readyState);
}

reader.readAsText(file);
// loadstart state: 1
// load state: 2
// result: abc
// loadend state: 2

readAsArrayBuffer()

readAsArrayBuffer(file)ArrayBuffer 的形式讀取 file 中(文件或 Blob)的數據;

var blob = new Blob(['a', 'b', 'c']);
var reader = new FileReader();

// 使用監聽器觸發效果相同
reader.addEventListener('load', event => {
    console.log(event.target.result);
    console.log(new Uint8Array(event.target.result));
})

reader.readAsArrayBuffer(blob);
// ArrayBuffer(3) {}
// Uint8Array(3) [97, 98, 99]

readAsDataURL()

readAsDataURL(file) 同樣是讀取 file 中的數據,只是將文件中的內容以 base64 編碼後,放進一個 DataURL 中(內容可以通過 URL 鏈接直接訪問);

var file = new File(['abc'], 'test.txt', {
    type: 'text/plain'
})
var reader = new FileReader();

reader.onload = event => {
    console.log(event.target.result);
}

reader.readAsDataURL(file);
// data:text/plain;base64,YWJj

如果將最後的輸出內容粘貼複製進瀏覽器的地址欄,回車後就能直接看見文本內容;

呈現數據

DataURL

Data URL 指的是一種 URL 協議,語法格式爲:

data:[<mediatype>][;base64],<data>

可以類比常見的 http: 協議,例如上例中的返回值:

data:text/plain;base64,YWJj

具體用法如之前所述,輸入到瀏覽器地址欄回車後會直接呈現出原內容,比如上例就是一串文本(文件類型被指定爲 text/plain),如果類型 image/png 等圖片格式的,則會直接顯示該圖片;

Data URL 除了可以通過瀏覽器地址欄訪問,也可以在 HTML 文檔中展示,例如使 <img>src 屬性值等於這個 Data URL,這個標籤就會展示爲相應的圖片,同樣地,數據指定給 <iframe>src 屬性,也可以展示圖片或者文本數據,指定給 <video> 標籤的 src 則可以展示視頻;

ObjectURL

ObjectURL 使用 URL.createObjectURL() 方法創建,返回結果也是一種類型的 URL,類似於上面的 Data URL,區別在於 ObjectURL 的生命週期與當前網站頁面相關,例如 刷新頁面 頁面後不無法繼續訪問了;

例如在本地網頁控制檯中運行下面的代碼:

var blob = new Blob(['abc']);

console.log(URL.createObjectURL(blob));

則會輸出類似下面的內容:

blob:http://127.0.0.1:8080/4064e759-231f-466e-a6ef-778505e56d2b

鏈接臨時有效,會展示數據內容刷新頁面失效,不過格式基本一致;同樣,ObjectURL 也可以用於設置爲 <img><iframe>src 屬性,進行單獨展示;

需要 注意createObjectURL() 方法每次調用都會返回一個新的 ObjectURL 對象,即使數據源相同,所以如果調用量較多,可能就會內存劇增,這時需要手動回收,使用的是 revokeObjectURL() 這個方法,示例:

var url = URL.createObjectURL(new Blob(['test']));

URL.revokeObjectURL(url); // 完成回收

文件下載

除了使用 <img, <iframe> 等標籤對數據進行展示,也可以將文件提供給用戶下載,使用的是 <a> 標籤,把 DataURL 或者 ObjectURL 指定給它的 href 屬性即可,另外還要指定 download 屬性值,不然有可能會是跳轉到相關頁面而不是下載;

一個下載組件的示例:

<a href="data:text/plain;base64,YWJj" download="test.txt" type="text/plain">Download</a>

download 屬性指代下載到用戶本地的文件名稱,不加後綴則系統自動識別類型,同樣 type 屬性也是可選的,可用於固定下載文件類型;

上傳數據

讓用戶通過網頁上傳文件,最重要的當然就是最後的上傳階段了,即把用戶選擇的文件上傳到服務器;下面的例子使用 XMLHttpRequest() 接口來實現數據的上傳;

var file = new File(['hello world!'], 'hello.txt', {
    type: 'text/plain'
}); // 此處用於模擬用戶上傳的文件,即有具體的文件名、類型和內容
var xhr = new XMLHttpRequest();
var reader = new FileReader();

// 查看上傳進度
xhr.upload.onprogress = event => {
    if (event.lengthComputable) {
        console.log('進度:', event.loaded + '/' + event.total);
    }
}
// 上傳完成的回調
xhr.upload.onload = event => {
    console.log('upload success.');
}
// 上傳地址,參數換成實際地址
xhr.open('POST', 'http://localhost/upload/upload.php');
// 服務器沒有指定文件類型則自行指定
xhr.overrideMimeType('text/plain');

reader.onload = event => {
    // 數據讀取完畢就開始上傳
    xhr.send(event.target.result);
}
reader.readAsText(file);

另外也可以使用 form 表格來上傳文件,更加直接:

<form action="upload/upload.php" method="post" enctype="multipart/form-data">
    <input type="file" name="upload" >
    <input type="submit" value="Upload">
</form>

需要 注意 的是,上傳文件時 必須 加上 enctype="multipart/form-data",不然上傳上去的只是一個文件名;

接收數據

投我以木瓜,報之以瓊琚;有時也會接收來着服務端的數據,通常就是使用 XMLHttpRequest 來異步獲取文本或 JSON 數據,但是它也能用於獲取其他類型的數據,只不過需要手動設置 responseType 這個屬性進行申明,該屬性支持以下幾個值:

  • "":默認值,與 text 類型相同;
  • "text":以文本類型響應;
  • "arraybuffer":以 ArrayBuffer 二進制數據響應;
  • "blob":以 Blob 類型數據響應;
  • "json":響應解析爲 JSON 對象;
  • "document":解析爲 HTML 或 XML 內容;

一個接收數據的實例:

var xhr = new XMLHttpRequest();

xhr.responseType = 'arraybuffer';
xhr.onload = () => {
    var buffer = xhr.response;
    // 可以轉換爲類型化數組進行數據修改
    console.log(new Uint8Array(buffer));
}
xhr.open('GET', 'test.png');
xhr.send();

至此,歷經幾番交戰,刀光劍影,戰況激烈空前,難分難解,不下幾十回合,能閱讀至此處的諸位也都是真正的勇士,敢於面對慘淡的生活,正視淋漓的鮮血…又扯遠了,俗話說,物以稀爲貴,人以和爲貴,JavaScript 劍客與二進制隱士此番交戰,不求勝負,若這過程中的原理能被大家理解參透得透徹,也算是名留青史了;

恩怨自了結,情仇終消散,天下沒有不散的宴席,一個人的路也叫江湖,暫且就此別過,江湖再見!


技術文章推送
手機、電腦實用軟件分享
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章