纯前端 Docx 和 html2canvas 库导出 Word 报告:从表格到图表的完整实现

738 阅读6分钟

作为一名前端开发工程师,搞事情是我的日常。今天的主题是:如何用 Ant Design 表格AntV/G2 图表 实现一个 Word 文档导出功能!别急,听我慢慢道来,保证让你在 30 分钟内从菜鸟蜕变成“导出 Word 文档小能手”!

起因:需求猝不及防,挑战一触即发

故事是这样的,老板递给我一张需求单:
“我们现在做了一个数据分析平台,数据展示要用 Ant Design 的表格,图表要用 AntV/G2。除此之外,客户还需要一键导出统计结果,生成高端大气的 Word 文档。”

你说,这是不是标准的“来活儿了”?做吧,不做老板就得对我“降龙十八催”;怼回去吧,可能年底年终奖就凉了。于是,我硬着头皮接下了这个“送命题”。

第一步:选工具——docx 是我的好帮手

Word 文档操作,一提到 Microsoft Office 的 API,我就头皮发麻。还好,我们有开源神器 docx。它让生成 Word 文档像搭乐高一样简单,写个表格、插张图片、加个标题,分分钟搞定。

优点

  • 全 JS 操作,前端也能轻松上手。
  • API 设计优雅,用起来很有手感。

缺点:没有缺点,你写的代码也许有,但工具是无辜的。

第二步:定义需求结构,分块实现功能

好戏开场了!目标明确:

  1. 表格部分用 Ant Design Table 渲染;
  2. 图表部分用 AntV/G2 展示;
  3. 最后用 docx 组合数据导出成 Word 文档。

前端页面展示是这样的,根据不同类型的题目展示不同类型的图表展示,并且可以随意切换图表类型,下面就是展示图比较粗糙

飞书20241224-220020.gif

前置准备

在开始之前,请确保你已经安装了 docx 和 html2canvas 库。你可以使用以下命令进行安装:

npm install docx html2canvas

此外,我们将使用一些自定义的常量和枚举,请确保这些在你的项目中已经定义或引入。

预定义页面尺寸

我们首先定义了一些常见的页面尺寸,这在创建 Word 文档时非常有用:

JavaScript

const PageSizeMap = {
  A4: { width: 11906, height: 16838 },
  A3: { width: 16838, height: 23811 },
};

表格部分

前端页面展示的表格部分这里是用Ant Design Table组件 WX20241224-222113@2x.png

Ant Design Table 定义列和数据结构非常简单:

  const columns = [
    {
      title: '选项',
      dataIndex: 'type',
    },
    {
      title: '小计',
      dataIndex: 'value',
      minWidth: 80,
    },
    {
      title: '比例',
      dataIndex: 'scale',
      width: 300,
      render(text, record, index) {
        return (
          <div
            style={{ width: '100%', height: '100%' }}
            id={`progress-${control.controlId}-${index}`}
            className="scale"
          >
            <div style={{ width: 260 }}>
              <Progress percent={text} />
            </div>
          </div>
        );
      },
    },
  ];
const data = [
  { type: '男', value: 4 },
  { type: '女', value: 5 },
];

上下文中使用的数据结构

后面用到的方法里面会使用到这个state,这个是state的数据结构,这里的controlId 在本文中可以理解为题目的id,对应的tableDataMap 里面就是存放题目数据的地方。

// 我这里的state结构是这样的
const state = {
   tableDataMap: {
   '67627477ed79ff7ec5beb49a': [
        { type: '男', value: 4 },
        { type: '女', value: 3 }
     ]
   },
   controls: [
     {
       controlId: '67627477ed79ff7ec5beb49a',
       controlName: "个人信息"
     },
     {
       controlId: "676385a4ed79ff7ec5beb909",
       controlName: "您的姓名"
     }
   ],
}

生成问题标题

我们定义了一个函数 createQuestionTitle 用于生成每个问题的标题:



/**
 * 生成题目标题
 * @param {*} controls 表头数据
 * @param {*} controlId 表头列id
 * @param {*} index 题目索引
 * @returns
 */
