外观
从 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 自身长期无法响应取消、暂停等控制消息。
项目安装:引入 SheetJS 与 Worker
本文使用 SheetJS 解析 xlsx,示例基于 Vite + Vue3。安装依赖:
bash
npm i xlsxVite 对 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_json | 8.6s | 无 | 7.9s | 明显卡死 |
| Worker 普通 postMessage File | 2.4s | 约 420ms | 无明显长任务 | 流畅 |
| Worker 转移 ArrayBuffer | 1.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])
// 后续哈希使用 bufferForHash2. 把 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 内处理、主线程只拿小结果”的原则,避免从解析卡顿变成结果回传卡顿。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。