前言
隨着前端應用場景的逐漸複雜化,伴隨而來的對大數據的處理就不可避免。 那麼今天就以一個真實的應用場景為例來談談前端中如何通過子線程來處理大數據。
目前主流顯示器的刷新率為 60Hz,即一幀為 16ms,因此播放動畫時建議小於 16ms,用户操作響應建議小於 100ms,頁面打開到開始呈現內容建議小於 1000ms。
-- 根據 Chrome 團隊提出的用户感知性能模型 RAIL。
以上這段應用是 google 團隊提出的用户最優體驗模型,從 js 運行的角度,大致意思就是儘量保證每一個 js 任務在最短的時間內執行完畢。
案例場景
現代 web 程序中,要求數據、報表導出的需求已經非常普遍。導出的數據量越來越大、數據的複雜程度也越來越高,最常見的時間字段大多數情況下也可能需要前端去轉換,因此對源數據的遍歷總避免不了。 現在以導出某站點各類因子的監測數據報表為例:
報表格式要求
- 每一條數據包含 若干項因子數據,每一個因子項數據包含改因子的監測數據以及對應的評價等級;
- 要求導出上一季度 90 天的小時數據,數據源大概在 2100 條左右(有分頁查詢的條件);
- 報表要求時間格式為
YYYY年MM月DD日 HH時(例如: 2020年12月25日 23時),每一項因子內容為 因子數據 + 因子等級(例如:2.36(I))。
數據源格
後端返回數據格式如下
{
"dateTime": "2021-06-05 14:00:00",
"name": "站點一",
"factorDatas": [
{"code": "w01010", "grade": 1, "value": 26.93},
{"code": "w666666", "grade": 1, "value": 1.26}
]
}
數據源基本處理
對應報表導出需求,對這 2000 多條數據的遍歷總避免不了,甚至會有大循環嵌套小循環的處理。
- 大循環需要處理 dateTime 字段;
- 小循環中需要循環 factorDatas 字段,查詢 grade 對應的等級名, 最後在拼接出報表需要的格式。
拋磚引玉
簡單實現
以下代碼僅是模擬代碼,默認前端已經完成了所有數據的加載
正常的開發流程當然是採用 for 循環不斷的調用分頁的接口不斷地查詢數據,直到數據查詢完畢,然後再進行統一循環處理每一行數據。 為方便對數據處理單獨將某些公共方法單獨抽一個工具類:
class UtilsSerice {
/**
* 獲取水質類別信息
* @param waterType
* @param keyValue
* @param keyName?
*/
static async getGradeInfo(waterType: WaterTypeStringEnum, keyValue: string | number, keyName?: string): Promise<WaterGrade | null | undefined> {
// 緩存中數據的 key
const flagId: string = waterType + keyValue;
// 緩存中有對應的值,直接返回
if (TEMP_WATER_GRADE_MAP.get(flagId)) {
return TEMP_WATER_GRADE_MAP.get(flagId);
}
// 獲取等級列表
const gradeList: WaterGrade[] = await this.getEnvData(waterType);
// 查詢等級值對應的等級信息
const gradeInfo: WaterGrade = gradeList.find((item: WaterGrade) => {
const valueName: string | number | undefined = keyName === 'id' ? 'id' : item.hasOwnProperty('value') ? 'value' : 'level';
return item[valueName] === keyValue;
}) as WaterGrade;
// 將查詢到的等級信息緩,方便下一次查詢該等級時直接返回
if (gradeInfo) {
TEMP_WATER_GRADE_MAP.set(flagId, gradeInfo);
}
return gradeInfo;
}
}
數據導出邏輯如下:
// 假設 allList 已經是 2100 條數據集合
const allList = [{"dateTime": "2021-06-05 14:00:00", "code": "sssss", "name": "站點一", "factorDatas": [{"code": "w01010", "grade": 1, "value": 26.93}, {"code": "w666666", "grade": 1, "value": 1.26}]}]
const table: ObjectUnknown[] = [];
for (let i = 0; i < allList.length; i ++) {
const rows = {...allList[i]}
// 按需求處理時間格式
rows['tiemStr'] = moment(allList[i].dateTime).format('YYYY年MM月DD日 HH時')
for (let j = 0; j < allList[i].factorDatas.length; j ++) {
const code = allList[i].factorDatas[j].code
const value = allList[i].factorDatas[j].value
const grade = allList[i].factorDatas[j].grade
// 此處按需求異步獲取等級數據---- 此方法已經儘可能的做了性能優化
const gradeStr = await UtilsSerice.getGradeInfo('surface', grade, 'value')
rows[code] = `${value}(${gradeStr})`
}
table.push(rows)
}
const downConfig: ExcelDownLoadConfig = {
tHeader: ['點位名稱', '接入編碼', '監測時間', '因子1', '因子2', '因子2' ],
bookType: 'xlsx',
autoWidth: 80,
filename: `數據查詢`,
filterVal: ['name', 'code', 'tiemStr', 'w01010', 'w01011', 'w01012'],
multiHeader: [],
merges: []
};
// 此方法是通用的 excel 數據處理邏輯
const res: any = await ExcelService.downLoadExcelFileOfMain(table, downConfig);
const file = new Blob([res.data], { type: 'application/octet-stream' });
// 文件保存
saveAs(file, res.filename);
由於 JS 引擎線程是單線程且與 GUI 渲染線程是互斥的,因此在執行復雜的 js 計算任務時,用户的直觀感受就是系統卡頓,例如輸入框無法輸入、動畫停止、按鈕無效等。以上代碼可以實現數據的導出,可以看到在主線程導出數據時圖片旋轉已經停止、輸入框已經無法輸入。
我相信無論多麼好説話的甲方,對於這樣的系統估計也是無法接受的。
問題思考
稍微有編程經驗的開發多多少少都會明白,是因為大數據的 for 循環遍歷阻塞了其他腳本的執行,基於這個思想,有性能優化經驗的開發工程師大概率會將這個大遍歷拆分成多個小的任務來較少卡頓,這種方案也可以一定程度上解決卡頓的問題。但這種時間分片、任務拆分的優化方案並不適合並不是所有的大數據處理,尤其是前後數據有強依賴關係的,在這篇文章中暫不探討這種優化方案。這篇文章來聊聊 webWorker:
它允許在 Web 程序中併發執行多個 JavaScript腳本,每個腳本執行流都稱為一個線程,彼此間互相獨立,並且有瀏覽器中的 JavaScript引擎負責管理。這將使得線程級別的消息通信成為現實。使得在 Web 頁面中進行多線程編程成為可能。
-- IMWeb社區
webWorker 有幾個特點:
- 能夠長時間運行(響應)
- 快速啓動和理想的內存消耗
- 天然的沙箱環境
webWorker使用
創建
//創建一個Worker對象,並向它傳遞將在新線程中執行的腳本url
const worker = new Worker('worker.js');
通信
// 發送消息
worker.postMessage({first:1,second:2});
// 監聽消息
worker.onmessage = function(event){
console.log(event)
};
銷燬
主線程中終止worker,此後無法再利用其進行消息傳遞。注意:一旦 terminate 後,無法重新啓用,只能另外創建。
worker.terminate();
導出功能遷移
接下來聊聊如何把數據導出這部分的代碼遷移到 webWorker 中,在功能遷移前,首先需要梳理下數據導出的先決條件:
1:在 webWorker 中需要能調用 ajax 獲取接口數據;
2:在 webWorker 中要能加載 excel.js 的腳本;
3:能正常調用 file-saver 中的 saveAs 功能;
基於以上的條件,我們逐一討論,第一點很幸運 webWorker 支持發起 ajax 請求數據;第二點 webWorker 中提供了 importScripts() 接口,因此在 webWorker 中也能生成 Excel 的實例;第三點有些遺憾,webWorker 中是無法使用 DOM 對象, 而 file-saver 正好使用了 DOM,因此只能是子線程中處理完數據後傳遞數據給主線程由主線程執行文件保存操作(此處有個小優化,後續講)。
方案對比
目前行業內集成 webWorker 的方案有很多,以下簡單做個對比(來自騰訊前端團隊):
| 項目 | 簡介 | 構建打包 | 底層API封裝 | 跨線程調用申明 | 可用性監控 | 易拓展性 |
|---|---|---|---|---|---|---|
| worker-loader | Webpack 官方,源碼打包能力 | ✔️ | ✘ | ✘ | ✘ | ✘ |
| promise-worker | 封裝基本 API 為 Promise 化通信 | ✘ | ✔️ | ✘ | ✘ | ✘ |
| comlink | Chrome 團隊, 通信 RPC 封裝 | ✘ | ✔️ | 同名函數(基於Proxy) | ✘ | ✘ |
| workerize-loader | 社區目前比較完整的方案 | ✔️ | ✔️ | 同名函數(基於AST生成) | ✘ | ✘ |
| alloy-worker | 面向事務的高可用 Worker 通信框架 | 提供構建腳本 | 通信️控制器 | 同名函數(基於約定), TS 聲明 | 完整監控指標, 全週期錯誤監控 | 命名空間, 事務生成腳本 |
| webpack5 | webpack5 中用於替換 worker-loader | 提供構建腳本 | ✘ | ✘ | ✘ | ✘ |
基於以上對比和我個人對 ts 的偏愛吧,該案例採用 alloy-worker 來做 webWorker 的集成,由於官方的 npm 包有問題,無法一次到位的集成,所以只能手動集成。
worker集成
官方集成文檔
首先將核心的基礎的 worker 通信源碼複製到項目目錄 src/worker下。
聲明事務
第一步在 src/worker/common/action-type.ts 中添加用於數據導出的事務。
export const enum TestActionType {
MessageLog = 'MessageLog',
// 聲明數據導出的事務
ExportStationReportData = 'ExportStationReportData'
}
請求、響應數據類型聲明
在 src/worker/common/payload-type.ts 文件中聲明請求、響應數據類型。
跨線程通信各事務的請求數據類型聲明
export declare namespace WorkerPayload {
namespace ExcelWorker {
// 調用ExportStationReportData 導出數據時需要傳這兩個參數
type ExportStationData = {
factorList: SelectOptions[];
accessCodes: string[];
} & Transfer;
}
}
跨線程通信各事務的響應數據類型聲明
export declare namespace WorkerReponse {
namespace ExcelWorker {
type ExportStationData = {
data: any;
} & Transfer;
}
}
主線程邏輯
src/worker/main-thread 下新建 excel.ts 文件,用於編寫數據事務代碼。
/**
* 第四步:聲明主線程業務邏輯代碼
* TODO
*/
export default class Excel extends BaseAction {
protected threadAction: IMainThreadAction;
/**
* 導出監測點數據
* @param payload
*/
public async exportStationReportData(payload?: WorkerPayload.ExcelWorker.ExportStationData): Promise<WorkerReponse.ExcelWorker.ExportStationData> {
return this.controller.requestPromise(TestActionType.ExportStationReportData, payload);
}
protected addActionHandler(): void {}
}
主線程邏輯實例化
src/worker/main-thread/index 中引入 excel;
主線程聲明事務命名空間
// 只聲明事務命名空間, 用於事務中調用其他命名空間的事務
export interface IMainThreadAction {
// ....
excel: Excel;
}
主線程聲明事務實例化
export default class MainThreadWorker implements IMainThreadAction {
// ......
public excel: Excel;
public constructor(options: IAlloyWorkerOptions) {
// .....
this.excel = new Excel(this.controller, this);
}
// ........ 省略代碼
}
子線程邏輯
src/worker/worker-thread 下新建 excel.ts 文件,用於編寫數據事務代碼,此文件中是核心的數據導出功能。
數據請求、數據處理
export default class Test extends BaseAction {
protected threadAction: IWorkerThreadAction;
protected addActionHandler(): void {
this.controller.addActionHandler(TestActionType.ExportStationReportData, this.exportStationReportData.bind(this));
}
/**
* 獲取數據查詢
* @protected
*/
@HttpGet('/list')
protected async getDataList(@HttpParams() queryDataParams: QueryDataParams, factors?: SelectOptions[], @HttpRes() res?: any): Promise<{ total: number; list: TableRow[] }> {
return {list: res.rows}
}
/**
* 測試導出數據
* @private
*/
private async exportExcel(payload?: WorkerPayload.ExcelWorker.ExportExcel): Promise<any> {
try {
// worker 中引入 xlsx
importScripts('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.16.9/xlsx.core.min.js');
const table: ObjectUnknown[] = [];
for (let i = 0; i < allList.length; i ++) {
const rows = {...allList[i]}
// 按需求處理時間格式
rows['tiemStr'] = moment(allList[i].dateTime).format('YYYY年MM月DD日 HH時')
for (let j = 0; j < allList[i].factorDatas.length; j ++) {
const code = allList[i].factorDatas[j].code
const value = allList[i].factorDatas[j].value
const grade = allList[i].factorDatas[j].grade
// 此處按需求異步獲取等級數據---- 此方法已經儘可能的做了性能優化
const gradeStr = await UtilsSerice.getGradeInfo('surface', grade, 'value')
rows[code] = `${value}(${gradeStr})`
}
table.push(rows)
}
const downConfig: ExcelDownLoadConfig = {
tHeader: ['點位名稱', '接入編碼', '監測時間', '因子1', '因子2', '因子2' ],
bookType: 'xlsx',
autoWidth: 80,
filename: `數據查詢`,
filterVal: ['name', 'code', 'tiemStr', 'w01010', 'w01011', 'w01012'],
multiHeader: [],
merges: []
};
const res = await ExcelService.downLoadExcelFile(table, downConfig, (self as any).XLSX);
// 由於之前提到的 worker 侷限性(無法訪問 DOM) 因此子線程中處理完 excel 所所需的對象後 將數據傳遞給主線程,由主線程進行數據導出
// 普通 postMessage 時會進行 樹的克隆,但此處處理完的數據可能會非常大,估計直接將進行 transfer 傳輸數據
return {
transferProps: ['data'],
data: res.data,
filename: res.filename,
}
} catch (e) {
console.log(e);
}
}
}
子線程邏輯實例化
src/worker/worker-thread/index 中引入 excel;
主線程聲明事務命名空間
// 只聲明事務命名空間, 用於事務中調用其他命名空間的事務
export interface IWorkerThreadAction {
// ....
excel: Excel;
}
子線程聲明事務實例化
class WorkerThreadWorker implements IWorkerThreadAction {
public excel: Excel
// ... 省略代碼
public constructor() {
this.controller = new Controller();
this.excel = new Excel(this.controller, this);
// ... 省略代碼
}
}
至此,導出功能已完整遷移到子線程中。
主線程調用
主線程調用數據導出功能也很簡單,首先實例化一個子線程,然後就可以愉快的將複雜的計算邏輯丟給子線程了,類似於這樣。
class HomPage extends VueComponent {
public created() {
try {
// 實例化一個子線程,並將其掛載在 window 上
const alloyWorker = createAlloyWorker({
workerName: 'alloyWorker--test',
isDebugMode: true
});
}catch (e) {
console.log(e);
}
}
/**
* 子線程數據導出
* @private
*/
private async exportExcelFile() {
// 直接調用申明的方法就可以
(window as any).alloyWorker.excel.exportStationReportData({
factorList: factors,
accessCodes: [{ accessCode: 'sss', name: '測試監測點' }]
}).then((res: any) => {
// 大數據導出效果,子線程傳回來的數據
console.log(res);
// 將子線程傳回來的二進制數據轉換為 Blob 方便文件保存
const file = new Blob([res.data], { type: 'application/octet-stream' });
// 保存文件
saveAs(file, res.filename);
});
}
}
效果如下,可以明確感受到數據導出過程中,頁面沒有絲毫的卡頓之感。
總結
以上代碼中以一個真實的需求案例驗證了 webWorker 對用户體驗的提升是非常大的。這種需求在大多數的開發中可能也不多,但偶爾也會有。當然 webWorker 也並非是唯一解,在同等計算量的情況下,在子線程中做計算並不會比主線程快多少, 甚至會比主線程慢,因此只能將一些對及時反饋要求不高的計算放到子線程中計算。如果想單純的提高計算效率,那隻能從算法上入手或者使用 WebAssembly 來提高計算效率,關於 WebAssembly 在後續中可以再講講。
參考
- Web_Workers_API
- worker資料
- alloy-worker