作为一名前端开发工程师,搞事情是我的日常。今天的主题是:如何用 Ant Design 表格 和 AntV/G2 图表 实现一个 Word 文档导出功能!别急,听我慢慢道来,保证让你在 30 分钟内从菜鸟蜕变成“导出 Word 文档小能手”!
起因:需求猝不及防,挑战一触即发
故事是这样的,老板递给我一张需求单:
“我们现在做了一个数据分析平台,数据展示要用 Ant Design 的表格,图表要用 AntV/G2。除此之外,客户还需要一键导出统计结果,生成高端大气的 Word 文档。”
你说,这是不是标准的“来活儿了”?做吧,不做老板就得对我“降龙十八催”;怼回去吧,可能年底年终奖就凉了。于是,我硬着头皮接下了这个“送命题”。
第一步:选工具——docx 是我的好帮手
Word 文档操作,一提到 Microsoft Office 的 API,我就头皮发麻。还好,我们有开源神器 docx。它让生成 Word 文档像搭乐高一样简单,写个表格、插张图片、加个标题,分分钟搞定。
优点:
- 全 JS 操作,前端也能轻松上手。
- API 设计优雅,用起来很有手感。
缺点:没有缺点,你写的代码也许有,但工具是无辜的。
第二步:定义需求结构,分块实现功能
好戏开场了!目标明确:
- 表格部分用 Ant Design Table 渲染;
- 图表部分用 AntV/G2 展示;
- 最后用
docx
组合数据导出成 Word 文档。
前端页面展示是这样的,根据不同类型的题目展示不同类型的图表展示,并且可以随意切换图表类型,下面就是展示图比较粗糙
前置准备
在开始之前,请确保你已经安装了 docx
和 html2canvas
库。你可以使用以下命令进行安装:
npm install docx html2canvas
此外,我们将使用一些自定义的常量和枚举,请确保这些在你的项目中已经定义或引入。
预定义页面尺寸
我们首先定义了一些常见的页面尺寸,这在创建 Word 文档时非常有用:
JavaScript
const PageSizeMap = {
A4: { width: 11906, height: 16838 },
A3: { width: 16838, height: 23811 },
};
表格部分
前端页面展示的表格部分这里是用Ant Design Table组件
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其他配置项进行调整。
导出部分的完整代码
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 文档,再到触发下载等多个步骤。希望这篇文章能帮助你在前端项目中实现类似的功能,并提高你的开发效率。