用 Web Worker 计算大视频文件 Hash:从“页面卡死”到流畅上传

在做视频上传系统时,产品经理提了个需求:

"所有上传的 .mov 视频文件,必须先计算 SHA-256 Hash,防止重复上传。"

我心想:"简单",于是写了段同步代码:

js 复制代码
// ❌ 危险!不要这样写
function calcHash(file) {
  const reader = new FileReaderSync()
  const arrayBuffer = reader.readAsArrayBuffer(file)
  const hash = crypto.subtle.digest('SHA-256', arrayBuffer)
  return hash
}

结果测试时,一个 1GB 的 .mov 文件直接让页面卡了 30 秒,用户根本没法操作。

这就是典型的 主线程阻塞 问题。今天我们用 Web Worker 来解决它。


一、问题场景:大文件 Hash 计算导致 UI 卡死

我们有个视频素材管理系统,要求:

  1. 用户上传 .mov 视频文件
  2. 前端计算文件的 SHA-256 Hash
  3. 检查服务端是否已存在相同文件
  4. 避免重复上传

但大文件的 Hash 计算是 CPU 密集型操作,会阻塞主线程,导致:

  • 页面无响应
  • 动画卡顿
  • 用户以为浏览器崩溃

二、解决方案:用 Web Worker 异步计算 Hash

我们把计算逻辑移到 Web Worker 中:

js 复制代码
// main.js(主线程)
async function calcHash(file) {
  return new Promise((resolve, reject) => {
    // 创建 Worker
    const worker = new Worker('/hash-worker.js', { type: 'module' })

    // 监听结果
    worker.onmessage = (e) => {
      if (e.data.error) {
        reject(new Error(e.data.error))
      } else {
        resolve(e.data.hash)
      }
      worker.terminate() // 🔍 用完即毁
    }

    worker.onerror = (err) => {
      reject(err)
      worker.terminate()
    }

    // 发送文件给 Worker
    worker.postMessage({ file }, [file]) // 🔐 Transferable 优化
  })
}

// 使用
document.getElementById('fileInput').addEventListener('change', async (e) => {
  const file = e.target.files[0]
  if (file && file.name.endsWith('.mov')) {
    try {
      const hash = await calcHash(file)
      console.log('File Hash:', hash)
      // 继续上传逻辑...
    } catch (err) {
      console.error('Hash 计算失败:', err)
    }
  }
})
js 复制代码
// hash-worker.js(Worker 线程)
self.onmessage = async function(e) {
  const { file } = e.data
  
  try {
    // 🔍 分块读取,避免内存溢出
    const chunkSize = 1024 * 1024 // 1MB 每块
    const hasher = new Hasher('SHA-256')
    
    let offset = 0
    while (offset < file.size) {
      const chunk = file.slice(offset, offset + chunkSize)
      const arrayBuffer = await chunk.arrayBuffer()
      hasher.update(arrayBuffer)
      offset += chunkSize
      
      // 🔐 定期通知主线程进度
      if (offset % (chunkSize * 10) === 0) {
        self.postMessage({ 
          progress: Math.round((offset / file.size) * 100) 
        })
      }
    }
    
    const hash = await hasher.digest()
    self.postMessage({ hash })
  } catch (err) {
    self.postMessage({ error: err.message })
  }
}

// 简易 Hasher 类(实际可用 crypto.subtle)
class Hasher {
  constructor(algorithm) {
    this.algorithm = algorithm
    this.chunks = []
  }
  
  update(chunk) {
    this.chunks.push(chunk)
  }
  
  async digest() {
    const combined = this.concatArrays(this.chunks)
    const hashBuffer = await crypto.subtle.digest(this.algorithm, combined)
    return this.bufferToHex(hashBuffer)
  }
  
  concatArrays(arrays) {
    const totalLength = arrays.reduce((acc, arr) => acc + arr.byteLength, 0)
    const result = new Uint8Array(totalLength)
    let offset = 0
    arrays.forEach(arr => {
      result.set(new Uint8Array(arr), offset)
      offset += arr.byteLength
    })
    return result
  }
  
  bufferToHex(buffer) {
    return Array.from(new Uint8Array(buffer))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('')
  }
}

三、原理剖析:从表面到浏览器线程模型的三层机制

1. 表面用法:Web Worker 是什么?

Web Worker 是运行在后台的 JavaScript 线程,独立于主线程,不会阻塞 UI。

我们来画一张 主线程与 Worker 线程通信图

