前端必学 - 大文件上传如何实现

news2025/7/8 15:08:40

前端必学 - 大文件上传如何实现

  • 写在前面
  • 问题分析
  • 开始操作
    • 一、文件如何切片
    • 二、得到原文件的hash值
    • 三、文件上传
    • 四、文件合并
  • 技术点总结【重要】
    • 一、上传文件?
    • 二、显示进度
    • 三、暂停上传
    • 四、Hash有优化空间吗?
    • 五、限制请求个数
    • 六、拥塞控制,动态计算文件切片大小
  • 演示&源码

写在前面

1、正常的向后端发送请求,常见的 getpost 大家都很熟悉,是没有任何问题的;我们也可以用 post 或者表单请求发送 file文件 到后端。 但是大文件的上传是一个特殊的情况: 大文件上传最主要的问题就在于:在一个请求中,要上传大量的数据,导致整个过程会比较漫长,且失败后需要重头开始上传。

  • 首先是上传过程时间比较久(要传输更多的报文,丢包重传的概率也更大),在这个过程中不能做其他操作,用户不能刷新页面,只能耐心等待请求完成。
  • 常见的软件应用中,前端/后端都会对一个请求的时间进行限制,那么大文件的上传就会很容易超时,导致上传失败。
  • 上传失败只能从头再来,你能接受吗?

2、面试/实际工作中,这也是一个常见的问题;所以,我们今天来彻底搞懂它。

源代码:https://github.com/Neveryu/bigfile-upload

问题分析

如果我们将这个文件拆分,将一次性上传大文件拆分成多个上传小文件的请求,因为请求是可以并发的,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样不就可以解决大文件上传的问题了!

【明确目标】大文件上传需要实现下面几个需求:

  • 支持拆分上传请求(即文件切片)
  • 支持断点续传
  • 支持显示上传进度和暂停上传

开始操作

一、文件如何切片

用户选择了一个大文件后,我们该如何处理它?

JavaScript 中,文件 File 对象是 Blob 对象的子类,Blob 对象包含一个重要的方法 slice,通过这个方法,我们就可以对二进制文件进行拆分。

 // 生成文件切片
function createFileChunk(file, size = SIZE) {
  const fileChunkList = []
  let cur = 0
  while (cur < file.size) {
    fileChunkList.push({
      file: file.slice(cur, cur + size),
    })
    cur += size
  }
  return fileChunkList
}

将文件拆分成 size 大小(可以是100k、500k、1M…)的分块,得到一个 file 的数组 fileChunkList,然后每次请求只需要上传这一个部分的分块即可。服务器接收到这些切片后,再将他们拼接起来就可以了。

二、得到原文件的hash值

拿到原文件的 hash 值是关键的一步,同一个文件就算改文件名,hash 值也不会变,就可以避免文件改名后重复上传的问题。

这里,我们使用 spark-md5.min.js 来根据文件的二进制内容计算文件的 hash

说明:考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互。

由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5。

计算 hash 代码如下:

// public/hash.js
self.onmessage = e => {
	const { fileChunkList } = e.data
	const spark = new self.SparkMD5.ArrayBuffer()
	let percentage = 0
	let count = 0
	const loadNext = index => {
		const reader = new FileReader()
		reader.readAsArrayBuffer(fileChunkList[index].file)
		reader.onload = e => {
			count++
			spark.append(e.target.result)
			if (count === fileChunkList.length) {
				self.postMessage({
					percentage: 100,
					hash: spark.end()
				})
				self.close()
			} else {
				percentage += 100 / fileChunkList.length
				self.postMessage({
					percentage
				})
				loadNext(count)
			}
		}
	}
	loadNext(count)
}

我们传入切片后的 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程。

【重要说明】spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档 spark-md5。

三、文件上传

1)验证文件是否已经在服务端存在,如果存在,那就不用上传了,相当于秒传成功。

/**
 * 返回值说明
 * shouldUpload:标识这个文件是否还需要上传
 * uploadedList: 服务端存在该文件的切片List
 */
