上传文件

上传文件

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
86
87
<script setup>
import { ref, getCurrentInstance } from 'vue';

const {proxy} = getCurrentInstance()

const props = defineProps({
modelValue: {
type: Object,
default: null
},
imageUrlPrefix: {
type: String,
}
})
//如果父组件传来一个图片地址, 就用父组件的, 否则展示用户上传的图片

const localPreview = ref(false)
const localFile = ref(null)

const emit = defineEmits(['update:modelValue'])
const selectorFile = async(file) => {
file = file.file
let img = new FileReader()
img.readAsDataURL(file)
img.onload = ({target}) => {
localFile.value = target.result
}
localPreview.value = true
emit("update:modelValue", file)
}
</script>

<template>
<div class="attachment-selector">
<el-upload
name="file"
:show-file-list="false"
accept=".zip, .rar"
:multiple="false"
:http-request="selectorFile"
>
<div class="attachment-selector-btn">
<template v-if="localFile">
<img :src="localFile">
</template>
<template v-else>
<img
:src="(imageUrlPrefix ? imageUrlPrefix : proxy.globalInfo.imageUrl)
+ modelValue.imageUrl"
v-if="modelValue && modelValue.imageUrl"
>
<span class="iconfont icon-add"></span>
</template>
</div>
</el-upload>
<template
v-if="modelValue">
<div
class="file-name"
:title="modelValue.name">
{{ modelValue.name }}
</div>
<div class="iconfont iconfont-del"></div>
</template>
</div>
</template>

<style scoped>
.attachment-selector {
.attachment-selector-btn {
background: #dddddd3d;
width: 150px;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
height: auto;
}
.iconfont {
font-size: 50px;
color: #ddd;
}
}
}
</style>

上传附件

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
<script setup>
import { getCurrentInstance } from 'vue';

const {proxy} = getCurrentInstance()

const props = defineProps({
modelValue: {
type: Object,
default: null
},
imageUrlPrefix: {
type: String,
}
})

const emit = defineEmits(['update:modelValue'])
const selectorFile = async(file) => {
file = file.file
emit("update:modelValue", file)
}

const delFile = () => {
emit("update:modelValue", null)
}
</script>

<template>
<div class="attachment-selector">
<el-upload
name="file"
:show-file-list="false"
accept=".zip, .rar"
:multiple="false"
:http-request="selectorFile"
>
<el-button
type="primary"
v-if="!modelValue">上传附件</el-button>
</el-upload>
<template
v-if="modelValue">
<div
class="file-name"
:title="modelValue.name">
{{ modelValue.name }}
</div>
<div
class="iconfont icon-del"
@click="delFile"
></div>
</template>
</div>
</template>

<style scoped>
.attachment-selector {
width: 100%;
display: flex;
.iconfont {
cursor: pointer;
font-size: 19px;
color: var(--link);
}
.file-name {
white-space: nowrap;
overflow: hidden;
color: #9b9b9b;
flex: 1;
}
}
</style>

大文件上传

核心内容涵盖:分片原理基础代码实现、以及关键性能优化(Web Worker & 抽样Hash)


一、 核心原理

大文件上传面临的最大问题是:耗时久网络波动导致失败需重传占用浏览器主线程

解决方案是 “分而治之”

  1. 分片 (Chunking): 利用 Blob.prototype.slice 方法,将大文件(如 1GB)切割成多个小块(如 5MB/块)。
  2. 唯一标识 (Hash): 计算整个文件的 Hash 值(MD5),作为文件的“身份证”,用于秒传和断点续传。
  3. 并发上传: 同时发送多个 HTTP 请求上传切片。
  4. 合并 (Merge): 所有切片上传完成后,通知服务端将切片合并成完整文件。

二、 基础代码实现

1. 文件切片 (createChunks)

这是最基础的一步,利用 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
/**
* @param {File} file - 上传的文件对象
* @param {number} size - 每个切片的大小 (例如 5 * 1024 * 1024)
* @returns {Array} - 切片数组
*/
function createChunks(file, size = 5 * 1024 * 1024) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + size));
cur += size;
}
return chunks;
}
````

#### 2. 计算 Hash (使用 spark-md5)

为了验证文件完整性和实现秒传,需要计算 Hash
*注意:直接_注意:直接在主线程计算大文件 Hash 会导致页面卡死,后续优化会讲到解决方案。_

