前端中如何使用 WebWorker 對用戶體驗進行革命性的提升
大廠技術(shù)??高級前端??Node進階
點擊上方?程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
前言
隨著前端應用場景的逐漸復雜化,伴隨而來的對大數(shù)據(jù)的處理就不可避免。那么今天就以一個真實的應用場景為例來談?wù)勄岸酥腥绾瓮ㄟ^子線程來處理大數(shù)據(jù)。
目前主流顯示器的刷新率為 60Hz,即一幀為 16ms,因此播放動畫時建議小于 16ms,用戶操作響應建議小于 100ms,頁面打開到開始呈現(xiàn)內(nèi)容建議小于 1000ms。
-- 根據(jù) Chrome 團隊提出的用戶感知性能模型 RAIL。
以上這段應用是 google 團隊提出的用戶最優(yōu)體驗模型,從 js 運行的角度,大致意思就是盡量保證每一個 js 任務(wù)在最短的時間內(nèi)執(zhí)行完畢。
案例場景
現(xiàn)代 web 程序中,要求數(shù)據(jù)、報表導出的需求已經(jīng)非常普遍。導出的數(shù)據(jù)量越來越大、數(shù)據(jù)的復雜程度也越來越高,最常見的時間字段大多數(shù)情況下也可能需要前端去轉(zhuǎn)換,因此對源數(shù)據(jù)的遍歷總避免不了?,F(xiàn)在以導出某站點各類因子的監(jiān)測數(shù)據(jù)報表為例:
報表格式要求
每一條數(shù)據(jù)包含 若干項因子數(shù)據(jù),每一個因子項數(shù)據(jù)包含改因子的監(jiān)測數(shù)據(jù)以及對應的評價等級; 要求導出上一季度 90 天的小時數(shù)據(jù),數(shù)據(jù)源大概在 2100 條左右(有分頁查詢的條件); 報表要求時間格式為 YYYY年MM月DD日 HH時(例如:2020年12月25日 23時),每一項因子內(nèi)容為 因子數(shù)據(jù) + 因子等級(例如:2.36(I))。
數(shù)據(jù)源格
后端返回數(shù)據(jù)格式如下
{
????????"dateTime":?"2021-06-05?14:00:00",
????????"name":?"站點一",
????????"factorDatas":?[
????????????{"code":?"w01010",?"grade":?1,?"value":?26.93},
????????????{"code":?"w666666",?"grade":?1,?"value":?1.26}
????????]
}
復制代碼
數(shù)據(jù)源基本處理
對應報表導出需求,對這 2000 多條數(shù)據(jù)的遍歷總避免不了,甚至會有大循環(huán)嵌套小循環(huán)的處理。
大循環(huán)需要處理 dateTime 字段; 小循環(huán)中需要循環(huán) factorDatas 字段,查詢 grade 對應的等級名, 最后在拼接出報表需要的格式。
拋磚引玉
簡單實現(xiàn)
以下代碼僅是模擬代碼,默認前端已經(jīng)完成了所有數(shù)據(jù)的加載
正常的開發(fā)流程當然是采用 for 循環(huán)不斷的調(diào)用分頁的接口不斷地查詢數(shù)據(jù),直到數(shù)據(jù)查詢完畢,然后再進行統(tǒng)一循環(huán)處理每一行數(shù)據(jù)。為方便對數(shù)據(jù)處理單獨將某些公共方法單獨抽一個工具類:
class?UtilsSerice?{
????/**
?????*?獲取水質(zhì)類別信息
?????*?@param?waterType
?????*?@param?keyValue
?????*?@param?keyName?
?????*/
????static?async?getGradeInfo(waterType:?WaterTypeStringEnum,?keyValue:?string?|?number,?keyName?:?string):?Promisenull?|?undefined>?{
????????//?緩存中數(shù)據(jù)的?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;
????}
}
復制代碼
數(shù)據(jù)導出邏輯如下:
//?假設(shè)?allList?已經(jīng)是?2100?條數(shù)據(jù)集合
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?????const?rows?=?{...allList[i]}
????//?按需求處理時間格式
????rows['tiemStr']?=?moment(allList[i].dateTime).format('YYYY年MM月DD日?HH時')
????for?(let?j?=?0;?j?????????const?code?=?allList[i].factorDatas[j].code
????????const?value?=?allList[i].factorDatas[j].value
????????const?grade?=?allList[i].factorDatas[j].grade
????????//?此處按需求異步獲取等級數(shù)據(jù)----??此方法已經(jīng)盡可能的做了性能優(yōu)化
????????const?gradeStr?=?await?UtilsSerice.getGradeInfo('surface',?grade,?'value')
????????rows[code]?=?`${value}(${gradeStr})`
????}
????table.push(rows)
????
}
const?downConfig:?ExcelDownLoadConfig?=?{
????tHeader:?['點位名稱',?'接入編碼',?'監(jiān)測時間',?'因子1',?'因子2',?'因子2'?],
????bookType:?'xlsx',
????autoWidth:?80,
????filename:?`數(shù)據(jù)查詢`,
????filterVal:?['name',?'code',?'tiemStr',?'w01010',?'w01011',?'w01012'],
????multiHeader:?[],
????merges:?[]
};
//?此方法是通用的?excel?數(shù)據(jù)處理邏輯
const?res:?any?=?await?ExcelService.downLoadExcelFileOfMain(table,?downConfig);
const?file?=?new?Blob([res.data],?{?type:?'application/octet-stream'?});
//?文件保存
saveAs(file,?res.filename);
復制代碼
由于 JS 引擎線程是單線程且與 GUI 渲染線程是互斥的,因此在執(zhí)行復雜的 js 計算任務(wù)時,用戶的直觀感受就是系統(tǒng)卡頓,例如輸入框無法輸入、動畫停止、按鈕無效等。以上代碼可以實現(xiàn)數(shù)據(jù)的導出,可以看到在主線程導出數(shù)據(jù)時圖片旋轉(zhuǎn)已經(jīng)停止、輸入框已經(jīng)無法輸入。