scss 复制代码
[主线程]                    [Worker 线程]
   |                               |
   |--- postMessage(data) -------->|
   |                               |
   |                               | → 执行耗时计算
   |                               |
   |<-- onmessage(result) ---------|
   |                               |
  • 通信必须通过 postMessage / onmessage
  • 数据传递是复制转移(Transferable)
  • 无法访问 DOM、windowdocument

2. 底层机制:浏览器如何调度 Worker?

当创建 Web Worker 时:

markdown 复制代码
1. 浏览器创建新线程(或从线程池分配)
2. 加载并执行指定脚本
3. 主线程与 Worker 通过消息队列通信
4. V8 引擎为每个线程维护独立的堆内存
5. 垃圾回收独立进行
6. GPU/CPU 资源由操作系统调度

关键优化点:

  • Transferable Objects :如 ArrayBuffer,可以用 postMessage(data, [data]) 转移所有权,避免复制
  • SharedArrayBuffer:多个线程共享内存(需 HTTPS + COOP/COEP)
  • Worker Pool:复用 Worker 实例,避免频繁创建销毁

3. 设计哲学:为什么需要 Web Worker?

问题 Web Worker 解决方案
主线程阻塞 耗时任务移出主线程
UI 卡顿 保持 60fps 流畅渲染
大文件处理 分块计算 + 进度反馈
并行计算 多 Worker 处理不同任务

💡 类比:

Web Worker 就像"后台厨房"------

  • 前台(主线程)负责接待顾客(UI 交互)
  • 厨房(Worker)负责做菜(计算任务)
  • 服务员(MessageChannel)传递订单和菜品

四、实战优化:处理超大 .mov 文件

1. 分块读取避免内存溢出

js 复制代码
async function* readInChunks(file, chunkSize = 1024 * 1024) {
  let offset = 0
  while (offset < file.size) {
    const chunk = file.slice(offset, offset + chunkSize)
    yield await chunk.arrayBuffer()
    offset += chunkSize
  }
}

// Worker 中使用
for await (const chunk of readInChunks(file)) {
  hasher.update(chunk)
}

2. 添加进度反馈

js 复制代码
// Worker
self.postMessage({ progress: 50 })

// 主线程
worker.onmessage = (e) => {
  if (e.data.progress !== undefined) {
    updateProgressBar(e.data.progress)
  }
}

3. 错误处理与超时

js 复制代码
const worker = new Worker('/hash-worker.js')
const timeout = setTimeout(() => {
  worker.terminate()
  reject(new Error('Hash 计算超时'))
}, 60000) // 60秒超时

worker.onmessage = (e) => {
  clearTimeout(timeout)
  // 处理结果
}

五、对比主流方案

方案 优点 缺点 适用场景
Web Worker 真实多线程,不阻塞 UI 通信成本高,不能访问 DOM 大文件计算、加密、解析
setTimeout 分片 简单,兼容性好 仍是单线程,总耗时不变 小任务分片
requestIdleCallback 利用空闲时间 执行时机不确定 低优先级任务
WebAssembly + Worker 极致性能 学习成本高 视频编码、图像处理

六、举一反三:三个变体场景实现思路

  1. 需要计算多个文件的 Hash
    创建 Worker 池,并发处理多个文件。
js 复制代码
class WorkerPool {
  constructor(size, script) {
    this.workers = Array(size).fill().map(() => new Worker(script))
    this.queue = []
  }
  
  exec(task) {
    const worker = this.workers.find(w => !w.busy)
    if (worker) {
      worker.busy = true
      worker.postMessage(task)
      return worker
    } else {
      return new Promise(resolve => this.queue.push({ task, resolve }))
    }
  }
}
  1. 实现断点续算(大文件上传)

    计算每块的 Hash,最后合并,支持暂停恢复。

  2. 结合 Service Worker 预计算

    在用户选择文件后,后台静默计算 Hash,提升上传体验。


总结

Web Worker 不是"高级技巧",而是处理 CPU 密集型任务的必备工具

记住这个口诀:

耗时任务移出主线程,
消息通信是唯一通道,
分块读取防内存爆,
用完即毁别忘了。

当你在处理:

  • 大文件 Hash 计算
  • 图片/视频压缩
  • 数据加密解密
  • 复杂 JSON 解析

先想 Web Worker,再想其他方案

它可能增加一点复杂度,但换来的是流畅的用户体验和专业的工程素养

下次再有人问你"怎么避免页面卡死",别再说"加个 loading"了------直接甩出这行代码:

js 复制代码
const worker = new Worker('/task.js')
worker.postMessage(data)
相关推荐
@大迁世界8 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路17 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug20 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213822 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中44 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全