由於格式和圖片解析問題,可以前往博客 閲讀原文
在實際的開發過程中經常會遇到二進制數據,常見的就有文件的上傳、下載等等,還有比較重要的圖片裁剪、灰度處理等等,這些場景都會涉及到二進制。相信很多開發者對這方面可能一知半解或者就是久而忘之,本人剛開始也是對這方面空白,通過全方位的學習後其實也挺簡單,整體總結可以直奔文中
前端二進制是一種關鍵的數據表示和處理技術,它在前端開發中具有廣泛的應用。瞭解和掌握二進制數據有助於優化性能、原生數據的處理等等
掃碼關注公眾號,查看更多優質文章
ArrayBuffer
ArrayBuffer 對象用來表示通用的、固定長度的原始二進制數據緩衝區;它是一個字節數組,通常在其他語言中稱為“byte array”。你不能直接操作 ArrayBuffer 中的內容;而是要通過 類型化數組對象 或 DataView 對象來操作
構造函數:
new ArrayBuffer(bytelength: number); // 創建指定字節的buffer緩衝區
實例屬性方法:
- byteLength:獲取buffer的字節長度和構造函數傳入的值相等
- slice:拷貝指定位置的內容並返回新的buffer
視圖對象
arraybuffer只是創建了一塊連續的內存地址引用,裏面是什麼內容不能直接讀取,如果要操作buffer對象需要使用視圖對象,這些視圖對象只是用來解析buffer中的內容實際並不會儲存任何內容
TypeArray
這些視圖對象看上去更像數組Array,但他們並不是數組而是在ArrayBuffer上統稱的類型術語,JS提供了多種視圖對象:Uint8Array、Int8Array、Uint16Array、Int16Array、Uint32Array、Int32Array、Float32Array、Float64Array、Uint8ClampedArray、BigInt64Array、BigUint64Array等等
這些視圖對象享有共有的方法和屬性,所以搞懂某一個通用方法就可以了, 唯一不同的是不同的視圖對象對buffer的解析不同如:Uint8Array中是以1字節8位為一個基本單位,Uint16Array則是以2字節16位為一個基本單位,如果buffer的長度為2字節,那麼在uint8長度也為2,而uint16的長度則為1
Uint8Array
Uint8Array 數組類型表示一個 8 位無符號整型數組,8位為一個字節用來表示每個位置上的數字,也就是説Uint8的每一位的數字範圍為:0 - 2^8-1(255),只需要記住每位是由1字節8位組成就可以推到出來(後面會有其他類型的數組都是同一個道理)
構造函數
類型定義:
new Uint8Array(); // 創建空長度
new Uint8Array(len: number); // 創建用0太填滿的指定長度
new Uint8Array(array | arraylike); // 創建時指定值
new Uint8Array(buffer, byteOffset?, len?); // 從存在的buffer中的指定位置截取指定長度
列子:
// 傳入存在的buffer
const buffer = new ArrayBuffer(10);
new Uint8Array(buffer);
// 確定值
new Uint8Array([1,2,3])
// 創建指定長度
new Uint8Array(10); // 長度為10
實例化Uint8Array底層都會創建相應的ArrayBuffer,對實例的操作都是作用到ArrayBuffer上
屬性
- BYTES_PER_ELEMENT:每個元素的字節數,uint8為1字節8位,uint16為2字節16位,以此類推
- buffer:所引用的ArrayBuffer
- byteLength:所引用的ArrayBuffer的長度
- byteOffset:返回具體其引用ArrayBuffer的起始位置偏移量
- length:數組的長度
方法
Uint8Array等擁有數組Array的所有方法, 初次之外還有set方法常用來合併多個類型數組
一個將很大的圖片分段請求最後合併下載的例子:
const buffers: ArrayBuffer[] = []; // 儲存請求的所有的圖片buffer
const bufferTotalLen = buffers.reduce((p, c) => (p += c.byteLength), 0); // buffer總長度
const allBuffer = new Uint8Array(bufferTotalLen); // 合併成最後的buffer
let position = 0, begin = 0;
while (begin < buffers.length) {
const subBuffer = new Uint8Array(buffers[begin]);
allBuffer.set(subBuffer, position);
position += subBuffer.length;
begin++;
}
const blob = new Blob([allBuffer.buffer], { type: "image/png" }); // 構造blob對象
const url = URL.createObjectURL(blob);
// ...
除此之外的其他有關的ArrayBuffer的視圖api雷同,可自行查看文檔
以下是幾個不同類型的array對同一段buffer的處理不同點:
Uint8Array—— 將ArrayBuffer中的每個字節視為 0 到 255 之間的單個數字(每個字節是 8 位,因此只能容納那麼多)。這稱為 “8 位無符號整數”。Uint16Array—— 將每 2 個字節視為一個 0 到 65535 之間的整數。這稱為 “16 位無符號整數”。Uint32Array—— 將每 4 個字節視為一個 0 到 4294967295 之間的整數。這稱為 “32 位無符號整數”。Float64Array—— 將每 8 個字節視為一個5.0x10-324到1.8x10308之間的浮點數。
DataView
DataView 是在 ArrayBuffer 上的一種特殊的超靈活“未類型化”視圖。它允許以任何格式訪問任何偏移量(offset)的數據
上面列出的TypeArray由於固定了格式所以每個索引的格式都是相同的,所以只能使用索引方式獲取;而DataView沒有固定格式直接作用於ArrayBuffer上,可以使用任何類型進行讀取,所以更加靈活
構造方法
其構造方法需要傳入buffer實例
new DataView(buffer, offset?, len?)
屬性
- byteLength:字節長度
- byteOffset:首位在ArrayBuffer中的偏移量
- buffer:ArrayBuffer引用對象
方法
- getUint8:以Uint8格式獲取指定索引的數據
- setUint8:setUint8(idx, value)以Uint8格式設置某個索引位置的值,這些值需滿足uint8的值範圍。如
setUint8(0, 256)將不會滿足0~255的範圍限制,其值將會變為0
除了Uint8格式外還有其它TypeArray的格式相同方法,請參考MDN
視圖對象總結
字符二進制
除了一些二進制數據外,還可以將一些字符進行二進制的相互轉換,js中提供了TextEncoder和TextDecoder分別將字符轉為二進制、將二進制轉為字符
TextEncoder例子:
// 將字符轉換成Uint8Array
const encoder = new TextEncoder();
const uint8 = encoder.encode("測試");
console.log(uint8); // Uint8Array(6) [230, 181, 139, 232, 175, 149, buffer: ArrayBuffer(6), byteLength: 6, byteOffset: 0, length: 6]
TextDecoder例子:
// 將ArrayBuffer轉換成字符
const decoder = new TextDecoder("utf8");
console.log(decoder.decode(uint8)); // 測試
console.log(decoder.decode(uint8.buffer)); // 測試
console.log(decoder.decode(uint8.slice(0, 3))); // 測
console.log(decoder.decode(uint8.buffer.slice(0, 3))); // 測
因為TypeArray位Uint8Array所以和ArrayBuffer的字節長度一致,所以對buffer或array的截取一致獲取的結果也一樣。從上面可以看到Uint8Array中的[230, 181, 139]表示一個字符測
const uint8 = new Uint8Array([230, 181, 139]);
const decoder = new TextDecoder("utf8");
decoder.decode(uint8); // 測
字符二進制流
字符二進制通常用來處理比較大的數據字符流,而TextEncoder這種通常是一次性進行轉換;二進制流也包含TextEncoderStream和TextDecoderStream兩種方法
Blob
Blob 對象表示一個不可變、原始數據的類文件對象。它的數據可以按文本或二進制的格式進行讀取,也可以轉換成 ReadableStream 來用於數據操作
前面我們講了ArrayBuffer和TypeArray等相關二進制的方法, 但這些都是操作比較低級的數據,而blob則是有類型的二進制數據,相對於比較低級的數據更容易大家所理解
構造器
new Blob(blobParts, options);
- blobParts:由blob、buffersource、string類型組成的數組值
-
options:
- type:表示blob類型,通常都是mime類型
- endings:是否轉換換行符,使 Blob 對應於當前操作系統的換行符(
\r\n或\n)。默認為"transparent"(啥也不做),不過也可以是"native"(轉換)
例子:
// 將字符轉為blob,並指定類型為 text/plain文本類型
new Blob(['測試'], { type: 'text/plain' });
// 將buffersource轉為blob
new Blob([new Uint8Array([1,2,3])]);
屬性
- size:blob的數據大小
- type:blob的類型
方法
-
arrayBuffer:異步返回blob的二進制格式的ArrayBuffer
blob = new Blob(['一段文本'], { type: 'text/plain' }); buffer = await blob.arrayBuffer(); // ArrayBuffer - slice:劃分指定範圍的blob,類似於array的slice
-
stream:返回blob的可讀流ReadableStream,通常流用來處理比較大的內容
const readableStream = blob.stream(); const reader = readableStream.getReader(); while(true) { const { done, value } = await reader.read(); if (done) break; } -
text:異步返回blob的所有內容的UTF8格式的字符串
blob = new Blob(['一段文本'], { type: 'text/plain' }); text = await blob.text(); // 一段文本
除了使用自身的方法外, 還可以使用FileReader讀取內容並轉換
reader = new FileReader();
reader.onload = e => console.log(e.target.result);
reader.readAsText(blob);
用途
-
用blob對象構造url使用,使用URL的createObjectURL方法生成一個唯一映射此blob的url
// 一個圖片blob blob = new Blob([], { type: 'image/png' }); url = URL.createObjectURL(blob); img.src = url; // 使用後銷燬 URL.revokeObjectURL(); -
文件分片上傳
const blob = new Blob([]); const chunkSize = 1024 * 1024; blobs = []; offset = 0; while(offset < blob.size) { blobs.push(blob.slice(offset, chunkSize)); offset += chunkSize; } blobs.map(blob => fetch('xx', data: blob))
除了以上外對於canvas也可以轉換為blob
const img = new Image();
const canvas = document.querySelector("canvas");
const ctx = canvas?.getContext("2d");
ctx?.drawImage(img, 0, 0);
canvas?.toBlob(e => console.log(e)); // 轉為為 blob
canvas?.toDataURL("text/plain"); // 轉為為 base64字符串
File
文件(File)接口提供有關文件的信息,File 對象繼承了 Blob,並擴展了與文件系統相關的功能,且可以用在任意的 Blob 類型的 context 中。比如説, FileReader, URL.createObjectURL, createImageBitmap, 及 XMLHttpRequest.send()) 都能處理 Blob 和 File
File對象
通常有兩種方式獲取File對象:構造函數、<input type="file">
構造函數:
new File(bits: Array<ArrayBuffer | ArrayBufferView | String | Blob>, name, { type, lastModified })
構造函數方式類似於blob的構造函數
例子:
const file = new File(['我是一段文本信息'], 'text.txt', { type: 'text/plain', lastModified: Date.now() })
輸入框獲取:
// 用户點擊選擇文件後,可以通過屬性獲取
<input type="file">
console.log(e.files[0])
屬性
- lastModified:當前file的最後修改時間,毫秒數
- lastModifiedDate:最後修改時間date對象
- name:文件名字
- size:文件大小
- type:文件的mime類型
由於File繼承於Blob對象所以也用了Blob的相關屬性
方法
由於File繼承於Blob對象所以也用了Blob的相關屬性,如slice方法,通常用來對大文件做切片處理,參考以上blob的切片操作這裏就不演示了
FileReader
FileReader 對象允許 Web 應用程序異步讀取存儲在用户計算機上的文件(或原始數據緩衝區)的內容,主要目的就是從 File 或 Blob 對象中讀取的文件或數據
構造函數
Filereader通過構造函數生成一個對象
const reader = new FileReader();
屬性
-
readyState:當前的讀取狀態,其中包含以下幾個常量
常量名 值 描述 EMPTY0還沒有加載任何數據。 LOADING1數據正在被加載。 DONE2已完成全部的讀取請求。 - result:讀取完後的內容,讀取操作後效,文件內容的格式取決於哪種讀取方式
- error:讀取錯誤的對象
事件
- onabort:讀取時中斷時觸發
- onerror:讀取時發生錯誤時觸發
- onload:所有內容的讀取都是異步的,需要通過此事件獲取讀取的數據,讀取成功後觸發
方法
- abort:中斷讀取
- readAsArrayBuffer:將文件內容讀取為arraybuffer形式的數據
- readAsDataURL:將文件內容讀取為base64字符串
- readAsText:將文件內容讀取為文本字符串
- readAsBinaryString:將文件內容讀取為原始二進制數據
讀取數據都是異步的,需要通過onload事件獲取讀取內容
FileReaderSync
FileReaderSync接口允許以同步的方式讀取 File 或 Blob 對象中的內容,該接口只能在webwoker中使用,由於讀取文件是非常耗時的過程,在主線程使用會造成頁面卡頓現象,因此對於文件的操作在webwoker不會影響主線程
FileReaderSync和FileReader擁有相同的方法和屬性,只不過前者的讀取是同步的
// webwoker
function readFileSync(file) {
const reader = new FileReaderSync();
const buffer = reader.readAsArrayBuffer(file);
return buffer;
}
Stream
Stream API 允許 JavaScript 以編程方式訪問從網絡接收的數據流,並且允許開發人員根據需要處理它們
流可以讓程序不需要接受全部的內容後才可以展示操作,使用流可以將大型數據拆分成小塊並逐步處理,如視頻播放不需要加載全部減小延遲、提高內存的吞吐量
除此之外可以檢測流何時開始或結束,將流鏈接在一起,根據需要處理錯誤和取消流,並對流的讀取速度做出反應
流的基礎應用圍繞着使響應可以被流處理展開。例如,一個成功的 fetch 請求返回的響應體可以暴露為 ReadableStream,之後你可以使用 ReadableStream.getReader 創建一個 reader 讀取它,使用 ReadableStream.cancel 取消它等等。
更復雜的應用包括使用 ReadableStream 構造函數創建你自己的流,例如進入 service worker 去處理流
ReadableStream
ReadableStream 可以構造一個可讀流,在前端領域通常fetch的 Response的body屬性 就是一個ReadableStream對象
構造函數
new ReadableStream(underlyingSource?, queuingStrategy?)
underlyingSource包括以下屬性:
- start(controller):對象在創建時會執行,controller是個 ReadableStreamDefaultController ,通常在自己構造可讀流時在此方法中 使用 controller.equeue 方法往可讀流中添加數據;可以返回promise,則下一次的必須等待上一次結束後才會執行
- pull(controller):流內部隊列不滿時會重複調用這個方法,根據流的背壓來判斷流有沒有滿,通常這裏不做任何事
- cancel(reason):當流被取消時觸發,如:取消、出錯等等
- type:表示流的內容類型,通常都是bytes
queuingStrategy 定義流的隊列策略 背壓
- highWaterMark:在背壓前內部隊列可以容納的總塊數
- size:每個chunk的大小
例子:
const chunks = [...];
let offset = 0;
new ReadableStream(
{
start(controller) {
console.log("開始讀取");
async function read() {
if (offset < chunks.length) {
const chunk = chunks[offset];
// 往可讀流中寫入數據
controller.enqueue(chunk);
read();
} else {
console.log('讀取結束');
// 關閉
controller.close();
}
}
read();
},
type: "bytes",
},
{ highWaterMark: 100 } // 定義背壓
);
這個例子自定義了一個可讀流,在構建ReadableStream時不斷地往可讀流中寫入數據,以便可讀流可以讀取到數據
ReadableStream構造函數返回一個可讀流實例,其包含多個方法和屬性
實例屬性
- locked:返回改可讀流是否被鎖定到一個reader,也就是説當被鎖定時,同時只能被一個可讀流使用
實例方法
- cancel:取消流的讀取,取消後會觸發內部的cancel屬性
-
getReader:創建一個讀取器並將流鎖定於其上,其他讀取器將不能讀取它直到它被釋放;這個讀取器是一個 ReadableStreamDefaultReader 實例,其包含 read、cancel等方法,通常都是使用 read來讀取 ReadableStream 內部的數據
// 一個簡單的例子 const readstream = new ReadableStream(); // 假如自定義的可讀流內部有數據 const reader = readstream.getReader(); // 獲取 reader對象 while(true) { const { done, value } = await reader.read(); // 不斷讀取數據 if (done) { console.log('讀取完畢'); } else { console.log('當前數據塊:', value); } }上面演示了下從自己創建的可讀流中讀取,通常都是從fetch的response的body屬性中獲取reader,然後不斷讀取接受的數據 (注:response的body屬性是一個 ReadableStream)
-
pipeThrough:提供將當前流管道輸出到一個轉換(transform)流或可寫/可讀流對的鏈式方法。簡單來説就是一個管道對原始數據做點什麼,比如修改、壓縮啥的,對於平時的需求一般用不上,不過使用此功能可以很方便的做到一些有趣的效果
pipeThrough類型定義為:
pipeThrough(transformStream, options?)transformStream是由可讀流和可寫流組成的 TransformStream(或者結構為
{writable, readable}的對象),writable 流寫入的數據在某些狀態下可以被 readable 流讀取。詳細使用請看WriableStream -
pipeTo:將當前 ReadableStream 管道輸出到給定的 WritableStream,此方法是個異步方法,當所有的寫入操作結束後表示結束
pipeTo類型定義:
pipeTo(destination, options?)destination表示一個可寫流WriableStream對象
- tee:拷貝當前可讀流,返回包含兩個 ReadableStream 實例分支的數組
WritableStream
WritableStream 接口將流數據寫入目的地,該對象帶有內置的背壓和隊列,一般是將可讀流的數據寫入
構造函數
new WritableStream(underlyingSink, queuingStrategy)
underlyingSink包括以下屬性:
- start(controller):對象被構建時立刻執行,controller是一個 WritableStreamDefaultController 對象
- write(chunk, controller):當一個新的數據準備好寫入底層接收器時調用此方法,chunk表示當前要寫入的數據塊,controller同上。一般可以在這裏對數據的進行進度條計算
- close(controller):所有數據寫入完畢後將會調用此方法
- abort(reason):可寫流取消或出現錯誤時觸發
queuingStrategy 定義流的隊列策略 背壓
- highWaterMark:在背壓前內部隊列可以容納的總塊數
例子:
// 模擬請求
const res = await fetch();
const totalLength = xxx;
let offset = 0;
const uint8 = new Uint8Array(totalLength);
// 定義背壓
const highWaterMark = new CountQueuingStrategy({ highWaterMark: 100 });
const writer = new WritableStream(
{
write(chunk: Uint8Array, controller) {
return new Promise(resolve => {
uint8.set(chunk, offset); // 將讀取的數據儲存
offset += chunk.byteLength; // 計算已經接受到的數據大小
progress.textContent =
((offset / totalLength) * 100).toFixed(2) + "%"; // 計算進度
setTimeout(resolve, 0);
});
},
},
highWaterMark // 設置背壓,避免大文件讀到內存中
);
await res.body?.pipeTo(writer); // 記住 fetch的Response的body是個可讀流,使用pipeTo方法
以上通過將fetch的Response的body可讀流通過自定義的WriableStream寫入,在write方法中對每塊數據進行獲取,並計算進度
WritableStream構造函數返回一個可寫流實例,其包含多個方法和屬性
實例屬性
- locked:表示 WritableStream 是否鎖定到一個 writer
實例方法
- abort:取消流的寫入,會觸發內部的abort屬性方法
- close:關閉可寫流,會觸發內部的close屬性方法
-
getWriter:獲取返回一個新的 WritableStreamDefaultWriter 實例並且將流鎖定到該實例,該實例對象包含abort、close、write方法;還包含一個ready屬性返回一個promise,當流填充內部隊列的所需大小從非正數變為正數時兑現,表明它不再應用背壓
const chunks: Uint8Array[] = []; const highWaterMark = new CountQueuingStrategy({ highWaterMark: 100 }); const writeStream = new WritableStream( { write(chunk) { return new Promise(resolve => { // chunk為讀取的數據,用它做點啥... resolve(); }); }, }, highWaterMark ); const writer = writeStream.getWriter(); // 獲取writer對象 chunks.forEach(chunk => writer.ready.then(() => writer.write(chunk))); // 循環讀取數據
TransformStream
TransformStream 接口表示鏈式管道傳輸(pipe chain)轉換流(transform stream)概念的具體實現。他可以用於將編碼或解碼視頻幀、壓縮或解壓縮數據或以其他的方式從一種數據轉換成另一種數據
以下是一個將小寫字母變成大寫字母例子:
const strs = ["a", "b", "c", "d"];
let offset = 0;
const reader = new ReadableStream({
start(controller) {
function read() {
if (offset < strs.length) {
controller.enqueue(strs[offset]);
offset++;
read();
} else {
controller.close();
}
}
read();
},
});
const transfer = new TransformStream({
transform(chunk: string, controller) {
console.log("transform:", chunk);
controller.enqueue(chunk.toUpperCase());
},
});
const writer = new WritableStream({
write(chunk) {
console.log("write:", chunk);
},
});
reader.pipeThrough(transfer).pipeTo(writer);
Response.body
一個 ReadableStream,或者對於使用空的 body 屬性構建的任意的 Response 對象,或沒有任何主體的實際 HTTP 響應,則為null
實踐
視頻流
頁面上添加視頻播放器:
// 視頻播放器
<video src="/api/video/range" controls="controls" muted style="max-width: 100%;"></video>
node端進行返回請求數據,默認在沒有結束範圍時只返回1M的數據,如果有結束返回就返回指定範圍的數據:
// 對視頻進行範圍請求
@Get('/api/video/range')
async getVideoRanges(@Req() req: Request, @Res() res: Response) {
// 直接打開鏈接時禁止訪問
const referer = req.headers.referer;
const host = req.headers.host;
const url = referer && new URL(referer);
if (!referer || url?.host !== host) {
throw new ForbiddenException('禁止訪問');
}
const requestRange = req.headers['range'];
const parts = requestRange?.replace(/bytes=/, '').split('-');
const filePath = resolve(__dirname, '../data/video.mp4');
const fileStat = await stat(filePath);
const fileSize = fileStat.size;
const start = Math.min(parseInt(parts?.[0], 10) || 0, fileSize - 1); // 這裏的大小值需減1
const end = Math.min(
parseInt(parts?.[1]) || start + 1024 * 1024, // 每次沒有結束值,只返回1MB的數據
fileSize - 1,
);
const chunkSize = end - start + 1;
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': 'video/mp4',
};
// range請求返回部分內容
if (requestRange) {
const stream = createReadStream(filePath, { start, end });
res.writeHead(206, head);
stream.pipe(res);
} else {
// 非range請求返回整個視頻
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
});
createReadStream(filePath).pipe(res);
}
}
效果演示:
文件下載
生活中會遇到文件的下載這種場景,主要還是讀取文件流寫入到blob中,這樣也可以獲取到指定的進度。需要注意的是這種方式不適合大文件的下載,容易撐爆內存,具體的大文件下載可以查看我的「如何實現大文件下載」 一文
// 請求資源
async function fetchBigImage() {
progress.setAttribute('style', 'transform: translate3d(-100%, 0, 0)');
progressNum.textContent = 0.00;
const res = await fetch('/big-size.png');
const fileSize = res.headers.get('content-length');
const filename = res.headers.get('Content-Disposition')?.split(";")[1]?.split('=')[1] || res.url.match(/\/([^\/]\.[^\/]*)/i)?.[1] || 'download.txt';
const blobs = [];
let downloaded = 0;
const writer = new WritableStream({
write(chunk) {
blobs.push(chunk);
downloaded += chunk.length;
const percentComplete = (downloaded / fileSize) * 100;
progress.setAttribute('style', `transform: translate3d(-${(100 - percentComplete).toFixed(2)}%, 0, 0)`);
progressNum.textContent = percentComplete.toFixed(2);
},
close() {
useDownload(filename, ...blobs);
}
});
res.body.pipeTo(writer);
}
// 下載
function useDownload(filename, ...blobs) {
const blob = new Blob([...blobs]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('href', url);
a.download = filename || 'file.txt';
a.click();
a.remove();
URL.revokeObjectURL(url);
}