跳到主内容

从 8.6s 卡死到 1.1s:Web Worker 分片解析 10 万行 Excel 实测 ​

问题背景:10 万行 Excel 为什么会卡死页面 ​

在后台管理、数据导入、BI 配置等场景里,前端直接解析 Excel 很常见。小文件时一切正常,但当文件达到 10 万行、20 列以上时,使用 SheetJS 或类似库在主线程直接读取,很容易出现以下现象:

  • 点击导入后按钮长时间无响应;
  • 输入框、弹窗动画、滚动全部掉帧;
  • Chrome DevTools 里出现数秒红色 Long Task;
  • 文件解析完成前,Vue3 的 loading 状态甚至来不及渲染。

根本原因是:Excel 解析属于 CPU 密集型任务。浏览器主线程同时负责 JavaScript 执行、DOM 更新、样式计算和用户交互,一旦解析函数连续占用线程,渲染管线就会被阻塞。

实测一个约 38MB、100000 行、18 列的 xlsx 文件,在 MacBook Air M2、Chrome 126 下,主线程直接解析平均耗时约 8.6 秒,并伴随明显卡死;将解析放入 Web Worker 后,页面交互保持流畅;再配合分片调度和 Transferable 传输 ArrayBuffer,整体可稳定在约 1.1 到 1.5 秒,且文件发送阶段不产生结构化克隆拷贝。

方案对比:主线程、Worker、分片调度怎么选 ​

方案页面是否卡顿数据传输成本实现复杂度适用场景
主线程直接解析明显卡顿无低1 万行以内
Web Worker 全量解析不卡顿普通 postMessage 会复制中中等文件
Worker + Transferable不卡顿ArrayBuffer 所有权转移,接近零拷贝中大文件导入
Worker + 分片调度不卡顿,可显示进度多次消息调度较高10 万行以上、需进度反馈
WASM 解析器性能可能更好需处理内存与胶水代码高超大规模、固定格式

这里的“零拷贝”需要严谨理解:Transferable 并不是让 JS 对象完全不经过浏览器内部处理,而是转移 ArrayBuffer 的底层内存所有权。调用后,主线程中的 ArrayBuffer 会变为 neutered 状态,长度变成 0,不能继续读取。Worker 接收后直接持有同一块内存,避免了结构化克隆带来的复制成本。

架构设计:Worker 分片调度流程 ​

整体思路是:主线程只负责选择文件、展示进度和接收结果;Worker 负责读取工作簿、按行切片、逐片解析,并在每个切片之间让出事件循环,避免 Worker 自身长期无法响应取消、暂停等控制消息。

选择 Excel 文件读取 ArrayBufferpostMessageTransferable 转移Web Worker解析工作簿按 10000 行分片逐片生成 JSON进度消息支持取消任务Vue3 页面渲染进度与结果

项目安装:引入 SheetJS 与 Worker ​

本文使用 SheetJS 解析 xlsx,示例基于 Vite + Vue3。安装依赖:

bash
npm i xlsx

Vite 对 Web Worker 提供原生支持,推荐使用 new Worker(new URL('./excel.worker.js', import.meta.url), { type: 'module' })。这样 Worker 可以独立打包,也能在模块中引入第三方库。

主线程实现:零拷贝发送 ArrayBuffer ​

下面是可直接放入 Vue3 组件的核心代码。文件通过 file.arrayBuffer() 读取后,不直接把 File 对象发给 Worker,而是发送底层 ArrayBuffer,并放入 postMessage 第二个参数的 transfer 列表。

vue
<template>
  <section>
    <input type="file" accept=".xlsx,.xls" @change="handleFile" :disabled="loading" />
    <button v-if="loading" @click="cancel">取消解析</button>
    <p v-if="loading">进度:{{ progress }}%,已处理 {{ parsedRows }} 行</p>
    <p v-else>总行数:{{ parsedRows }}</p>
  </section>
</template>

<script setup>
import { ref, onBeforeUnmount } from 'vue'

const loading = ref(false)
const progress = ref(0)
const parsedRows = ref(0)
let worker = null

