大文件上传和下载解决方案

更新日期: 2023-12-25阅读: 928标签: 文件

前言

前端处理 “大” 一直是一个痛点和难点,比如大文件、大数据量。虽然浏览器硬件有限,但是聪明的工程师总是能够最大化利用浏览器的能力和特性,优雅的解决一个个极端问题,满足用户的多样化需求。


断点上传

对于大文件,如果我们直接上传,用户网速够慢的话,可能需要等上几天几夜才能上传完成,这样的用户体验可能导致用户直接放弃,那么有没有一种方式能够更好的上传大文件呢?

首先我们可以想到一些浏览器常见的优化套路:

多线并行处理
缓存结果
按需使用

有了优化思路,那么看看浏览器支持能力:

HTTP 1.x,浏览器可以并行处理请求,比如 Chrome 可以并行处理 6 个请求。HTTP 2.x,理论上可以无限制并行处理请求。
浏览器支持 WebWorker单独子线程来处理一些耗时任务。
HTTP 没有状态,所以我们只能将状态缓存到服务器。

浏览器也提供了支持能力,那么我们怎么把一个文件并发上传,又如何做缓存呢?

文件切割和唯一标识

我们知道,计算机底层数据都是由 0 和 1 的二进制数据构成,文件也不例外,那么我们可以按照字节数将大文件切割成一个个小文件块,然后并行上传。但是切割之后的文件块是无法标识的,所以我们需要为文件确定一个唯一标识,我们常见会使用文件名来标识文件,但是文件名是可修改的,这样的标识是非常不可靠的,所以我们会基于文件内容来做一个标识,也就是计算文件的 md5 值,这样只要文件内容不修改,文件的 md5 值就不会变化。

//【前端代码】文件切割块和计算唯一标识
const CHUNK_SIZE = 10 * 1024 * 1024;
const slice = File.prototype.slice;
// 获取文件块
function getFileChunks(size: number) {
const chunks = []
const chunkCount = Math.ceil(size / CHUNK_SIZE)
for (let i = 1; i < chunkCount; i++) {
chunks.push(i * CHUNK_SIZE)
}
if (chunkCount) {
chunks.push(size)
}
return chunks
}
// 计算 MD5 值
function computedMD5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
const chunks = getFileChunks(file.size)
let currentChunk = 0
reader.onload = (e: any) => {
spark.append(e?.target?.result)
currentChunk++
if (currentChunk < chunks.length) {
loadNext()
} else {
resolve(spark.end())
}
}
reader.onerror = (error) => {
console.error(error)
reject('computed fail')
}
function loadNext() {
const start =
(Math.ceil(chunks[currentChunk] / CHUNK_SIZE) - 1) * CHUNK_SIZE
const end = chunks[currentChunk]
reader.readAsArrayBuffer(slice.call(file, start, end))
}
loadNext()
})
}

我们通过文件大小和固定文件块大小来计算需要上传的文件块数量和每个块对应字节范围。然后使用 spark-md5库来计算文件的 md5 值,如果注意的是,如果文件较大,计算 md5 时间可能较长,所以需要利用 WebWorker来计算。

文件并行上传和缓存实现

通过对文件切块和 md5 值计算,我们可以并行的上传文件块,并缓存上传的文件块。

//【前端代码】缓存实现和并发请求
// 获取将要上传的块
async function getUploadChunks(file: File, md5: string): Promise<number[]> {
// 全部文件块
const chunks = getFileChunks(file.size)
// 已上传的文件块索引
const uploadChunks: number[] | boolean = await getChunks({
md5: md5,
filename: file.name,
})
// 秒传
if (typeof uploadChunks === 'boolean') {
return []
}
return chunks.filter((chunk, index) => {
return uploadChunks.indexOf(index) === -1
})
}
// 并发上传
async function uploadParallel(file: File, chunks: number[], md5: string) {
const res = chunks.map((chunk: number, index) => {
const formData = new FormData()
formData.append('md5', md5)
formData.append(
'chunk',
slice.call(
file,
(Math.ceil(chunks[index] / CHUNK_SIZE) - 1) * CHUNK_SIZE,
chunks[index]
)
)
formData.append('filename', file.name)
formData.append(
'index',
String(Math.ceil((chunk - CHUNK_SIZE) / CHUNK_SIZE))
)
return uploadChunk(formData)
})
Promise.allSettled(res).then(() => {
notifyCombine(file, md5)
})
}

这里我们通过一个接口来保持上传的状态。使用File.prototype.slice来进行文件切割,并通过Promise.allSettled来实现并发上传。

