文件上传
上传文件慢如何解决?
文件切片
- 大文件进行分片上传 【解决了大文件上传超时和服务器的限制】
- 对文件进行切片
- 将切片传输给服务端
上传文件为什么慢?
- 文件大小超出限制,后端对单个请求大小做了限制,一般 nginx 和 server 都会做这个限制
- 大文件上传可能会导致内存溢出
- 网络原因上传失败
文件上传,如何获取到读取进度?
上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来
- 可以使用
XMLHttpRequest对象的upload属性来跟踪文件切片上传的进度 upload属性是一个XMLHttpRequestUpload对象,它有一个onprogress事件
XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(event) {
// 如果文件切片总字节数未知,则 event.lengthComputable 属性会返回 false
if (event.lengthComputable) {
// event.loaded 表示已上传的字节数 event.total 表示文件切片总字节数
const percentComplete = (event.loaded / event.total) * 100
console.log(`Uploaded ${percentComplete}%`)
}
};
xhr.open('POST', '/upload');
xhr.send(slice); // slice 是要上传的文件切片
大文件上传如何做?
大文件上传, 支持分片上传和断点续传,如果分片的上传时候,用户突然刷新了或者网络故障,不应该从头开始上传,用户体验很不好,需要支持断点续传
- 每次切片上传的时候,后端都会返回当前文件的进度,如果突然手动刷新了或者网络故障,就根据这个进度拿到后面的切片重新继续上传
- 上传的页面,每次刷新后,后端返回最近一次已经上传那些切片,前端就根据后端返回那些切片,过滤掉,继续上传后面的切片即可
- 一个大文件 切割数组 [file1,file2,file3,file4] ,后端返回前面[file1,file2]成功上传的
- 每个切片文件可以给个id,返回只需要返回成功上传的文件id即可
上传大文件时,以下几个变量会影响我们的用户体验
- 服务器处理数据的能力
- 请求超时
- 网络波动
分片上传
大致流程如下:
- 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
- 初始化一个分片上传任务,返回本次分片上传唯一标识;
- 并发传多个小的文件切片,可以大大减少上传时间
- 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件
为什么需要分片上传?
- 1、增加上传效率: 当需要上传大文件时,将整个文件作为一个单独的请求传输可能会导致较长的传输时间和较高的网络负载,通过将文件切分成较小的分片,可以将文件上传任务划分为多个独立的请求,减少单个请求的传输时间和网络负载
- 2、稳定性和可靠性,防止上传失败:如果在上传过程中出现网络故障,只需要重新上传失败的分片,避免了整个文件上传失败的情况。
何时合并切片,即切片什么时候传输完成?
当所有切片都已成功上传到服务器时,就可以进行切片合并了,
实现方式
-
当最后一个切片上传完成后,客户端向服务器发送一个请求,告诉服务器所有切片都已上传完成,可以进行切片合并了
发一个合并请求,主动通知服务端进行切片的合并
如果某一个切片出错,或者某一个切片的网络异常,如何解决?
有两种方式
- 1、重新发送这个切片请求
- 2、也可以直接抛错,然后中止运行程序,通过throw抛错,然后promise.all的时候的catch捕捉之后就"提醒网络出错,请重新发送"
// 发送切片
await Promise.all(reqList).then(res => {
}).catch(err => {
//接受到错误,在这里终止断js的进程程序,不再继续往下走了
message.error('系统错误,请重发');
throw '系统错误,请重发'
});
代码实现
react例子
// 文件上传 【文件切片】
import { useMemo, useState } from 'react'
import { createChunk, request, mergeRequest, createProgressHandler } from './utils'
type FileType = {
chunk: Blob
hash: string // 严格来说,应该要根据文件内容生成 hash
name: string
percentage: number // 进度百分比
}
const FileUpload = () => {
// 存储文件对象
const [fileData, setFileData] = useState<File>()
// 文件切片
const [fileSlice, setFileSlice] = useState<FileType[]>([])
/**
* 总的上传进度
* 将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度
*/
const uploadPercentage = useMemo(() => {
if (!fileData?.name || !fileSlice.length) return 0
const totalPercentage = fileSlice
.map((item) => item.chunk.size * item.percentage)
.reduce((acc, cur) => acc + cur)
return parseInt((totalPercentage / fileData.size).toFixed(2))
}, [fileSlice])
const handleFileChange = (e) => {
const [file] = e.target.files // 拿到文件对象
if (file) {
setFileData(file)
}
}
// 文件上传
const handleUpload = async () => {
if (fileData) {
const fileChunkList = createChunk(fileData)
const fileSlice = fileChunkList.map((item, index) => {
return {
chunk: item.file,
hash: fileData.name + '-' + index,
name: fileData.name,
percentage: 0,
}
})
const formList = fileSlice.map((item) => {
const { chunk, hash, name } = item
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('hash', hash)
formData.append('filename', name)
return formData
})
const requestList = formList.map((formData, index) => {
return request({
url: 'http://localhost:3000',
data: formData,
onProgress: createProgressHandler(fileSlice[index]),
})
})
try {
// 并发请求处理文件切片块
await Promise.all(requestList)
// 当所有切片都已成功上传到服务器时,就可以进行切片合并了
await mergeRequest({ fileName: fileData.name })
} catch (err) {
console.log(err, 'err')
}
// 文件切片,保存切片块、上传进度
setFileSlice(fileSlice)
}
}
return (
<div>
file-upload
<input type='file' id='upload-file' onChange={handleFileChange} />
<button onClick={handleUpload}>上传</button>
</div>
)
}
export default FileUpload
utils.ts
type RequestParams = {
url: string
method?: string
data: XMLHttpRequestBodyInit
headers?: HeadersInit
onProgress?: (event: ProgressEvent) => void // 监听上传进度回调
}
export function request({ url, method = 'post', data, headers = {}, onProgress }: RequestParams) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
// 监听上传进度
Object.keys(headers).forEach((key) => {
xhr.setRequestHeader(key, headers[key])
})
xhr.open(method, url)
xhr.send(data)
xhr.upload.onprogress = onProgress
// 捕获错误
xhr.onerror = (e) => {
reject('发生错误,请重试')
return
}
// 监听请求,接受响应
xhr.onreadystatechange = () => {
// readySates == 4 表示 请求已完成,且响应已就绪
// xhr.status == 200 表示请求成功
// 304 协商缓存
if ((xhr.readyState == 4 && xhr.status == 200) || xhr.status == 304) {
resolve(JSON.parse(xhr.responseText))
} else {
reject('发生错误,请重试')
}
}
})
}
// 文件上传进度
export function createProgressHandler(fileItem) {
return (event: ProgressEvent) => {
// 如果文件切片总字节数未知,则 event.lengthComputable 属性会返回 false
if (event.lengthComputable) {
// event.loaded 表示已上传的字节数 event.total 表示文件切片总字节数
const percentComplete = (event.loaded / event.total) * 100
console.log(`Uploaded ${percentComplete}%`)
fileItem.percentage = percentComplete
}
}
}
/**
* 创建切片
* @param file 文件对象
* @param size 切片大小
* @returns
*/
export function createChunk(file: File, size = 2 * 1024 * 1024) {
//两个形参:file是大文件,size是切片的大小
const chunkList = []
let cur = 0
while (cur < file.size) {
chunkList.push({
// //使用slice()进行切片,每份以size进行切片
file: file.slice(cur, cur + size),
})
cur += size
}
return chunkList
}
// 合并请求
export async function mergeRequest({ fileName }: { fileName: string }) {
await request({
url: 'http://localhost:3000/merge',
headers: {
'content-type': 'application/json',
},
data: fileName,
})
}
断点续传
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
- 前端使用 localStorage 记录已上传的切片 hash
- 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者
为什么使用断点续传
- 1、防止网络异常,当需要上传大文件时,网络环境可能不稳定,上传过程中可能会出现网络中断或上传失败的情况。如果没有断点续传机制,用户需要重新上传整个文件,耗费时间和浪费带宽资源。
- 2、用户体验:断点续传可以提供更好的用户体验。用户可以在上传过程中暂停、恢复或取消上传操作,而不必从头开始上传,节省时间和精力。
文件秒传
所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户 再次上传时会直接提示上传成功
文件秒传需要依赖上一步生成的 hash,即在 上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可
暂停上传
断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传
- 原理是使用 XMLHttpRequest 的
abort方法,可以取消一个 xhr 请求的发送
实现步骤:
- 在request请求中,保存xhr对象
- 在上传切片时,把所有上传切片的请求xhr保存到数组中
- 每当一个切片上传成功时,将对应的 xhr 从数组中删除
- 创建一个按钮,点击这个按钮的时候,调用xhr的abort方法 ,取消并清空所有正在上传的切片
恢复上传 【续传】
- 由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口
- 服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
生成 hash
计算文件的hash,为了判断文件是否存在,从而实现秒传
无论是前端还是服务端,都必须要生成文件和切片的 hash
- 之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash
webpack 的产物 contenthash 也是基于这个思路实现的
spark-md5,它可以根据文件内容计算出文件的 hash 值
对于超大文件,计算 hash 是非常耗费时间的,如何解决?
- 如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会
引起 UI 的阻塞,导致页面假死状态 - 所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
- 在 worker 中也是不允许访问 dom 的,但它提供了
importScripts函数用于导入外部脚本,通过它导入 spark-md5
文件上传一部分怎么处理?
- 当有一个分片上传失败时,就标记,给个提示重新上传,只需要上传失败的切片
- 如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始
文件交互处理
如何选择多个文件
- multiple属性
<input type="file" multiple>
- 通过files对象,拿到选中的文件对象
fileDom.files
如何选择文件夹
- 选择文件夹增加三个布尔值 , 来适配webkit内核的浏览器、火狐浏览器、opera浏览器
- IE不支持
- 子文件夹也是可以支持的,会默认给你递归出来
<input type="file" webkitdirectory mozdirectory odirectory >
如何拖拽文件和文件夹
- 拖拽api 【
ondragenter、ondragover、ondrop】
<script>
const dragContainer = document.querySelector("#dragContainer");
dragContainer.ondragenter = (e) => {
e.preventDefault();
};
dragContainer.ondragover = (e) => {
e.preventDefault();
};
// 当有东西拖拽到该容器,松手会触发, 可以拿到所有拖拽到这个容器的东西
dragContainer.ondrop = (e) => {
e.preventDefault();
const items = e.dataTransfer.items;
console.log("items", items);
for (const item of items) {
console.log("item", item);
const entry = item.webkitGetAsEntry();
console.log('entry',entry)
if (entry.isFile) {
// 处理文件,拿到file文件
entry.file((file) => {
console.log("file", file);
});
}else{
// 处理文件夹:文件夹中文件file对象
const reader = entry.createReader()
console.log('reader',reader)
reader.readEntries(entries=>{
console.log('entries',entries)
})
}
}
};
</script>
上传网络处理
如何实现多文件上传
-
1、可以把多个文件合并一次在一次请求中上传
- multipie/form-data
-
2、也可以把每一个文件,分开分别去请求服务器做单文件上传
- 需要和后端商量配合
- 一般来说,都是做单文件上传,这样可以保证每个文件请求都是隔离的,一个失败了,不会影响其他上传结果,也方便后续做进度监控
如何实现进度追踪
- fetch目前暂时不支持上传进度追踪 , 下载进度可以,上传进度不支持!
- xhr可以通过
xhr.upload.onprogress
如何实现取消上传
- xhr.abort()
- fetch可以通过AbortControll()
谈谈文件的hash
为什么客户端和服务端都需要生成hash?
hash的目的:主要是判断文件是否存在
- 避免重复上传:客户端生成哈希值可以在上传之前就判断文件是否已存在于服务器,通过这个哈希值向服务器请求,检查服务器上是否已经存在相同哈希值的文件,提高上传的效率
- 如果仅在服务端生成哈希值,则需要先上传文件,才能判断是否重复,无法提前优化上传流程
- 服务端判断文件的hash与客户端上传的hash值一样,表示已经这个切片已经上传过了,直接返回结果,这也是实现文件秒传
- 服务端校验hash: 服务端接受到文件后会计算hash值,与客户端的hash值进行对比,确保文件没被篡改,如果hash值一致,会存储文件
如何根据文件内容生成hash?
- 利用
spark-md5根据文件内容计算出唯一标识hash,服务端可根据该值判断是否已经上传,上传了部分分片,并返回给前端
hash计算耗时,如何解决
- 计算
hash耗时的问题,不仅可以通过web-workder,还可以参考React的Fiber架构,通过requestIdleCallback来利用浏览器的空闲时间计算,也不会卡死主线程
谈谈小程序是如何文件上传
-
通过
wx.chooseImage或wx.chooseVideo来让用户选择文件,拿到文件路径 -
在通过
wx.uploadFile来实现文件上传,传入文件路径、接口的url
小程序如何做文件切片
-
选择文件:用户选择完文件之后,读取文件的内容
-
选择文件:
wx.chooseFile或wx.chooseImage -
读取文件的内容:wx.getFileSystemManager().readFile
-
-
文件切片:按照一定的大小,将文件分割成多个小块切片,每个小块有固定的大小
- 读取文件内容之后fileData之后,把fileData文件的内容数据进行切片,切成多个chunk
-
上传切片:并发请求处理文件切片块