前言
本文介绍了在前端项目中,如何通过使用不同的第三方库实现将前端界面导出为PDF、Excel和Word的功能。通过本文,你将了解到如何利用这些库,并结合后端提供的数据,实现用户友好的导出功能。
正文
一、导出PDF
-
技术选型
- 使用
html2canvas将图表转换成图片 - 使用
jspdf将图片导出成PDF - 使用
jspdf将其他标题,文字导出 - 使用
jspdf-autotable将表格导出
- 使用
-
实现步骤
- 安装依赖:
npm install --save html2canvas jspdf jspdf-autotable - 使用
jspdf的pdf.value.text()将页面的标题,小标题,正文添加到pdf实例中。 - 通过
html2canvas将需要导出的图表转换成canvas,在通过canvas.toDataURL('image/png')转化为base64格式的图片,通过pdf.value.addImage()将图表图片添加到pdf实例中。 - 我是通过组件的形式通过接收一个数组来动态渲染以及导出
- 具体看代码示例
- 代码示例
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import html2canvas from 'html2canvas';
import '@/assets/fonts/SimHei-normal'; // 字体文件
const data = [
{
type: 'title',
value: '我是标题'
},
{
type: 'text',
value: `
据央视新闻消息,11月8日下午,国家主席习近平在北京人民大会堂同来华进行国事访问的意大利总统马塔雷拉举行会谈。
习近平指出,欢迎总统先生时隔7年再次对中国进行国事访问。你是中国人民的老朋友,也是我的好朋友。近年来,我同总统先生成功实现互访,并通过电话、信函保持密切沟通,每一次交流都能够深化友谊、增进互信。
今年是中意建立全面战略伙伴关系20周年。7月,梅洛尼总理成功访华,双方发表了加强全面战略伙伴关系的行动计划,一致同意秉持历史悠久的丝路精神,推动双边关系进入发展新阶段。相信总统先生这次访问将在新的历史起点上,为中意关系发展注入更强劲动力,为两国人民带来更大福祉。
今年也是马可·波罗逝世700周年。早在13世纪,这位伟大的意大利旅行家能够不加偏见地认知和描述中国,为西方世界开启了认识中国的大门,也激励了一代又一代友好使者为东西方文明交流互鉴作出卓越贡献。当今世界正经历百年未有之大变局,中国和意大利作为两大文明古国,应该弘扬两国开放包容、兼容并蓄的传统,推动国际社会以对话化解分歧、以合作超越冲突,携手构建和合共生的美好世界。
编辑 李忆林子
`
},
{
type: 'subtitle',
value: '一、基本信息'
},
{
type: 'table',
value: {
head: ['名称', '年龄', '城市'],
body: [
['名称1', '20', '城市1'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称4', '35', '城市4'],
['名称5', '40', '城市5'],
['名称6', '45', '城市6'],
],
}
},
{
type: 'subtitle',
value: '二、广告'
},
{
type: 'chart',
value: {
chartType: 'line',
id: 'qqq',
data: {
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
series: [{
name: '邮件营销',
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: '联盟广告',
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: '视频广告',
data: [150, 232, 201, 154, 190, 330, 410]
},]
}
}
},
{
type: 'chart',
value: {
chartType: 'pie',
id: 'kkk',
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
]
}
},
{
type: 'table',
value: {
head: ['xxx', 'yyy', 'zzz'],
body: [
['名称1', 'xx', '城市asd1'],
['名称2', '25', 'ss'],
['名称6', '45', '城sd市6'],
],
}
}
]
// 导出PDF
const pdf = ref<any>(null)
const yOffset = ref(50)
const toPdf = async (fileName:string) => {
pdf.value = new jsPDF({
unit: 'pt', // 使用 pt 为单位,以更精确控制宽度
format: 'a4',
});
// 添加自定义字体
pdf.value.setFont('SimHei');
for (const item of data as any) {
switch (item.type) {
case 'title':
setTitle(item.value);
break;
case 'text':
setText(item.value);
break;
case 'subtitle':
setSubtitle(item.value);
break;
case 'table':
setTable(item.value);
break;
case 'chart':
await setChart(item.value);
break;
}
}
pdf.value.save(`${fileName}.pdf`);
setTimeout(() => {
pdf.value = null
yOffset.value = 50;
}, 1000);
}
// 设置标题
const setTitle = (title: string) => {
pdf.value.setFontSize(20);
const pageWidth = pdf.value.internal.pageSize.getWidth();
const averageCharWidth = 20;
const textWidth = title.length * averageCharWidth;
const centerX = (pageWidth - textWidth) / 2; // 计算文本的 X 轴位置 居中显示
pdf.value.text(title, centerX, yOffset.value);
yOffset.value += 30;
}
// 设置文本
const setText = (text: string) => {
pdf.value.setFontSize(12);
const textLine = pdf.value.splitTextToSize(text, 500); // 控制宽度
for (let i = 0; i < textLine.length; i++) {
if (yOffset.value > 770) { // 控制 Y 轴,避免超出页面
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.text(textLine[i], 40, yOffset.value);
yOffset.value += 20;
}
}
// 设置小标题
const setSubtitle = (title: string) => {
pdf.value.setFontSize(14);
pdf.value.text(title, 40, yOffset.value);
yOffset.value += 20;
}
// 设置表格
const setTable = (table: any) => {
if (yOffset.value > 750) {
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.autoTable({
startY: yOffset.value,
head: [table.head],
body: table.body,
styles: { font: 'SimHei' }, // 使用自定义字体
});
yOffset.value = pdf.value.autoTable.previous.finalY + 30;
}
// 设置图表
const setChart = async (echarts: any) => {
const chartElement = document.getElementById(echarts.id);
if (chartElement) {
const canvas = await html2canvas(chartElement);
const imgData = canvas.toDataURL('image/png');
const imageHeight = canvas.height * (500 / canvas.width);
if (yOffset.value + imageHeight > 770) {
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.addImage(imgData, 'PNG', 40, yOffset.value, 500, imageHeight);
yOffset.value += imageHeight + 50;
}
}
最后通过调用方法导出就可以了
<button @click="toPdf('测试')" ></button>
二、导出Excel
这个相对简单太多了(不要求样式的情况下)
-
技术选型
- 使用
file-saver的saveAs方法直接进行Excel导出
- 使用
-
实现步骤
- 安装依赖:
npm install file-saver - 调整表格的样式,把图表转为图片不然导出不了
- 安装依赖:
-
代码示例
/**
*
* @param element 需要导出的元素
* @param fileName 导出的文件名称
*/
export async function downloadExcel(element:any, fileName: string) {
// 查找并修改表格的样式
const tables = element.querySelectorAll('table');
tables.forEach((table:any) => {
table.style.borderCollapse = 'collapse';
table.style.width = '100%';
table.querySelectorAll('td, th').forEach((cell:any) => {
if (cell) {
cell.style.border = '1px solid #D0D0D0'
cell.style.padding = '8px';
}
});
});
// 处理 canvas 元素
const canvases = element.querySelectorAll('canvas');
canvases.forEach((canvas:any) => {
const img = new Image();
img.width = canvas.width;
img.height = canvas.height;
img.src = canvas.toDataURL('image/png'); // 转换为 PNG 图像
const div = document.createElement('div');
div.style.position = 'relative';
div.appendChild(img);
canvas.parentNode.replaceChild(div, canvas);
});
const htmlContentCopy = element.cloneNode(true);
const blob = new Blob([htmlContentCopy.innerHTML], {
type: 'application/vnd.ms-excel',
});
saveAs(blob, `${fileName}.xlsx`);
}
三、导出Word
-
技术选型
- 使用
html-docx-js-typescript库结合file-saver库 进行导出
- 使用
-
实现步骤
- 安装依赖:
npm install html-docx-js-typescript file-saver - 使用
asBlob将数据转化,并使用file-saver保存
- 安装依赖:
-
代码示例
/**
*
* @param element 需要下载的dom元素------------------word-------------------
* @param fileName 下载的文件名,不需要后缀名
*/
export async function downloadWord(element: any, fileName: string) {
// 查找并修改表格的样式
const tables = element.querySelectorAll('table')
tables.forEach((table: any) => {
table.style.borderCollapse = 'collapse'
table.style.width = '100%'
table.querySelectorAll('td, th').forEach((cell: any, index: number) => {
if (cell) {
cell.style.border = '1px solid black'
cell.style.padding = '8px'
}
})
})
//去除<br>标签,内容加到<div>标签内
const brs = element.querySelectorAll('br')
brs.forEach((br: any) => {
const parent = br.parentNode //获取父节点
let textNode = br.previousSibling //前一个兄弟节点
// while (textNode && textNode.nodeType !== Node.TEXT_NODE) {
// textNode = textNode.previousSibling; //循环查找,直到找到一个文本节点或没有更多的兄弟节点
// }
if (textNode && textNode.nodeType === Node.TEXT_NODE && textNode.textContent.trim()) { //找到文本节点,并且内容不为空
const div = document.createElement('div')
div.textContent = textNode.textContent
parent.insertBefore(div, br)
parent.removeChild(textNode) //移除原有的文本节点,避免内容重复
} else {
const div = document.createElement('div')
div.innerHTML = ' '
parent.insertBefore(div, br)
}
parent.removeChild(br)
})
// 处理 canvas 元素
const canvases = element.querySelectorAll('canvas');
canvases.forEach((canvas: any) => {
const img = new Image();
img.width = canvas.width;
img.height = canvas.height;
img.src = canvas.toDataURL('image/png'); // 转换为 PNG 图像
const div = document.createElement('div');
div.style.position = 'relative';
div.appendChild(img);
canvas.parentNode.replaceChild(div, canvas);
});
const htmlContentCopy = element.cloneNode(true)
const imgs = htmlContentCopy.querySelectorAll('img')
imgs.forEach((img: any) => {
let docxWidth = 620
if (img.width > docxWidth) {
img.height = img.height * docxWidth / img.width
img.width = docxWidth
}
})
const htmlString = await handleStyle(htmlContentCopy);
const margins = { top: 1440 };
asBlob(htmlString, { margins }).then((data: any) => {
saveAs(data, `${fileName}.docx`);
});
}
const handleStyle = async (cloneEle: any) => {
const cssString = '';
const innerHtml = cloneEle.innerHTML
// strong在word中不生效问题
.replace(/<strong>/g, '<b>')
.replace(/<\/strong>/g, '</b>')
// 背景色不生效问题
.replace(/<mark/g, '<span')
.replace(/<\/mark>/g, '</span>')
// 将上面生成的多个ul/ol组成一个
.replace(/<\/ol><ol.*?>/g, '')
.replace(/<\/ul><ul.*?>/g, '');
// 最终生成的html字符串
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<style type="text/css">${cssString}</style>
</head>
<body>
<div id="q-editor">
${innerHtml}
</div>
</body>
</html>`;
return htmlString;
};
最后全部代码示例
exportFile.vue
<script setup lang="tsx">
import { ref } from 'vue'
import echarts from './echarts.vue'
import { downloadWord, downloadExcel } from '@/utils/exportFile'
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import html2canvas from 'html2canvas';
import '@/assets/fonts/SimHei-normal';
// 表格value类型
interface TableValues {
head: string[]
body: string[][]
}
// 饼图value类型
interface ChartValuesPie {
chartType: string
id: string
data: {
value: number
name: string
}[]
}
// 折线图和柱状图value类型
interface ChartValuesLineAndBar {
chartType: string
id: string
data: {
xAxis: string[]
series: {
name: string
data: number[]
}[]
}
}
type ValuesMap = {
title: string,
text: string,
subtitle: string,
table: TableValues,
chart: ChartValuesPie | ChartValuesLineAndBar
}
// 定义组件类型的联合类型
export type CompType = keyof ValuesMap;
type dataParam = {
[key in CompType]: {
type: key;
value: ValuesMap[key]
}
}[CompType]
interface Props {
data: dataParam[]
width?: number // 页面宽度 默认为1000
}
const props = withDefaults(defineProps<Props>(), {
width: 1000,
})
// 导出Word文档
const toWord = (fileName:string) => {
downloadWord(document.getElementById('content'), fileName)
}
// 导出Excel
const toExcel = (fileName:string) => {
downloadExcel(document.getElementById('content'),fileName)
}
// 导出PDF
const pdf = ref<any>(null)
const yOffset = ref(50)
const toPdf = async (fileName:string) => {
pdf.value = new jsPDF({
unit: 'pt', // 使用 pt 为单位,以更精确控制宽度
format: 'a4',
});
// 添加自定义字体
pdf.value.setFont('SimHei');
for (const item of props.data as any) {
switch (item.type) {
case 'title':
setTitle(item.value);
break;
case 'text':
setText(item.value);
break;
case 'subtitle':
setSubtitle(item.value);
break;
case 'table':
setTable(item.value);
break;
case 'chart':
await setChart(item.value);
break;
}
}
pdf.value.save(`${fileName}.pdf`);
setTimeout(() => {
pdf.value = null
yOffset.value = 50;
}, 1000);
}
// 设置标题
const setTitle = (title: string) => {
pdf.value.setFontSize(20);
const pageWidth = pdf.value.internal.pageSize.getWidth();
const averageCharWidth = 20;
const textWidth = title.length * averageCharWidth;
const centerX = (pageWidth - textWidth) / 2; // 计算文本的 X 轴位置 居中显示
pdf.value.text(title, centerX, yOffset.value);
yOffset.value += 30;
}
// 设置文本
const setText = (text: string) => {
pdf.value.setFontSize(12);
const textLine = pdf.value.splitTextToSize(text, 500); // 控制宽度
for (let i = 0; i < textLine.length; i++) {
if (yOffset.value > 770) { // 控制 Y 轴,避免超出页面
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.text(textLine[i], 40, yOffset.value);
yOffset.value += 20;
}
}
// 设置小标题
const setSubtitle = (title: string) => {
pdf.value.setFontSize(14);
pdf.value.text(title, 40, yOffset.value);
yOffset.value += 20;
}
// 设置表格
const setTable = (table: any) => {
if (yOffset.value > 750) {
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.autoTable({
startY: yOffset.value,
head: [table.head],
body: table.body,
styles: { font: 'SimHei' }, // 使用自定义字体
});
yOffset.value = pdf.value.autoTable.previous.finalY + 30;
}
// 设置图表
const setChart = async (echarts: any) => {
const chartElement = document.getElementById(echarts.id);
if (chartElement) {
const canvas = await html2canvas(chartElement);
const imgData = canvas.toDataURL('image/png');
const imageHeight = canvas.height * (500 / canvas.width);
if (yOffset.value + imageHeight > 770) {
pdf.value.addPage();
yOffset.value = 30;
}
pdf.value.addImage(imgData, 'PNG', 40, yOffset.value, 500, imageHeight);
yOffset.value += imageHeight + 50;
}
}
defineExpose({
toWord,
toExcel,
toPdf
})
</script>
<template>
<div id="content" :style="{ width: props.width + 'px' }">
<div v-for="(item, index) in props.data" :key="index">
<div v-if="item.type == 'title'" style="text-align: center;">
<h1>{{ item.value }}</h1>
</div>
<div v-if="item.type == 'text'">
<p style="text-indent: 2em;">{{ item.value }}</p>
</div>
<div v-if="item.type == 'subtitle'">
<h3>{{ item.value }}</h3>
</div>
<div v-if="item.type == 'table'">
<table>
<thead>
<tr>
<th v-for="(head, index) in item.value.head" :key="index">{{ head }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(body, index) in item.value.body" :key="index">
<td v-for="(item, index) in body" :key="index">{{ item }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="item.type == 'chart'">
<echarts :chartType="item.value.chartType" :id="item.value.id" :data="item.value.data"></echarts>
</div>
</div>
</div>
</template>
<style scoped>
#content {
margin: 0 auto;
background-color: #FFF;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
h1 {
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
}
h3 {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
p {
font-size: 16px;
line-height: 1.5;
margin-bottom: 10px;
}
</style>
echarts.vue
<template>
<div class="echarts-container">
<div v-if="props.chartType === 'pie'" ref="chartRef" style="height: 400px;width: 600px;margin: 0 auto;"></div>
<div v-if="props.chartType === 'line'" ref="chartRef" style="height: 400px;width: 100%;margin: 0 auto;"></div>
<div v-if="props.chartType === 'bar'" ref="chartRef" style="height: 400px;width: 100%;margin: 0 auto;"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import * as echarts from 'echarts';
const props = defineProps(['data', 'chartType'])
const chartRef = ref<HTMLDivElement | null>(null);
onMounted(() => {
if (chartRef.value) {
const chart = echarts.init(chartRef.value);
if (props.chartType === 'pie') {
chart.setOption({
title: {
text: '',
left: 'center'
},
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '',
type: 'pie',
radius: '50%',
data: props.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
});
}
if (props.chartType === 'line') {
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
data: props.data.series.map((item: any) => item.name)
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'category',
boundaryGap: false,
data: props.data.xAxis
}
],
yAxis: [
{
type: 'value'
}
],
series: props.data.series.map((item: any) => {
return {
name: item.name,
type: 'line',
stack: 'Total',
areaStyle: {},
emphasis: {
focus: 'series'
},
data: item.data
}
}
)
});
}
if (props.chartType === 'bar') {
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
data: props.data.series.map((item: any) => item.name),
top: '10'
},
xAxis: {
type: 'category',
data: props.data.xAxis
},
yAxis: {
type: 'value'
},
grid: {
bottom: '30px',
top: '50px',
right: '20px',
left: '40px'
},
series: props.data.series.map((item: any) => {
return {
name: item.name,
type: 'bar',
data: item.data
}
}
)
})
}
}
});
</script>
<style scoped></style>
preview.vue
<script setup lang="ts">
import { computed, reactive, watch, ref } from 'vue';
import exportFile from './exportFile.vue';
const exportFileRef = ref()
const data = [
{
type: 'title',
value: '我是标题'
},
{
type: 'text',
value: `
据央视新闻消息,11月8日下午,国家主席习近平在北京人民大会堂同来华进行国事访问的意大利总统马塔雷拉举行会谈。
习近平指出,欢迎总统先生时隔7年再次对中国进行国事访问。你是中国人民的老朋友,也是我的好朋友。近年来,我同总统先生成功实现互访,并通过电话、信函保持密切沟通,每一次交流都能够深化友谊、增进互信。
今年是中意建立全面战略伙伴关系20周年。7月,梅洛尼总理成功访华,双方发表了加强全面战略伙伴关系的行动计划,一致同意秉持历史悠久的丝路精神,推动双边关系进入发展新阶段。相信总统先生这次访问将在新的历史起点上,为中意关系发展注入更强劲动力,为两国人民带来更大福祉。
今年也是马可·波罗逝世700周年。早在13世纪,这位伟大的意大利旅行家能够不加偏见地认知和描述中国,为西方世界开启了认识中国的大门,也激励了一代又一代友好使者为东西方文明交流互鉴作出卓越贡献。当今世界正经历百年未有之大变局,中国和意大利作为两大文明古国,应该弘扬两国开放包容、兼容并蓄的传统,推动国际社会以对话化解分歧、以合作超越冲突,携手构建和合共生的美好世界。
编辑 李忆林子
`
},
{
type: 'subtitle',
value: '一、基本信息'
},
{
type: 'table',
value: {
head: ['名称', '年龄', '城市'],
body: [
['名称1', '20', '城市1'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称3', '30', '城市3'],
['名称4', '35', '城市4'],
['名称5', '40', '城市5'],
['名称6', '45', '城市6'],
],
}
},
{
type: 'subtitle',
value: '二、广告'
},
{
type: 'chart',
value: {
chartType: 'line',
id: 'qqq',
data: {
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
series: [{
name: '邮件营销',
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: '联盟广告',
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: '视频广告',
data: [150, 232, 201, 154, 190, 330, 410]
},]
}
}
},
{
type: 'chart',
value: {
chartType: 'pie',
id: 'kkk',
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
]
}
},
{
type: 'table',
value: {
head: ['xxx', 'yyy', 'zzz'],
body: [
['名称1', 'xx', '城市asd1'],
['名称2', '25', 'ss'],
['名称6', '45', '城sd市6'],
],
}
}
]
</script>
<template>
<button @click="exportFileRef.toPdf()">导出为PDF</button>
<button @click="exportFileRef.toword()">导出为Word</button>
<button @click="exportFileRef.toexcel()">导出为Excel</button>
<exportFile ref="exportFileRef" :width="800" :data="data" />
</template>
<style scoped lang="scss"></style>