const { shouldUpload, uploadedList } = await verifyUpload(
  container.file.name,
  container.hash
)

如果 shouldUploadfalse,则表明这个文件不需要上传,提示:秒传成功。

2)然后上传除了 uploadedList 之外的文件切片。

 /**
* 上传切片,同时过滤已上传的切片
* uploadedList:已经上传了的切片,这次不用上传了
*/
async function uploadChunks(uploadedList = []) {
  console.log(uploadedList, 'uploadedList')
  const requestList = data.value
    .filter(({ hash }) => !uploadedList.includes(hash))
    .map(({ chunk, hash, index }) => {
      const formData = new FormData()
      // 切片文件
      formData.append('chunk', chunk)
      // 切片文件hash
      formData.append('hash', hash)
      // 大文件的文件名
      formData.append('filename', container.file.name)
      // 大文件hash
      formData.append('fileHash', container.hash)
      return { formData, index }
    })
    .map(async ({ formData, index }) =>
      request({
        url: 'http://localhost:9999',
        data: formData,
        onProgress: createProgressHandler(index, data.value[index]),
        requestList: requestListArr.value,
      })
    )
  // 并发切片
  await Promise.all(requestList)
  // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
  // 切片并发上传完以后,发个请求告诉后端:合并切片
  if (uploadedList.length + requestList.length === data.value.length) {
    // ok,都上传完了,请求合并文件
    mergeRequest()
  }
}

四、文件合并

文件合并方案有这么几种。

1、前端发送切片完成后,发送一个合并请求,后端收到请求后,将之前上传的切片文件合并。
2、后台记录切片文件上传数据,当后台检测到切片上传完成后,自动完成合并。
3、创建一个和源文件大小相同的文件,根据切片文件的起止位置直接将切片写入对应位置。

我们这里采用的是第一种方案。

下面以用 node.js 的实现为例:

/**
 * 合并文件夹中的切片,生成一个完整的文件
 * @Author   Author
 * @DateTime 2021-12-30T17:41:19+0800
 * @param    {[string]}                 filePath [完整的文件路径(最终文件切片合并为一个完整的文件)]
 * @param    {[type]}                 fileHash [大文件的文件名]
 * @param    {[type]}                 size     [单个切片的大小]
 * @return   {[type]}                          [description]
 */
const mergeFileChunk = async (filePath, fileHash, size) => {
  // 所有的文件切片放在以“大文件的文件hash命名文件夹”中
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
  const chunkPaths = await fse.readdir(chunkDir)
  // 根据切片下标进行排序
  // 否则直接读取目录的获得的顺序可能会错乱
  chunkPaths.sort((a, b) => {
	return a.split('-')[1] - b.split('-')[1]
  })
  await Promise.all(
    chunkPaths.map((chunkPath, index) => {
	  return pipeStream(
		path.resolve(chunkDir, chunkPath),
		/**
		 * 创建写入的目标文件的流,并指定位置,
		 * 目的是能够并发合并多个可读流到可写流中,这样即使流的顺序不同也能传输到正确的位置,
		 * 所以这里还需要让前端在请求的时候多提供一个 size 参数。
		 * 其实也可以等上一个切片合并完后再合并下个切片,这样就不需要指定位置,
		 * 但传输速度会降低,所以使用了并发合并的手段,
		 */
		fse.createWriteStream(filePath, {
			start: index * size,
			end: (index + 1) * size
		})
	  )
	})
  )

  // 文件合并后删除保存切片的目录
  fse.rmdirSync(chunkDir)
}

服务端根据文件标识,分片顺序进行合并,合并完以后删除分片文件。

技术点总结【重要】

一、上传文件?

我们都知道如果要上传一个文件,需要把 form 标签的 enctype 设置为 multipart/form-data,同时method 必须为 post 方法。(这是最原始的方式)

那么 multipart/form-data 表示什么呢?

