一、流式API能解決瀏覽器端的什么問題?
1、對于非前端的同學來說,流是個很常見的概念,它能讓我們一段一段地接收與處理數據。相比較于獲取整個數據再處理,流不僅不需要占用一大塊內存空間來存放整個數據,節省內存占用空間。
2、在每一個流式片段匯總,還能實時地對數據進行處理(比如數據壓縮),不需要等待整個數據獲取完畢,從而縮短整個操作的耗時;流也同時具有管道的概念,可以寫一些中間件來處理業務邏輯。
二、實現前端的流式API
1、很遺憾,以前的JavaScript中是沒有流式API的能力的,過去我們使用 XMLHttpRequest 獲取一個文件時,我們必須等待瀏覽器下載完整的文件,等待瀏覽器處理成我們需要的格式,收到所有的數據后才能進行數據的處理。
2、得益于瀏覽器的發展,瀏覽器已經逐步的支持了流式API,而我們前端熟悉的Fetch API也是受益者之一,流式API賦予了網絡請求以片段處理數據的能力.我們可以以 TypedArray 片段的形式接收一部分二進制數據,然后直接對數據進行處理
Fetch API 會在發起請求后得到的 Promise 對象中返回一個 Response 對象,而 Response 對象除了提供 headers等參數和方法外,在 Body 上我們才看到我們常用的那些 res.json()、res.text()、res.arrayBuffer() 等方法。在 Body 上還有一個 body 參數,這個 body 參數就是一個 ReadableStream,下面我們來寫一個簡單的demo,來展示一下如何使用這個API,更加高階的能力,如:如何分流tee(),鎖機制,pipeTo等方法后續有機會繼續分享
fetch('/tableData.json', {
method: 'GET',
headers: {
'content-type': 'application/json'
},
credentials: 'include',
}).then(res => {
totalSize = res.headers.get('content-length'); // 獲取字節總長度,可以用用于做傳輸進度
console.log(`獲取的數據大小${res.headers.get('content-length') / 1000 / 1000}M`);
// 這里返回的就是一個ReadableStream對象,我們可以調用流的api,進行數據的讀取
return res.body.getReader();
})
.then(readProcess)
// 遞歸讀取流中的數據,直到讀取完畢
const readProcess = (reader) => {
// 調用ReadableStream對象的read方法,返回的也是一個Promise對象
return reader.read().then(({ value, done }) => {
// 這里的value是一個Uint8Array前端二進制數據,不能夠直接拿來使用
if (done) {
console.log('讀取完畢');
return;
}
// 使用TextDecoder將二進制數據進行解析,返回文本
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value, { stream: true });
console.log(text);
// 遞歸,去讀取剩下的數據
return readProcess(reader);
});
};
執行后,通過圖片來看,我們可以拿到正常的處理進度,以及處理的片段,但是我們發現一個問題,因為前端拿到部分二進制數據,進行解析,流式API也不可能幫我們把數據做好切割,它按照默認的塊級大小切割,直接丟給我們,我們去解析的時候,就有可能把整一個JSON數據截斷,如果我們拿到當前的片段,直接進行JSON.parse很大概率會報錯的,這時,我們就需要根據json結構,來實現一個解析器,保留最大可用JSON字符串來解析,把截斷的部分,和下一段已解析的流進行合并在進行JSON解析即可。實現方案如下:
// 根據你的業務數據,進行處理,處理最大完整的Json結構數據,返回剩下截斷的數據,跟之后的chunk進行拼接
function execChunkData(chunkStr) {
// 處理開頭
if (chunkStr.indexOf('[{')) {
// 如果連一條記錄都不完整
if (chunkStr.indexOf('}') == -1) {
return chunkStr;
}
const lastIndex = chunkStr.lastIndexOf('},');
// 如果是最后一天記錄,則直接補全頭部返回
if (lastIndex == -1 && chunkStr.indexOf(']')) {
return chunkStr.slice(0);
}
let resolvedString = chunkStr.slice(0, lastIndex);
resolvedString += "}]";
let restString = '[' + chunkStr.slice(lastIndex + 2);
// 這里的temp對象是通過對大可用json字符串解析出來,保證可用
const temp = JSON.parse(resolvedString);
// 將截斷的數據,返回給后續的塊繼續使用
return restString;
}
}
tips:這里實現的比較簡單粗暴,可滿足大部分需求,如果對于正則表達式比較熟悉的同學,完全可以寫出更好的解決方案
三、能不能在讀取數據的時候做點事情?
跟普通請求全量數據對比,這里我們總結一下流式數據處理的優勢
1、 分片處理,我們可以在加載分片數據的時候,做一些事情(比如數據壓縮),這個后面內容會說
2、加載全量數據的時候,如果不用worker的時候,會將全量數據拿到之后,在主線程進行統一計算,這時主進程被完全占用,performance monitor可以看到,cpu在100%的峰值,這時瀏覽器什么也干不了,只能等待計算完成,有了塊級處理,將這個long task進行大量的拆分,cpu的計算性能在20%水平,完全不影響其他渲染進程以及動畫的展示。
3、前端數據壓縮實現
思想:如果數據量非常大的情況下,JSON文件存在大量的key重復,如果我們把key單獨抽離,頭部的每個key引用當前key下的所有數據,那么將會壓縮一部分數據,節省了頁面內存,減少瀏覽器端出現out of memory的風險
// 根據你的業務數據以及邏輯,對行數據,進行列式存儲
const ArrayBufferPool = {};// 定義數據池,可以用ArrayBuffer也行,這里我就簡單處理下,思想是一樣的
function execChunkData(chunkStr) {
// 處理開頭
if (chunkStr.indexOf('[{')) {
// 如果連一條記錄都不完整
if (chunkStr.indexOf('}') == -1) {
return chunkStr;
}
const lastIndex = chunkStr.lastIndexOf('},');
// 如果是最后一天記錄,則直接補全頭部返回
if (lastIndex == -1 && chunkStr.indexOf(']')) {
return chunkStr.slice(0);
}
let firstString = chunkStr.slice(0, lastIndex);
firstString += "}]";
let restString = '[' + chunkStr.slice(lastIndex + 2);
const temp = JSON.parse(firstString);
const keys = Object.keys(temp[0]);// 取出列頭
temp.forEach(item => {
keys.forEach(key => {
// 將數據歸類,把橫向的數據,進行列式存儲
if (ArrayBufferPool[key]) {
ArrayBufferPool[key].push(item[key]);
return;
}
ArrayBufferPool[key] = [item[key]];
})
});
return restString;
}
}
總結:最終我們得到的數據結構如圖所示
我將之前的原始數據,和壓縮數據下載下來,都去除了宮格,做了對比,壓縮率在40%左右
四、展望
1、還有其他可玩的,可實現的功能,如終止一個request,以及斷點續傳等,有興趣的同學可以繼續深挖fetch API 以及ReadableStream接口協議