//【后端代码】获取上传的文件块和文件上传
const SAVE_DIR = "public";
router.get(
"/getChunks",
validQuery({
md5: {
type: "string",
required: true,
},
filename: {
type: "string",
required: true,
},
}),
(ctx) => {
const { query } = ctx.request;
const { md5, filename } = query;
// 如果存在文件,则秒传
const ext = path.extname(filename);
const bool = existsSync(`${SAVE_DIR}/${md5}${ext}`);
if (bool) {
ctx.success({
data: true,
});
return;
}
mkdirSync(`${UPLOAD_DIR}/${md5}`);
const files = [];
traverseSync(`${UPLOAD_DIR}/${md5}`, (path) => {
files.push(+path.replace(`${UPLOAD_DIR}/${md5}/`, ""));
});
ctx.success({
data: files,
});
}
);
router.post(
"/uploadChunk",
validFiles({
chunk: {
type: "boolean",
required: true,
},
}),
validBody({
md5: { type: "string", required: true },
index: { type: "string", required: true },
}),
async (ctx) => {
const { body, files } = ctx.request;
const { md5, index } = body;
await copy(files.chunk.filepath, `${UPLOAD_DIR}/${md5}/${index}`);
ctx.success({});
}
);

合并文件,文件上传校验

当服务器文件块和本地切割块一致时,则通知服务器进行文件合并。

//【前端代码】文件合并
// 通知文件合并
async function notifyCombine(file: File, md5: string) {
const chunks = await getUploadChunks(file, md5)
if (chunks.length === 0) {
await mergeChunk({
md5: md5,
filename: file.name,
})
} else {
uploadParallel(file, chunks, md5)
}
}
// 【后端代码】文件合并
router.post(
"/mergeChunk",
validBody({
md5: {
type: "string",
required: true,
},
filename: {
type: "string",
required: true,
},
}),
async (ctx) => {
const { body } = ctx.request;
const { md5, filename } = body;
const ext = path.extname(filename);
try {
const result = await mergeFile(
`${UPLOAD_DIR}/${md5}`,
`public/${md5}${ext}`
);
if (result) {
rmdir(`${UPLOAD_DIR}/${md5}`);
ctx.success({});
} else {
ctx.fail({});
}
} catch {
ctx.fail({});
}
}
);

至此,断点续传已经完成了。


断点下载

对于大文件上传,上面那节我们给了解法,那么对于大文件下载,我们应该怎么做呢?
其实原理也是一样的:利用浏览器请求并发能力和缓存能力。

获取文件信息

首先我们需要获取文件的总大小,从而进行分块下载。

// 【前端代码】
async function download() {
const filepath = '34ffeb6eac2cc74423421538b2b35d68.zip'
const res = await headDownload({
filepath: filepath,
})
const length = res?.['content-length'] as number
const filename = getFileName(res?.['content-disposition'] as string)
const chunks = getFileChunks(+length)
retryDownload(filepath, filename, chunks, [])
}

我们使用 head 请求来获取文件的大小和文件名称,从而进行分块下载。

//【后端代码】获取文件信息
router.head(
"/downloadFile",
validQuery({
filepath: {
type: "string",
required: true,
},
}),
(ctx) => {
const { query } = ctx.request;
const { filepath } = query;
const pathname = "public/" + filepath;
try {
const statObj = statSync(pathname);
ctx.set(
"Content-Disposition",
`attachment;filename=${encodeURIComponent(filepath)}`
);
ctx.body = "success";
ctx.length = statObj.size;
} catch (error) {
console.error(error);
ctx.fail({});
return;
}
}
);

分块下载和重试机制

我们利用请求头 range来进行分块下载,并添加重试机制。

//【前端代码】分块下载和重试
// 分块下载
async function downloadChunk(filename: string, start: number, end: number) {
const buffer = await downloadFile(
{
filepath: filename,
},
{
headers: { range: `bytes=${start}-${end}` },
responseType: 'arraybuffer',
}
)
return buffer
}
// 分块下载和重试
function retryDownload(
downloadPath: string,
filename: string,
chunks: number[],
result: Record<string, any>[]
) {
const list = chunks.map((chunk, index) => {
return downloadChunk(
downloadPath,
(Math.ceil(chunks[index] / CHUNK_SIZE) - 1) * CHUNK_SIZE,
chunks[index]
)
})
Promise.allSettled(list).then((res) => {
// 下载完全
const successList = res.filter((i, index) => {
if (i.status === 'fulfilled') {
result[Math.ceil((chunks[index] - CHUNK_SIZE) / CHUNK_SIZE)] = i
}
return i.status === 'fulfilled'
})
if (successList.length === list.length) {
const buffers: Uint8Array[] = (result || []).map((i) => {
return new Uint8Array(i?.value)
})
const res = mergeBlobChunk(buffers)
if (res) {
saveAs(filename, res)
}
} else {
// 下载剩余块
const failList = res.reduce((acc: number[], cur, index) => {
if (cur.status === 'rejected') {
acc.push(index)
}
return acc
}, [])
const list = chunks.filter((chunk, index) => {
return failList.indexOf(index) !== -1
})
retryDownload(downloadPath, filename, list, result)
}
})
}

我们通过一个递归函数,每次上传检测下载进度,从而完成下载重试。