我相信無論多么好說話的甲方,對于這樣的系統(tǒng)估計也是無法接受的。
問題思考
稍微有編程經(jīng)驗的開發(fā)多多少少都會明白,是因為大數(shù)據(jù)的 for 循環(huán)遍歷阻塞了其他腳本的執(zhí)行,基于這個思想,有性能優(yōu)化經(jīng)驗的開發(fā)工程師大概率會將這個大遍歷拆分成多個小的任務(wù)來較少卡頓,這種方案也可以一定程度上解決卡頓的問題。但這種時間分片、任務(wù)拆分的優(yōu)化方案并不適合并不是所有的大數(shù)據(jù)處理,尤其是前后數(shù)據(jù)有強依賴關(guān)系的,在這篇文章中暫不探討這種優(yōu)化方案。這篇文章來聊聊 webWorker:
它允許在 Web 程序中并發(fā)執(zhí)行多個 JavaScript腳本,每個腳本執(zhí)行流都稱為一個線程,彼此間互相獨立,并且有瀏覽器中的 JavaScript引擎負責管理。這將使得線程級別的消息通信成為現(xiàn)實。使得在 Web 頁面中進行多線程編程成為可能。
-- IMWeb社區(qū)
webWorker 有幾個特點:
能夠長時間運行(響應) 快速啟動和理想的內(nèi)存消耗 天然的沙箱環(huán)境
webWorker使用
創(chuàng)建
//創(chuàng)建一個Worker對象,并向它傳遞將在新線程中執(zhí)行的腳本url
const?worker?=?new?Worker('worker.js');
復制代碼
通信
//?發(fā)送消息
worker.postMessage({first:1,second:2});
//?監(jiān)聽消息
worker.onmessage?=?function(event){
????console.log(event)
};
復制代碼
銷毀
主線程中終止worker,此后無法再利用其進行消息傳遞。注意:一旦 terminate 后,無法重新啟用,只能另外創(chuàng)建。
worker.terminate();
復制代碼
導出功能遷移
接下來聊聊如何把數(shù)據(jù)導出這部分的代碼遷移到 webWorker 中,在功能遷移前,首先需要梳理下數(shù)據(jù)導出的先決條件:
1:在 webWorker 中需要能調(diào)用 ajax 獲取接口數(shù)據(jù);2:在 webWorker 中要能加載 excel.js 的腳本;3:能正常調(diào)用 file-saver 中的 saveAs 功能;
基于以上的條件,我們逐一討論,第一點很幸運 webWorker 支持發(fā)起 ajax 請求數(shù)據(jù);第二點 webWorker 中提供了 importScripts() 接口,因此在 webWorker 中也能生成 Excel 的實例;第三點有些遺憾,webWorker 中是無法使用 DOM 對象, 而 file-saver 正好使用了 DOM,因此只能是子線程中處理完數(shù)據(jù)后傳遞數(shù)據(jù)給主線程由主線程執(zhí)行文件保存操作(此處有個小優(yōu)化,后續(xù)講)。
方案對比
目前行業(yè)內(nèi)集成 webWorker 的方案有很多,以下簡單做個對比(來自騰訊前端團隊):
| 項目 | 簡介 | 構(gòu)建打包 | 底層API封裝 | 跨線程調(diào)用申明 | 可用性監(jiān)控 | 易拓展性 |
|---|---|---|---|---|---|---|
| worker-loader[1] | Webpack 官方,源碼打包能力 | ?? | ? | ? | ? | ? |
| promise-worker[2] | 封裝基本 API 為 Promise 化通信 | ? | ?? | ? | ? | ? |
| comlink[3] | Chrome 團隊, 通信 RPC 封裝 | ? | ?? | 同名函數(shù)(基于Proxy) | ? | ? |
| workerize-loader[4] | 社區(qū)目前比較完整的方案 | ?? | ?? | 同名函數(shù)(基于AST生成) | ? | ? |
| alloy-worker[5] | 面向事務(wù)的高可用 Worker 通信框架 | 提供構(gòu)建腳本 | 通信?控制器 | 同名函數(shù)(基于約定), TS 聲明 | 完整監(jiān)控指標, 全周期錯誤監(jiān)控 | 命名空間, 事務(wù)生成腳本 |
| webpack5[6] | webpack5 中用于替換 worker-loader | 提供構(gòu)建腳本 | ? | ? | ? | ? |
基于以上對比和我個人對 ts 的偏愛吧,該案例采用 alloy-worker 來做 webWorker 的集成,由于官方的 npm 包有問題,無法一次到位的集成,所以只能手動集成。
worker集成
官方集成文檔[7]
首先將核心的基礎(chǔ)的 worker 通信源碼復制到項目目錄 src/worker下。
聲明事務(wù)
第一步在 src/worker/common/action-type.ts 中添加用于數(shù)據(jù)導出的事務(wù)。
export?const?enum?TestActionType?{
??MessageLog?=?'MessageLog',
??//?聲明數(shù)據(jù)導出的事務(wù)
??ExportStationReportData?=?'ExportStationReportData'
??}
復制代碼
請求、響應數(shù)據(jù)類型聲明
在 src/worker/common/payload-type.ts 文件中聲明請求、響應數(shù)據(jù)類型。
跨線程通信各事務(wù)的請求數(shù)據(jù)類型聲明
export?declare?namespace?WorkerPayload?{
????namespace?ExcelWorker?{
????????//?調(diào)用ExportStationReportData?導出數(shù)據(jù)時需要傳這兩個參數(shù)
????????type?ExportStationData?=?{
????????????factorList:?SelectOptions[];
????????????accessCodes:?string[];
????????}?&?Transfer;
????}
}
復制代碼
跨線程通信各事務(wù)的響應數(shù)據(jù)類型聲明
export?declare?namespace?WorkerReponse?{
????namespace?ExcelWorker?{
????????type?ExportStationData?=?{
????????????data:?any;
????????}?&?Transfer;
????}
}
復制代碼
主線程邏輯
src/worker/main-thread 下新建 excel.ts 文件,用于編寫數(shù)據(jù)事務(wù)代碼。
/**
?*?第四步:聲明主線程業(yè)務(wù)邏輯代碼
?*?TODO
?*/
export?default?class?Excel?extends?BaseAction?{
????protected?threadAction:?IMainThreadAction;
????/**
?????*?導出監(jiān)測點數(shù)據(jù)
?????*?@param?payload
?????*/
????public?async?exportStationReportData(payload?:?WorkerPayload.ExcelWorker.ExportStationData):?Promise?{
????????return?this.controller.requestPromise(TestActionType.ExportStationReportData,?payload);
????}
????protected?addActionHandler():?void?{}
}
復制代碼
主線程邏輯實例化
src/worker/main-thread/index 中引入 excel;
主線程聲明事務(wù)命名空間
//?只聲明事務(wù)命名空間,?用于事務(wù)中調(diào)用其他命名空間的事務(wù)
export?interface?IMainThreadAction?{
????//?....
????excel:?Excel;
}
復制代碼
主線程聲明事務(wù)實例化
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 文件,用于編寫數(shù)據(jù)事務(wù)代碼,此文件中是核心的數(shù)據(jù)導出功能。
數(shù)據(jù)請求、數(shù)據(jù)處理
export?default?class?Test?extends?BaseAction?{
????protected?threadAction:?IWorkerThreadAction;
????protected?addActionHandler():?void?{
????????this.controller.addActionHandler(TestActionType.ExportStationReportData,?this.exportStationReportData.bind(this));
????}
????/**
?????*?獲取數(shù)據(jù)查詢
?????*?@protected
?????*/
????@HttpGet('/list')
????protected?async?getDataList(@HttpParams()?queryDataParams:?QueryDataParams,?factors?:?SelectOptions[],?@HttpRes()?res?:?any):?Promise<{?total:?number;?list:?TableRow[]?}>?{
????????return?{list:?res.rows}
????}
????/**
?????*?測試導出數(shù)據(jù)
?????*?@private
?????*/
????private?async?exportExcel(payload?:?WorkerPayload.ExcelWorker.ExportExcel):?Promise?{
????????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?????????????????const?rows?=?{...allList[i]}
????????????????//?按需求處理時間格式
????????????????rows['tiemStr']?=?moment(allList[i].dateTime).format('YYYY年MM月DD日?HH時')
????????????????for?(let?j?=?0;?j?????????????????????const?code?=?allList[i].factorDatas[j].code
????????????????????const?value?=?allList[i].factorDatas[j].value
????????????????????const?grade?=?allList[i].factorDatas[j].grade
????????????????????//?此處按需求異步獲取等級數(shù)據(jù)----??此方法已經(jīng)盡可能的做了性能優(yōu)化
????????????????????const?gradeStr?=?await?UtilsSerice.getGradeInfo('surface',?grade,?'value')
????????????????????rows[code]?=?`${value}(${gradeStr})`
????????????????}
????????????????table.push(rows)
????????????}
????????????const?downConfig:?ExcelDownLoadConfig?=?{
????????????????tHeader:?['點位名稱',?'接入編碼',?'監(jiān)測時間',?'因子1',?'因子2',?'因子2'?],
????????????????bookType:?'xlsx',
????????????????autoWidth:?80,
????????????????filename:?`數(shù)據(jù)查詢`,
????????????????filterVal:?['name',?'code',?'tiemStr',?'w01010',?'w01011',?'w01012'],
????????????????multiHeader:?[],
????????????????merges:?[]
????????????};
????????????const?res?=?await?ExcelService.downLoadExcelFile(table,?downConfig,?(self?as?any).XLSX);
????????????//?由于之前提到的?worker?局限性(無法訪問?DOM)?因此子線程中處理完?excel?所所需的對象后?將數(shù)據(jù)傳遞給主線程,由主線程進行數(shù)據(jù)導出
????????????//??普通?postMessage?時會進行?樹的克隆,但此處處理完的數(shù)據(jù)可能會非常大,估計直接將進行?transfer?傳輸數(shù)據(jù)
????????????return?{
????????????????transferProps:?['data'],
????????????????data:?res.data,
????????????????filename:?res.filename,
????????????}
????????}?catch?(e)?{
????????????console.log(e);
????????}
????}
}
復制代碼
子線程邏輯實例化
src/worker/worker-thread/index 中引入 excel;
主線程聲明事務(wù)命名空間
//?只聲明事務(wù)命名空間,?用于事務(wù)中調(diào)用其他命名空間的事務(wù)
export?interface?IWorkerThreadAction?{
????//?....
????excel:?Excel;
}
復制代碼
子線程聲明事務(wù)實例化
class?WorkerThreadWorker?implements?IWorkerThreadAction?{
????public?excel:?Excel
????//?...?省略代碼
????public?constructor()?{
????????this.controller?=?new?Controller();
????????this.excel?=?new?Excel(this.controller,?this);
????????//?...?省略代碼
????}
}
復制代碼
至此,導出功能已完整遷移到子線程中。
主線程調(diào)用
主線程調(diào)用數(shù)據(jù)導出功能也很簡單,首先實例化一個子線程,然后就可以愉快的將復雜的計算邏輯丟給子線程了,類似于這樣。
class?HomPage?extends?VueComponent?{
????public?created()?{
????????try?{
????????????//?實例化一個子線程,并將其掛載在?window?上
????????????const?alloyWorker?=?createAlloyWorker({
????????????????workerName:?'alloyWorker--test',
????????????????isDebugMode:?true
????????????});
????????}catch?(e)?{
????????????console.log(e);
????????}
????}
????/**
?????*?子線程數(shù)據(jù)導出
?????*?@private
?????*/
????private?async?exportExcelFile()?{
????????//?直接調(diào)用申明的方法就可以
????????(window?as?any).alloyWorker.excel.exportStationReportData({
????????????factorList:?factors,
????????????accessCodes:?[{?accessCode:?'sss',?name:?'測試監(jiān)測點'?}]
????????}).then((res:?any)?=>?{
????????????//?大數(shù)據(jù)導出效果,子線程傳回來的數(shù)據(jù)
????????????console.log(res);
????????????//?將子線程傳回來的二進制數(shù)據(jù)轉(zhuǎn)換為?Blob?方便文件保存
????????????const?file?=?new?Blob([res.data],?{?type:?'application/octet-stream'?});
????????????//?保存文件
????????????saveAs(file,?res.filename);
????????});
????}
}
復制代碼
效果如下,可以明確感受到數(shù)據(jù)導出過程中,頁面沒有絲毫的卡頓之感。

總結(jié)
以上代碼中以一個真實的需求案例驗證了 webWorker 對用戶體驗的提升是非常大的。這種需求在大多數(shù)的開發(fā)中可能也不多,但偶爾也會有。當然 webWorker 也并非是唯一解,在同等計算量的情況下,在子線程中做計算并不會比主線程快多少, 甚至會比主線程慢,因此只能將一些對及時反饋要求不高的計算放到子線程中計算。如果想單純的提高計算效率,那只能從算法上入手或者使用 WebAssembly 來提高計算效率,關(guān)于 WebAssembly 在后續(xù)中可以再講講。
關(guān)于本文
來自:殘月公子
https://juejin.cn/post/6970336963647766559
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
???“分享、點贊、在看” 支持一波??
