学以致用,知行合一
前言
老生常谈的文件上传,在日常工作中,作为前端的我们,理解一个功能的整体流程是片面的,废话不多说,本文将从以下几个方面,带你尽可能的理解大文件上传的工作流程。
前端部分
- 分片上传
- 分片信息摘要
- 控制并发量
- 断点续传
后端部分
- 查询分片
- 上传分片
- 合并分片
技术栈: vue3 + ts + koa2
为什么需要分片上传及其优势
为什么?
想象一下,今天周五,领导让你将一个压缩包上传服务器,下周一开会用,压缩包10个G,你想都不想,直接去往服务器丢,离下班还有2个小时,时间是刚好够的,而在下班的前一刻,上传进度条99%,不出意外意外发生了,电脑死机,当场要提离职,还好同事把你拦住了,只能加班了。重新上传,从头再来。
而分片上传将大文件分成多个小片段,每次上传一个小片段,这样即使网络中断,只需要重新上传未完成的片段,而不必从头再来。
优势
除了可以避免以上情况外,还有哪些好处呢?
服务器端:
减轻服务器压力,一次性上传大文件会给服务器带来很大压力,甚至可能导致服务器崩溃。分片上传将文件分成若干小块逐一上传,服务器在处理这些小片段时压力会小很多。
业务处理时: 如果因为大量业务逻辑导致的代码宕机时,也可以最大程度的拯救你的上传进度,避免浪费不必要的时间。
知识储备
前端知识
- Js的File API
(File 和 Blob 对象 包括读取文件、创建分片等) - Promise和async/await
(异步操作) - 浏览器存储
(IndexedDB,处理断点续传的核心) - Web Workers
(实现多线程处理,避免在主线程中执行耗时操作导致页面卡顿)
后端知识
- nodejs或者其他后端语言均可
实现
分片上传
将获取到的文件对象进行分片处理根据实际情况定义分片大小(chunkSize),如果拿到的是二进制流,或者其他格式,可以想办法转成文件对象。
export interface FilePiece {
chunk: Blob
size: number
}
// 分片的长度
const CHUNK_SIZE = 5 * 1024 * 1024
/**
* @file 目标文件
* @chunkSize 分片大小
*/
export const splitFile = (file: File, chunkSize = CHUNK_SIZE) => {
const fileChunkList: FilePiece[] = []
let len = 0
while (len < file.size) {
// 一个分片
const piece = file.slice(len, len + chunkSize)
fileChunkList.push({
chunk: piece,
size: piece.size,
})
len += chunkSize
}
return fileChunkList
}
分片信息摘要
因为javascript是单线程脚本语言,单线程会堵塞网络请求,上一个请求未结束时,后面的请求必须等待,所以这里选择使用worker实现多线程,并发进行分片的加密处理,worker的好处,让js代码多线程的工作模式,充分利用CPU的计算能力。
import { type FilePiece } from './file'
import SparkMD5 from 'spark-md5'
// 可以了解下worker的工作模式
self.onmessage = async (e) => {
const fileChunkList = e.data as FilePiece[]
// 进行加密处理
const spark = new SparkMD5.ArrayBuffer()
for (let i = 0; i < fileChunkList.length; i++) {
const chunk = fileChunkList[i].chunk
const res = await readChunk(chunk)
spark.append(res as ArrayBuffer)
// 发送进度消息
self.postMessage({
percentage: ((i + 1) / fileChunkList.length) * 100,
})
}
self.postMessage({
percentage: 100,
hash: spark.end(),
})
self.close()
}
const readChunk = async (chunk: Blob) => {
return new Promise((resolve, reject) => {
const read = new FileReader()
read.readAsArrayBuffer(chunk)
read.onload = (e) => {
if (e.target) {
resolve(e.target.result)
}
}
})
}
调用加密方法
//分片加密
export const createHash = ({
chunks,
onTick,
}: {
chunks: FilePiece[]
onTick?: (percentage: number) => void
}): Promise<string> => {
return new Promise((resolve) => {
// 开启多线程
const worker = getWorker()
worker.postMessage(chunks)
worker.onmessage = (e) => {
const { hash, percentage } = e.data
onTick?.(parseInt(percentage.toFixed(2)))
if (hash) {
resolve(hash)
}
}
})
}
//单例模式 全局只开启一次worker 防止资源浪费
let workerInstance: Worker | null = null
export const getWorker = (): Worker => {
if (!workerInstance) {
workerInstance = new Worker(new URL('./worker', import.meta.url), {
type: 'module',
})
}
return workerInstance
}
需要注意的是,为什么要做加密处理,加密是为了保证上传文件的唯一性,以便后续,做文件的秒传和断点续传等工作,所以这一步至关重要。
控制并发量
实现高性能的大文件上传,这一步必不可少,制并发量,可以防止服务器因为同时处理太多请求而过载,帮助我们合理使用电脑和网络资源,不会因为一次上传太多文件块而导致系统变慢或崩溃。
const total = originChunks.length; //分片总数
const poolLimit = 5; // 并发限制数
let currentIndex = 0; // 当前上传的分片索引
let completed = 0; // 完成的分片数量
const pool: Promise<void>[] = [];
const doUpload = async (piece: FilePiece, index: number) => {
const params = { hash, chunk: piece.chunk, index };
try {
....
} catch (error) {
// 中断上传时候保存分片
...
return;
}
...
if (completed === total) {
// 合并文件
...
}
// 每当一个分片上传完成后,继续上传下一个分片
if (currentIndex < total) {
const nextPiece = originChunks[currentIndex];
currentIndex += 1;
pool.push(doUpload(nextPiece, currentIndex - 1).then(() => {
// 从并发池中移除已完成的任务
pool.splice(pool.findIndex(p => p === this), 1);
}));
}
};
// 初始化并发池
while (currentIndex < poolLimit && currentIndex < total) {
const piece = originChunks[currentIndex];
currentIndex += 1;
pool.push(doUpload(piece, currentIndex - 1).then(() => {
// 从并发池中移除已完成的任务
pool.splice(pool.findIndex(p => p === this), 1);
}));
}
// 等待所有上传任务完成
await Promise.all(pool);
断点续传
针对暂停、断网、停电、刷新页面等意外情况的处理,让用户不必重头开始上传文件,大概思路是拿到中断前分片的唯一标识,去服务端对比标识,从上次断开的位置开始上传。这里需要具备indexDB知识。
在控制并发量模块中的try/catch进行处理
// 利用浏览器自带的数据库,存放我们的分片信息
const DB_NAME = 'fileUploadDB';
const DB_VERSION = 1;
const STORE_NAME = 'chunks';
const openDB = () => {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event: Event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event: Event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
};
const saveChunkInfo = async (hash: string, index: number) => {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.put({ id: `${hash}-${index}`, hash, index });
};
const getUploadedChunks = async (hash: string) => {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
return new Promise<{ hash: string, index: number }[]>((resolve, reject) => {
request.onsuccess = (event: Event) => {
const result = (event.target as IDBRequest).result as { hash: string, index: number }[];
resolve(result.filter(chunk => chunk.hash === hash));
};
request.onerror = (event: Event) => {
reject((event.target as IDBRequest).error);
};
});
};
const clearChunks = async (hash: string) => {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = (event: Event) => {
const chunks = (event.target as IDBRequest).result as { hash: string, index: number }[];
chunks.filter(chunk => chunk.hash === hash).forEach(chunk => {
store.delete(chunk.id);
});
};
};
如何存 如何取
// 如何存
...
try {
const { flag } = await findFile({ hash, index });
if (!flag) {
await uploadChunk({ ...params, cancelToken });
// 每次上传完存
await saveChunkInfo(hash, index);
}
} catch (error) {
if (!navigator.onLine) {
// 异常的时候存
await saveChunkInfo(hash, index);
}
return;
}
...
// 如何取
...
// 每次调用uploadChunks时 获取已上传的分片信息
const uploadedChunks = await getUploadedChunks(hash);
const uploadedIndexes = uploadedChunks.map(chunk => chunk.index);
completed = uploadedIndexes.length;
...
后端部分(koa),相对于前端部分,这部分更多是对文件接收、存储、校验和确认等工作
查询分片
import { type Context } from 'koa'
import { isExistFile } from '../storages/files'
import path from 'path'
import { UPLOAD_DIR } from '../const'
export const findFileController = (ctx: Context) => {
let flag = false
let hashDir = ''
const { hash, index } = ctx.request.query
const hashD = path.resolve(UPLOAD_DIR, hash as string)
hashDir = path.resolve(hashD, index as string)
flag = isExistFile(hashDir)
ctx.body = {
code: 0,
flag: flag,
message: '查询成功',
}
return
}
上传分片
import path from 'path'
import { type Context } from 'koa'
import { UPLOAD_DIR } from '../const'
import { readFile, writeFile, isExistFile, mkdir } from '../storages/files'
interface testFile {
filepath?: string
}
export const uploadFileController = (ctx: Context) => {
const { hash, index } = ctx.request.body
const tempath = (ctx.request.files?.chunk as testFile)?.filepath
if (!tempath) {
throw console.error('文件地址无效!')
}
// 如果文件夾不存在 則創建
const hashDir = path.resolve(UPLOAD_DIR, hash)
if (!isExistFile(hashDir)) {
mkdir(hashDir, true)
}
const filePath = path.resolve(hashDir, index)
// 写入之前判断文件是否已经存在
if (isExistFile(filePath)) {
ctx.body = {
code: 0,
message: '该分片已经存在',
}
return
}
try {
writeFile(filePath, readFile(tempath))
ctx.body = {
code: 0,
message: '上传成功',
}
} catch (error) {
ctx.body = {
code: -1,
message: '上传分片失败',
}
return
}
}
合并分片
import { type Context } from 'koa'
import { readdir, appendFile, readFile } from 'fs/promises'
import { UPLOAD_DIR } from '../const'
import path from 'path'
import { isExistFile } from '../storages/files'
export const mergeFileController = async (ctx: Context) => {
// 获取当前目录下的文件列表
const { hash, filename } = ctx.request.query
const hashDir = path.resolve(UPLOAD_DIR, hash as string)
const flag = isExistFile(hashDir)
if (!flag) {
ctx.body = {
code: 0,
flag,
message: '文件不存在!',
}
}
try {
// 读取目录中的文件
const files = await readdir(hashDir)
// 文件名转换为索引并排序
const fn2idx = (filename: string) => +path.basename(filename)
const sortedFiles = files.sort((r1, r2) => fn2idx(r1) - fn2idx(r2))
// 读取所有文件的内容,使用 Promise.all 进行并行读取
const fileReadPromises = sortedFiles.map((chunkPath) => {
return readFile(path.join(hashDir, chunkPath))
})
// 等待所有文件读取完成
const fileContents = await Promise.all(fileReadPromises)
// 逐个将读取到的文件内容写入目标文件
for (const chunkData of fileContents) {
await appendFile(path.join(UPLOAD_DIR, filename as string), chunkData)
}
console.log('Files merged successfully!')
} catch (error) {
console.error('Error merging files:', error)
}
// 返回文件地址
ctx.body = {
code: 0,
flag,
message: '合并完毕',
}
}
总结
大文件上传技术涉及前后端多个方面的知识,是一个复杂但非常实用的功能。分片上传通过将大文件切割成小片段逐一上传,解决了传统一次性上传方式中的网络不稳定、服务器压力大、上传失败需重传等问题。其优势在于提升上传效率、提高上传可靠性、减少资源浪费、实现大文件上传和提升用户体验等。