// 【后端代码】分块下载
router.post(
"/downloadFile",
validBody({
filepath: {
type: "string",
required: true,
},
}),
(ctx) => {
const { headers, body } = ctx.request;
const { filepath } = body;
const { range } = headers;
const pathname = "public/" + filepath;
let statObj = {};
try {
statObj = statSync(pathname);
} catch (error) {
console.error(error);
ctx.fail({});
return;
}
if (range) {
let [, start, end] = range.match(/(\d*)-(\d*)/);
// 文件总字节数
let total = statObj.size;
// 处理请求头中范围参数不传的问题
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(PassThrough());
} else {
ctx.body = fs.createReadStream(pathname).pipe(PassThrough());
}
}
);

文件合并和下载

在获取到所有文件数据之后,我们需要对文件进行合并,并下载。

// 【前端代码】文件合并和下载
// 文件合并
function mergeBlobChunk(arrays: Uint8Array[]) {
if (!arrays.length) return
const totalLength = arrays.reduce((acc, value) => acc + value.length, 0)
const result = new Uint8Array(totalLength)
let length = 0
for (const array of arrays) {
result.set(array, length)
length += array.length
}
return result
}
// 文件下载
export function saveAs(
filename = '',
buffers: BlobPart,
mime = 'application/octet-stream'
) {
const blob = new Blob([buffers], { type: mime })
const blobUrl = URL.createObjectURL(blob)
const a: htmlAnchorElement = document.createElement('a')
a.download = filename
a.href = blobUrl
a.click()
URL.revokeObjectURL(blobUrl)
}

我们获取到的文件数据是ArrayBuffer类型,这个数据是不能直接操作的,所以我们需要使用类型数组来操作它,这里我们使用Unit8Array类型数组来合并文件数据,最后通过生成BlobUrl来进行文件下载。


总结

断点上传和断点下载都是利用常见的优化套路:并行计算和缓存。充分发挥浏览器特性能力,达到更佳的效果。其实大数据渲染也是相似套路,比如懒加载、分片渲染、虚拟列表等等,使用的是按需加载、异步渲染、按需渲染的套路来达到大数据的渲染效果。

来自:https://www.aitimi.cn/blog/big-file-upload-download/

链接: https://fly63.com/article/detial/12621

h5移动端实现图片文件上传

PC端上传文件多半用插件,引入flash都没关系,但是移动端要是还用各种冗余的插件估计得被喷死,项目里面需要做图片上传的功能,既然H5已经有相关的接口且兼容性良好,当然优先考虑用H5来实现。

前端使用js读取文件操作

首先我们定义一个input标签type=file、然后我们定义一个jsReadFiles的方法将文件作为参数;当上传文件的时候读取这个文件。图片上传成功,只是图片路径变成了base64编码的形式。

HTML5实现文件读取、编辑、保存

HTML5读取文件主要利用的就是FileReader这个API,它的使用需要从一个构造函数开始,保存文件的关键是生成文件对象,可以使用URL.createObjectURL()方法实现,该方法能返回给定对象的URL,用在<a>标签的href属性上就可以创建可下载的文件链接。

血淋淋的事实告诉你:你为什么不应该在JS文件中保存敏感信息

在JavaScript文件中存储敏感数据,不仅是一种错误的实践方式,而且还是一种非常危险的行为,长期以来大家都知道这一点。

在js文件中引入另一个js文件的实现方法总汇

比如我写了一个JS文件,这个文件需要调用另外一个JS文件,该如何实现呢?这篇文章主要介绍:在js文件中引入另一个js文件的实现

使用HTML5来实现本地文件读取和写入

最近有这样一个需求,就是在HTML页面中有个按钮导出,点击它,将构造一个文档并存储到本地文件系统中。另外还有个按钮,点击它,从本地文件系统中读取一个文件并对内容进行分析。

lock文件_我们为什么需要 lock 文件

从 Yarn 横空出世推出 lock 文件以来,已经两年多时间了,npm 也在 5.0 版本加入了类似的功能,lock 文件越来越被开发者们接收和认可。本篇文章想从前端视角探讨一下我们为什么需要 lock 文件,以及它的一些成本与风险,当然其中一些观点对于后端也是适用的

什么是断点续传?前端如何实现文件的断点续传

什么是断点续传?就是下载文件时,不必重头开始下载,而是从指定的位置继续下载,这样的功能就叫做断点续传。前端通过FileList对象获取到相应的文件,按照指定的分割方式将大文件分段,然后一段一段地传给后端,后端再按顺序一段段将文件进行拼接。

form表单文件上传_multipart/form-data文件上传

form表单的enctype属性:规定了form表单数据在发送到服务器时候的编码方式.。application/x-www-form-urlencoded:默认编码方式,multipart/form-data:指定传输数据为二进制数据,例如图片、mp3、文件,text/plain:纯文本的传输。空格转换为“+”,但不支持特殊字符编码。

使用HttpClient发送文件流到服务器端

适用场景: 网络绝对路径的URL文件或图片,不存储到本地,转换成stream,直接使用HTTPClient传送到SpringBoot的服务端,将文件存储下来,并返回一个文件地址。目前分层架构的系统越来越多这种需求,所以记录下来以备不时之需。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!