function createWorker() {
  return new Worker(
    new Worker(new URL('./excel.worker.js', import.meta.url), {
      type: 'module'
    })
  )
}

async function handleFile(event) {
  const file = event.target.files?.[0]
  if (!file) return

  loading.value = true
  progress.value = 0
  parsedRows.value = 0

  const buffer = await file.arrayBuffer()
  worker = createWorker()

  worker.onmessage = (event) => {
    const { type, rows, total, current, message } = event.data

    if (type === 'progress') {
      parsedRows.value = current
      progress.value = Math.round((current / total) * 100)
      return
    }

    if (type === 'done') {
      parsedRows.value = rows.length
      progress.value = 100
      loading.value = false
      worker.terminate()
      worker = null
      console.log('前 100 行预览:', rows.slice(0, 100))
    }

    if (type === 'error') {
      loading.value = false
      console.error(message)
      worker.terminate()
      worker = null
    }
  }

  // 关键:第二个参数声明 transfer,ArrayBuffer 所有权转移给 Worker
  worker.postMessage(
    {
      type: 'parse',
      buffer,
      filename: file.name,
      chunkSize: 10000
    },
    [buffer]
  )

  // 注意:执行到这里后,主线程的 buffer 已被转移,不能再读取。
  console.log(buffer.byteLength) // 0
}

function cancel() {
  worker?.postMessage({ type: 'cancel' })
}

onBeforeUnmount(() => {
  worker?.terminate()
})
</script>

这里有一个容易忽略的细节:postMessage 的第二个参数是 transfer 列表,不是普通配置项。若写成 worker.postMessage(data, buffer),浏览器无法按 Transferable 处理,仍可能走结构化克隆。

Worker 实现:分片解析与让出事件循环 ​

创建 excel.worker.js:

javascript
import * as XLSX from 'xlsx'

let cancelled = false
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

self.onmessage = async (event) => {
  const { type } = event.data

  if (type === 'cancel') {
    cancelled = true
    return
  }

  if (type !== 'parse') return

  try {
    const { buffer, chunkSize = 10000 } = event.data
    cancelled = false

    // 读取工作簿,dense 可减少稀疏数组带来的额外开销
    const workbook = XLSX.read(buffer, { type: 'array', dense: true })
    const firstSheetName = workbook.SheetNames[0]
    const worksheet = workbook.Sheets[firstSheetName]

    // 先得到二维数组,避免一次性 sheet_to_json 生成大量对象
    const matrix = XLSX.utils.sheet_to_json(worksheet, {
      header: 1,
      raw: true,
      defval: null
    })

    if (!matrix.length) {
      self.postMessage({ type: 'done', rows: [] })
      return
    }

    const headers = matrix[0].map((cell, index) => {
      return cell === null || cell === undefined ? `column_${index + 1}` : String(cell)
    })

    const total = matrix.length - 1
    const result = []

    for (let start = 1; start <= total; start += chunkSize) {
      if (cancelled) {
        self.postMessage({ type: 'done', rows: [], cancelled: true })
        return
      }

      const end = Math.min(start + chunkSize, total + 1)

      for (let rowIndex = start; rowIndex < end; rowIndex++) {
        const row = matrix[rowIndex] || []
        const record = {}

        for (let colIndex = 0; colIndex < headers.length; colIndex++) {
          record[headers[colIndex]] = row[colIndex] ?? null
        }

        result.push(record)
      }

      self.postMessage({
        type: 'progress',
        current: end - 1,
        total
      })

      // 每处理一片主动让出,使 cancel 等消息有机会进入事件循环
      await sleep(0)
    }

    // 大结果不建议整体回传主线程,可按业务只回传预览或聚合结果
    self.postMessage({
      type: 'done',
      rows: result.slice(0, 1000),
      totalRows: result.length
    })
  } catch (error) {
    self.postMessage({
      type: 'error',
      message: error instanceof Error ? error.message : String(error)
    })
  }
}

为什么分片大小建议先设 10000 行 ​

