这次终于轮到前端给后端兜底了🤣
需求交代
最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下:
<h1>前端人</h1><p>学好前端,走遍天下都不怕</p>
数据抓取到后,存储到数据库,然后前端请求接口获取到数据,直接在页面预览
<divv-html='articleContent'></div>整个需求已经交代清楚
这个需求有点为难后端了
前天,客户说要新增一个文章的 pdf 导出功能,但就是这么一个合情合理的需求,却把后端为难住了,原因是部分数据采集过来的结构可能是这样的:
<h1>前端人</h1><p>学好前端,走遍天下都不怕</p><div>前端强,前端狂,交互特效我称王!<p>JS 写得好,需求改不了!</p><p>React Vue 两手抓,高薪 offer 到你家!<p><p>浏览器里横着走, bug 见我都绕道!</p<p>Chrome 调试一声笑, IE 泪洒旧时光!</p><span>Git 提交不留情,版本回退我最行!
仔细的人就能发现问题了,很多 html 元素存在没有完整的闭合情况
但浏览器是强大的,丝毫不影响渲染效果,原来浏览器自动帮我们补全结构了
可后端处理这件事就没那么简单了,爬取到的数据也比我举例的要复杂的多,使用第三方插件将 html 转 pdf 时会识别标签异常等问题,因此程序会抛异常
来自后端的建议
苦逼的后端折腾了很久,还是没折腾出来,终于他发现前端页面有个右键打印的功能,也就是:
于是他说:浏览器这玩意整挺好啊,前端能不能研究研究,尝试从前端实现导出
那就研究研究
我印象中,确实有个叫vue-print-nb的前端插件,可以实现这个功能
但....... 等等,这个插件仅仅是唤起打印的功能,我总不能真做成这样,让用户另存为 pdf 吧
于是,只能另辟蹊径,终于我找到了这么个仓库:github.com/burc-li/vue…[1]
里面实现了 dom 元素导出 pdf 的功能
效果很不错,技术用到了jspdf、html2canvas这两个第三方库,代码十分简单
const downLoadPdfA4Single = () => {const pdfContaniner = document.querySelector('#pdfContaniner')html2canvas(pdfContaniner).then(canvas => {// 返回图片dataURL,参数:图片格式和清晰度(0-1)const pageData = canvas.toDataURL('image/jpeg', 1.0)// 方向纵向,尺寸ponits,纸张格式 a4 即 [595.28, 841.89]const A4Width = 595.28const A4Height = 841.89 // A4纸宽const pageHeight = A4Height >= A4Width * canvas.height / canvas.width ? A4Height : A4Width * canvas.height / canvas.widthconst pdf = new jsPDF('portrait', 'pt', [A4Width, pageHeight])// addImage后两个参数控制添加图片的尺寸,此处将页面高度按照a4纸宽高比列进行压缩pdf.addImage(pageData,'JPEG',0,0,A4Width,A4Width * canvas.height / canvas.width,)pdf.save('下载一页PDF(A4纸).pdf')})}
技术流程大致就是:
dom -> canvas canvas -> image image -> pdf
似乎一切都将水到渠成了
困在眼前的难题
这个技术栈,最核心的就是:必须要用到 dom 元素渲染
如果你尝试将打印的元素设置样式:
display: none;或
visibility: hidden;或
opacity: 0;执行导出功能都将抛异常或者只能导出一个空白的 pdf
这时候有人会问了:为什么要设置 dom 元素为不可见?
试想一下,你做了一个导出功能,总不能让客户必须先打开页面等 html 渲染完后,再导出吧?
客户的理想状态是:在列表的操作列里,有个导出按钮,点击就可以导出 pdf 了
何况还需要实现批量勾选导出的功能,总不能程序控制,导出一个 pdf 就 open 一个窗口渲染 html 吧
寻找新方法
此路不通,就只能重新寻找新的方向,不过也没费太多功夫,就找到了另外一个插件html2pdf.js解决了这事
这插件用起来也极其简单
npm install html2pdf.js<template><divclass="container"><button @click="generatePDF">下载PDF</button></div></template><scriptsetup>import html2pdf from 'html2pdf.js'// 使用示例let element = `<h1>前端人</h1><p>学好前端,走遍天下都不怕</p><div>前端强,前端狂,交互特效我称王!<p>JS 写得好,需求改不了!</p><p>React Vue 两手抓,高薪 offer 到你家!<p><p>浏览器里横着走, bug 见我都绕道!</p<p>Chrome 调试一声笑, IE 泪洒旧时光!</p><span>Git 提交不留情,版本回退我最行!`;function generatePDF() {// 配置选项const opt = {margin: 10,filename: 'hello_world.pdf',image: { type: 'jpeg', quality: 0.98 },html2canvas: { scale: 2 },jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }};// 生成PDF并导出html2pdf().from(element).set(opt).save();}</script>
功能正常,似乎一切都完美
问题没有想的那么简单
如果我们的html是纯文本元素,这程序跑起来没有任何问题,但我们抓取的信息都源于互联网,html结构怎么可能会这么简单?如果我们的html中包含图片信息,例如:
// 使用示例let element = `<div><imgsrc='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500'style="width: 300px;" /><p>职业:前端</p><p>技能:唱、跳、rap</p></div>`;
此时你会发现,导出来的 pdf,图片占位处是个空白块
❝思考一下:类似案例中的图片加载方式,都是 get 方式的异步请求,而异步请求就会导致图片还没渲染完成,但导出的程序已经执行完成情况(最直接的观察方式就是,把这个元素放到浏览器上渲染,会发现图片也是过一会才慢慢加载完成的)
不过我不确定
❞html2pdf.js这个插件是否会发起图片请求,但不管发不发起,导出的行为明显是在图片渲染前完成的,就导致了这个空白块的存在
问题分析完了,那就解决吧
既然图片异步加载不行,那就使用图片同步加载吧
不是吧,你问我:什么是图片同步加载?我也不晓得,这个词是我自己当下凭感觉造的,如有雷同,纯属巧合了
那我理解的图片同步加载是什么意思呢?简单来说,就是将图片转成Base64,因为这种方式,即使说无网的情况也能正常加载图片,因此我凭感觉断定,这就是图片同步加载
基于这个思路,我写了个 demo
<template><divclass="container"><button @click="generatePDF">下载PDF</button></div></template><scriptsetup>import html2pdf from 'html2pdf.js'async function convertImagesToBase64(htmlString) {// 创建一个临时DOM元素来解析HTMLconst tempDiv = document.createElement('div');tempDiv.innerHTML = htmlString;// 获取所有图片元素const images = tempDiv.querySelectorAll('img');// 遍历每个图片并转换for (const img of images) {try {const base64 = await getBase64FromUrl(img.src);img.src = base64;} catch (error) {console.error(`无法转换图片 ${img.src}:`, error);// 保留原始URL如果转换失败}}// 返回转换后的HTMLreturn tempDiv.innerHTML;}// 图片转base64function getBase64FromUrl(url) {return new Promise((resolve, reject) => {const img = new Image();img.crossOrigin = 'Anonymous'; // 处理跨域问题img.onload = () => {const canvas = document.createElement('canvas');canvas.width = img.width;canvas.height = img.height;const ctx = canvas.getContext('2d');ctx.drawImage(img, 0, 0);// 获取Base64数据const dataURL = canvas.toDataURL('image/png');resolve(dataURL);};img.onerror = () => {reject(new Error('图片加载失败'));};img.src = url;});}// 使用示例let element = `<div><img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" /><p>职业:前端</p><p>技能:唱、跳、rap</p></div>`;function generatePDF() {convertImagesToBase64(element).then(convertedHtml => {// 配置选项const opt = {margin: 10,filename: '前端大法好.pdf',image: { type: 'jpeg', quality: 0.98 },html2canvas: { scale: 2 },jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }};// 生成PDF并导出html2pdf().from(convertedHtml).set(opt).save();}).catch(error => {console.error('转换过程中出错:', error);});}</script>
此时就大功告成啦!不过得提一句:图片的 URL 链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题
修复图片过大的问题
部分图片的宽度会过大,导致图片加载不全的问题,这在预览的情况下也存在
因为需要加上样式限定
img {max-width: 100%;max-height: 100%;vertical-align: middle;height: auto !important;width: auto !important;margin: 10px 0;}
这样就正常啦
故此需要在导出 pdf 前,给元素添加一个图片的样式限定
element =`<style>img {max-width: 100%;max-height: 100%;vertical-align: middle;height: auto !important;width: auto !important;margin: 10px 0;}</style>` + element;
完整代码:
<template><divclass="container"><button @click="generatePDF">下载PDF</button></div></template><scriptsetup>import html2pdf from 'html2pdf.js'async function convertImagesToBase64(htmlString) {// 创建一个临时DOM元素来解析HTMLconst tempDiv = document.createElement('div');tempDiv.innerHTML = htmlString;// 获取所有图片元素const images = tempDiv.querySelectorAll('img');// 遍历每个图片并转换for (const img of images) {try {const base64 = await getBase64FromUrl(img.src);img.src = base64;} catch (error) {console.error(`无法转换图片 ${img.src}:`, error);// 保留原始URL如果转换失败}}// 返回转换后的HTMLreturn tempDiv.innerHTML;}// 图片转base64function getBase64FromUrl(url) {return new Promise((resolve, reject) => {const img = new Image();img.crossOrigin = 'Anonymous'; // 处理跨域问题img.onload = () => {const canvas = document.createElement('canvas');canvas.width = img.width;canvas.height = img.height;const ctx = canvas.getContext('2d');ctx.drawImage(img, 0, 0);// 获取Base64数据const dataURL = canvas.toDataURL('image/png');resolve(dataURL);};img.onerror = () => {reject(new Error('图片加载失败'));};img.src = url;});}// 使用示例let element = `<div><img src='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500' style="width: 300px;" /><p>职业:前端</p><p>技能:唱、跳、rap</p></div>`;function generatePDF() {element =`<style>img {max-width: 100%;max-height: 100%;vertical-align: middle;height: auto !important;width: auto !important;margin: 10px 0;}</style>` + element;convertImagesToBase64(element).then(convertedHtml => {// 配置选项const opt = {margin: 10,filename: '前端大法好.pdf',image: { type: 'jpeg', quality: 0.98 },html2canvas: { scale: 2 },jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }};// 生成PDFhtml2pdf().from(convertedHtml).set(opt).save();}).catch(error => {console.error('转换过程中出错:', error);});}</script>
3.31 更新
前端和后端联合解决方案
评论区有位热心市民 一名爱小惠的前端[2]提到:就让后端做,你在前端渲染出来后,让它提供一个接口,你在把 innerHTML 发给它(浏览器自动补全了)
基于这个思路,我尝试了前端补全 html,提交到后端,后端则可顺利 pdf 流
// 使用示例,纯在标签闭合不完整的情况let element = `<div><imgsrc='http://t13.baidu.com/it/u=2041049195,1001882902&fm=224&app=112&f=JPEG?w=500&h=500'style="width: 300px;" ><p>职业:前端</p><p>技能:唱、跳、rap</p</div`;function generatePDF() {element =`<style>img {max-width: 100%;max-height: 100%;vertical-align: middle;height: auto !important;width: auto !important;margin: 10px 0;}</style>` + element;const tempDiv = document.createElement('div');tempDiv.innerHTML = element;// 获取到完整的html结构,提交给后端处理console.log(tempDiv.innerHTML);}
后端解决方案
使用com.itextpdf.html2pdf解决
<dependency><groupId>com.itextpdf</groupId><artifactId>html2pdf</artifactId><version>5.0.2</version></dependency>
闹腾一圈下来,原来后端也有自己的 html2pdf 库,不过这个库不需要将图片转成base64,但需要设置中文字体,不设置的话中文将解析异常
后话
前天提的需求,昨天兜的底,今天写的文章记录
这种问题,理应该后端处理,但后端和我吐槽过他处理起来的困难与问题,寻求前端帮助时,我也会积极配合。可在现实中,我遇过很多后端,死活不愿意配合前端,例如日期格式化、数据 id 类型 bigint 过大不字符化返回给前端等等,主打一个本着前端可以做就前端做的原则,说实在:属实下头
前后端本应该就是相互打配合的关系,谁方便就行个方便,没必要僵持不下
今天的分享就到此结束,如果你对技术 / 行业交流有兴趣,欢迎留言讨论哦!