multipart 互联网上的混合资源,就是资源由多种元素组成,form-data 表示可以使用 HTML Forms 和 POST 方法上传文件,具体的定义可以参考 RFC 7578。

但是现在,我们很少使用这种 form 的方式了,我们都是直接使用 XMLHttpRequest 来发送 Ajax 请求。

最开始 XMLHttpRequest 是不支持传输二进制文件的。文件只能使用表单的方式上传,我们需要写一个 Form,然后将 enctype 设置为 multipart/form-data

后来 XMLHttpRequest 升级为 Level 2 之后,新增了 FormData 对象,用于模拟表单数据,并且支持发送和接收二进制数据。我们目前使用的文件上传基本都是基于 XMLHttpRequest Level 2

xhr.send(data)data 参数的数据类型会影响请求头部 content-type 的值。我们上传文件,data 的类型是 FormData,此时 content-type 默认值为 multipart/form-data在上传文件场景下,不必设置 content-type 的值,浏览器会根据文件类型自动配置

二、显示进度

我们可以通过 onprogress 事件来实时显示进度,默认情况下这个事件每 50ms 触发一次。需要注意的是,上传过程和下载过程触发的是不同对象的 onprogress 事件:上传触发的是 xhr.upload 对象的 onprogress 事件,下载触发的是 xhr 对象的 onprogress 事件。

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;

function updateProgress(event) {
  if (event.lengthComputable) {
    var completedPercent = event.loaded / event.total;
  }
}

PS 特别提醒:xhr.upload.onprogress 要写在 xhr.send 方法前面。

三、暂停上传

一个请求能被取消的前提是,我们需要将未收到响应的请求保存在一个列表中,然后依次调用每个 xhr 对象的 abort 方法。调用这个方法后,xhr 对象会停止触发事件,将请求的 status 置为 0,并且无法访问任何与响应有关的属性。

/**
 * 暂停
 */
function handlePause() {
  requestListArr.value.forEach((xhr) => xhr?.abort())
  requestListArr.value = []
}

从后端的角度看,一个上传请求被取消,意味着当前浏览器不会再向后端传输数据流,后端此时会报错,如下,错误信息也很清楚,就是文件还没到末尾就被客户端中断。当前文件切片写入失败。

四、Hash有优化空间吗?

计算 hash 耗时的问题,不仅可以通过 web-workder,还可以参考 ReactFFiber 架构,通过 requestIdleCallback 来利用浏览器的空闲时间计算,也不会卡死主线程;

如果觉得文件计算全量 Hash 比较慢的话,还有一种方式就是计算抽样 Hash,减少计算的字节数可以大幅度减少耗时;

在前文的代码中,我们是将大文件切片后,全量传入 spark-md5.min.js 中来根据文件的二进制内容计算文件的 hash 的。

那么,举个例子,我们可以这样优化: 文件切片以后,取第一个和最后一个切片全部内容,其他切片的取 首中尾 三个地方各2各字节来计算 hash。这样来计算文件 hash 会快很多。

五、限制请求个数

解决了大文件计算 hash 的时间优化问题;下一个问题是:如果一个大文件切了成百上千来个切片,一次发几百个 http 请求,容易把浏览器搞崩溃。那么就需要控制并发,也就是限制请求个数

思路就是我们把异步请求放在一个队列里,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可。

我们通过并发数 max 来管理并发数,发起一个请求 max--,结束一个请求 max++ 即可。

【预留】

六、拥塞控制,动态计算文件切片大小

【预留】

演示&源码

源代码:https://github.com/Neveryu/bigfile-upload

在这里插入图片描述

源代码:https://github.com/Neveryu/bigfile-upload

—————————— 【正文完】——————————

前端学习交流群,想进来面基的,可以加群: 685486827,832485817;
Vue学习交流 React学习交流

写在最后: 约定优于配置 —— 软件开发的简约原则

——————————【完】——————————

我的:
个人网站: https://neveryu.github.io/neveryu/
Github: https://github.com/Neveryu
新浪微博: https://weibo.com/Neveryu
微信: miracle421354532

