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

需求交代

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

html 复制代码
<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>

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

html 复制代码
<div v-html='articleContent'></div>

整个需求已经交代清楚

这个需求有点为难后端了

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

html 复制代码
<h1>前端人</h1>
<p>学好前端,走遍天下都不怕</p>
<div>前端强,前端狂,交互特效我称王!</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...

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

效果很不错,技术用到了jspdfhtml2canvas这两个第三方库,代码十分简单

js 复制代码
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元素渲染

如果你尝试将打印的元素设置样式:

css 复制代码
display: none;

css 复制代码
visibility: hidden;

css 复制代码
opacity: 0;

执行导出功能都将抛异常或者只能导出一个空白的pdf

这时候有人会问了:为什么要设置dom元素为不可见?

试想一下,你做了一个导出功能,总不能让客户必须先打开页面等html渲染完后,再导出吧?

客户的理想状态是:在列表的操作列里,有个导出按钮,点击就可以导出pdf了

何况还需要实现批量勾选导出的功能,总不能程序控制,导出一个pdf就open一个窗口渲染html吧

寻找新方法

此路不通,就只能重新寻找新的方向,不过也没费太多功夫,就找到了另外一个插件html2pdf.js解决了这事

这插件用起来也极其简单

bash 复制代码
npm install html2pdf.js
html 复制代码
<template>
  <div class="container">
    <button @click="generatePDF">下载PDF</button>
  </div>
</template>
<script setup>
import html2pdf from 'html2pdf.js'

// 使用示例
let element = `
  <h1>前端人</h1>
  <p>学好前端,走遍天下都不怕</p>
  <div>前端强,前端狂,交互特效我称王!</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中包含图片信息,例如:

js 复制代码
// 使用示例
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>
`;

此时你会发现,导出来的pdf,图片占位处是个空白块

思考一下:类似案例中的图片加载方式,都是get方式的异步请求,而异步请求就会导致图片还没渲染完成,但导出的程序已经执行完成情况(最直接的观察方式就是,把这个元素放到浏览器上渲染,会发现图片也是过一会才慢慢加载完成的)

不过我不确定html2pdf.js这个插件是否会发起图片请求,但不管发不发起,导出的行为明显是在图片渲染前完成的,就导致了这个空白块的存在

问题分析完了,那就解决吧

既然图片异步加载不行,那就使用图片同步加载吧

不是吧,你问我:什么是图片同步加载?我也不晓得,这个词是我自己当下凭感觉造的,如有雷同,纯属巧合了

那我理解的图片同步加载是什么意思呢?简单来说,就是将图片转成Base64,因为这种方式,即使说无网的情况也能正常加载图片,因此我凭感觉断定,这就是图片同步加载

基于这个思路,我写了个demo

html 复制代码
<template>
  <div class="container">
    <button @click="generatePDF">下载PDF</button>
  </div>
</template>
<script setup>
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;
}

// 图片转base64
function 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链接必须是同源或者允许跨越的,否则就会存在图片加载异常的问题

修复图片过大的问题

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

因为需要加上样式限定

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

这样就正常啦

故此需要在导出pdf前,给元素添加一个图片的样式限定

js 复制代码
element =`<style>
img {
  max-width: 100%;
  max-height: 100%;
  vertical-align: middle;
  height: auto !important;
  width: auto !important;
  margin: 10px 0;
}
</style>` + element;

完整代码:

html 复制代码
<template>
  <div class="container">
    <button @click="generatePDF">下载PDF</button>
  </div>
</template>
<script setup>
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;
}
// 图片转base64
function 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>

后话

前天提的需求,昨天兜的底,今天写的文章记录

这种问题,理应该后端处理,但后端和我吐槽过他处理起来的困难与问题,寻求前端帮助时,我也会积极配合。可在现实中,我遇过很多后端,死活不愿意配合前端,例如日期格式化、数据id类型bigint过大不字符化返回给前端等等,主打一个本着前端可以做就前端做的原则,说实在:属实下头

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

今天的分享就到此结束,如果你对技术/行业交流有兴趣,欢迎添加howcoder微信,邀你进群交流

往期精彩

《你不了解的Grid布局》

《就你小子还不会 Grid布局是吧?》

《超硬核:从零到一部署指南》

《私活2年,我赚到了人生的第一桶金》

《接入AI后,开源项目瞬间有趣了😎》

《肝了两个月,我们无偿开源了》

《彻底不NG前端路由》

《vue项目部署自动检测更新》

《一个公告滚动播放功能引发的背后思考》

《前端值得学习的开源socket应用》

相关推荐
inxunoffice7 分钟前
批量在多个 PDF 的指定位置插入页,如插入封面、插入尾页
前端·pdf
木木黄木木12 分钟前
HTML5 Canvas绘画板项目实战:打造一个功能丰富的在线画板
前端·html·html5
ElasticPDF_新国产PDF编辑器13 分钟前
React 项目 PDF 批注插件库在线版 API 示例教程
javascript
豆芽81914 分钟前
基于Web的交互式智能成绩管理系统设计
前端·python·信息可视化·数据分析·交互·web·数据可视化
不是鱼14 分钟前
XSS 和 CSRF 为什么值得你的关注?
前端·javascript
顺遂时光18 分钟前
微信小程序——解构赋值与普通赋值
前端·javascript·vue.js
anyeyongzhou20 分钟前
img标签请求浏览器资源携带请求头
前端·vue.js
Captaincc29 分钟前
腾讯云 EdgeOne Pages「MCP Server」正式发布
前端·腾讯·mcp
最新资讯动态1 小时前
想让鸿蒙应用快的“飞起”,来HarmonyOS开发者官网“最佳实践-性能专区”
前端
雾岛LYC听风1 小时前
3. 轴指令(omron 机器自动化控制器)——>MC_GearInPos
前端·数据库·自动化