```javascript
// 引入 spark-md5 库
import SparkMD5 from 'spark-md5';

function calculateHash(chunks) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
let index = 0;

reader.onload = e => {
spark.append(e.target.result);
index++;
if (index < chunks.length) {
// 递归读取下一个切片
reader.readAsArrayBuffer(chunks[index]);
} else {
resolve(spark.end());
}
};
reader.readAsArrayBuffer(chunks[0]);
});
}

三、 关键优化 (重难点)

优化 1:利用 Web Worker (多线程计算)

为了不阻塞主线程(UI渲染),将耗时的 Hash 计算放入 Web Worker 中执行。

Worker 脚本 (hash.worker.js):

1
2
3
4
5
6
7
8
// 接收主线程发来的切片
self.onmessage = e => {
const { chunks } = e.data;
const spark = new SparkMD5.ArrayBuffer();
// ... 读取并计算逻辑 ...
// 计算完成后发送回主线程
self.postMessage({ hash: spark.end() });
};

优化 2:时间切片 (Time Slicing)

如果不想用 Worker,利用 requestIdleCallbackrequestIdleCallback` 在浏览器空闲时段计算,避免卡顿。但这种方式对于超大文件依然较慢。

优化 3:抽样 Hash (Sampling Hash) —— 视频核心亮点

计算全量 Hash 非常慢(10GB 文件可能需要几分钟)。为了“牺牲一点准确率换取极高的效率”,采用抽样策略

抽样逻辑:

  1. 头尾全取: 文件第一个 2M 和最后一个 2M 全部读取(因为文件头尾通常包含关键元数据)。

  2. **中间抽样: 中间部分,每隔 2M,取前 2KB。

  3. **合并计算: 将这些片段组合起来计算 Hash。

效果: 10GB 的文件,原本需要全量读取,现在可能只需要读取几十 MB,速度提升百倍,且 Hash 冲突概率极低。

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
async function calculateHashSample(file) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const size = file.size;
const offset = 2 * 1024 * 1024; // 2M

let chunks = [file.slice(0, offset)]; // 头

let cur = offset;
while (cur < size) {
if (cur + offset >= size) {
// 到了尾部
chunks.push(file.slice(cur, cur + offset));
} else {
// 中间部分,取前 2个字节 (或其他小段)
// 渡一的策略通常是:每块取一小段
const mid = cur + offset / 2;
const end = cur + offset;
// 这里简化逻辑,实际可取 cur 到 cur+2kb
chunks.push(file.slice(cur, cur + 2));
}
cur += offset;
}

reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = e => {
spark.append(e.target.result);
resolve(spark.end());
};
});
}

优化 4:并发控制

不要使用 Promise.allPromise.all` 一次性发出所有请求,这会导致浏览器卡死或 TCP 连接过多。应该维护一个**并发池(并发池(Concurrency Pool),比如同一时间只允许 4 个请求。


四、 断点续传与秒传逻辑

这是与服务端配合的逻辑:

  1. 秒传 (Instant Upload):

    • 前端算出 Hash 发给服务端验证。

    • 服务端查库:如果 Hash 已存在 &&如果 Hash 已存在 && 上传完成 -> 返回“上传成功”`。

    • 用户体验:进度条瞬间 100%。

    • 前端算出 Hash 发给服务端验证。

    • 服务端查库:如果 Hash 已存在 && 上传完成 -> 返回“上传成功”

    • 用户体验:进度条瞬间 100%。

  2. 断点续传 (Resume):

    • 服务端返回:Hash 已存在,但Hash 已存在,但未完成,已存在的切片索引是 [0, 1, 2, 5]`。

    • 前端逻辑:过滤掉 index 为 0, 1, 2, 5 的切片,只上传剩下的。


五、 总结图表

功能点 实现方案 优化手段
切片 Blob.slice 根据文件大小动态调整切片大小
Hash计算 spark-md5 Web Worker (不卡顿) 或 抽样 Hash (速度极快)
上传 FormData + XHR/Fetch 并发控制 (限制最大请求数,如 max=6)
完整性 合并接口 /merge 服务端校验 Hash 是否一致

完整代码

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div>
<label for="maxfill">maxfill</label>
<input type="file" id="maxfill" />
</div>

<script src="./saprk-md5.js"></script>
<script>
const inp = document.querySelector('input')
inp.onchange = async (e) => {
const file = inp.files[0]
if (!file) {
return
}

const chunks = createChunks(file, 10 * 1024 * 1024)
const result = await hash(chunks)
console.log(result)
}

function hash(chunks) {
//注意读取时分片读取, 整个读取将消耗大量内存
return new Promise((resolve, reject) => {
const spark = new SparkMD5()
function _read(i) {
if (i >= chunks.length) {
console.log(spark.end())
return //读取完成
}
const blob = chunks[i]
const reader = new FileReader()

reader.onload = (e) => { //读取时异步的
const bytes = e.target.result //读取到的字节数组
spark.append(bytes) //完成增量运算
_read(i + 1)
}
reader.readAsArrayBuffer(blob)
}
_read(0)
})
}

function createChunks(file, chunkSize) {
const result = []
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file, file.slice(i, i + chunkSize))
}
return result
}
</script>
</body>
</html>