分片过小,例如 100 行一片,会产生大量消息和 setTimeout 调度,反而增加总耗时;分片过大,例如 50000 行一片,进度更新粗糙,取消指令也可能延迟。10000 行是比较稳妥的初始值:在 10 万行数据里约产生 10 个切片,既能及时更新进度,也不会引入过多消息开销。

正式项目中可根据设备性能动态调整:低端设备使用 5000,桌面端使用 10000 到 20000。不要盲目固定为一个常量。

实测数据:传输优化到底省了什么 ​

在同一份 38MB、100000 行文件上进行 5 次测试,取中位数:

实现方式解析耗时发送文件阶段主线程 Long Task页面交互
主线程 XLSX.read + sheet_to_json8.6s无7.9s明显卡死
Worker 普通 postMessage File2.4s约 420ms无明显长任务流畅
Worker 转移 ArrayBuffer1.5s< 10ms无明显长任务流畅
Worker + 分片 + 进度1.6s< 10ms无明显长任务流畅,可取消

可以看到,最大收益首先来自把 CPU 任务移出主线程;Transferable 主要降低大文件发送时的克隆成本;分片调度并不会显著提升纯计算速度,它的价值在于进度反馈、取消响应和避免 Worker 消息饥饿。

避坑总结:大文件 Excel 解析的实战注意事项 ​

1. Transfer 后继续读取 ArrayBuffer,得到空数据或报错 ​

常见错误现象是:发送给 Worker 后,主线程还想用同一个 buffer 做 MD5 校验、文件预览或二次解析,结果 byteLength 变成 0。

修正方法:在 transfer 之前完成校验,或在转移前使用 buffer.slice(0) 复制一份。复制会带来额外内存成本,不应无意义使用。

javascript
const bufferForParse = await file.arrayBuffer()
const bufferForHash = bufferForParse.slice(0)

worker.postMessage({ buffer: bufferForParse }, [bufferForParse])
// 后续哈希使用 bufferForHash

2. 把 10 万行完整结果 postMessage 回主线程 ​

很多人把解析移到 Worker 后,又把 10 万个对象一次性传回页面,导致主线程反序列化卡顿,内存瞬间上涨。

修正方法:主线程只接收表格预览、分页数据、校验结果或聚合指标。如果确实需要后续处理,可在 Worker 内继续完成过滤、字段映射、错误统计,再传小结果。

3. Worker 内写死同步循环,取消按钮没有反应 ​

即使任务在 Worker 中,紧密的同步 for 循环仍会占满 Worker 的事件循环,主线程发出的 cancel 消息要等当前同步任务结束后才能处理。

修正方法:按固定行数切片,每片结束后 await setTimeout(0),让 Worker 有机会处理控制消息。若追求更高调度精度,也可以使用 MessageChannel 构建分片队列。

4. 日期、数字和空单元格格式与预期不一致 ​

Excel 单元格存在序列号日期、科学计数法、空单元格和合并单元格等问题。使用 raw: false 会让库格式化值,但性能下降且可能改变原始语义;完全不处理空值,又会出现字段缺失。

修正方法:导入阶段保留原始值,用 raw: true 和 defval: null,再针对日期列、金额列做显式转换。不要依赖库自动猜测业务类型。

5. Vite 开发环境正常,生产构建找不到 Worker ​

常见原因是把 Worker 路径写成普通字符串,或在动态路径中使用运行时变量,导致构建工具无法静态分析。

修正方法:使用 new URL('./excel.worker.js', import.meta.url),路径必须是静态可分析的相对路径;部署到子路径时,由 Vite 的 base 配置统一处理,不要手写 CDN 地址。

结论 ​

解析 10 万行 Excel 的关键不是单纯寻找“更快的 API”,而是把 CPU 密集任务移出主线程、减少大缓冲区复制、通过分片让任务可控。Web Worker 解决页面卡死,Transferable 解决大文件传输复制,分片调度解决进度与取消。三者组合后,浏览器端处理十万级表格已经可以达到可上线的体验,但完整业务数据仍应遵循“Worker 内处理、主线程只拿小结果”的原则,避免从解析卡顿变成结果回传卡顿。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。