export const createQuestionTitle = (controls, controlId, index) => {
  const control = controls.find(item => item.controlId === controlId);
  const questionText = QuestionType[control.type] || '填空题';
  return new Paragraph({
    spacing: {
      before: 300, // 15 pt
      after: 200, // 10 pt
    },
    children: [
      new TextRun({
        text: `第 ${index + 1} 题:${control.controlName}`,
        size: 24, // 字体大小:36 半磅(等于 18 pt)
        bold: true, // 加粗
        font: 'SimSun',
      }),
      new TextRun({
        text: `[${questionText}]`,
        size: 24, // 字体大小:36 半磅(等于 18 pt)
        bold: true, // 加粗
        font: 'SimSun',
        color: '#0f6fff',
      }),
    ],
  });
};

生成表格进度条

为了在表格中显示进度条,我们使用 html2canvas 将 HTML 元素转换为图像:

JavaScript


/**
 * 生成表格进度条
 * @param {*} controlId 表头列id
 * @param {*} index 题目索引
 * @returns
 */
export const createProgressCell = async (controlId, index) => {
  const dom = document.getElementById(`progress-${controlId}-${index}`);
  if (!dom) return;

  const chartCanvas = await html2canvas(dom);
  const chartImage = chartCanvas.toDataURL('image/png');
  return new Paragraph({
    children: [
      new ImageRun({
        data: chartImage.split(',')[1], // 提取 base64 数据
        transformation: { width: 220, height: chartCanvas.height },
      }),
    ],
  });
};

生成表格

createDocTable 函数用于生成 Word 表格,并包含表头、数据行和汇总行:


/**
 * 生成表格
 * @param {*} data 表格数据
 * @param {*} controlId 表头列id
 * @returns
 */
export const createDocTable = async (data, controlId, state) => {

  // 生成表头
  const tableHeader = new TableRow({
    height: { value: 500 },
    children: columns.map(col => new TableCell({
      shading: { fill: '#f5f5f5' },
      width: col.width,
      children: [new Paragraph({ text: col.title, alignment: AlignmentType.CENTER })],
      verticalAlign: VerticalAlign.CENTER,
    })),
  });
  
  // 数据行
  const tableRows = await Promise.all(
    data.map(async (row, index) => {
      // 比例列数据
      const progress = await createProgressCell(controlId, index);
      return new TableRow({
        height: { value: 500 },
        children: columns.map(col => new TableCell({
          margins: { left: 100 },
          verticalAlign: VerticalAlign.CENTER,
          children: [
            col.dataIndex === 'scale' ? progress : new Paragraph({ text: String(row[col.dataIndex]), alignment: AlignmentType.LEFT }),
          ],
        })),
      });
    }),
  );
  
  // 汇总行
  const summaryRow = new TableRow({
    height: { value: 500 },
    children: [
      ...['本题有效填写人次', state.dataSource.length.toString(), ''].map(text => new TableCell({
        margins: { left: 100 },
        shading: { fill: '#f5f5f5' },
        verticalAlign: VerticalAlign.CENTER,
        children: [new Paragraph({ text, alignment: AlignmentType.LEFT })],
      })),
    ],
  });
   
  // 设置表格的边框
  const borderStyle = { style: 'single', size: 2, color: '#e0e0e0' }
   
  // 生成表格
  return new DocxTable({
    rows: [tableHeader, ...tableRows, summaryRow],
    width: { size: 100, type: 'pct' },
    borders: {
      top: borderStyle,
      bottom: borderStyle,
      left: borderStyle,
      right: borderStyle,
      insideHorizontal: borderStyle,
      insideVertical: borderStyle,
    },
  });
};

生成图表

类似的,我们可以生成图表并插入到 Word 文档中:


/**
 * 生成图表
 * @param {*} controlId 表头列id
 * @returns
 */
export const createChartImage = async (documentId, controlId, pageType) => {
  const dom = document.getElementById(documentId);
  if (!dom) return;
  const pageWidths = { A4: 595, A3: 842 };
  const pageWidth = pageWidths[pageType];
  const imageWidth = pageWidth * 0.8;

  const chartCanvas = await html2canvas(dom);
  const chartImage = chartCanvas.toDataURL('image/png');
  return new Paragraph({
    spacing: { before: 300, after: 200 },
    children: [
      new ImageRun({
        data: chartImage.split(',')[1],
        transformation: { width: imageWidth, height: chartCanvas.height * (imageWidth / chartCanvas.width) },
      }),
    ],
  });
};

根据题目类型处理图表或者表格

