大文件上传/下载

news2024/11/20 20:29:21

一、前言

大文件上传下载一直以来是前端常用且常考的热门话题。本文将分别介绍大文件上传/下载的思路和前端实现代码。

二、分片上传

整体流程

  1. 对文件做切片,选择文件后,对获取到的file对象使用slice方法可以将其按照制定的大小进行切片,通俗来讲就是分割文件(因为File对象基于Blob,Blob实例具有Blob.prototype.slice()方法;
  2. 利用spark-md5.min.js计算整个文件的全量Hash,这一步操作可以放在web worker中执行,以免造成浏览器卡顿;
  3. 将获取到的文件hash值传给后端,判断文件是否存在:
    如果存在,即满足"秒传"条件,返回上传成功。
    如果不存在,即进行“断点续传“查询是否存在该文件的切片文件,如果存在,返回切片文件的详细信息;
  4. 如果未上传则全部上传,如果上传了一段,则将剩下的文件片段进行上传(控制并发和断点续传);
  5. 上传完成后通知后端合并,后端合并结束返回文件地址。

如果不存在的话,查询是否存在该文件的切片文件,如果存在,返回切片文件的详细信息。

image.png

1. 文件切片

/**
 * 开始文件切片
 * @param file
 * @param size
 * @returns {*[]}
 */
const CHUNK_SIZE = 1024 * 50;   //50KB 指定切片大小
function splitFile(file, size = CHUNK_SIZE) {
  const fileChunkList = []
  let curChunkIndex = 0
  while (curChunkIndex < file.size) {
    const chunk = file.slice(curChunkIndex, curChunkIndex + size)
    fileChunkList.push(chunk)
    curChunkIndex += size
  }
  return fileChunkList
}

2. 判断文件是否存在(全量校验)

web worker使用示例:

//主线程代码
const worker  = new Worker('worker.js') //创建worker,worker.js是你要执行的脚本路径同级目录下直接写名字
worker.postMessage(files) //将得到的文件对象传递给worker线程
worker.onmessage = (e)=>{ // 接受worder传递回来的参数
  console.log(e.data) //参数在e.data中
}

// worker.js  worker文件代码
onmessage = (e)=>{
  const files = e.data //拿到文件对象后既可以执行相关切片操作了
} 

我们这里切片还是在主线程进行,只是将文件是否存在的校验放到了web worker中

// 计算hash值
const chunkList = splitFile(file) //上面分割完的文件切片
function calculateHash(chunkList) {
  return new Promise(resolve => {
    const woker = new Worker('/hash.js')
    woker.postMessage({chunkList})
    woker.onmessage = e => {
      const {hash} = e.data;
      if (hash) {
        resolve(hash)
      }
    }
  })
}

hash.js web worker,需要引入spark-md5.min.js分片计算文件的md5值
注意:

  • 每个文件的md5值都是唯一的,spark.end()就是文件的md5值.
  • 由于浏览器有并发请求数限制,如果所有请求同时发出,超出限制的请求将会进行等待,可能会超时,所以我们要对切片上传做并发池控制
/**
 * 创建web worker 进行文件校验计算hash值
 * @param e
 */
self.onmessage = e => {
  self.importScripts("/spark-md5.min.js");
  const { chunkList } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;
  const loadNext = index => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(chunkList[index]);
    reader.onload = e => {
      count++;
      spark.append(e.target.result);
      if (count === chunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / chunkList.length;
        self.postMessage({
          percentage: Number.parseFloat(percentage).toFixed(2)
        });
        // calculate recursively
        loadNext(count);
      }
    };
  };
  loadNext(0);
};

3. 切片上传

    // 获取已经上传了多少片段  hash:整个文件的md5值
    const uploadRes = await getUploadedPartList(hash)
    let fileData = fileChunkList.map((file, index) => ({
        fileHash: hash, // 文件唯一hash值
        hash,
        file, // 文件bolb
        index,
        size: file.size, // 文件大小
        percentage: uploadRes.data.includes(index) ? 100 : 0
      })
    )
	// 开始上传片段
    let targetRes = await uploadChunks(uploadRes.data, hash, fileData, file, loaded => {
      onProgress(parseInt((loaded / file.size).toFixed(2)))
    })
    resolve(targetRes)

