文档文件、图片导出那点事

1,210 阅读7分钟

在我们日常的开发中,经常会碰到需要导出文件的需求场景,比如Excel、PDF、Word、image等等,一直都是碰到解决当下问题后,没有进行复盘总结,今天就把这点事好好罗列一下,一劳永逸!

一、Excel、Word、PowerPoint文件导出

文件格式

  1. 用于数据统计的Excel文件,主流文件格式为:.xls 和 .xlsx;

  2. 用于文档通知的Word文件,主流文件格式为:.doc和 .docx;

  3. 用于幻灯片的PowerPoint文件,主流文件格式为:.ppt 和 .pptx; 文件导出通常有三种方法

  4. a链接直接跳转文件链接进行下载

  5. 从后台API获取二进制流文件地址,进行处理;(重点介绍

  • 配置前端请求方式,以 blob的方式请求获取文件流;
  • 通过 URL.createObjectURL() 静态方法创建一个 DOMString
  • 动态创建用于文件下载的 a 标签,并赋值 herfdownload 属性,并触发该标签的点击事件;
  • 通过 URL.revokeObjectURL() 静态方法来释放之前通过 URL.createObjectURL() 创建的 URL 对象
  1. 服务端返回数据,前端自行渲染成表格并下载
### 此方法就是封装好的处理服务端返回二进制流数据的方法,

/**
 * OBJECT为axios请求体参数
 * NAME为导出文件的名称
 * TYPE为处理流数据的类型
 **/
export const blobExport = function (OBJECT, NAME = null, TYPE = '') {
    return new Promise(((resolve, reject) => {
        try {
            let url = ''
            let name = NAME
            let type = TYPE
            let method = 'get'
            let options={
                method,
                url, // 请求地址
                responseType: 'blob', // 表明返回服务器返回的数据类型,
                allData: true
            }
            if (Object.prototype.toString.call(OBJECT) === '[object Object]') {
                options = Object.assign({
                    DONT_TRANSFORM: true
                }, options, OBJECT)
            }
            axios(options).then(data => {
                const blob = new Blob([data.data], {type})
                const reader = new FileReader()
                reader.addEventListener('loadend', function () {
                    if (data.data.type !== 'application/json') {
                        const disposition=data.headers['content-disposition'].toLowerCase()
                        const startReg = /filename\*[^;=\n]*=(?:\S+'')?([^;=\n]*)/
                        const reg = /filename[^;=\n]*=([^;=\n]*)/
                        let fileName = ''
                        try {
                            fileName = name ?
                                name :
                                decodeURIComponent(
                                    disposition.match(startReg.test(disposition)?startReg:reg)[1]
                                )
                        } catch (e) {
                            fileName = ''
                        }
                        if ('download' in document.createElement('a')) { // 非IE下载
                            const elink = document.createElement('a')
                            elink.download = fileName
                            elink.style.display = 'none'
                            elink.href = URL.createObjectURL(blob)
                            document.body.appendChild(elink)
                            elink.click()
                            URL.revokeObjectURL(elink.href) // 释放URL 对象
                            document.body.removeChild(elink)
                        } else { // IE10+下载
                            navigator.msSaveBlob(blob, fileName)
                        }
                        resolve()
                    } else {
                        const result = JSON.parse(reader.result)
                        reject(result.message)
                    }
                })
                reader.readAsText(blob)
            }).catch((e) => {
                reject(e)
            })
        } catch (e) {
            reject(e)
        }
    }))
}

导出文件后缀名的定义

// 文件命名格式

// 5位数随机数
let timeStr = parseInt(Math.random(0, 1) * 1e5);

// 当前时间戳
let timeStr = new Date().toLocaleString();

// 获取headers中的文件名
let fileName = data.headers['content-disposition'].toLowerCase().split(";")[1].split("filename=")[1];

注:上述“获取headers中的文件名”为在请求接口时后台携带的文件名,若“无”则需自定义文件名。

二、导出PDF

导出PDF文件有点特殊,由后端来做比较麻烦,因为服务端要去生成一张图比较麻烦,并不是像Excel文件一下,处理数据就行。放在前端来做就比较简单了,原理就是通html2Canvas将页面的html结构转出一张图片,再通过jspdf将图片转化出一个后缀名为.pdf的文件并下载。

import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'

export default {
    install(Vue) {
        Vue.prototype.getPdf = function(title, options = { id: '#pdfDom' }) {
            html2Canvas(document.querySelector(options.id), {
                allowTaint: true,
            }).then(function(canvas) {
                let contentWidth = canvas.width
                let contentHeight = canvas.height
                let pageHeight = (contentWidth / 592.28) * 841.89
                let leftHeight = contentHeight
                let position = 0
                let imgWidth = 595.28
                let imgHeight = (592.28 / contentWidth) * contentHeight
                let pageData = canvas.toDataURL('image/jpeg', 1.0)
                let PDF = new JsPDF('', 'pt', 'a4')
                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()
                        }
                    }
                }
                PDF.save(title + '.pdf')
            })
        }
    },
}

