Vue中如何批量导出文件(图片/PDF)并打包压缩成ZIP

最近遇到了一个需求:
1.前端根据后端提供的图片url批量下载,并压缩成zip包
2.根据后端提供的数据批量生成pdf文件,并压缩成zip包
本文记录了这个需求的实现过程

1.批量下载图片并打包压缩

思路
因为图片是静态资源,根据url直接获得二进制数据,然后压缩成zip格式就行了
利用到的插件:JSZip和FileSaver
将图片的链接和名字改成下面这种形式的数组,注意
1.title不能重复,否则不会正常生成期望数量的图片
2.title要带正确的图片格式后缀,否则会导致文件打不开
数组参考如下:

const downArray = [
  {
    title: '小猫.jpg',
    href: 'https://pic.com/pic1'
  },
  {
    title: '小猪.jpg',
    href: 'https://pic.com/pic2'
  }
]
// 批量下载并压缩图片
this.downImg(downArray);

downImg和getImgArrayBuffer函数代码如下:

//通过url 转为blob格式的数据
getImgArrayBuffer(url) {
      let _this = this;
      return new Promise((resolve, reject) => {
        //通过请求获取文件blob格式
        let xmlhttp = new XMLHttpRequest();
        xmlhttp.open("GET", url, true);
        xmlhttp.responseType = "blob";
        xmlhttp.onload = function () {
          if (this.status == 200) {
            resolve(this.response);
          } else {
            reject(this.status);
          }
        };
        xmlhttp.send();
      });
},
// imgDataUrl 数据的url数组
downImg(imagesParams) {
      let _this = this;
      let zip = new JSZip();
      let cache = {};
      let promises = [];
      _this.title = "正在加载压缩文件";

      for (let item of imagesParams) {
        const promise = _this.getImgArrayBuffer(item.href).then((data) => {
          // 下载文件, 并存成ArrayBuffer对象(blob)
          zip.file(item.title, data, { binary: true }); // 逐个添加文件
          cache[item.title] = data;
        });
        promises.push(promise);
      }

      Promise.all(promises)
        .then(() => {
          zip.generateAsync({ type: "blob" }).then((content) => {
            _this.title = "正在压缩";
            // 生成二进制流
            FileSaver.saveAs(
              content,
              `服务确认函-${dayjs(new Date()).format("YYYY-MM-DD")}`
            ); // 利用file-saver保存文件  自定义文件名
            _this.title = "压缩完成";
          });
        })
        .catch((res) => {
          _this.$message.error("文件压缩失败");
        });
},

2.批量生成PDF并打包压缩

2.1. 踩坑记录

本来是打算生成PDF这一步是前端根据接口返回的list数据生成的,但是尝试了几个方案,都是有很大的缺点
方案一:html2canvas+jspdf

npm install --save html2canvas  // 页面转图片
npm install jspdf --save  // 图片转pdf

网上出现最多的就是这个前端导出pdf的方法,先html2canvas 将html元素转换为图片,然后利用jspdf将图片转为pdf
版本一:(只能导出一页图片内容)

