导出大量数据时导致页面卡顿问题的前端解决方案

296 阅读4分钟

一、问题背景

1739512750330.png

这是某牛马进来公司负责的第一个项目,其实是一个车辆进出记录的管理系统,2023年上线,到目前为止数据量已经达到5亿+条,之前导出车辆进出记录的操作都是由后端来处理,前端这边只需要调一下接口把文件下载下来。不过随着数据量的增加,最近同事反馈说如果要导出过去一年的进出记录的,加上服务器资源比较紧张,会响应得很慢,页面像是卡顿了一样。而且感知最明显的还是页面的水印,因为页面水印有记录当前的时间戳,每秒渲染一次,点了导出按钮,水印就直接卡住不动了。

二、怎么办

1739512533632.png

查阅了相关资料,我好像知道要怎么做了。

1739517819198.png

在对数据导出为excel进行下载,数据最高可到百万个单元格以上,因此在主线程内对大量数据进行excel导出时不可避免的会对主线程进行阻塞,而主线程阻塞对于用户感知来说就是页面卡顿。如果可以放在主线程以外的线程进行执行那么就可以有效的避免对主线程的阻塞了;Worker 正是为此而生,通过创建worker来新建线程,就可以有效的分担主线程的压力。

WebWorker 允许在主线程之外再创建一个 worker 线程,在主线程执行任务的同时,worker 线程也可以在后台执行它自己的任务,互不干扰。

主线程:调用new Worker()构造函数,新建一个 worker 线程,构造函数的参数是一个 url,生成这个 url 的方法有两种:1脚本文件;2字符串形式。

子线程:self.onmessage监听主线程传过来的信息,self.postMessage发送信息给主线程。 主线程和子线程关系如下图: image.png 需要注意的是

因为 worker 创造了另外一个线程,不在主线程上,浏览器给设定了一些限制(无法使用:window 对象、document 对象、DOM 对象、parent 对象;可以使用:浏览器:navigator 对象、URL:location 对象 只读、发送请求:XMLHttpRequest 对象、定时器:setTimeout/setInterva、应用缓存:Application Cache)

而且主线程与 worker 线程之间的通信是拷贝关系,当需要传递一个巨大的二进制文件给 worker 线程处理时,这时候使用拷贝的方式来传递数据,无疑会造成性能问题。

但是一般现在的文件下载操作都是通过a标签来实现的,而worker本身是限制DOM访问的,因此不能通过上述方法在worker内实现下载操作,所以应该将下载操作放在主线程,而将必要的数据传送到主线程。

那么问题来了,应该选择传送什么样的数据到主线程?

传送 WorkBook 对象

  • 通信成本:WorkBook对象本质上是一个包含大量数据的对象结构(包含单元格数据和单元格设置等),而线程间的通信成本会随着数据量加大而陡增(结构化克隆,序列化,反序列化等等);当数据量较大时,主线程在接收来自Worker的消息依然会造成明显的阻塞。
  • 下载成本:由于WorkBook对象本身不能直接进行下载,需要先将WorkBook对象转为Blob/File对象,然后通过URL.createObjectURL()来创建一个可访问的Blob URL;但实际上将WorkBook对象转为Blob/File对象也是一个挺耗时的过程。

传送 Blob URL

worker内可以使用URL.createObjectURL()方法,创建的Blob URL可以被主线程访问 ,以这事就很简单了。

那就说干就干 重要的事情说三遍

1739513648715.png 1739513648715.png 1739513670076.png

下面代码涉及到xlsx和file-saver这两个库。

XLSX.utils.json_to_sheet 用于将JSON 数据转换为工作表对象。

XLSX.write 用于将 JavaScript 对象(通常是表示表格数据的二维数组或工作簿对象)转换为不同格式的 Excel 文件数据。

FileSaver.saveAs 用于在浏览器环境下将数据保存为文件。

创建worker.ts

import * as XLSX from "xlsx";
import { s2ab, exportOfBlob } from "./index";
onmessage = (e) => {
    const { num, excelKeyToName } = e.data;
    // 生成数据
    const jsonData = []
    for (let i = 0; i < num; i++) {
        jsonData.push({
        name: `张三${i+1}`,
        age: 1 + i,
        skill: `干饭${i}`,
        telephone: 2020083 + i,
        address: `测试地址${i}`,
        })
    };
    // 导出数据为xlsx格式
    const wbout = exportOfBlob({ jsonData, excelKeyToName });
    // 发送数据到主线程 
    self.postMessage({
        type: 'success',
        data: {
          xlsxBlob: new Blob([s2ab(wbout)], {
            type: ''
          }),
        }
      });
};

创建index.ts

const wbOption = {
  bookType: "xlsx",
  type: 'binary',
};
// 导出文件流格式的excel
export const exportOfBlob = ({
  jsonData,
  excelKeyToName, // 数据的映射:excel表头
  sheetName = "sheet1",
  fileName = "json2Excel.xlsx",
  wbOptionObj = wbOption,
}) => {
  // 格式化参数
  const data = jsonData.map((item:any) => {
    const newItem = {};
    Object.keys(item).forEach((key) => {
      newItem[excelKeyToName[key]] = item[key];
    });
    return newItem;
  });

  // 将 JSON 数据转换为工作表对象
  const jsonWorkSheet = XLSX.utils.json_to_sheet(data);
  const workBook = {
    SheetNames: [sheetName], // 指定有序 sheet 的 name
    Sheets: {
      [sheetName]: jsonWorkSheet, // 表格数据内容
    },
  };
  // 将工作表对象转换成二进制数据
  return XLSX.write(workBook, wbOptionObj);
};

// 将二进制数据转换为Blob对象
export const s2ab = (s: string)=> {
  if (typeof ArrayBuffer !== "undefined") {
    let buf = new ArrayBuffer(s.length);
    let view = new Uint8Array(buf);
    for (let i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xff;
    return buf;
  } else {
    let buf = new Array(s.length);
    for (let i = 0; i != s.length; ++i) buf[i] = s.charCodeAt(i) & 0xff;
    return buf;
  }
}

WebWorkerExcel.vue

// key -> name 的映射
const excelKeyToName = {
  name: "姓名",
  age: "年龄",
  skill: "特长",
  telephone: "电话",
  address: "地址",
};

const exportExcel = () => {
  // 创建worker实例
  const worker = new Worker('/src/utils/worker.ts', { type: "module" });
   // 向worker发送消息,传递参数
  worker.postMessage({
    num: 199999,
    excelKeyToName
  });
  // 监听worker的消息事件,拿到文件流数据后保存到本地
  worker.onmessage = (e) => {
     // 终止worker
    worker.terminate();
    // 保存文件流到本地
    FileSaver.saveAs(e.data.data.xlsxBlob, `${123}.xlsx`);
  };
};

三、总结

下班了下班了下班了。 1739517907779.png