上面的导出PDF,在正常导出一两页的情况下是没有问题的,近期碰到一个导出五十多页甚至更多页的情况,就出问题了。

问题描述:中间有些页面内容有缺失,显示不全,这个时候我一直在质疑这个方案是不是有问题,这种导出几十页上百也的工程叫一个前端来做,说实话心里是拒绝的。

解决思路:导出PDF的方案实现主要是分两部分,一步是html2canvas将html结构转换成图片(canvas.toDataURL('image/jpeg', 1))输出base64编码。二是将base64编码通过jsPdf插件生成PDF文件。所以第一步是研究第一个步骤并输出图片来看看,如果没有就查第二步,按照这个思路导出图片发现导出图片就已经出了问题,接着去啃html2canvas的文档,并没有生效,接着精简html的代码,把element的表格组件替换成原生的,奇迹出现了,正常导出了,此时成就感爆棚。提测!!!

问题描述:测试接着来个暴力测试,又出问题了,导出的PDF文档是空白。再一次质疑这个方案的可行性。

解决思路:根据表现基本可以确定是数据过长,手动的控制数据量试试可以确定超过二十多页就会出问题。百度查一下为啥会出现这个问题,后面发现果然canvas截图是有最大限制的,知道这个问题顺理成章就想到要分段截取,网上搜一篇文章就出来了下面这偏文章,撸一遍,原文如下

要实现将html文档下载成pdf,中间遇到的问题有:1、文件下载后如果没有置顶的话html2canvas会生成出黑屏。2、代码中有大片生成的echars图表,这种只能在原有的dom节点上生成canvas,进行下载,无法cloneNode()一个节点去进行操作。3、html2canvas生成图片时如果不对html2canvas进行配置可以显示图片的话,图片显示不出来。4、页面中有大量的图表(超过浏览器canvas绘制元素最大高度),剩余部分会显示显示黑屏,解决办法是超过部分再次生成canvas,并加到要生成的pdf对象中。

function f_uploadPdf() {
    handleHtml2Down(data.reportName);
}
//生成pdf
handleHtml2Down = async (fileName, pdfDom, reportBody) => {
    var pdf = new jsPDF('p', 'pt', 'a4');
    scrollTo(0, 0);
    const maxH = 16384
    let h = reportBody[0].scrollHeight
    var index = 0
    var pdfDom = pdfDom || $("#pdfDom")[0];
    while (h > 0) {
        await html2canvas(pdfDom, {
            background: "#fff",
            height: (h > maxH ? maxH : h),
            width: pdfDom.scrollWidth,
            foreignObjectRendering: true,
            allowTaint: false,
            useCORS: true,
        }).then((canvas => {
            var contentWidth = canvas.width;
            var contentHeight = canvas.height;
            //一页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 * contentHeight;
            var pageData = canvas.toDataURL('image/jpeg', 1);
            //有两个高度需要区分,一个是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();
                    }
                }
            }
            h = h - maxH;
            index++;
            pdfDom.style.height = h;
            reportBody[0].style.marginTop = `-${index * maxH}px`
        }))
    }
    reportBody[0].style.marginTop = '0px'
    pdf.save(fileName+".pdf");
}

