稀土掘金技术社区

这次终于轮到前端给后端兜底了🤣

需求交代

最近我们项目组开发了个互联网采集的功能,也就是后端合理抓取了第三方的文章,数据结构大致如下:

<h1>前端人</h1><p>学好前端,走遍天下都不怕</p>

数据抓取到后,存储到数据库,然后前端请求接口获取到数据,直接在页面预览

<divv-html='articleContent'></div>
Image

整个需求已经交代清楚

这个需求有点为难后端了

前天,客户说要新增一个文章的 pdf 导出功能,但就是这么一个合情合理的需求,却把后端为难住了,原因是部分数据采集过来的结构可能是这样的:

<h1>前端人</h1><p>学好前端,走遍天下都不怕</p><div>前端强,前端狂,交互特效我称王!<p>JS 写得好,需求改不了!</p><p>React Vue 两手抓,高薪 offer 到你家!<p><p>浏览器里横着走, bug 见我都绕道!</p<p>Chrome 调试一声笑, IE 泪洒旧时光!</p><span>Git 提交不留情,版本回退我最行!

仔细的人就能发现问题了,很多 html 元素存在没有完整的闭合情况

Image

但浏览器是强大的,丝毫不影响渲染效果,原来浏览器自动帮我们补全结构了

Image

可后端处理这件事就没那么简单了,爬取到的数据也比我举例的要复杂的多,使用第三方插件将 html 转 pdf 时会识别标签异常等问题,因此程序会抛异常

来自后端的建议

苦逼的后端折腾了很久,还是没折腾出来,终于他发现前端页面有个右键打印的功能,也就是:

Image

于是他说:浏览器这玩意整挺好啊,前端能不能研究研究,尝试从前端实现导出

那就研究研究

我印象中,确实有个叫vue-print-nb的前端插件,可以实现这个功能

但....... 等等,这个插件仅仅是唤起打印的功能,我总不能真做成这样,让用户另存为 pdf 吧

于是,只能另辟蹊径,终于我找到了这么个仓库:github.com/burc-li/vue…[1]

里面实现了 dom 元素导出 pdf 的功能

Image
Image

效果很不错,技术用到了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.28    const A4Height = 841.89 // A4纸宽    const pageHeight = A4Height >= A4Width * canvas.height / canvas.width ? A4Height :  A4Width * canvas.height / canvas.width    const 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>

功能正常,似乎一切都完美

Image

问题没有想的那么简单

如果我们的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,图片占位处是个空白块

Image
❝

思考一下:类似案例中的图片加载方式,都是 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元素来解析HTML  const 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如果转换失败    }  }
  // 返回转换后的HTML  return 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 链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题

修复图片过大的问题

部分图片的宽度会过大,导致图片加载不全的问题,这在预览的情况下也存在

Image

因为需要加上样式限定

img {  max-width: 100%;  max-height: 100%;  vertical-align: middle;  height: auto !important;  width: auto !important;  margin: 10px 0;}

这样就正常啦

Image

故此需要在导出 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元素来解析HTML  const 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如果转换失败    }  }
  // 返回转换后的HTML  return 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' }    };
    // 生成PDF    html2pdf().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 过大不字符化返回给前端等等,主打一个本着前端可以做就前端做的原则,说实在:属实下头

前后端本应该就是相互打配合的关系,谁方便就行个方便,没必要僵持不下

今天的分享就到此结束,如果你对技术 / 行业交流有兴趣,欢迎留言讨论哦!