/**
 * 图表或者表格展示
 * @param {*} param0
 * @returns
 */
export const createDocTableOrChart = async ({ state, controlId, data, pageType }) => {
  const findControl = state.controls.find(item => item.controlId === controlId);

  if (findControl.type === ControlType.text) {
    const documentId = `word-cloud-${controlId}`;
    return await createChartImage(documentId, controlId, pageType);
  } else {
    return await createDocTable(data, controlId, state);
  }
};

生成文档片段

我们通过 generateSections 函数生成文档片段,包括每个问题的标题、表格或图表:

/**
 * 生成文档片段
 * @param {*} state
 * @param {*} pageType 纸张大小 A3 A4
 * @returns
 */
const generateSections = async (state, pageType) => {
  return await Promise.all(
    Object.entries(state.tableDataMap).map(async ([controlId, data], index) => {
      const title = createQuestionTitle(state.controls, controlId, index);
      const block = await createDocTableOrChart({ state, controlId, data, pageType });
      const documentId = `chart-${controlId}`;
      const chart = await createChartImage(documentId, controlId, pageType);
      return [title, block, chart].filter(Boolean);
    }),
  );
};

创建并下载文档

最后,我们通过 createDocument 和 tirggerDonwload 函数来创建并下载 Word 文档:

/**
 * 生成文档
 * @param {*} state 数据源
 * @param {*} sections 文档片段
 * @param {*} pageType 纸张大小 A3 A4
 * @returns 
 */
const createDocument = (state, sections, pageType) => {
  const PageSize = PageSizeMap[pageType];
  return new Document({
    sections: [
      {
        properties: {
          page: {
            size: { ...PageSize, orientation: PageOrientation.PORTRAIT },
            margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 },
          },
        },
        children: [
          new Paragraph({
            spacing: { after: 200 },
            text: state.worksheetInfo.name,
            heading: HeadingLevel.TITLE,
            alignment: AlignmentType.CENTER,
          }),
          ...sections,
        ],
      },
    ],
  });
};

/**
 * 下载文件
 * @param {*} doc 文档
 * @param {*} fileName 文件名
 */
const tirggerDonwload = async (doc, fileName) => {
  const blob = await Packer.toBlob(doc);
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = `${fileName}_统计分析.docx`;
  link.click();
};

/**
 * 导出为Word
 * @param {*} state 数据源
 * @param {*} pageType 纸张大小
 */
export const exportToWord = async (state, pageType) => {
  const sections = await generateSections(state, pageType);
  const doc = createDocument(state, sections.flat(), pageType);
  tirggerDonwload(doc, state.worksheetInfo.name);
};

我们来看一下导出效果

当然我这里导出的样式比较简单,更复杂的样式也是可以通过docx其他配置项进行调整。

飞书20241225-093613.gif

导出部分的完整代码


import {
  Document,
  Packer,
  Paragraph,
  Table as DocxTable,
  TableRow,
  TableCell,
  AlignmentType,
  ImageRun,
  HeadingLevel,
  TextRun,
  VerticalAlign,
  PageOrientation,
  WidthType,
} from 'docx';
import html2canvas from 'html2canvas';
import { QuestionType } from '../constant';
import { ControlType } from 'src/pages/Survey/core/enum';

// 预定义页面尺寸
const PageSizeMap = {
  A4: { width: 11906, height: 16838 },
  A3: { width: 16838, height: 23811 },
};

const columns = [
  {
    title: '选项',
    dataIndex: 'type',
  },
  {
    title: '小计',
    dataIndex: 'value',
    width: { size: 1000, type: WidthType.DXA },
  },
  {
    title: '比例',
    dataIndex: 'scale',
    width: { size: 3600, type: WidthType.DXA },
  },
];

/**
 * 生成标题
 * @param {*} controls 表头数据
 * @param {*} controlId 表头列id
 * @param {*} index 题目索引
 * @returns
 */