// 开始上传切片 返回上传进度
/**
 * @param uploadList 已上传的切片
 * @param hash       整个文件的md5值
 * @param fileData   文件切片
 * @param fileOriginalData  文件源数据
 * @param onProgress  进度回调函数
 * @returns {Promise<any>}
 */
async function uploadChunks(uploadList = [], hash, fileData = [], fileOriginalData, onProgress) {
  return new Promise(async (resolve, reject) => {
      let pool = []//并发池
      let max = 3 //最大并发量
      let finish = 0//完成的数量
      let failList = []//失败的列表
      
      //获取未上传的切片数组 并格式化切片数据
      const requestList = fileData.filter(({index}) => !uploadList.includes(index)).map(({file,fileHash, index}) => {
      const formData = new FormData()
      formData.append('file', file)
      formData.append('uploadId', fileHash)
      formData.append('partNumber', index)
      return {formData}
    })
    
    // 控制并发和断点续传
    for(let i=0;i<requestList.length;i++){
      let item = requestList[i]
      let task = axios.post('xxxxx',{params: item.formData})
      let task = axios({
          url: 'xxxx',
          method: 'POST',
          data: item.formData,
          isUpload: 1,
          //监测切片上传进度
          onUploadProgress: e => {
            fileData[index].percentage = parseInt(String((e.loaded / e.total) * 100))
            const loaded = fileData.map(i => i.size * i.percentage).reduce((acc, cur) => acc + cur);
            onProgress(loaded)
          },
      }))
      task.then(()=>{
          //请求结束后将该Promise任务从并发池中移除
          let index = pool.findIndex(t=> t===task)
          pool.splice(index)
      }).catch(()=>{
          failList.push(item)
      }).finally(()=>{
          finish++
          //所有请求都请求完成,通知后端合并文件
          if(finish===list.length){
              mergeRequestList()
          }
      })
      pool.push(task)
      if(pool.length === max){
          //每当并发池跑完一个任务,就再塞入一个任务
          await Promise.race(pool)
      }
  }
}

// 上传完成后通知后端合并文件
function mergeRequestList(hash, file) {
  return new Promise(async (resolve, reject) => {
    let {data} = await axios({
      url:'xxx',
      method: 'POST',
      data,
      isUpload: 0
    })
    if (data.success) {
      Message.success('上传成功!')
      resolve(data.data)
    } else {
      reject(data.message)
    }
  })
}
/**
 * 自定义axios
 * @param url
 * @param method
 * @param data
 * @param isUpload
 * @param onUploadProgress
 * @returns {Promise<any>}
 */
function request({url, method = 'post', data, isUpload, onUploadProgress = e => e}) {
  const service = axios.create({
    baseURL,
    timeout: 0,
    onUploadProgress,
    headers: {
      'Content-Type': 'application/json'
    },
  })
  if (isUpload) {
    axios.defaults.headers.post['Content-Type'] = 'multipart/form-data'
    axios.defaults.headers.post['UpLoadFile'] = '1'
  }
  service.defaults.headers.common['Authorization'] = `Bearer ${token}`
  service.interceptors.response.use(response => {
    if (response) {
      return Promise.resolve(response)
    }
  }, error => {
    return Promise.reject(error)
  })
  return service.request({
    url,
    method,
    data
  })
}


三、分片下载

原理:
服务器使用 HTTP 响应头 Accept-Ranges 标识自身支持范围请求 (partial requests)。字段的具体值用于定义范围请求的单位。
当浏览器发现Accept-Ranges头时,可以尝试继续中断了的下载,而不是重新开始。

服务端需要支持Range请求首部

Range说明

在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。
状态码为 206 Partial Content:服务器返回的是范围响应。
状态码为 416 Range Not Satisfiable : 表示请求范围不合法,客户端错误。
状态码为 200 :服务器允许忽略 Range 头部,返回整个文件\

使用方法

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
  • unit : 范围请求所采用的单位,通常是字节(bytes)。
  • range-start : 范围的起始值,一个整数。
  • range-end : 范围的结束值,可选,如果不存在,就一直延续到文件末端。

image.png

简单实现

一个url指向的资源,如果浏览器可以解析就会渲染,解析不了就直接下载,所以如果是.html/.jpg/.mp4等就会在开一个tab渲染出来,如果是.rar/.zip等文件就会直接下载。

如果需要所有资源都直接下载,不管什么媒体类型,就需要在响应头加上Content-Disposition:attachment。

app.use(async (ctx, next) => {
  fileFilter(ctx)
  await next()
})
function fileFilter(ctx){
  const url = ctx.request.url
  const p = /^\/files\//
  if(p.test(url)){
    ctx.set('Accept-Ranges', 'bytes')
    ctx.set('Content-Disposition', 'attachment')
  }
}

断点下载

整体流程如下图:

image.png

  1. 获取文件大小
    首先我们需要获取文件的总大小,从而计算分片范围进行分块下载。
// 获取待下载文件的大小
async getFileSize (name = this.fileName) {
  try {
    const res = await http.get(`/size/${name}`)
    this.fileSize = res.data.data
    return res.data.data
  } catch (error) {
    console.log({ error })
  }
}
  1. 根据文件大小和分片大小计算分片数量
    const CHUNK_SIZE = 10 * 1024 * 1024 // 一个分片10MB
    async onDownload () {
      try {
        // 根据文件大小和分片大小计算分片数量
        const fileSize = await this.getFileSize(this.fileName)
        const chunksCount = Math.ceil(fileSize / CHUNK_SIZE)
	// 使用 asyncPool 实现并发下载
        const results = await asyncPool(3, [...new Array(chunksCount).keys()], (i) => {
          const start = i * CHUNK_SIZE
          const end = i + 1 === chunksCount ? fileSize : (i + 1) * CHUNK_SIZE - 1
          return this.getBinaryContent(start, end, i)
        })
        results.sort((a, b) => a.index - b.index)
        // 根据分片结果数组构建新的 Blob 对象
        const buffers = new Blob(results.map((r) => r.data.data))
        // 文件合并与下载
        saveFile(this.fileName, buffers)
      } catch (error) {
        console.log({ error })
      }
    }

上面使用 asyncPool 实现文件分片的并发下载,该函数具体实现为:

async function asyncPool(poolLimit, array, iteratorFn) {
  const allTask = [] // 存储所有的异步任务
  const executing = [] // 存储正在执行的异步任务
  for (const item of array) {
    // 调用 iteratorFn 函数创建异步任务
    const p = Promise.resolve().then(() => iteratorFn(item, array))
    allTask.push(p) // 保存新的异步任务

    // 当 poolLimit 值小于或等于总任务个数时,进行并发控制
    if (poolLimit <= array.length) {
      // 当任务完成后,从正在执行的任务数组中移除已完成的任务
      const e = p.then(() => executing.splice(executing.indexOf(e), 1))
      executing.push(e) // 保存正在执行的异步任务
      if (executing.length >= poolLimit) {
        await Promise.race(executing) // 等待较快的任务执行完成
      }
    }
  }
  return Promise.all(allTask)
}
  1. 请求下载分片内容
    设置请求头Accept-Ranges的开始和结束,获取文件的分片内容,因为下载完文件还需要合并所有分片,所以还需要文件切片的索引值。
    /**
     * 下载分片内容
     * @param {*} start
     * @param {*} end
     * @param {*} i
     * @param {*} ifRange
     */
    async getBinaryContent (start, end, i, ifRange = true) {
      try {
        let options = {
          responseType: "blob",
        }
        // 如果需要分片下载,则加上 Range 请求头
        if (ifRange) {
          options.headers = {
            Range: `bytes=${start}-${end}`
          }
        }
        const result = await http.get(`/down/${this.fileName}`, options);
        return { index: i, data: result };
      } catch (error) {
        return {}
      }
    },
  1. 文件合并和下载
    我们获取到的文件数据是ArrayBuffer类型,这个数据是不能直接操作的,所以我们需要使用类型数组来操作它,这里我们使用Unit8Array类型数组来合并文件数据,最后通过生成BlobUrl来进行文件下载。
const saveFile = (name, buffers, mime = 'application/octet-stream') => {
    //文件合并,result为合并的文件数据
    if (!buffers.length) return
    const totalLength = buffers.reduce((acc, value) => acc + value.length, 0)
    const result = new Uint8Array(totalLength)
    let length = 0
    for (const array of buffers) {
    result.set(array, length)
    length += array.length
    }
    //文件下载
    const blob = new Blob([result], { type: mime })
    const blobUrl = URL.createObjectURL(blob)
    const a: HTMLAnchorElement = document.createElement('a')
    a.download = filename
    a.href = blobUrl
    a.click()
    URL.revokeObjectURL(blobUrl)
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/194282.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

使用matplotlib,pylab进行python绘图

一提到python绘图&#xff0c;matplotlib是不得不提的python最著名的绘图库&#xff0c;它里面包含了类似matlab的一整套绘图的API。因此&#xff0c;作为想要学习python绘图的童鞋们就得在自己的python环境中安装matplotlib库了&#xff0c;安装方式这里就不多讲&#xff0c;方…

openmmlab学习打卡1

openmmlab学习打卡1通用视觉框架 OpenMMLab通过 conda 安装通用视觉框架 OpenMMLab 基于pytorch实现 其中&#xff1a; 分类算法在 mmclassification 模块下 目标检测在 mmdetection 模块下 分割模型在 mmsegmentation 模块下&#xff08;openmmlab 2.0 版本中加入&#xff09…

洛谷P1885 Moo —— 搜索

This way 题意&#xff1a; 奶牛 Bessie 最近在学习字符串操作&#xff0c;它用如下的规则逐一的构造出新的字符串&#xff1a; S(0)S(0) S(0) moo S(1)S(0)S(1) S(0) S(1)S(0) m ooo S(0) S(0) S(0) moo m ooo moo moomooomoo S(2)S(1)S(2) S(1) S(2)S(1) m oooo S(…

无js实现拖拽边框改变大小的笔记

前言 最近刷抖音看到一款游戏"拣爱",看到这个人手动拖动的很有意思,就想着能不能前端实现,来学习学习,虽然说最终的效果没有gif图片那么好,但是也算实现了,吧… 具体原理 利用resize属性所出现的小拖拽条 再配合::-webkit-scrollbar设置拖拽区域宽度,高度,结合opac…

手动签发证书配置nginx

openssl和ssh基本用法 通过OpenSSL工具生成证书 创建私钥 openssl genrsa -des3 -out server.key 2048 注意&#xff0c;centos版本如果是CentOS Linux release 8.0.1905 (Core)版本&#xff0c;私钥长度不能设置成1024位&#xff0c;必须2048位。不然再最后启动nginx时会出…

java之数组模块

数组定义格式1.1数组概述一次性声明大量的用于存储数据的变量要存储的数据通常都是同类型数据&#xff0c;例如&#xff1a;考试成绩1.2什么是数组数组(array)是一种用于存储多个相同类型数据的存储模型1.3数组的定义格式格式一&#xff1a;数据类型[] 变量名范例&#xff1a; …

h5实现相机

什么是取景器 取景器是什么&#xff1f;取景器是相机的一个专业术语&#xff0c;在前端就是扫描拍照 取景器的实现原理 请求手机的一个媒体类型的视频轨道&#xff0c;利用一个div或者图片作为上层蒙层&#xff0c;然后在利用canvas绘制视频中某一帧的画面绘制为图片。 前期…

HTML基础知识

一个网站由两部分组成&#xff1a;前端和后端。前端主流语言目前是HTML、CSS、JS等。HTML只是描述了页面的内容&#xff08;骨架&#xff09;&#xff0c;CSS才是描述了页面的样式。HTML结构HTML标签HTML代码是由“标签”构成的&#xff0c;HTML描述了页面上有什么东西&#xf…

数字化转型导师坚鹏:银行数字化转型为什么需要融合王阳明心学

在BLM银行数字化转型方法论中&#xff0c;我之所以融合BLM模型与王阳明心学&#xff0c;作为一个工科背景并拥有多年软硬件产品研发经验的人来说&#xff0c;深刻地知道很多人利用了科技的力量做了大量的恶事&#xff0c;而不是善事&#xff0c;如黑客大量盗取、泄漏、贩卖客户…

ESLint 的一些理解

ESLint ESLint 是在 ECMAScript/JavaScript 代码中识别和报告模式匹配的工具&#xff0c;它的目标是保证代码的一致性和避免错误。 为什么要使用ESLint 有的可以帮我们避免错误&#xff1b;有的可以帮我们写出最佳实践的代码&#xff1b;有的可以帮我们规范变量的使用方式&a…

Docker入门之使用Dockerfile 构建镜像(七)

文章目录1. 前言2. Docker file 核心要点2.1 注意事项2.2 Docker file 执行流程2.3 Docker Image、Docker file、Docker Container区别2.4 Dockerfile常用保留字指令2.4.1 FROM2.4.2 MAINTAINER2.4.3 RUN2.4.4 EXPOSE2.4.5 WORKDIR2.4.6 USER2.4.7 ENV2.4.8 ADD2.4.9 COPY2.4.1…

ansible 简单使用

运行过程 1.加载自己的配置文件&#xff0c;默认/etc/ansible/ansible.cfg&#xff1b; 2.查找对应的主机配置文件&#xff0c;找到要执行的主机或者组&#xff1b; 3.加载自己对应的模块文件&#xff0c;如 command&#xff1b; 4.通过ansible将模块或命令生成对应的临时py文…

OpenMMLab 实战营打卡 - 第 一 课

OpenMMLab 实战营打卡 - 第 一 课 复习下总忘的基础知识 卷积的通道数变化 前一层特征纬度&#xff08;通道数&#xff09;决定核的通道数 当前层输出的特征纬度&#xff0c;由核的数量决定 图像尺寸变化 padding 公式&#xff1a;H′H−K12pH^{\prime}H-K12 pH′H−K12p…

电源技术中的安森美 单通道电压电平转换器件FXLP34P5X 适合便携式应用方案

电源技术中的安森美 单通道电压电平转换器件FXLP34P5X 适合便携式应用方案 &#xff1a;输入转换器电源电压为VCC1&#xff0c;输出转换器电源电压为VCC。 该器件使用1.0V至3.6V的VCC值运行&#xff0c;主要用于要求超低功耗的便携式应用。内部电路由最小量的缓冲器级组成&…

普通大学生自学 JAVA 怎样才能进大厂?

前言 可以看一下现在大厂对于Java方面的要求 阿里 百度 腾讯 从上面可以看出&#xff0c;无论是阿里、百度亦或是腾讯对于Java方面的要求是比较高的&#xff0c;可以说要求的是一个全面&#xff0c;所以想要进入大厂&#xff0c;不能操之过急&#xff0c;需要先从基础做起&am…

php报错SERVER SENT CHARSET (255) UNKNOWN

配置文件PHP.ini修改打开; extension_dir "ext"&#xff0c;修改成; extension_dir "./" ; On windows: extension_dir "自己php的存放路径\ext"2.打开extensionmsql.dll; For example, on Windows: ;extensionmsql.dll3.修改配置&#xff08…

五、Linux 用户管理常用命令

一、用户管理命令 - useradd 命令名称&#xff1a;useradd 命令所在路径&#xff1a;/usr/sbin/useradd 执行权限&#xff1a;root 功能描述&#xff1a;添加新用户 语法&#xff1a;useradd 用户名 二、用户管理命令 - userdel 命令名称&#xff1a;userdel 命令所在路…

创业青年张继群

中央广播电视总台 -专访-张继群简介&#xff1a; 张继群&#xff0c;1995年10月生&#xff0c;男&#xff0c;临沂大学硕士研究生在读&#xff0c;现临沂城投思索信息技术有限公司智慧城市事业部员工&#xff0c;作为农业专班成员主要从事网络安全、大数据等新一代信息技术的科…

nuxt3:postcss-pxtorem

一、理解postcsshttps://www.postcss.com.cn/1.1、PostCSS是一个用 JavaScript 工具和插件转换 CSS 代码的工具。1.2、增强代码可读性&#xff1a;利用从 Can I Use 网站获取的数据为 CSS 规则添加特定厂商的前缀。 Autoprefixer 自动获取浏览器的流行度和能够支持的属性&#…

如何录制电脑屏幕和声音?分享3个实用的方法,赶紧收藏

使用电脑录屏工具&#xff0c;可以帮助我们轻松录制电脑屏幕。有时候我们不仅仅需要录制电脑上的画面&#xff0c;还需要在录制画面的同时录入声音。那您知道如何录制电脑屏幕和声音吗&#xff1f;如何在录屏的时候录制电脑内部声音或者电脑外部声音&#xff1f;现在小编就给大…