切换语言为:繁体

文件MD5生成性能大提升!前端如何实现分片与Worker优化

  • 爱糖宝
  • 2024-11-08
  • 2048
  • 0
  • 0

在处理大文件上传时,生成文件的MD5哈希是一项重要的工作,可以用于文件校验、重复文件检查和数据完整性验证。然而,传统的MD5计算方法在处理大文件(如1GB以上的文件)时,往往效率较低。如何通过分片(chunking)技术和Web Worker实现MD5计算的显著性能提升。

我们通过这种优化方式,将1.67GB文件的MD5生成时间从18秒缩短到了仅3秒!

旧版实现:串行分片MD5计算

在原始的实现中,我们使用 FileReader 对文件进行分片处理,每次将分片转为ArrayBuffer,再通过SparkMD5库的增量计算功能逐片计算文件的MD5。虽然这种方法能够实现文件的逐片处理,但它仅使用单线程顺序计算,因此在大文件上会花费大量时间。以下是旧版实现的代码结构:

function createHash(chunks: Blob[]) {
    return new Promise<string>(resolve => {
        const spark = new SparkMD5.ArrayBuffer()
        function _hash(index: number) {
            if (index >= chunks.length) {
                resolve(spark.end())
                return
            }
            const reader = new FileReader()
            reader.readAsArrayBuffer(chunks[index])
            reader.onload = e => {
                spark.append(e.target?.result as ArrayBuffer)
                _hash(index + 1)
            }
        }
        _hash(0)
    })
}

新版优化:利用Web Worker进行并行计算

新版实现的核心思路是通过Web Worker将分片处理和MD5计算过程并行化。具体来说,将文件划分为多个块,每个块通过Worker分发到独立的线程中进行处理,这样能够有效提高处理速度。

新版实现的关键步骤如下:

  1. 文件分块与分片:将文件划分为更大的块(例如50MB),并进一步拆分为小的分片(例如10MB),以适应Web Worker的处理。

  2. Worker管理与并发控制:通过线程池机制限制活跃Worker数量,避免性能瓶颈。

  3. 并行计算进度与总哈希合并:每个Worker计算完一个块的MD5后,将结果传回主线程,主线程合并所有块的哈希,得出最终的MD5值。

以下是新版优化中的关键代码:

const worker = new Worker('./hashWorker.js')
worker.postMessage({ chunks: chunkArray })
worker.onmessage = event => {
    if (event.data.hash) {
        workerResolve(event.data.hash)
    } else if (event.data.progress) {
        progress.value += (event.data.progress / totalSize) * 100
    }
}

性能对比:3秒 vs 18秒

使用新版的优化方法,我们将1.67GB文件的MD5计算时间从18秒降低到了3秒,提升了6倍以上的速度。这种性能提升在处理大文件时尤为显著,为用户提供了更加流畅的文件上传体验。

整个流程如下

处理大文件的上传和哈希计算可能会面临性能瓶颈。以下是通过分块上传和 Web Worker 并发计算优化大文件 MD5 哈希值的完整步骤:

  1. 创建文件块和分片

  • 将大文件分割为若干个较大的块(如每块 50MB)。

  • 将每个块进一步拆分为更小的分片(如每片 10MB)。

  • createChunks 函数中创建块结构,在 createChunksFromBlock 函数中完成从块到分片的转换。

  1. 初始化 Worker

  • 使用线程池管理 Web Worker 并发数量,限制同时处理的 Worker 数量,避免性能压力。

  • 通过 postMessage 方法向 Worker 发送分片和块的总字节数。

  1. 在 Worker 中处理分片

  • 在 Worker 中接收分片数据,利用 FileReader 逐片读取内容并通过 SparkMD5 进行增量哈希计算。

  • 每处理完一个分片后,向主线程发送进度(已处理字节数)和中间哈希值。

  1. 计算总进度

  • 主线程根据每个块的处理情况更新整体进度。

  • 每当一个块的哈希计算完成,增加已处理块计数,并相应更新总进度。

  1. 合并哈希值

  • 所有块的哈希计算完成后,主线程收集每个块的哈希值。

  • 使用 SparkMD5 合并各块的哈希,最终生成整个文件的 MD5 值。

  1. 返回结果

  • 所有块的处理完成后,返回最终文件的 MD5 哈希值和总进度(通常为 100%)。


通过Web Worker与分片技术的结合,我们显著提升了大文件的MD5计算性能,这种方法不仅适用于MD5计算,也可用于其他大文件的处理场景。未来,我们可以进一步优化多线程逻辑,如增加断点续传、内存优化等,让文件处理更加高效!

完整代码

  1. hashWorker.js

// hashWorker.js
// importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js')
importScripts('/node_modules/spark-md5/spark-md5.min.js')

self.onmessage = function (event) {
  const chunks = event.data.chunks

  const spark = new SparkMD5.ArrayBuffer()
  let loaded = 0

  const readChunk = index => {
    if (index >= chunks.length) {
      self.postMessage({ hash: spark.end() })
      return
    }

    const fileReader = new FileReader()
    fileReader.onload = e => {
      spark.append(e.target.result)
      loaded++
      const chunkSize = chunks[index].size
      // 发送当前块已读字节数
      // 当前已读字节数
      const progress = loaded * chunkSize
      // 发送当前块的进度
      self.postMessage({ progress, size: chunkSize })
      readChunk(index + 1)
    }
    fileReader.onerror = () => {
      self.postMessage({ error: 'File reading error' })
    }
    fileReader.readAsArrayBuffer(chunks[index])
  }

  readChunk(0)
}

  1. useFileChunks

