前端下载文件攻略

最近做一个前端导出图片的需求,本来以为是一个很简单的场景,结果接连踩了好几个坑,记录汇总一下遇到的几个问题。

前端常见的下载场景一般有两种,一种是文件存在于cdn或云端,接口返回下载文件的url;一种是接口返回二进制文件流。

先说返回url的场景,此种场景文件一般比较小。

  • 最简洁的方案:iframe下载
    • iframe下载只适用于浏览器不能直接预览的文件类型,图片或者pdf等不适用
    const downByFrame = (data) => {
        var url = data // 文件地址
        var iframe = document.createElement('iframe')
            iframe.src = url
            iframe.style.display = 'none'
            iframe.onload = function () {
              document.body.removeAttribute(iframe)
            }
        document.body.appendChild(iframe)
    }
    
  • 最常用的方案:a标签下载
    • a标签下载需要借助download属性
    const downByA = (imageData) => {
        const linkImg = document.createElement('a')
        linkImg.style.display = 'none'
        linkImg.href = imageData
        linkImg.download = 'test.png'
        document.body.appendChild(linkImg)
        linkImg.click()
    }
    
    • download属性有同域限制和兼容性问题
      • 在chrome浏览器中同域的链接地址可以下载,非同域的会跳转链接地址
      • 大部分web端浏览器已支持该属性,部分移动端浏览器不支持,可查看mdn文档
      • download only works for [same-origin URLs], or the blob: and data: schemes.故可以利用blob对象和dataUrl来实现非同域的下载。
    • Blob + a标签下载
      const downByABolb = (imageData) => {
        fetch(imageData)
          .then((res) => {
            return res.blob()
          })
          .then((blob) => {
            return URL.createObjectURL(blob)
          })
          .then((urlBolb) => {
            return downByA(urlBolb)
          })
      }
      
      • 考虑到fetch的兼容性问题,使用XMLHttpRequest还是最常用的写法
      const downByBlob = (imageData) => {
        const xhr = new XMLHttpRequest()
        xhr.open('get', imageData)
        // 设置responseType
        xhr.responseType = 'blob'
        xhr.send()
        xhr.onload = function () {
          if (this.status === 200 || this.status === 304) {
            // 或者xhr的请求不设置responseType,在返回结果后利用下面的代码转换成blob对象
            // const blob = new Blob([this.response], { type: xhr.getResponseHeader('Content-Type') });
            // const url = URL.createObjectURL(blob);
            const url = URL.createObjectURL(this.response)
            downByA(url)
          }
        }
      }
      
    • dataUrl + a标签下载
      • 转换成dataUrl可以利用canvas.toDataUrl方法,适用于下载图片的场景,且不同的图片格式在不同的浏览器下会存在兼容性问题
      • 另外在使用canvas转图片下载时会出现图片过大转换成的dataUrl长度超出a标签长度限制导致无法下载的情况,导出文件变成了空白的txt。
      const downByData = (imageData) => {
        // type为png\jpg\jpeg时,其他格式支持性不好,chrome支持webp
        let type = 'image/png'
        let image = new Image()
        // 避免抛出安全错误,此处需设置允许跨域访问
        image.setAttribute('crossOrigin', 'anonymous')
        image.onload = () => {
          let canvas = document.createElement('canvas')
          canvas.width = image.width
          canvas.height = image.height
      
          let ctx = canvas.getContext('2d')
          ctx?.drawImage(image, 0, 0, image.width, image.height)
          const url = canvas.toDataURL(type)
          downByA(url)
        }
        image.src = imageData
      }
      
    • 其他
      • a标签的download属性与HTTP 头中的 Content-Disposition 属性都可以表示下载文件的文件名,HTTP 头属性优先于此属性。
      • a标签中常使用a.click()来触发点击事件,但无法有效控制事件冒泡,在微前端架构中也可能存在一些其他问题。此时可以使用new mouseEvent()来创建一个鼠标事件来控制是否冒泡、是否可以取消等,再通过dispatchevent触发。
          var $a = document.createElement('a')
          $a.download = 'test.png'
          $a.target = '_blank'
          $a.href = imageData
          var evt = new MouseEvent('click', {
               // some micro front-end framework, window maybe is a Proxy
               view: document.defaultView,
               bubbles: true,
               cancelable: false,
          })
          $a.dispatchEvent(evt)
      

再说返回二进制文件流的情况