撸完示例文章后,改造的方法,做了一些微调。由于按照上面的方法会出现分页的上下页之间会出现几条数据的缺失,猜测是pdf.addImage时出现了几条数据被覆盖,但没找到解决办法,微调了maxH的高度,欢迎各位大佬指点指点

Vue.prototype.getNewPDF = async (title, pdfDom, reportBody) => { 
    let pdf = new JsPDF('p', 'pt', 'a4')
    scrollTo(0, 0)
    const maxH = 16100
    let h = reportBody.scrollHeight
    pdfDom.style.height = maxH
    window.console.log(h, maxH)
    let index = 0
    while (h > 0) {
        const opt = {
            height: (h > maxH ? maxH : h),
            width: pdfDom.clientWidth,
            allowTaint: false,
            useCORS: true
        }
        window.console.log(opt)
        await html2Canvas(pdfDom, opt).then((canvas) => {
            let contentWidth = canvas.width
            let contentHeight = canvas.height
            window.console.log(canvas)
            // 一页pdf显示html页面生成的canvas高度
            let pageHeight = contentWidth / 592.28 * 841.89
            // 未生成pdf的html页面高度
            let leftHeight = contentHeight
            // 页面偏移
            let position = 0
            // a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
            let imgWidth = 595.28
            let imgHeight = (592.28 / contentWidth) * contentHeight
            let pageData = canvas.toDataURL('image/jpeg', 1)
            // window.console.log(pageData)
            // 有两个高度需要区分,一个是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
                    //避免添加空白页
                    pdf.addPage()
                }
            }
            h = h - maxH
            index++
            pdfDom.style.height = h
            reportBody.style.marginTop = `-${index * maxH}px`
            // pdfDom.style.overflow = 'hidden'
        })
    }
    reportBody.style.marginTop = '0px'
    pdfDom.style.height = '100%'
    pdf.save(title+'.pdf')
}

问题虽然解决了,但实现得不是很完美,还留有上面描述的问题,会出现某些页面数据没充满整页。通过这个问题自己确实也成长不少,但这种方案确实不是一个很好的方案:一是用户点击下载后可能需要等待超过10s。二是因为数据量大,电脑性能差点的机子可能承受不了。这是产品设计不合理的地方,导出Excel挺好的一种方案,遇到这种类似根源性的问题应当说no,采用这样的方案即使解决了后续上线大概率还是会出问题

三、导出图片

//下载图片地址和图片名
downloadIamge(imageUrl) {
    var image = new Image()
    // 解决跨域 Canvas 污染问题
    image.setAttribute('crossOrigin', 'anonymous')
    image.onload = function() {
        var canvas = document.createElement('canvas')
        canvas.width = image.width
        canvas.height = image.height
        var context = canvas.getContext('2d')
        context.drawImage(image, 0, 0, image.width, image.height)
        var url = canvas.toDataURL('image/png') //得到图片的base64编码数据

        var a = document.createElement('a') // 生成一个a元素
        var event = new MouseEvent('click') // 创建一个单击事件
        a.download = 'qrcode' // 设置图片名称
        a.href = url // 将生成的URL设置为a.href属性
        a.dispatchEvent(event) // 触发a的单击事件
    }
    image.src = imageUrl  // 图片链接
    this.qrcodeShowFlag = false
}

to C的业务通常会生成各种炫酷样式的海报用来在微信及朋友圈传播,这个场景就可以使用html2Canvas将画好的精美的html结构转出图片

## 传入指定的ID获取对应的页面接口
html2Canvas(document.querySelector(options.id), {
    allowTaint: true,
}).then(function(canvas) {
    let pageData = canvas.toDataURL('image/jpeg', 1.0)
    ## 到这里,就获取到了图片的base64编码,可以通过接口发送的服务端,返回一个图片链接 
    ## 紧接着调用 downloadIamge 方法将图片下载到本地
})

注: 小编之前试过在前端画这种海报,但图片质量不是很高。如果是混合开发的话可以借助客户端的能力来生成,这样就可以解决。当海报尺寸达到一定的程度时客户端也难以满足时,可以通过搭个node服务来解决