之前也有做过计算文件MD5做相关校验,最近业务上再次遇上,同时需要大量文件同时计算,难免需要优化相关性能。这里用到分片计算,同时将计算过程放到webworker,避免长时间占用主线程造成卡顿,并且通过队列控制并发,避免占用过多计算机资源,造成浏览器卡顿。
整个文件直接计算MD5
javascript
import SparkMD5 from 'spark-md5'
const spark = new SparkMD5.ArrayBuffer()
export const createMD5 = file => {
// 验证file
return new Promise((resolve,reject)=>{
const fileReader = new FileReader()
fileReader.readAsArrayBuffer(file)
fileReader.onerror = e => {
console.error(e)
return reject(e)
}
fileReader.onload = () => {
try{
resolve(spark.hash(dataBuffer))
}catch(e){
console.error(e)
return reject(e)
}
}
})
}
直接加载整个文件,将整个文件的二进制数据arrayBuffer丢给计算工具计算MD5,如果是一个小文件就还好,但是大文件,批量的文件,一下子将大量资源放到内存里会造成明显的卡顿 .
分片计算MD5
spark-md5推荐:Incremental md5 performs a lot better for hashing large amounts of data, such as files. One could read files in chunks, using the FileReader & Blob's, and append each chunk for md5 hashing while keeping memory usage low.
增量 md5在散列大量数据(比如文件)时表现得更好。可以使用 FileReader & Blob 的块来读取文件,并为 md5哈希添加每个块,同时保持较低的内存使用。
通过File.slice将文件分片,一块一块地增量处理spark.append(chunkArrayBuffer),最后通过spark.end()获取最终的文件md5,可以保持低内存使用,当然,我们平时讲的文件分片上传等也是基于File.slice。
上代码
javascript
import SparkMD5 from 'spark-md5'
/*
* @description 创建文件的MD5值,根据文件大小动态选择分片大小和快速循环的方式
* @param {Object} file el文件对象或文件对象
* @return {String} MD5 值
* */
const maxChunkSize = 1024 * 1024 // 每次处理数据块的最大值,1MB
const chunksPerCycle = 100 // 每个计算周期中处理的数据块数
export const createMD5 = file => {
file = file.raw || file
let stamp = Date.now()
console.time('md计算耗时' + stamp)
return new Promise((resolve, reject) => {
// 分片放worker处理
const fileReader = new FileReader()
try {
let currentChunk = 0
const totalChunks = Math.ceil(file.size / maxChunkSize)
const spark = new SparkMD5.ArrayBuffer()
// 处理数据块
const processChunk = start => {
try {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const end = Math.min(start + maxChunkSize, file.size)
const chunk = blobSlice.call(file, start, end)
fileReader.readAsArrayBuffer(chunk)
} catch (e) {
console.error(e)
}
}
// 文件读取成功
fileReader.onload = () => {
spark.append(fileReader.result) // 将当前数据块内容追加到 MD5 计算器
currentChunk += 1
if (currentChunk >= totalChunks) {
const md5Hash = spark.end() // 完成 MD5 计算
spark.destroy() // 销毁计算器
fileReader.abort() //释放资源
console.timeEnd('计算MD5')
return resolve(md5Hash)
} else if (currentChunk % chunksPerCycle === 0) {
// 在处理指定数量的数据块后,设置一个任务延迟以使 UI 线程有空间处理
setTimeout(() => {
processChunk(currentChunk * maxChunkSize)
}, 0)
} else {
// 继续处理下一个数据块
processChunk(currentChunk * maxChunkSize)
}
}
fileReader.onerror = e => {
console.error(e)
return reject(e)
}
// 开始处理第一个数据块
processChunk(0)
} catch (e) {
fileReader.abort()
reject(e)
} finally {
}
})
}
借助WebWorker优化
md5计算属于高密度计算,如果直接放在js主线程,长时间占用造成卡顿难免对用户体验不好,这里也可以发挥出webworker的优势,本来webworker就是为了线程可以执行任务而不干扰用户界面而生。
这里也有两种方式,
第一种,将File直接传给worker,在worker里面分片读取计算,但是这样的话,因为File对象这种二进制数据直接传递给worker,浏览器会先给FIle做一层拷贝,但是拷贝方式多少会造成性能问题。
当然,二进制数据可以通过Transferable Objects,也就是转移数据的方法,主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了(防止出现多个线程同时修改数据的局面)。那么我们能不能将File通过这种方式传过去呢?不行,Transferable Objects只支持以下几个类型,就算支持将File转过去,我们还得想一个问题就是,将FIle转过去,主线程就不能用这个File对象了,那你后续怎么做进一步的处理,上传啥的。
以下是可以被转移的不同规范的对象:
ArrayBuffer
MessagePort
ReadableStream
WritableStream
TransformStream
AudioData
(en-US)ImageBitmap
VideoFrame
(en-US)OffscreenCanvas
RTCDataChannel
第二种,反正也是切片,我这里就直接在主线程切片读取,将读取后的chunkBuffer通过Transferable Objects传递到worker线程。
worker代码:
php
import SparkMD5 from 'spark-md5'
const spark = new SparkMD5.ArrayBuffer()
self.addEventListener('message', ({ data }) => {
const { dataBuffer, status } = data
try {
if (status === 'ing') {
spark.append(dataBuffer)
} else if (status === 'end') {
self.postMessage({ md5: spark.end(), status: 'success' })
spark.destroy() // 销毁计算器
}
} catch (e) {
self.postMessage({ status: 'error', error: e })
console.error(e)
}
})
主线程:
javascript
/*
* @description 创建文件的MD5值,根据文件大小动态选择分片大小和快速循环的方式
* @param {Object} file el文件对象或文件对象
* @return {String} MD5 值
* */
const maxChunkSize = 1024 * 1024 // 每次处理数据块的最大值,1MB
const chunksPerCycle = 100 // 每个计算周期中处理的数据块数
export const createMD5 = file => {
file = file.raw || file
let stamp = Date.now()
console.time('md计算耗时' + stamp)
let worker = new Worker(
/* webpackChunkName: "md5-encode.worker" */ new URL('../worker/md5-encode.worker.js', import.meta.url)
)
return new Promise((resolve, reject) => {
// 分片放worker处理
const fileReader = new FileReader()
try {
let currentChunk = 0
const totalChunks = Math.ceil(file.size / maxChunkSize)
// 处理数据块
const processChunk = start => {
try {
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const end = Math.min(start + maxChunkSize, file.size)
const chunk = blobSlice.call(file, start, end)
fileReader.readAsArrayBuffer(chunk)
} catch (e) {
console.error(e)
}
}
// 文件读取成功
fileReader.onload = () => {
worker.postMessage({ dataBuffer: fileReader.result, status: 'ing' }, [fileReader.result])
currentChunk += 1
if (currentChunk >= totalChunks) {
worker.postMessage({ status: 'end' })
fileReader.abort()
// console.timeEnd('计算MD5')
return
// return resolve(md5Hash)
} else if (currentChunk % chunksPerCycle === 0) {
// 在处理指定数量的数据块后,设置一个任务延迟以使 UI 线程有空间处理
setTimeout(() => {
processChunk(currentChunk * maxChunkSize)
}, 0)
} else {
// 继续处理下一个数据块
processChunk(currentChunk * maxChunkSize)
}
}
fileReader.onerror = e => {
console.error(e)
return reject(e)
}
worker.onmessage = ({ data }) => {
const { md5, status, error } = data
if (status === 'success') {
resolve(md5)
} else {
reject(error)
}
console.timeEnd('md计算耗时' + stamp)
worker.terminate()
}
// 开始处理第一个数据块
processChunk(0)
} catch (e) {
fileReader.abort()
reject(e)
} finally {
}
})
}
第二种方式地第一种直接传File对象会快出File拷贝的时间,处理一个300多m的文件,整体会快几百ms到1s。
借助队列函数进一步优化
现在计算md5不会堵塞主线程了,但是如果大量文件同时进行分片读取,同样会造成一定的卡顿,同时,大家也要知道,worker开到一定数量也是会造成浏览器卡顿的,和电脑的资源大小有关,所以可以通过队列控制计算md5方法的并发量,避免一下子占用太多资源。
以下是我写的一个队列函数,结合一下上面的createMD5即可达到目的。
javascript
/**
* 队列操作
* @param concurrency 同时执行的数量
* @param fn 操作函数 异步函数
* @param fn.dataItem 操作的数据
* @param fn.getRemoveQueue 撤销排队中的某一个任务
* @returns {function(*=, *=): Promise<unknown>}
* 使用方法
* let removeFn = null
* const getRemoveFn = fn => removeFn = fn
* const handleDataByQueue = createQueue(3, async (dataItem) => {
* await sleep(1000)
* console.log(dataItem)
* return dataItem
* })
* handleDataByQueue(1, getRemoveFn)
* // 取消排队
* setTimeout(() => {
* removeFn && removeFn()
* }, 1000)
* */
export const createQueue = (concurrency, fn) => {
const queue = []
const runningQueue = []
// 撤销排队中的某一个任务
const removeQueue = task => {
const index = queue.findIndex(item => item === task)
if (index !== -1) {
console.log('取消排队')
queue.splice(index, 1)
}
}
const process = (dataItem, getRemoveQueueSource) => {
return new Promise((resolve, reject) => {
const run = async () => {
if (runningQueue.length >= concurrency) {
queue.push(run)
getRemoveQueueSource && getRemoveQueueSource(() => removeQueue(run))
return
}
runningQueue.push(run)
try {
const result = await fn(dataItem)
resolve(result)
} catch (e) {
reject(e)
} finally {
runningQueue.splice(runningQueue.indexOf(run), 1)
if (queue.length) {
queue.shift()()
}
}
}
run()
})
}
return process
}
使用:
scss
// 10个并发量
const createMD5ByQueue = createQueue(10, createMD5)
// createMD5ByQueue(file1)
// createMD5ByQueue(file2)
// createMD5ByQueue(file3)
// createMD5ByQueue(file4)
如有不妥,多多指教!