在遇到大文件下载的情形,直接返回文件存储的url给前端下载,虽然可以使用webworker开辟一个下载进程,但仍然存在下载时间过长导致下载中途失败的情况。这时候一般会选择后端返回二进制文件流,前端再根据文件大小选择下载方式。

  • 非分片下载

    • 无需分片下载的文件流,可以参考blob+a标签的方式,只需要设置xhr/fetch返回的数据类型为arraybuffer。
     const handleBufferDownload = () => {
        axios({
          method: 'post',
          url: url,
          responseType: 'arraybuffer',
        }).then((res) => {
          let blob = new Blob([res.data])
          const blobUrl = URL.createObjectURL(blob)
          downByA(blobUrl)
        })
      }
    
  • 分片下载

    • 分片下载主要依赖HTTP 响应头 Accept-Ranges。相关的http头及含义可见下图。

    前端下载文件攻略

    • 大文件分片下载主要可以分为4步:根据下载文件大小进行切片请求,下载任务多线程并行操作,所有切片下载完成后进行拼装,拼装完成后下载。
      • 切片请求
        • 先根据文件大小对文件进行分割
        // Node.js代码
        async getfileSize() {
          const { ctx } = this;
          const { url } = ctx.request;
          const filepath = url.split('filepath')[1].slice(1);
          const pathname = path.resolve(this.app.config.static.dir, filepath);
          const statObj = fs.statSync(pathname);
          ctx.set('Access-Control-Allow-Origin', '*');
          ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
          ctx.set('Content-Disposition',
            `attachment;filename=${encodeURIComponent(filepath)}`);
          // 返回文件大小,前端可根据header中的filename获取文件名称
          ctx.body = { data: 'success', size: statObj.size };
        }
        // 前端一个普通的get/post请求即可,获取到文件大小后按照一定的规则进行分割
        const handleSplitDown = (filepath, fileSize) => {
            const CHUNK_SIZE = 1024 * 10 * 10
            const chunkCount = Math.ceil(fileSize / CHUNK_SIZE)
            let initList = []
            const resultList = []
            for (let i = 0; i < chunkCount; i++) {
              initList.push(i)
            }
            return new Promise((resolve, reject) => {
              return downList(initList, resultList, resolve, reject)
            })
        }
        
      • 多线程下载
        • 将大文件按照一定规则分割后,利用promise.allSettled并发多个下载请求,要有重试机制来应对下载失败的情况
        async function downList(arr, resultList, resolve?, reject?) {
          const fileList = []
          for (let i = 0; i < arr.length; i++) {
            fileList.push(downloadWithByte(filepath, arr[i] * CHUNK_SIZE, (arr[i] + 1) * CHUNK_SIZE - 1))
          }
          const failList = []
          Promise.allSettled(fileList).then((res) => {
            res.forEach((item, index) => {
            // 注意可能存在请求失败的情况,要有重试机制
              if (item.status === 'fulfilled') {
                resultList[index] = item
              } else {
                failList.push(index)
              }
              if (failList.length === 0) {
                resolve(resultList)
              } else {
                return downList(failList, resultList, resolve, reject)
              }
            })
          })
        }
        
        • 此时的后端代码要注意http header的处理
        // Node.js代码
        async downfileSplit() {
          const { ctx } = this;
          const { url } = ctx.request;
          const { range } = ctx.request.headers;
          const filepath = url.split('filepath')[1].slice(1);
          const pathname = path.resolve(this.app.config.static.dir, filepath);
          const statObj = fs.statSync(pathname);
          const total = statObj.size;
          let [ , start, end ] = range.match(/(\d*)-(\d*)/);
          console.log(ctx.request, total, start, end);
          ctx.set('Access-Control-Allow-Origin', '*');
          ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
          ctx.set('Content-Disposition',
            `attachment;filename=${encodeURIComponent(filepath)}`);
          start = start ? parseInt(start) : 0;
          end = end ? parseInt(end) : total - 1;
          ctx.status = 206;
          ctx.set('Accept-Ranges', 'bytes');
          ctx.set('Content-Range', `bytes=${start}-${end}/${total}`);
          ctx.body = fs
            .createReadStream(pathname, { start, end })
            .pipe(stream.PassThrough());
        
        }
        
      • 拼装切片
        • 所有分片都下载完成后,按照顺序拼接成blob
        handleSplitDown(filepath, fileSize).then((res) => {
            let blob = new Blob(res.map((r) => r.value.data))
            const blobUrl = URL.createObjectURL(blob)
            downByA(blobUrl)
        })
        
      • 下载
        • 获取到blob之后,利用a标签即可实现下载

参考链接

原文链接:https://juejin.cn/post/7231095643572420668 作者:Ambber

(0)
上一篇 2023年5月10日 上午10:26
下一篇 2023年5月10日 上午10:36

相关推荐

发表回复

登录后才能评论