1. 1. 文件流操作
    1. 1.1. Blob & ArrayBuffer
      1. 1.1.1. 区分
      2. 1.1.2. 互相转换
  2. 2. 文件分片 & Hash 计算
  3. 3. 分片上传
  4. 4. 分片下载
  5. 5. 断点续传
实现大文件分片上传与下载

文件流操作

在软件开发中,我们会看到各种形形色色的文件/资源(pdf/word/音频/视频),其实它们归根到底就是不同数据格式的以满足自身规则的情况下展示。说的更浅显易懂点,它们都是数据,并且最终都会以二进制形式展示

Blob & ArrayBuffer

在前端处理二进制数据时,有两个对象是绕不开的。

  • Blob 对象Binary Large Object)对象是一种可以在 JavaScript 中存储大量二进制数据的对象。可以通过构造函数创建 Blob 对象,或者通过其他 API(如 FormData 对象)生成。
  • ArrayBufferJavaScript 中的另一种对象类型,它们可以存储二进制数据ArrayBuffers 通常用于较低级别的操作,如直接操作和处理二进制数据。
区分
  • ArrayBuffer:只能存储二进制数据。它不具备直接的类型信息,开发者需要使用视图(如 TypedArray)来解释数据。

    1
    2
    let buffer = new ArrayBuffer(16); // 创建一个16字节的ArrayBuffer
    let view = new Uint8Array(buffer); // 通过Uint8Array视图来操作数据
  • Blob:可以包含任意类型的数据,包括文本、图像、音频和视频。Blob 提供了对文件类型的支持,并且可以通过 MIME 类型来描述数据。

    1
    let blob = new Blob(["Hello, world!"], { type: 'text/plain' }); // 创建一个文本Blob
互相转换
  • ArrayBuffer 可以通过 Blob 的构造函数转换为 Blob:

    1
    let blobFromBuffer = new Blob([buffer]); // 从ArrayBuffer创建Blob
  • Blob 可以通过 FileReader 将其读取为 ArrayBuffer

    1
    2
    3
    4
    5
    let reader = new FileReader();
    reader.onload = function(event) {
    let arrayBuffer = event.target.result; // 获取读取的ArrayBuffer
    };
    reader.readAsArrayBuffer(blob); // 读取Blob为ArrayBuffer

文件分片 & Hash 计算

无论是分片上传分片下载最核心的点就是需要对文件资源进行分片处理。其中最主要的API 就是 Blob: slice()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
* 文件分片 & 生成文件 HASH
* @param file
* @param chunkSizeStr
*/
async function sliceFile(file: File, chunkSizeStr = '10MB') {
/**
* 解析文件大小字符串,转为B单位的数值
* @param sizeStr
*/
const convertToBytes = (sizeStr: string): number => {
// 支持的单位数组(扩展至 YB)
const symbols = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

// 支持大小写混合和空格(如 "10 mb")
const regex = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/
const matches = sizeStr.trim().match(regex)

if (!matches) {
throw new Error('Invalid format. Expected format like "10MB" or "5.5 GB"')
}

// 解析数值和单位
const value = parseFloat(matches[1])
const inputUnit = matches[2].toLowerCase()

// 匹配单位在数组中的索引
const index = symbols.findIndex(
(unit) => unit.toLowerCase() === inputUnit || unit.toLowerCase() === inputUnit + 'b', // 兼容 "K" -> "KB"
)

if (index === -1) {
throw new Error(`Unsupported unit: ${matches[2]}`)
}

// 计算字节数:base^index
return value * 1024 ** index
}

return new Promise<{ chunkList: Blob[]; fileHash: string }>((resolve, reject) => {
let currentChunk = 0 // 当前分片的索引
let fileHash = ''
const chunkSize = convertToBytes(chunkSizeStr) // 分片大小
const chunks = file && Math.ceil(file.size / chunkSize) //分片数
const fileReader = new FileReader()
const chunkList: Blob[] = []
const spark = new SparkMD5.ArrayBuffer()
//初始化分片方法,兼容问题
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice

fileReader.onload = (e: ProgressEvent) => {
//当前读取的分块结果 ArrayBuffer
const chunk = (e.target as FileReader).result as ArrayBuffer
spark.append(chunk)
currentChunk++ // 增加当前分片索引
// 如果还有分片需要读取,继续读取下一个分片
if (currentChunk < chunks) {
loadNextChunk()
} else {
fileHash = spark.end()
// 所有分片读取完成,resolve Promise 并返回分片数组
resolve({ chunkList, fileHash })
}
}

// 文件读取出错时的回调函数
fileReader.onerror = function (e) {
console.warn('读取文件出错', e)
reject(e) // reject Promise 并传递错误信息
}

// 读取下一个分片的函数
function loadNextChunk() {
const start = currentChunk * chunkSize // 当前分片的起始字节
const end = start + chunkSize >= file.size ? file.size : start + chunkSize // 当前分片的结束字节

const chunk = blobSlice.call(file, start, end) // 切割文件得到当前分片
chunkList.push(chunk) // 将当前分片添加到分片数组中
fileReader.readAsArrayBuffer(chunk) // 读取当前分片为 ArrayBuffer
}

// 开始读取第一个分片
loadNextChunk()
})
}