export const createQuestionTitle = (controls, controlId, index) => {
  const control = controls.find(item => item.controlId === controlId);
  const questionText = QuestionType[control.type] || '填空题';
  return new Paragraph({
    spacing: {
      before: 300, // 15 pt
      after: 200, // 10 pt
    },
    children: [
      new TextRun({
        text: `第 ${index + 1} 题:${control.controlName}`,
        size: 24, // 字体大小:36 半磅(等于 18 pt)
        bold: true, // 加粗
        font: 'SimSun',
      }),
      new TextRun({
        text: `[${questionText}]`,
        size: 24, // 字体大小:36 半磅(等于 18 pt)
        bold: true, // 加粗
        font: 'SimSun',
        color: '#0f6fff',
        margins: {
          left: 100, // 设置左内边距
        },
      }),
    ],
  });
};

/**
 * 生成表格进度条
 * @param {*} controlId 表头列id
 * @param {*} index 题目索引
 * @returns
 */
export const createProgressCell = async (controlId, index) => {
  const dom = document.getElementById(`progress-${controlId}-${index}`);
  if (!dom) return;

  const chartCanvas = await html2canvas(dom);
  const chartImage = chartCanvas.toDataURL('image/png');
  return new Paragraph({
    children: [
      new ImageRun({
        data: chartImage.split(',')[1], // 提取 base64 数据
        transformation: { width: 220, height: chartCanvas.height },
      }),
    ],
  });
};

/**
 * 生成表格
 * @param {*} data 表格数据
 * @param {*} controlId 表头列id
 * @returns
 */
export const createDocTable = async (data, controlId, state) => {
  // 构建 Word 表格头部
  const tableHeader = new TableRow({
    height: {
      value: 500,
    },
    children: columns.map(
      col =>
        new TableCell({
          shading: {
            fill: '#f5f5f5', // 设置单元格背景色(十六进制颜色值)
          },
          width: col.width,
          children: [new Paragraph({ text: col.title, alignment: AlignmentType.CENTER })],
          verticalAlign: VerticalAlign.CENTER,
        }),
    ),
  });

  // 构建 Word 表格数据行
  const tableRows = await Promise.all(
    data.map(async (row, index) => {
      // 单元格进度条
      const progress = await createProgressCell(controlId, index);
      return new TableRow({
        height: {
          value: 500,
        },
        children: columns.map(col => {
          return new TableCell({
            margins: {
              left: 100, // 设置左内边距
            },
            verticalAlign: VerticalAlign.CENTER, // 垂直居中
            children: [
              col.dataIndex === 'scale'
                ? progress
                : new Paragraph({ text: String(row[col.dataIndex]), alignment: AlignmentType.LEFT }),
            ],
          });
        }),
      });
    }),
  );
  // 表格汇总行
  const summaryRow = new TableRow({
    height: {
      value: 500,
    },
    children: [
      ...['本题有效填写人次', state.dataSource.length.toString(), ''].map(
        text =>
          new TableCell({
            margins: { left: 100 },
            shading: { fill: '#f5f5f5' },
            verticalAlign: VerticalAlign.CENTER,
            children: [new Paragraph({ text, alignment: AlignmentType.LEFT })],
          }),
      ),
    ],
  });

  // 创建 Word 文档表格
  const wordTable = new DocxTable({
    rows: [tableHeader, ...tableRows, summaryRow],
    width: { size: 100, type: 'pct' },
    borders: {
      top: { style: 'single', size: 2, color: '#e0e0e0' },
      bottom: { style: 'single', size: 2, color: '#e0e0e0' },
      left: { style: 'single', size: 2, color: '#e0e0e0' },
      right: { style: 'single', size: 2, color: '#e0e0e0' },
      insideHorizontal: { style: 'single', size: 2, color: '#e0e0e0' }, // 灰色内横线
      insideVertical: { style: 'single', size: 2, color: '#e0e0e0' }, // 灰色内竖线
    },
  });

  return wordTable;
};

/**
 * 生成图表
 * @param {*} controlId 表头列id
 * @returns
 */
export const createChartImage = async (documentId, controlId, pageType) => {
  const dom = document.getElementById(documentId);
  if (!dom) return;
  const pageWidths = { A4: 595, A3: 842 };
  const pageWidth = pageWidths[pageType];
  const imageWidth = pageWidth * 0.8; // 占页面宽度 80%

  const chartCanvas = await html2canvas(dom);
  const chartImage = chartCanvas.toDataURL('image/png');
  return new Paragraph({
    spacing: {
      before: 300, // 15 pt
      after: 200, // 10 pt
    },
    children: [
      new ImageRun({
        data: chartImage.split(',')[1], // 提取 base64 数据
        transformation: { width: imageWidth, height: chartCanvas.height * (imageWidth / chartCanvas.width) },
      }),
    ],
  });
};

