乐趣区

关于javascript:前端中如何使用webWorker对户体验进行革命性的提升

前言

随着前端利用场景的逐步复杂化,随同而来的对大数据的解决就不可避免。那么明天就以一个实在的利用场景为例来谈谈前端中如何通过子线程来解决大数据。

目前支流显示器的刷新率为 60Hz,即一帧为 16ms,因而播放动画时倡议小于 16ms,用户操作响应倡议小于 100ms,页面关上到开始出现内容倡议小于 1000ms。

— 依据 Chrome 团队提出的用户感知性能模型 RAIL。

以上这段利用是 google 团队提出的用户最优体验模型,从 js 运行的角度,大抵意思就是尽量保障每一个 js 工作在最短的工夫内执行结束。

案例场景

古代 web 程序中,要求数据、报表导出的需要曾经十分广泛。导出的数据量越来越大、数据的复杂程度也越来越高,最常见的工夫字段大多数状况下也可能须要前端去转换,因而对源数据的遍历总防止不了。当初以导出某站点各类因子的监测数据报表为例:

报表格局要求

  1. 每一条数据蕴含 若干项因子数据,每一个因子项数据蕴含改因子的监测数据以及对应的评估等级;
  2. 要求导出上一季度 90 天的小时数据,数据源大略在 2100 条左右(有分页查问的条件);
  3. 报表要求工夫格局为 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 多条数据的遍历总防止不了,甚至会有大循环嵌套小循环的解决。

  1. 大循环须要解决 dateTime 字段;
  2. 小循环中须要循环 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 = `${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 有几个特点:

  1. 可能长时间运行(响应)
  2. 疾速启动和现实的内存耗费
  3. 人造的沙箱环境

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 = `${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 在后续中能够再讲讲。

参考

  1. Web_Workers_API
  2. worker 材料
  3. alloy-worker
退出移动版