1.背景
某天产品同学和我说要搞一个页面的pdf导出
我知道前端有成熟的库可以把页面导出成pdf的,但是我却和产品说不如想把这东西抛给后端同学,当然html的模板由前端提供,然后后端帮忙查询业务数据,再往前端提供的模板把对应的变量替换成字段的值,再导出成pdf应该是可以的,而且导出pdf的压力不由浏览器来承担肯定效果会更好
结果后端同学说这个东西前端弄就可以了吧,而且他不会弄
看来这个坑还是得前端的弟弟来填
2.需求
用户往表单,表格填写数据,点击预览按钮会出现弹窗,弹窗的内容就是整个pdf页面的内容,点解弹窗内的下载按钮即可把当前弹窗的内容下载成pdf
2.1 思路
我上社区查询了一下,目前前端使用的pdf导出主要有几个主流的库 (没提及到的主要是我没安装实际使用过不代表不是主流哈)
1.html2pdf.js(这个库是html2canvas与jsPDF两个库的结合体,它集成了两个库的全部api)
2.pdfmake (这个库是用类似canvas的语法来编写页面的pdf,学习成本很高,而且里面的中文字体需要自己特定把字体下载到本地或者项目里面,当加一种语言就要自己手动加一种,维护起来成本很高)
所以这里就暂时使用html2pdf.js这个库来完成这个需求
3.问题
3.1 在生成pdf的过程中,浏览器处于假死
因为页面元素实在太多了,我看了一下源码这个库需要我传一个元素id作为生成pdf的内容,而这个id是从我整个页面元素里面遍历递归取的,当我页面存在很多元素嵌套的情况下(尤其微前端),他要生成pdf的时间就越长,然后浏览器在操作dom的时候是占主线程的,所以浏览器在这段期间会假死
解决方法:
使用html2canvas的api(ignoreElements)可以忽略元素的方法,这样就可以让我们提前去识别一些不用遍历的元素,直接让这个库去遍历和我们传id最快捷的遍历路径,极大缩短浏览器假死时间
3.2 pdf的内容含有图片,导出pdf后图片会消失
解决方法:
使用html2canvas的api(useCORS)设置为true即可
3.3 产品说如果我现在不需要直接导出pdf的文件,我想要把pdf导出成文件流,然后再把整个文件流上传到oss(云盘)里呢
当我以为直接使用html2pdf的output,默认让pdf输出为pdf对象,然后再把pdf对象转成blob流再转成file流,再调用上传oss的接口即可实现,然而当我上传成功后,从云盘下载下来的这份pdf文件,完全是空白,我在想中间肯定有转换没对,肯定是在pdf对象转blob流这里,缺少charCode转换,然后我试了一下遍历pdf对象里面的内容全部转成 charCodeAt,再上传就对了
正确的流程:
output转成pdf对象 -> new Unit8Array -> 遍历整个pdf对象转换每一个字符为Unicode编码 -> 把转换之后的Unicode编码串转成blob流 -> 把blob流转成file流 -> 调用接口上传到oss
4.代码
直接上代码
4.1 编写 main.vue
因为项目有其他触发器需要使用整个导出按钮的逻辑,所以封装成一个hooks,这样可以在不同业务情况下进行复用
vue
<script setup>
import { usePdf } from './hooks';
const props = defineProps({
/**
* 元素id
*/
elementId: {
type: String,
default: null,
},
/**
* 文件名称
*/
filename: {
type: String,
default: 'download',
},
/**
* 页面断点
*/
pagebreak: {
type: Object,
default: () => {
return {
avoid: ['.avoid-break'],
};
},
},
/**
* HTML 类型
*/
htmlType: {
type: String,
default: 'button',
validator(value) {
return ['text', 'button'].includes(value);
},
},
/**
* md-button/md-link 类型
*/
type: {
type: String,
default: '',
},
});
const { loading, download } = usePdf({
filename: props.filename,
pagebreak: props.pagebreak,
});
/**
* 点击下载按钮时触发
*/
function click() {
const pdfEl = document.querySelector(`#${props.elementId}`);
download(pdfEl);
}
</script>
<template>
<button
:type="type"
:loading="loading"
@click="click"
>
{{ loading ? '生成中' : '下载' }}
</button>
</template>
4.2 编写 hooks.js
包含下载/获取文件流功能
js
import { ref, nextTick } from 'vue';
import html2pdf from 'html2pdf.js';
/**
* 生成pdf钩子
* @param {object} root0 - 对象
* @param {string} root0.filename -文件名
* @param {object} root0.pagebreak - 分页
* @returns {object} - loading, download
*/
export function usePdf({ filename, pagebreak }) {
const loading = ref(false);
const options = {
margin: [10, 10, 10, 10],
filename,
pagebreak,
html2canvas: {
scale: 2,
useCORS: true,
ignoreElements: (e) => {
if (
e.querySelector('.html2pdf__overlay') ||
e.closest('.html2pdf__overlay') ||
e === document.head ||
e === document.body ||
e.tagName === 'STYLE' ||
e.tagName === 'LINK'
) {
return false;
}
return true;
},
},
};
/**
* 下载pdf
* @param {object} pdfEl -dom元素
*/
function download(pdfEl) {
nextTick(() => {
loading.value = true;
html2pdf()
.from(pdfEl)
.set(options)
.save()
.then(() => {
console.log('PDF生成完成');
loading.value = false;
return true;
})
.catch((error) => {
console.error('PDF生成失败:', error);
loading.value = false;
return false;
});
});
}
/**
* 生成文件流
* @param {object} pdfEl -dom元素
* @returns {object} - 文件流
*/
async function getFile(pdfEl) {
try {
const res = await html2pdf().from(pdfEl).set(options).output();
const pdfArray = new Uint8Array(res.length);
for (let i = 0; i < res.length; i += 1) {
pdfArray[`${i}`] = res.charCodeAt(i);
}
const blobType = { type: 'application/pdf;charset=utf-8' };
const blob = new Blob([pdfArray], blobType);
const file = new File([blob], `${filename}.pdf`, blobType);
return file;
} catch (e) {
return e;
}
}
return { loading, download, getFile };
}
4.3 编写index.js组件入口
把hooks导出出去方便任意触发器调用
vue
import PdfDownload from './main.vue';
import { usePdf } from './hooks';
export { PdfDownload, usePdf };
4.4 使用组件
js
<script setup>
import { PdfDownload, usePdf } from '@/components';
const filename = '测试pdf导出';
const pagebreak = {
avoid: ['.avoid-break'],
};
const { getFile } = usePdf({ filename, pagebreak });
/**
* 点击获取文件流按钮时触发
*/
async function onClick() {
const pdfEl = document.querySelector('#pdfEl');
const res = await getFile(pdfEl);
console.log(res);
}
</script>
<template>
<PdfDownload
element-id="pdfEl"
:filename="filename"
:pagebreak="pagebreak"
/>
<button @click="onClick">
{{ '获取文件流' }}
</button>
<div id="pdfEl">
{{ '我是pdf的内容' }}
</div>
</template>
4.5 整体文件编排

5.结语
如果需求是生成一份50页以上并且size很大的pdf恐怕会放大这个假死的时间,恐怕要去文档里仔细看jspdf的api,除了分片段生成还需考虑如何重新整合这些分段的pdf文件流再进行下载,后续有时间我再研究下这个问题。