真实业务场景:在React中使用Web Worker实现HTML导出PDF的性能优化实践

背景

在管理后台系统中,报表导出是常见的需求。当我们将复杂DOM结构转换为PDF时,传统方案(如直接使用jsPDF)在会导致主线程阻塞,造成页面卡顿。本文介绍如何通过Web Worker优化这一过程。

技术选型

  • html2canvas:将DOM转换为Canvas
  • jsPDF:生成PDF文件
  • Web Worker:避免主线程阻塞

实现方案对比

传统方案痛点

  • 同步操作阻塞UI渲染
  • 复杂DOM处理时卡顿明显

传统方案性能图:

Web Worker方案优势

  • 将CPU密集型操作转移到独立线程
  • 保持主线程响应流畅

web worker方案性能图:

核心技术分析

html2canvas:DOM 到 Canvas 的桥梁

技术定位 :基于纯前端实现的 DOM 渲染捕获库
核心价值

将任意 HTML 元素(包括复杂布局、CSS 样式)无损转换为 Canvas 图像,解决「可视化内容转文件」的关键第一步。
应用要点

  • 需处理跨域资源(useCORS: true)和滚动区域捕获(scrollY: -scrollTop
  • 注意浏览器兼容性(如 CSS 特性支持度)
  • 性能敏感(大尺寸 DOM 转换需优化)

jsPDF:客户端 PDF 生成引擎

技术定位 :浏览器端动态创建 PDF 文件的轻量级解决方案
核心价值

摆脱服务端依赖,实现纯前端 PDF 文件构建,支持图片嵌入、多页排版等基础功能。
应用要点

  • 需动态计算页面尺寸(如 A4 比例适配 [210mm, (height*210)/width]
  • 字体管理需特殊处理(示例中使用图片规避字体嵌入问题)
  • 建议 CDN 动态加载(减少主包体积)

Web Worker:浏览器多线程实践

技术定位 :浏览器环境的多线程并行计算能力
核心价值

将 CPU 密集的 PDF 生成逻辑与主线程解耦,避免 UI 冻结,提升用户体验。
应用要点

  • 采用 Blob 动态构造 Worker(规避独立文件维护成本)
  • 需严格管理生命周期(terminate() + revokeObjectURL 防内存泄漏)
  • 线程间通信需序列化数据(示例中通过 ArrayBuffer 传输二进制)

技术联动关系

三者形成 「DOM捕获 → 后台生成 → 结果回传」 的完整链路,各司其职的同时通过 Worker 实现性能解耦。

核心实现步骤

1. DOM转Canvas处理

less 复制代码
const canvas = await html2canvas(ref.current, {
  height: scrollHeight,
  width: scrollWidth,
  useCORS: true, // 处理跨域资源
  scrollY: -originalScrollTop // 捕获完整滚动区域
});

2. 动态创建Web Worker

ini 复制代码
const createWorkerBlob = (): Blob => {
  const workerCode = `
    importScripts('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js');
    // ...Worker逻辑
  `;
  return new Blob([workerCode], { type: "application/javascript" });
};

3. Worker线程处理PDF生成

ini 复制代码
self.onmessage = async (e) => {
  const pdf = new jsPDF();
  pdf.addImage(...);
  const pdfData = pdf.output('arraybuffer');
  self.postMessage({ success: true, pdfData });
};

4. 主线程通信处理

ini 复制代码
const worker = new Worker(workerUrl);

worker.onmessage = (e) => {
  // 处理生成结果
  const link = document.createElement("a");
  link.download = filename;
  link.click();
};

// 异常处理
worker.onerror = (e) => {
  throw new Error("PDF生成失败: Worker错误");
};

效果对比

指标 传统方案 Worker方案
主线程阻塞时间 2.73s -
用户感知卡顿 明显

全部代码

ini 复制代码
// import { jsPDF } from "jspdf";
import html2canvas from "html2canvas";

// 定义Worker返回的数据类型
interface WorkerSuccessResult {
  success: true;
  pdfData: ArrayBuffer;
}

interface WorkerErrorResult {
  success: false;
  error: string;
}

type WorkerResult = WorkerSuccessResult | WorkerErrorResult;

/**
 * 创建包含 Web Worker 代码的 Blob,用于通过 `jsPDF` 生成 PDF。
 * TODO:目前是引入官方cdn的jsPDF库,后续应该把文件转移到内部cdn库
 *
 * @returns {Blob} 包含 Web Worker 代码的 Blob。
 */
const createWorkerBlob = (): Blob => {
  const workerCode = `
    self.onmessage = async function(e) {
      const { imgData, imgWidth, imgHeight, filename } = e.data;

      try {
        // 动态导入jsPDF库 (Worker内需要重新导入)
        importScripts('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js');

        // 创建PDF
        const pdf = new self.jspdf.jsPDF('p', 'mm', [imgWidth, imgHeight]);
        pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);

        // 生成PDF数据
        const pdfData = pdf.output('arraybuffer');

        // 将生成的PDF数据返回给主线程
        self.postMessage({ success: true, pdfData });
      } catch (error) {
        self.postMessage({ success: false, error: error.message });
      }
    };
  `;
  return new Blob([workerCode], { type: "application/javascript" });
};

/**
 * 从指定的 React `RefObject` 引用的 HTML 元素生成 PDF,并触发下载。
 *
 * @param {React.RefObject<HTMLDivElement>} ref - 要转换为 PDF 的 HTML 元素的 React 引用。
 * @param {string} [filename="document.pdf"] - 生成的 PDF 文件的名称。
 *
 * @throws 如果 PDF 生成失败,将抛出错误。
 */
export const printRefAsPDF = async (
  ref: React.RefObject<HTMLDivElement>,
  filename: string = "document.pdf"
) => {
  if (!ref.current) return;

  try {
    // 保存原始滚动位置
    const originalScrollTop = ref.current.scrollTop;
    const originalScrollLeft = ref.current.scrollLeft;
    // 获取元素实际总高度(包括溢出部分)
    const scrollHeight = ref.current.scrollHeight;
    const scrollWidth = ref.current.scrollWidth;
    // 使用html2canvas捕获元素,设置高度为实际滚动高度
    const canvas = await html2canvas(ref.current, {
      height: scrollHeight,
      width: scrollWidth,
      scrollY: -originalScrollTop,
      scrollX: -originalScrollLeft,
      windowHeight: scrollHeight,
      windowWidth: scrollWidth,
      useCORS: true,
      allowTaint: true,
      logging: false,
    });
    const imgData = canvas.toDataURL("image/png");

    // 计算PDF尺寸(A4纸比例)
    const imgWidth = 210; // A4宽度(mm)
    const imgHeight = (canvas.height * imgWidth) / canvas.width;
    // 恢复原始滚动位置
    ref.current.scrollTop = originalScrollTop;
    ref.current.scrollLeft = originalScrollLeft;

    // 创建Web Worker进行PDF生成
    const workerBlob = createWorkerBlob();
    const workerUrl = URL.createObjectURL(workerBlob);
    const worker = new Worker(workerUrl);

    // 等待Worker处理结果
    const workerResult = await new Promise<WorkerResult>((resolve, reject) => {
      worker.onmessage = (e) => resolve(e.data as WorkerResult);
      worker.onerror = (e) => reject(new Error("PDF生成失败: Worker错误"));
      worker.postMessage({ imgData, imgWidth, imgHeight, filename });
    });

    // 释放Worker资源
    worker.terminate();
    URL.revokeObjectURL(workerUrl);

    // 处理Worker返回结果
    if (workerResult.success) {
      // 创建Blob链接并下载
      const pdfBlob = new Blob([workerResult.pdfData], { type: "application/pdf" });
      const pdfUrl = URL.createObjectURL(pdfBlob);
      const link = document.createElement("a");
      link.href = pdfUrl;
      link.download = filename;
      link.click();
      // 清理URL
      setTimeout(() => URL.revokeObjectURL(pdfUrl), 100);
    } else {
      throw new Error(workerResult.error || "生成PDF时发生错误");
    }
  } catch (error) {
    // console.error("PDF生成失败", error);
    throw new Error("PDF生成失败");
  }
};

// 初始版本,但是转化为pdf的时候会导致主线程卡顿
// export const printRefAsPDFLegacy = async (
//   ref: React.RefObject<HTMLDivElement>,
//   filename: string = "document.pdf"
// ) => {
//   if (!ref.current) return;

//   try {
//     // 保存原始滚动位置
//     const originalScrollTop = ref.current.scrollTop;
//     const originalScrollLeft = ref.current.scrollLeft;

//     // 获取元素实际总高度(包括溢出部分)
//     const scrollHeight = ref.current.scrollHeight;
//     const scrollWidth = ref.current.scrollWidth;

//     // 使用html2canvas捕获元素,设置高度为实际滚动高度
//     const canvas = await html2canvas(ref.current, {
//       height: scrollHeight,
//       width: scrollWidth,
//       scrollY: -originalScrollTop,
//       scrollX: -originalScrollLeft,
//       windowHeight: scrollHeight,
//       windowWidth: scrollWidth,
//       useCORS: true,
//       allowTaint: true,
//       logging: false,
//     });

//     const imgData = canvas.toDataURL("image/png");

//     // 计算PDF尺寸(A4纸比例)
//     const imgWidth = 210; // A4宽度(mm)
//     const imgHeight = (canvas.height * imgWidth) / canvas.width;

//     // 创建PDF,设置高度为内容的实际高度
//     const pdf = new jsPDF("p", "mm", [imgWidth, imgHeight]);

//     // 添加内容到PDF
//     pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);

//     // 恢复原始滚动位置
//     ref.current.scrollTop = originalScrollTop;
//     ref.current.scrollLeft = originalScrollLeft;

//     // 保存PDF
//     pdf.save(filename);
//   } catch (error) {
//     console.error("PDF生成失败", error);
//   }
// };

总结

通过Web Worker方案,我们成功将PDF生成的耗时操作转移到独立线程,在保证功能完整性的同时显著提升了用户体验。这种模式也适用于其他CPU密集型前端操作,如图像处理、复杂计算等场景。

相关推荐
杰尼橙子2 小时前
DPDK graph图节点处理框架:模块化数据流计算的设计与实现
网络协议·性能优化
悟道|养家2 小时前
数据库性能优化指南:解决ORDER BY导致的查询性能问题( SQL Server )
数据库·性能优化
儿歌八万首2 小时前
从卡顿到丝滑:uni-app房产App性能优化实践
性能优化·uni-app
程序员岳焱3 小时前
MySQL 基础 SQL 优化秘籍:4 大技巧让查询性能飙升!
后端·mysql·性能优化
山海上的风11 小时前
Spring Batch终极指南:原理、实战与性能优化
spring·性能优化·batch·springbatch
哎呦薇17 小时前
一篇文章说明白web前端性能优化
性能优化
kingsley18 小时前
刷新页面前, 我们能够将请求发出么
浏览器
bo521001 天前
浏览器事件机制详解以及发展史
前端·面试·浏览器
cloudy4911 天前
Java 各种 IO 模型端口转发性能对比实测(BIO、NIO、AIO、虚拟线程)
java·性能优化
修电脑的猫2 天前
Performance Monitoring on Production Systems in SAP ERP(ABAP性能优化)
性能优化·abap