上传文件 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) 。
一、 核心原理 大文件上传面临的最大问题是:耗时久 、网络波动导致失败需重传 、占用浏览器主线程 。
解决方案是 “分而治之” :
分片 (Chunking): 利用 Blob.prototype.slice 方法,将大文件(如 1GB)切割成多个小块(如 5MB/块)。
唯一标识 (Hash): 计算整个文件的 Hash 值(MD5),作为文件的“身份证”,用于秒传和断点续传。
并发上传: 同时发送多个 HTTP 请求上传切片。
合并 (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 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 文件可能需要几分钟)。为了“牺牲一点准确率换取极高的效率”,采用抽样策略 。
抽样逻辑:
头尾全取: 文件第一个 2M 和最后一个 2M 全部读取(因为文件头尾通常包含关键元数据)。
**中间抽样: 中间部分,每隔 2M,取前 2KB。
**合并计算: 将这些片段组合起来计算 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 ; let chunks = [file.slice (0 , offset)]; let cur = offset; while (cur < size) { if (cur + offset >= size) { chunks.push (file.slice (cur, cur + offset)); } else { const mid = cur + offset / 2 ; const end = cur + offset; 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 个请求。
四、 断点续传与秒传逻辑 这是与服务端配合的逻辑:
秒传 (Instant Upload):
断点续传 (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 >