更多学习资源请关注我的新浪微博…好吗

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

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

相关文章

Vue+element ui实现好看的个人中心

目录一、效果图二、项目结构三、界面效果和代码实现1.路由注册2.个人主页实现3.编辑弹窗按钮实现4.个人简介实现5.发贴页实现6.收藏页实现7.关注和收藏页实现四、总结一、效果图 仿照原神社区的个人中心写了个个人中心界面&#xff0c;下图分别为原神社区个人中心主页和我画的…

vue项目天地图使用

最近的项目中遇到了新的需求&#xff0c;需要在项目中使用天地图&#xff0c;因为第一次接触&#xff0c;官方的网站引用之类的也没有进行详细的介绍&#xff0c;自己去找的时候发现这部分的文章也比较少&#xff0c;有的问题也没有讲清楚&#xff0c;所以发布这篇文章分享总结…

关于将tomcat卸载干净

这学期我们开始学习Java Web技术&#xff0c;要求安装tomcat&#xff0c;我到官网上下载的时候不小心下载了最新的测试版&#xff0c;但是安装的eclipse无法配置最新班的tomcat&#xff0c;就开启了我的下载、卸载之旅&#x1f62d;&#x1f62d; 在此之前也有在网上找了很多相…

小程序怎么自定义导航栏,导航栏放图片、设置高度

今天来说一下小程序的自定义导航栏。 1、设置导航栏style为custom&#xff1a; 2、这是刷新页面&#xff0c;页面的内容就跑到了页面的顶端&#xff0c;不留丝毫间隙&#xff1a; 3、然后定义一个components&#xff0c;就是我们自定义的导航栏组件&#xff1a; &#xff…

Vue3 + Element Plus 按需引入 - 自动导入

文章目录1 前言1.1 目的1.2 最终效果2 准备工作3 按需引入3.1 安装插件3.2 修改 vite.config.ts 文件4 其他4.1 ElMessageBox 使用时报错4.1.1 Eslint 报错&#xff1a; ElMessageBox is not defined.eslint(no-undef)4.1.2 TS 报错&#xff1a; Cannot find name ElMessageBox…

html设置背景颜色以及背景图片

背景颜色 backgroud-color:transparent color transparent : 背景色透明 color : 指定背景颜色 直接设置标签的style属性&#xff08;行内样式&#xff09; 例&#xff1a;将这个段落的背景设为红色 用选择器进行设置&#xff08;内嵌样式、外链样式&#xff0…

做技术,最忌讳东张西望

又好长时间没更新&#xff0c;研二了&#xff0c;忙着做实验、写论文、发论文&#xff0c;再加上给我导做一些事情&#xff08;都习惯了&#xff0c;以前很不爽的事情&#xff0c;现在居然能这么平静的说出来&#xff09;。 但这不是我今天说的重点&#xff0c;而是另外一件事…

3 分钟掌握 Node.js 版本的区别

在我们日常开发中&#xff0c;Node.js 使用场景越来越多&#xff0c;大到服务端项目&#xff0c;小到开发工具脚本&#xff0c;所以掌握 Node.js 一些基础知识是非常有必要的。 今天主要聊一下 Node.js 中 LTS 和 Current 的区别和如何选择合适的版本。 一、版本介绍 在官网上…

vue使用jsMind(思维导图)

前言 jsMind 是一个显示/编辑思维导图的纯 javascript 类库&#xff0c;其基于 html5 的 canvas 进行设计。 我们使用它可能需要在网页上单纯的使用这种图样的效果&#xff0c;而其他交互却是自定义的&#xff0c;我这边选择的是jsMind 与 网上的一个jsmind.menu.js&#xff…

Node.js 全网最详细教程 (第一章:Node学习入门必看教程)

1&#xff1a;Node的学前必知&#xff1a; 1: 在学习node之前&#xff0c;想必你应该学习过HTML&#xff0c;CSS&#xff0c;JavaScript 2: 浏览器中的JavaScript由两部分组成&#xff1a;JS核心语法和WebAPI JS核心语法WebAPI变量&#xff0c;数据类型DOM操作循环&#xff0…