/**
 * 图表或者表格展示
 * @param {*} param0
 * @returns
 */
export const createDocTableOrChart = async ({ state, controlId, data, pageType }) => {
  const findControl = state.controls.find(item => item.controlId === controlId);

  if (findControl.type === ControlType.text) {
    const documentId = `word-cloud-${controlId}`;
    return await createChartImage(documentId, controlId, pageType);
  } else {
    return await createDocTable(data, controlId, state);
  }
};

/**
 * 生成文档片段
 * @param {*} state
 * @param {*} pageType
 * @returns
 */
const generateSections = async (state, pageType) => {
  return await Promise.all(
    Object.entries(state.tableDataMap).map(async ([controlId, data], index) => {
      // 题目标题
      const title = createQuestionTitle(state.controls, controlId, index);
      // 表格或者图表
      const block = await createDocTableOrChart({ state, controlId, data, pageType });

      const documentId = `chart-${controlId}`;
      const chart = await createChartImage(documentId, controlId, pageType);
      return [title, block, chart].filter(Boolean);
    }),
  );
};

/**
 * 生成文档
 * @param {*} state 
 * @param {*} sections 
 * @param {*} pageType 
 * @returns 
 */
const createDocument = (state, sections, pageType) => {
  const PageSize = PageSizeMap[pageType];
  return new Document({
    sections: [
      {
        properties: {
          page: {
            size: {
              ...PageSize, // 快速设置 A4 页面尺寸
              orientation: PageOrientation.PORTRAIT, // 纵向
            },
            margin: {
              top: 1440, // 上边距 1 英寸
              bottom: 1440, // 下边距 1 英寸
              left: 1440, // 左边距 1 英寸
              right: 1440, // 右边距 1 英寸
            },
          },
        },
        children: [
          new Paragraph({
            spacing: {
              after: 200, // 10 pt
            },
            text: state.worksheetInfo.name,
            heading: HeadingLevel.TITLE,
            alignment: AlignmentType.CENTER,
          }),
          ...sections,
        ],
      },
    ],
  });
};

/**
 * 下载文件
 * @param {*} doc 文档
 * @param {*} fileName 
 */
const tirggerDonwload = async (doc, fileName) => {
  const blob = await Packer.toBlob(doc);
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = `${fileName}_统计分析.docx`;
  link.click();
};

/**
 * 导出为Word
 * @param {*} state 
 * @param {*} pageType 
 */
export const exportToWord = async (state, pageType) => {
  // 生成sections
  const sections = await generateSections(state, pageType);

  // 创建 Word 文档
  const doc = createDocument(state, sections.flat(), pageType);

  // 导出为 Word 文件
  tirggerDonwload(doc, state.worksheetInfo.name);
};

性能问题

在导出功能中,当图表数量较多,且表格每行都包含需要转换为图片的进度条时,使用 html2canvas 会导致性能问题。由于 html2canvas 需要遍历大量节点并解析每个节点的样式,处理复杂的结构会显著增加耗时。结果是,原本几十 KB 的文件可能需要十几秒才能生成,这对于产品体验来说是难以接受的。

经过一番研究,我发现 modern-screenshot是一个更优秀的替代方案,性能表现更佳,非常值得尝试。

我们来更换一下生成图的方式。

import { domToDataUrl } from 'modern-screenshot';

/**
 * 生成表格进度条
 * @param {*} controlId 表头列id
 * @param {*} index 题目索引
 * @returns
 */
export const createProgressCell = async (controlId, index) => {
  const dom = document.getElementById(`progress-${controlId}-${index}`);
  if (!dom) return;
  const chartImage = await domToDataUrl(dom);

  return new Paragraph({
    children: [
      new ImageRun({
        data: chartImage.split(',')[1], // 提取 base64 数据
        transformation: { width: 220, height: 30 },
      }),
    ],
  });
};

总结

通过本文的介绍,了解了如何使用 docx 库结合 html2canvas 将表格和图表导出为 Word 文档。这个过程包括了从生成表格和图表,到创建 Word 文档,再到触发下载等多个步骤。希望这篇文章能帮助你在前端项目中实现类似的功能,并提高你的开发效率。