// useFileChunks.ts
import { ref } from 'vue'
import SparkMD5 from 'spark-md5'

/**
 *
 * 大文件分块上传和哈希
 * 1. 创建文件块和分片:
 * 1.1 将文件分成若干个较大的块(例如 50MB),每个块内部再分成更小的分片(例如 10MB)。
 * 1.2 在 createChunks 函数中实现块的创建,并在 createChunksFromBlock 函数中实现从块到分片的转换。
 *
 * 2. 初始化 Worker:
 * 2.1 在处理每个块时,使用线程池管理并发的 Web Worker,限制同时处理的 Worker 数量以避免性能问题。
 * 2.2 通过 postMessage 方法将分片和当前块的总字节数发送给 Worker。
 *
 * 3. 在 Worker 中处理分片:
 * 3.1 在 Worker 中接收分片数据,通过 FileReader 读取每个分片的内容,并使用 SparkMD5 计算每个分片的哈希值。
 * 3.2 每读取完一个分片,向主线程发送当前进度(已读取字节数)和最终的哈希值。
 *
 * 4. 计算总进度:
 * 4.1 在主线程中,根据每个块的处理进度,计算和更新总进度。
 * 4.2 每当一个块的哈希计算完成时,增加已处理的块计数,并根据已处理块数更新总进度。
 *
 * 5. 合并哈希值:
 *  在所有块的哈希计算完成后,收集每个块的哈希值,并使用 SparkMD5 合并这些哈希值,得到最终的文件哈希。
 *
 * 6. 返回结果:
 *  在所有处理完成后,返回文件的 MD5 哈希值和当前进度(可能达到 100%)。
 * @param blockSize 块大小 (默认 50MB)
 * @param chunkSize 分片大小 (默认 10MB)
 * @returns {md5: string, progress: number}
 */
export const useFileChunk = (
  blockSize = 50 * 1024 * 1024,
  chunkSize = 10 * 1024 * 1024,
) => {
  const md5 = ref<string>()
  const progress = ref<number>(0)
  const activeWorkers = ref<number>(0)

  // 设置最大 Worker 数量
  const MAX_WORKERS = 4

  // 创建块和分片
  function createChunks(file: File) {
    const blocks = []
    let cur = 0

    // 分块
    while (cur < file.size) {
      const block = file.slice(cur, cur + blockSize)
      blocks.push(block) // 保存块本身以计算字节数
      cur += blockSize
    }

    return blocks
  }

  // 从块中创建分片
  function createChunksFromBlock(block: Blob) {
    const result = []
    let cur = 0

    while (cur < block.size) {
      const chunk = block.slice(cur, cur + chunkSize)
      result.push(chunk)
      cur += chunkSize
    }

    return result
  }

  async function get(file: File) {
    if (!file) throw new Error('File is required')
    progress.value = 0
    md5.value = ''

    const blocks = createChunks(file) // 创建块和分片
    const totalBlocks = blocks.length // 计算块总数

    const workerPromises = blocks.map(block => {
      return new Promise((workerResolve, workerReject) => {
        const processBlock = () => {
          if (activeWorkers.value < MAX_WORKERS) {
            activeWorkers.value++
            const worker = new Worker(
              new URL('./hashWorker.js', import.meta.url),
            )
            const chunkArray = createChunksFromBlock(block)

            // 将块中的所有片发送给 Worker
            worker.postMessage({ chunks: chunkArray })

            worker.onmessage = event => {
              if (event.data.hash) {
                workerResolve(event.data.hash) // 返回当前块的哈希
                activeWorkers.value-- // 完成后减少活跃 Worker 数量
              } else if (event.data.size !== undefined) {
                // 更新块进度
                progress.value += (event.data.size / file.size) * 100
              } else if (event.data.error) {
                workerReject(event.data.error)
                activeWorkers.value-- // 出错时也减少活跃 Worker 数量
              }
            }

            worker.onerror = error => {
              workerReject('Worker error: ' + error.message)
              activeWorkers.value-- // 出错时减少活跃 Worker 数量
            }
          } else {
            // 如果当前活跃 Worker 已满,稍后重试
            setTimeout(processBlock, 100)
          }
        }

        processBlock() // 启动块处理
      })
    })

    // 等待所有块的哈希返回
    const hashes: string[] = (await Promise.all(
      workerPromises,
    )) as unknown as string[]

    // 合并哈希值
    const finalSpark = new SparkMD5()
    hashes.forEach(hash => finalSpark.append(hash))
    md5.value = finalSpark.end() // 计算最终的 MD5 值

    return { md5: md5.value, progress: progress.value, chunks: [] }
  }

  return { getFileInfo: get, progress }
}


作者:ErvinHowell
链接:https://juejin.cn/post/7434460220845539367

0条评论

您的电子邮件等信息不会被公开,以下所有项均必填

OK! You can skip this field.