Nginx静态资源部署

目录 Nginx静态资源概述 Nginx静态资源的配置指令 listen指令 server_name指令 location指令 设置请求资源的目录root / alias index指令 error_page指令 静态资源优化配置语法 Nginx静态资源压缩实战 Gzip模块配置指令 Gzip压缩功能的实例配置 Gzip和sendfil…

geoserver发布地图服务

geoserver发布地图服务发布wmts服务发布样式发布映像服务发布要素服务发布wmts服务 新建工作空间 保存后点击工作区 将shp文件上传到服务器 发布geoserver 服务 选择数据存储-》添加新的数据存储 这时可以选择两种方式 一种是直接将整个shp文件导入&#xff0c;一种是一…

【TS】object类型

object是一个对象&#xff0c;在ts中定义对象类型的语法为&#xff1a;let 变量名 &#xff1a;object { } 在object类型中&#xff0c;对象内部定义的值是不受类型约束的&#xff0c;只要是一个object类型即可&#xff0c;例如&#xff1a; let obj : object {name : 艺术概…

HTML <span>标签

HTML 中的<span>标签被视为内联元素。它类似于 div 标记&#xff0c;但 div 标记特意用于块级元素&#xff0c;而 span 用于内联元素。它主要用于用户想要将内联元素分组到其代码结构中。HTML 中的 Span 标记用于通过使用元素类或 id 属性为特定内容提供样式。使用 HTML …

element-ui table使用type=‘selection‘复选框全禁用-全选禁用

目录 问题总结&#xff1a; 当条件数据全被禁用时&#xff0c;全选按钮也变成禁用的状态&#xff0c;而不是隐藏。有会做的小伙伴希望跟帖。谢谢&#xff01; 复选框框架&#xff1a;通过调用selectable方法&#xff0c;进行禁用复选框。 1.指定行禁用&#xff1a; 2.条件禁用&…

在Tomcat中部署web项目出现http状态-404 -未找到详细解决方案

问题描述&#xff1a; 当我们向tomcat服务器发起请求时&#xff0c;出现如下的错误状态提示–404.这个问题在开发过程中可能会经常遇到&#xff0c;所以做一个归纳总结&#xff1a; 以下的内容适用于IDEA&#xff0c;使用其他编辑器的小伙伴们需要注意区别。 情景① –> …

overflow:auto的用法和实现弹性盒横向滚动

1. 前言引入&#xff1a; overflow&#xff1a;auto含义是&#xff1a;如果高度撑开了原有设定的高度&#xff0c;那么可以添加这个属性&#xff0c;让它出现滚动条滚动显示。 举例说明&#xff1a; 我们做一个京东移动端&#xff0c;以iphone-XE分辨率为准的例子&#xff…

NavMenu导航菜单el-submenu点击事件及激活状态变化

记录多级菜单时&#xff0c;NavMenu导航菜单的一级菜单点击事件以及当前激活状态变化 原因&#xff1a; 由于项目的需求变化&#xff0c;原本是点击二级子菜单才发生跳转&#xff0c;点击子菜单后&#xff0c;el-menu组件也会执行select的方法&#xff0c;导航栏的菜单也会对应…

vue全局引入scss样式文件

在vue中全局引入非功能性的scss样式文件很简单&#xff0c;只需要在main.js文件中引入对应文件就行 import { createApp } from vue import App from ./App.vue import router from ./router import store from ./store // 全局引入样式文件 import /assets/scss/index.scss cr…

Vue3点击侧边导航栏完成切换页面内组件(WEB)

Vue3点击侧边导航栏完成切换页面组件 目录效果思路过程获取当前点击DOM并添加点击class将其它的导航未点击项isclick样式类去除完整代码导航代码显示页面代码路由设置感谢效果 点击左侧导航&#xff0c;右面页面切换。 思路 使用router-view显示点击后需要切换的组件&#xf…