分片上传

这里以axios上传为例,我们将每个分片同时上传,并通过onUploadProgressAPI来获取每个分片已上传的文件大小,从而计算出整体的上传进度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
async function uploadFile(
file: File,
chunkSizeStr: string,
onProgress: (percentage: number) => void,
) {
const { chunkList, fileHash } = await sliceFile(file, chunkSizeStr)
const totalChunks = chunkList.length
const uploadedBytes: number[] = new Array(totalChunks).fill(0)
// 生成所有分片的上传任务
const uploadPromises = Array.from({ length: totalChunks }, (_, index) => {
const formData = new FormData()
formData.append('file', chunkList[index])
formData.append('chunkIndex', index.toString())
formData.append('totalChunks', totalChunks.toString())
formData.append('fileHash', fileHash)

return axios.post('/api/upload', formData, {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
// 确保分片索引正确捕获
const currentChunkIndex = index
uploadedBytes[currentChunkIndex] = progressEvent.loaded
const totalUploaded = uploadedBytes.reduce((sum, bytes) => sum + bytes, 0)
const progress = (totalUploaded / file.size) * 100
onProgress(progress)
},
})
})
// 并发上传所有分片
await Promise.all(uploadPromises)

// 所有分片上传完成后,确保进度为100%
onProgress(100)
}

分片下载

实现客户端分片下载的基本解决方案如下:

  1. 服务器端将大文件切割成多个分片,并为每个分片生成唯一标识符。
  2. 客户端发送请求以获取分片列表并开始下载第一个分片。
  3. 在下载过程中,客户端基于分片列表发起并发请求以下载其他分片,并逐渐拼接和合并下载的数据。
  4. 当所有分片下载完成后,客户端将下载的数据合并为一个完整的文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
async function downloadable() {
try {
// 发送文件下载请求,获取文件的总大小和总分片数
const response = await fetch('/download', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})

// 解析响应数据
const data = await response.json()
const totalSize = data.totalSize
const totalChunks = data.totalChunks

// 初始化变量
let downloadedChunks = 0
const chunks: Blob[] = []

// 下载每个分片
for (let chunkNumber = 0; chunkNumber < totalChunks; chunkNumber++) {
try {
const chunkResponse = await fetch(`/download/${chunkNumber}`, {
method: 'GET',
})

const chunk = await chunkResponse.blob()
downloadedChunks++
chunks.push(chunk)

// 当所有分片下载完成时
if (downloadedChunks === totalChunks) {
// 合并分片
const mergedBlob = new Blob(chunks)

// 创建对象 URL 以生成下载链接
const downloadUrl = window.URL.createObjectURL(mergedBlob)

// 创建一个 <a> 元素并设置属性
const link = document.createElement('a')
link.href = downloadUrl
link.setAttribute('download', 'file.txt')

// 模拟点击下载
link.click()

// 释放资源
window.URL.revokeObjectURL(downloadUrl)
}
} catch (chunkError) {
console.error(`Chunk ${chunkNumber} download failed:`, chunkError)
}
}
} catch (error) {
console.error('文件下载失败:', error)
}
}

我们先使用 Blob 对象创建一个总对象 URL,用于生成下载连接。然后创建一个标签,并将 href 属性设置为刚创建的对象 URL。继续设置标签的属性以下载文件名,这样在点击时可以自动下载文件。

断点续传

在前端,可以使用localStoragesessionStorage存储已上传分片的信息,包括已上传的分片索引和分片大小。

每次上传前,检查本地存储中是否存在已上传分片信息。如果存在,则从断点处继续上传。

在后端,可以使用临时文件夹或数据库记录已接收的分片信息,包括已上传的分片索引和分片大小。

上传完成前,保存上传状态,以便在上传中断时能够恢复上传进度。