const canvas2PDF = (canvas) => {
  // 原版
  let contentWidth = canvas.width;
  let contentHeight = canvas.heigh
  //a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
  let imgWidth = 595.28;
  let imgHeight = (592.28 / contentWidth) * contentHeigh
  // 第一个参数: l:横向  p:纵向
  // 第二个参数:测量单位("pt","mm", "cm", "m", "in" or "px")
  let pdf = new jsPDF("p", "pt"
  pdf.addImage(
    canvas.toDataURL("image/jpeg", 1.0),
    "JPEG",
    0,
    0,
    imgWidth,
    imgHeight
  );
  pdf.save("导出.pdf");
};
html2canvas(this.$refs.pdf).then(function (canvas) {
  // page.appendChild(canvas);
  canvas2PDF(canvas);
});

版本二:(可以导出多页)

var contentWidth = canvas.width;
var contentHeight = canvas.heig
//一页pdf显示html页面生成的canvas高度;
var pageHeight = (contentWidth / 592.28) * 841.89;
//未生成pdf的html页面高度
var leftHeight = contentHeight;
//页面偏移
var position = 0;
//a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
var imgWidth = 595.28;
var imgHeight = (592.28 / contentWidth) * contentHeig
var pageData = canvas.toDataURL("image/jpeg", 1.
var pdf = new jsPDF("", "pt", "a4
//有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
//当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
  pdf.addImage(pageData, "JPEG", 0, 0, imgWidth, imgHeight);
} else {
  while (leftHeight > 0) {
    pdf.addImage(pageData, "JPEG", 0, position, imgWidth, imgHeight);
    leftHeight -= pageHeight;
    position -= 841.89;
    //避免添加空白页
    if (leftHeight > 0) {
      pdf.addPage();
    }
  }
}

但是上面的两个版本都会很大的局限性,因为是导出图片的原因,导致导出的文件体积很大,而且清晰度不高,版本二即使支持分页,经过试验,最多也就只能导出40页,再多就全是空白页了。另外还有很大的缺点,就是导出的内容无法复制,这就失去了pdf文件的意义了。

有个vue插件vue-html2pdf也可以实现将html元素导出为pdf,但也是图片形式的,和html2canvas+jspdf效果是一样的,这里就不再赘述

可以直接使用jspdf插件将html元素导出为pdf文件,试过试验,发现是可以的,但是存在着更巨大的缺陷,首先是中文会乱码,这个貌似是可以全局写入字体解决的,但是导出的pdf内容每个元素都需要通过坐标去手动定位,这个太麻烦了,示例代码:

var pdf = new jsPDF("", "pt", "a4");
pdf.text('hello world !', 10, 10);
pdf.text('哈哈哈 !', 40, 40);
pdf.text('哈哈哈 !', 80, 80);
pdf.text('哈哈哈 !', 160, 160);
pdf.text('哈哈哈 !', 220, 220);
pdf.addPage();
pdf.save("content.pdf");

导出效果如下:


效果示意图.png

2.2. 解决方案

至此,只能优先考虑寻找后端解决方案了,最终确定的方案是后端通过Freemarker模板引擎生成pdf(体积小,文字形式的),模板语法的常见用法记录如下

<#-- 如果值为null/空,则设置为空值 -->
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Freemarker</title>
    <style type="text/css">
        #all {
            width: 600px;
            font-family: SimSun;
        <#-- 使文档在pdf页面居中 --> margin: auto;
        }

        table {
            width: 100%;
            height: auto;
        <#-- @@提醒@@:此处必须指定字体,不然不识别中文  --> font-family: SimSun;
            text-align: center;
        <#-- table中单元格自动换行 --> table-layout: fixed;
            word-wrap: break-word;
        }
    </style>
</head>
<body>
<div id="all">
    <#if model.businessTypes??>
            <#if model.businessTypes?size??>
                <table  border="1" cellspacing="0" cellpadding="0" align="left" style="margin-top: 10px;">
                    <#list model.businessTypes as typeI>
                        <tr>
                            <#list typeIs as typeInner>
                                <td colspan="2">
                                    <#if typeInner !='Empty'>
                                        <span class="label">${ typeInner?split(':')[0] }:</span>
                                        <span class="content">${ typeInner?split(':')[1] }</span>
                                    </#if>
                                </td>
                            </#list>
                        </tr>
                    </#list>
                </table>
            </#if>
    </#if>
</div>
</body>
</html>

和后端接口约定好,浏览器通过二进制流数据的形式拿到pdf数据,然后将刚才下载打包图片的downImg函数改造成downBlob函数,把拿到的二进制数据直接塞进promise,然后生成压缩包,需求顺利实现。

const blob = new Blob([res], { type: "application/pdf" });
// 从响应头的content-disposition获取文件名称
const fileName = decodeURI(resALL.headers["content-disposition"]).split('filename=')[1] || `检查报告-${dayjs(new Date()).format("YYYY-MM-DD")}.pdf`;
this.PDFOriginObject.push({
  title: fileName,
  href: blob,
});

_this.downBlob(_this.PDFOriginObject);

downBlob(imagesParams) {
      let _this = this;
      let zip = new JSZip();
      let cache = {};
      let promises = [];
      _this.title = "正在加载压缩文件";

      for (let item of imagesParams) {
        const promise = new Promise((resolve) => {
          resolve();
        }).then(() => {
          zip.file(item.title, item.href, { binary: true });
          cache[item.title] = item.href;
        });
        promises.push(promise);
      }

      Promise.all(promises)
        .then(() => {
          zip.generateAsync({ type: "blob" }).then((content) => {
            _this.title = "正在压缩";
            // 生成二进制流
            FileSaver.saveAs(
              content,
              `质检报告-${dayjs(new Date()).format("YYYY-MM-DD")}`
            ); // 利用file-saver保存文件  自定义文件名
            _this.title = "压缩完成";
          });
        })
        .catch((res) => {
          _this.$message.error("文件压缩失败");
        });
},

注意点一:
1.前端接口请求的responseType设置为blob才能拿到二进制流数据
2.如果是想要在拿到的流数据的时候还要拿到后端返回的返回文件名称,则要在响应拦截器service.interceptors.response中将整个response都返给接口进行处理,这样才能拿到响应头的content-disposition
3.后端在content-disposition就算返回了文件名字,但是因为安全问题我们只能在network中看到,无法通过js在请求头中拿到,需要后端增加一段代码

response.setHeader("Access-Control-Expose-Headers","Content-Disposition");

4.后端添加的content-disposition是经过URLEncoder处理的,js还要通过decodeURI处理一下
5.拿pdf流数据的接口因为需要轮询请求,接口只有在能生成pdf数据的时候返回blob二进制流数据,其他还约定了一些json格式的返回数据,需要前端进行处理,而这时候因为接口的responseType已经设置成了blob,js已经无法拿到正常的json了,我用的解决方案是根据size进行判断,因为json格式的返回的数据转换为blob后size最多只有几百,而pdf文件的size最少有一两万

if (res.size < 1000) {
  const reader = new FileReader();
  reader.readAsText(res, "utf-8");
  reader.onload = function () {
    _this.resObj = JSON.parse(reader.result);
    if (_this.resObj && _this.resObj.code === 0) {
      ....some code
    }else if (_this.resObj && _this.resObj.code === 99999){
      ....some code
    }
  }
}else{
  ....some code
}

6.本次需求的批量导出因为耗时太长,所以是弹出新窗口进行处理的

let routeUrl = this.$router.resolve({
  path: "/taskInfo/task/patrol/record/pdfexport",
});
window.open(routeUrl.href, "_blank");

新窗口自定义一个特殊的loading图

import { Loading } from "element-ui";

this.loadingInstance = Loading.service({
  text: "导出中,请等待",
  spinner: "el-icon-loading",
  background: "rgba(255, 255, 255, 0.8)",
  fullscreen: true,
});

this.loadingInstance.close();
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容