feat(文件上传): 使用 rusha 实现七牛云 ETag 计算优化文件上传
替换 browser-md5-file 为 rusha 实现七牛云 ETag 计算算法,提升大文件哈希计算性能 新增 qiniuFileHash 工具函数,支持进度回调 重构 FileUploaderField 和 ImageUploaderField 组件上传逻辑
Showing
5 changed files
with
214 additions
and
39 deletions
| ... | @@ -94,6 +94,7 @@ | ... | @@ -94,6 +94,7 @@ |
| 94 | "pinia": "^2.0.14", | 94 | "pinia": "^2.0.14", |
| 95 | "postcss-px-to-viewport": "^1.1.1", | 95 | "postcss-px-to-viewport": "^1.1.1", |
| 96 | "qs": "^6.10.3", | 96 | "qs": "^6.10.3", |
| 97 | + "rusha": "^0.8.14", | ||
| 97 | "tslint": "^6.1.3", | 98 | "tslint": "^6.1.3", |
| 98 | "unplugin-auto-import": "^0.8.8", | 99 | "unplugin-auto-import": "^0.8.8", |
| 99 | "unplugin-vue-components": "^0.23.0", | 100 | "unplugin-vue-components": "^0.23.0", | ... | ... |
| ... | @@ -92,10 +92,9 @@ import { showSuccessToast, showFailToast, showToast } from "vant"; | ... | @@ -92,10 +92,9 @@ import { showSuccessToast, showFailToast, showToast } from "vant"; |
| 92 | import _ from "lodash"; | 92 | import _ from "lodash"; |
| 93 | import { v4 as uuidv4 } from "uuid"; | 93 | import { v4 as uuidv4 } from "uuid"; |
| 94 | import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; | 94 | import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; |
| 95 | -import BMF from "browser-md5-file"; | 95 | +import { qiniuFileHash } from "@/utils/qiniuFileHash.js"; |
| 96 | import { useRoute } from "vue-router"; | 96 | import { useRoute } from "vue-router"; |
| 97 | import axios from "axios"; | 97 | import axios from "axios"; |
| 98 | -import { getEtag } from "@/utils/qetag.js"; // 生成hash值 | ||
| 99 | import { wxInfo } from "@/utils/tools"; | 98 | import { wxInfo } from "@/utils/tools"; |
| 100 | 99 | ||
| 101 | const $route = useRoute(); | 100 | const $route = useRoute(); |
| ... | @@ -320,21 +319,20 @@ const beforeDelete = (files) => { | ... | @@ -320,21 +319,20 @@ const beforeDelete = (files) => { |
| 320 | const loading = ref(false); | 319 | const loading = ref(false); |
| 321 | const formCode = $route.query.code; // 表单code | 320 | const formCode = $route.query.code; // 表单code |
| 322 | 321 | ||
| 323 | -// 上传文件返回文件URL | 322 | +/** |
| 323 | + * 上传文件并返回文件URL | ||
| 324 | + * @param {Object} files 当前上传项(van-uploader传入对象) | ||
| 325 | + * @returns {Promise<Object>} 服务端返回的文件信息 | ||
| 326 | + * @description 使用 qiniuFileHash 计算文件哈希(遵循七牛ETag),替换原MD5逻辑 | ||
| 327 | + */ | ||
| 324 | const handleUpload = async (files) => { | 328 | const handleUpload = async (files) => { |
| 325 | loading.value = true; | 329 | loading.value = true; |
| 326 | - // 获取HASH值 | 330 | + return new Promise(async (resolve, reject) => { |
| 327 | - // const hash = getEtag(files.content); | 331 | + try { |
| 328 | - return new Promise((resolve, reject) => { | 332 | + // 计算文件哈希(ETag),用于服务端去重及生成上传key |
| 329 | - // 获取MD5值 | 333 | + const md5 = await qiniuFileHash(files.file, (progress) => { |
| 330 | - const bmf = new BMF(); | 334 | + // 计算进度(此处保留钩子,便于后续展示进度) |
| 331 | - bmf.md5( | 335 | + }); |
| 332 | - files.file, | ||
| 333 | - async (err, md5) => { | ||
| 334 | - if (err) { | ||
| 335 | - console.log(err); | ||
| 336 | - reject(err); | ||
| 337 | - } | ||
| 338 | // 获取七牛token | 336 | // 获取七牛token |
| 339 | const filename = files.file.name; // 真实文件名 | 337 | const filename = files.file.name; // 真实文件名 |
| 340 | const getToken = await qiniuTokenAPI({ | 338 | const getToken = await qiniuTokenAPI({ |
| ... | @@ -355,11 +353,10 @@ const handleUpload = async (files) => { | ... | @@ -355,11 +353,10 @@ const handleUpload = async (files) => { |
| 355 | imgUrl = getToken.data; | 353 | imgUrl = getToken.data; |
| 356 | } | 354 | } |
| 357 | resolve(imgUrl); | 355 | resolve(imgUrl); |
| 358 | - }, | 356 | + } catch (err) { |
| 359 | - (process) => { | 357 | + console.log(err); |
| 360 | - //计算进度 | 358 | + reject(err); |
| 361 | } | 359 | } |
| 362 | - ); | ||
| 363 | }); | 360 | }); |
| 364 | }; | 361 | }; |
| 365 | 362 | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2022-08-31 16:16:49 | 2 | * @Date: 2022-08-31 16:16:49 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2024-11-14 13:06:43 | 4 | + * @LastEditTime: 2025-11-11 17:27:12 |
| 5 | * @FilePath: /data-table/src/components/ImageUploaderField/index.vue | 5 | * @FilePath: /data-table/src/components/ImageUploaderField/index.vue |
| 6 | * @Description: 图片上传控件 | 6 | * @Description: 图片上传控件 |
| 7 | --> | 7 | --> |
| ... | @@ -75,10 +75,9 @@ import { showSuccessToast, showFailToast, showToast, showImagePreview } from "va | ... | @@ -75,10 +75,9 @@ import { showSuccessToast, showFailToast, showToast, showImagePreview } from "va |
| 75 | import _ from "lodash"; | 75 | import _ from "lodash"; |
| 76 | import { v4 as uuidv4 } from "uuid"; | 76 | import { v4 as uuidv4 } from "uuid"; |
| 77 | import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; | 77 | import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from "@/api/common"; |
| 78 | -import BMF from "browser-md5-file"; | 78 | +import { qiniuFileHash } from "@/utils/qiniuFileHash.js"; |
| 79 | import { useRoute } from "vue-router"; | 79 | import { useRoute } from "vue-router"; |
| 80 | import axios from "axios"; | 80 | import axios from "axios"; |
| 81 | -import { getEtag } from "@/utils/qetag.js"; // 生成hash值 | ||
| 82 | 81 | ||
| 83 | const $route = useRoute(); | 82 | const $route = useRoute(); |
| 84 | const props = defineProps({ | 83 | const props = defineProps({ |
| ... | @@ -257,21 +256,20 @@ const formCode = $route.query.code; // 表单code | ... | @@ -257,21 +256,20 @@ const formCode = $route.query.code; // 表单code |
| 257 | // return uuid; | 256 | // return uuid; |
| 258 | // }; | 257 | // }; |
| 259 | 258 | ||
| 260 | -// 上传图片返回图片URL | 259 | +/** |
| 260 | + * 上传图片并返回图片URL | ||
| 261 | + * @param {Object} files 当前上传项(van-uploader传入对象) | ||
| 262 | + * @returns {Promise<Object>} 服务端返回的图片信息 | ||
| 263 | + * @description 使用 qiniuFileHash 计算文件哈希(遵循七牛ETag),替换原MD5逻辑 | ||
| 264 | + */ | ||
| 261 | const handleUpload = async (files) => { | 265 | const handleUpload = async (files) => { |
| 262 | loading.value = true; | 266 | loading.value = true; |
| 263 | - // 获取HASH值 | 267 | + return new Promise(async (resolve, reject) => { |
| 264 | - // const hash = getEtag(files.content); | 268 | + try { |
| 265 | - return new Promise((resolve, reject) => { | 269 | + // 计算文件哈希(ETag),用于服务端去重及生成上传key |
| 266 | - // 获取MD5值 | 270 | + const md5 = await qiniuFileHash(files.file, (progress) => { |
| 267 | - const bmf = new BMF(); | 271 | + // 计算进度(此处保留钩子,便于后续展示进度) |
| 268 | - bmf.md5( | 272 | + }); |
| 269 | - files.file, | ||
| 270 | - async (err, md5) => { | ||
| 271 | - if (err) { | ||
| 272 | - console.log(err); | ||
| 273 | - reject(err); | ||
| 274 | - } | ||
| 275 | // 获取七牛token | 273 | // 获取七牛token |
| 276 | const filename = files.file.name; // 真实文件名 | 274 | const filename = files.file.name; // 真实文件名 |
| 277 | const getToken = await qiniuTokenAPI({ | 275 | const getToken = await qiniuTokenAPI({ |
| ... | @@ -292,11 +290,10 @@ const handleUpload = async (files) => { | ... | @@ -292,11 +290,10 @@ const handleUpload = async (files) => { |
| 292 | imgUrl = getToken.data; | 290 | imgUrl = getToken.data; |
| 293 | } | 291 | } |
| 294 | resolve(imgUrl); | 292 | resolve(imgUrl); |
| 295 | - }, | 293 | + } catch (err) { |
| 296 | - (process) => { | 294 | + console.log(err); |
| 297 | - //计算进度 | 295 | + reject(err); |
| 298 | } | 296 | } |
| 299 | - ); | ||
| 300 | }); | 297 | }); |
| 301 | }; | 298 | }; |
| 302 | 299 | ... | ... |
src/utils/qiniuFileHash.js
0 → 100644
| 1 | +import Rusha from 'rusha' | ||
| 2 | +/** | ||
| 3 | + * 完全遵循七牛云对象存储(Qiniu Kodo)的 ETag 计算规则,在浏览器中计算文件 ETag。 | ||
| 4 | + * | ||
| 5 | + * @param {File} file 用户通过 <input> 选择的 File 对象 | ||
| 6 | + * @param {(progress: { computed: number, total: number, percent: number }) => void} [progressCallback] 计算进度的回调函数 | ||
| 7 | + * @returns {Promise<string>} 一个 Promise,最终解析为计算出的 eTag 字符串 | ||
| 8 | + */ | ||
| 9 | +export const qiniuFileHash = async(file, progressCallback) => { | ||
| 10 | + // --- 辅助函数 --- | ||
| 11 | + | ||
| 12 | + // 1. SHA-1 计算 | ||
| 13 | + const createSha1 = window.crypto && window.crypto.subtle | ||
| 14 | + ? (data) => window.crypto.subtle.digest('SHA-1', data) // 使用 Web Crypto API | ||
| 15 | + : (data) => Rusha.createHash().update(data).digest(); // https://github.com/srijs/rusha | ||
| 16 | + | ||
| 17 | + // 2. 拼接 ArrayBuffer | ||
| 18 | + const concatArrayBuffers = (buffers) => { | ||
| 19 | + const totalLength = buffers.reduce((acc, b) => acc + b.byteLength, 0); | ||
| 20 | + const result = new Uint8Array(totalLength); | ||
| 21 | + let offset = 0; | ||
| 22 | + for (const buffer of buffers) { | ||
| 23 | + result.set(new Uint8Array(buffer), offset); | ||
| 24 | + offset += buffer.byteLength; | ||
| 25 | + } | ||
| 26 | + return result.buffer; | ||
| 27 | + }; | ||
| 28 | + | ||
| 29 | + // 3. ArrayBuffer 到 URL 安全 Base64 的转换 | ||
| 30 | + // 使用一个更健壮的 Base64 编码函数,避免大文件时出现栈溢出 | ||
| 31 | + const urlSafeBase64Encode = (buffer) => { | ||
| 32 | + let binary = ''; | ||
| 33 | + const bytes = new Uint8Array(buffer); | ||
| 34 | + // 对于小数据量 (21字节),这种方式性能足够,且比 fromCharCode.apply 更安全 | ||
| 35 | + for (let i = 0; i < bytes.byteLength; i++) { | ||
| 36 | + binary += String.fromCharCode(bytes[i]); | ||
| 37 | + } | ||
| 38 | + return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-'); | ||
| 39 | + }; | ||
| 40 | + | ||
| 41 | + // 4. 带节流的进度回调包装器 | ||
| 42 | + let lastUpdateTime = 0; | ||
| 43 | + const throttleInterval = 100; // 每 100ms 更新一次进度 | ||
| 44 | + | ||
| 45 | + const throttledProgressCallback = (progress) => { | ||
| 46 | + if (!progressCallback) return; | ||
| 47 | + | ||
| 48 | + const now = Date.now(); | ||
| 49 | + // 对于最后 100% 的进度,我们总是希望它被立即调用,以确保状态最终正确。 | ||
| 50 | + if (progress.percent === 100) { | ||
| 51 | + setTimeout(() => progressCallback(progress), 0); | ||
| 52 | + return; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + if (now - lastUpdateTime > throttleInterval) { | ||
| 56 | + lastUpdateTime = now; | ||
| 57 | + // 使用 setTimeout 解耦,防止阻塞 | ||
| 58 | + setTimeout(() => progressCallback(progress), 0); | ||
| 59 | + } | ||
| 60 | + }; | ||
| 61 | + | ||
| 62 | + // --- 主逻辑 --- | ||
| 63 | + | ||
| 64 | + if (file.size === 0) { | ||
| 65 | + throttledProgressCallback({ computed: 0, total: 0, percent: 100 }); | ||
| 66 | + return 'Fto5o-5ea0sNMlW_75VgGJCv2AcJ'; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + const blockSize = 4 * 1024 * 1024; // 4MB | ||
| 70 | + | ||
| 71 | + // --- 1. 小文件处理 (小于等于4MB) --- | ||
| 72 | + if (file.size <= blockSize) { | ||
| 73 | + const fileBuffer = await file.arrayBuffer(); | ||
| 74 | + const sha1Buffer = await createSha1(fileBuffer); | ||
| 75 | + const prefix = new Uint8Array([0x16]); | ||
| 76 | + const finalBuffer = concatArrayBuffers([prefix.buffer, sha1Buffer]); | ||
| 77 | + const hash = urlSafeBase64Encode(finalBuffer); | ||
| 78 | + throttledProgressCallback({ computed: file.size, total: file.size, percent: 100 }); | ||
| 79 | + return hash; | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + // --- 2. 大文件处理 (需要分块) --- | ||
| 83 | + const sha1Results = []; // 存放每个完整块的 SHA-1 结果 | ||
| 84 | + let computed = 0; | ||
| 85 | + | ||
| 86 | + // 尝试使用高性能的 BYOB 流模式 | ||
| 87 | + let reader; | ||
| 88 | + try { | ||
| 89 | + const stream = file.stream(); | ||
| 90 | + reader = stream.getReader({ mode: 'byob' }); | ||
| 91 | + } catch (error) { | ||
| 92 | + // console.warn("BYOB reader not supported, falling back to slice() mode.", error); | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + if (reader) { | ||
| 96 | + // --- 2a. 高性能 BYOB 流模式 --- | ||
| 97 | + let buffer = new Uint8Array(blockSize); // 我们需要一个缓冲区来累积数据,直到达到 4MB | ||
| 98 | + let offset = 0; // 当前缓冲区已填充的数据量 | ||
| 99 | + | ||
| 100 | + while (true) { | ||
| 101 | + // BYOB 读取器需要一个视图 (view) 来写入数据 | ||
| 102 | + // 我们让它写入到我们累积缓冲区的剩余空间 | ||
| 103 | + // 每次都基于当前 buffer 和 offset 创建 view | ||
| 104 | + const view = new Uint8Array(buffer.buffer, offset, buffer.byteLength - offset); | ||
| 105 | + const { done, value } = await reader.read(view); | ||
| 106 | + | ||
| 107 | + // 恢复对 buffer 的引用,因为 read() 后它可能被转移 (detached) | ||
| 108 | + buffer = new Uint8Array(value.buffer); | ||
| 109 | + | ||
| 110 | + if (done) { | ||
| 111 | + // 文件读取完毕,处理最后一个不满 4MB 的块 | ||
| 112 | + if (offset > 0) { | ||
| 113 | + const finalChunkView = new Uint8Array(buffer.buffer, 0, offset); | ||
| 114 | + const chunkSha1 = await createSha1(finalChunkView); | ||
| 115 | + // 最后一次计算文件分片时,不给计算进度,在所有计算都完成后,在给出计算进度 | ||
| 116 | + computed += finalChunkView.byteLength; | ||
| 117 | + sha1Results.push(chunkSha1); | ||
| 118 | + } | ||
| 119 | + break; | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + // 更新偏移量,value.byteLength 是本次实际读取到的字节数 | ||
| 123 | + offset += value.byteLength; | ||
| 124 | + | ||
| 125 | + // 检查缓冲区是否已满 | ||
| 126 | + if (offset === blockSize) { | ||
| 127 | + // 缓冲区满了,计算整个块的 SHA-1 | ||
| 128 | + const chunkSha1 = await createSha1(buffer); | ||
| 129 | + computed += buffer.byteLength; | ||
| 130 | + throttledProgressCallback({ computed: computed, total: file.size, percent: computed / file.size * 100 }); | ||
| 131 | + sha1Results.push(chunkSha1); | ||
| 132 | + offset = 0; // 重置偏移,复用 buffer | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + reader.releaseLock(); // 释放流的锁 | ||
| 136 | + } | ||
| 137 | + else { | ||
| 138 | + // --- 2b. 回退到 slice() 并行模式 --- | ||
| 139 | + const blockCount = Math.ceil(file.size / blockSize); | ||
| 140 | + const promises = []; | ||
| 141 | + | ||
| 142 | + for (let i = 0; i < blockCount; i++) { | ||
| 143 | + const start = i * blockSize; | ||
| 144 | + const end = Math.min(start + blockSize, file.size); | ||
| 145 | + const chunk = file.slice(start, end); | ||
| 146 | + | ||
| 147 | + const promise = (async () => { | ||
| 148 | + const buffer = await chunk.arrayBuffer(); | ||
| 149 | + const chunkSha1 = await createSha1(buffer); | ||
| 150 | + computed += buffer.byteLength; | ||
| 151 | + if (computed < file.size) { | ||
| 152 | + // 最后一次计算文件分片时,不给计算进度,在所有计算都完成后,在给出计算进度 | ||
| 153 | + throttledProgressCallback({ computed: computed, total: file.size, percent: computed / file.size * 100 }); | ||
| 154 | + } | ||
| 155 | + return chunkSha1; | ||
| 156 | + })(); | ||
| 157 | + promises.push(promise); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + // 使用 Promise.all 并行执行所有块的读取和 SHA-1 计算 | ||
| 161 | + const resolvedSha1s = await Promise.all(promises); | ||
| 162 | + sha1Results.push(...resolvedSha1s); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + // --- 3. 最终计算 --- | ||
| 166 | + // 所有块的 SHA-1 计算完毕,进行最终的合并计算 | ||
| 167 | + const concatenatedSha1s = concatArrayBuffers(sha1Results); | ||
| 168 | + const finalSha1 = await createSha1(concatenatedSha1s); | ||
| 169 | + const prefix = new Uint8Array([0x96]); | ||
| 170 | + const finalBuffer = concatArrayBuffers([prefix.buffer, finalSha1]); | ||
| 171 | + | ||
| 172 | + const hash = urlSafeBase64Encode(finalBuffer); | ||
| 173 | + throttledProgressCallback({ computed: file.size, total: file.size, percent: 100 }); | ||
| 174 | + return hash; | ||
| 175 | +} |
| ... | @@ -3066,6 +3066,11 @@ run-parallel@^1.1.9: | ... | @@ -3066,6 +3066,11 @@ run-parallel@^1.1.9: |
| 3066 | dependencies: | 3066 | dependencies: |
| 3067 | queue-microtask "^1.2.2" | 3067 | queue-microtask "^1.2.2" |
| 3068 | 3068 | ||
| 3069 | +rusha@^0.8.14: | ||
| 3070 | + version "0.8.14" | ||
| 3071 | + resolved "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68" | ||
| 3072 | + integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA== | ||
| 3073 | + | ||
| 3069 | rxjs@^7.5.1: | 3074 | rxjs@^7.5.1: |
| 3070 | version "7.5.6" | 3075 | version "7.5.6" |
| 3071 | resolved "https://mirrors.cloud.tencent.com/npm/rxjs/-/rxjs-7.5.6.tgz" | 3076 | resolved "https://mirrors.cloud.tencent.com/npm/rxjs/-/rxjs-7.5.6.tgz" | ... | ... |
-
Please register or login to post a comment