70MB Excel 浏览器预览失败:定位 XLSX 重复解压与内存峰值

一份 70MB 的 .xlsx 在 File Viewer 2.2.5 里连续失败:Spreadsheet Worker 先报错并回退主线程,随后图表解析抛出 RangeError: Invalid string length

我在维护 File Viewer。排查这次问题时,最先排除的是"70MB 文件天然超出浏览器能力"这个结论。堆栈显示,失败发生在图表发现阶段,而不是单元格、公式或样式解析阶段。继续追下去,原因很具体:主解析已经处理过 worksheet,图表链路又把同一份 worksheet XML 解压成了完整字符串。

这篇记录完整的读取路径、修改点和回归断言。修复已在 2.2.6 发布;2026-08-18 复核时,npm 当前可安装版本为 2.2.9

先确认失败发生在哪条链路

.xlsx 本质上是 ZIP 容器。一个工作簿通常包含 workbook、worksheet、共享字符串、样式、drawing、chart 以及它们的关系文件。

问题样本的公开错误是:

text 复制代码
[file-viewer] Spreadsheet chart parsing failed;
continuing with cell content.
RangeError: Invalid string length

这里有两个信号。

第一,主解析已经进入图表增强阶段,不能简单归因为文件下载失败。第二,Invalid string length 指向一次超大文本展开,应该先找 ZIP entry 到字符串的转换位置,而不是先调 Worker 阈值。

磁盘上的 70MB 只是压缩包大小。worksheet 解压后会膨胀,再转换为 JavaScript 字符串、XML DOM 和渲染模型,峰值内存会继续增加。排查大文件时,只限制上传体积远远不够,还要统计同一份主体内容被解压、复制和解析了多少次。

旧实现为什么会重复展开 worksheet

主解析器先调用 SheetJS read() 建立 workbook。为了恢复 Excel 图表,旁路还会读取 OOXML 关系,定位 drawing 和 chart part。

旧代码同时加载 worksheet 正文和关系文件:

ts 复制代码
const [worksheetDocument, worksheetRelationships] = await Promise.all([
  loadXml(zip, worksheetRelationship.target),
  loadRelationships(zip, worksheetRelationship.target)
])

const drawingParts = elementsByLocal(
  worksheetDocument.documentElement,
  'drawing'
)
  .map((drawing) =>
    relationById(worksheetRelationships, relationshipId(drawing))
  )
  .map((relationship) => relationship!.target)

loadXml() 会让 JSZip 把 sheet1.xml 解压成完整文本,然后构建 XML DOM。图表链路只是为了找 <drawing r:id="rId1"/>,却再次展开了整张工作表。

小表格里,这个问题不容易暴露。worksheet 足够大时,第二次展开可能先撞上 V8 字符串上限,图表 XML 甚至还没开始解析。

图表发现只需要关系文件

OOXML 已经把 drawing 位置写进 sheet1.xml.rels。关系文件给出 worksheet 到 drawing 的映射,drawing 自己的关系文件再指向 chart XML。

因此可以直接删掉 worksheet 正文读取:

ts 复制代码
const worksheetRelationships = await loadRelationships(
  zip,
  worksheetRelationship.target
)

const drawingParts = Array.from(new Set(
  worksheetRelationships
    .filter((relationship) =>
      relationship.type.endsWith('/drawing')
    )
    .map((relationship) => relationship.target)
))

修复后的发现路径是:

text 复制代码
workbook.xml
  -> workbook.xml.rels
  -> sheet1.xml.rels
  -> drawing1.xml
  -> chart1.xml

sheet1.xml 不再为了发现图表被读成字符串。图表的位置、类型、系列和数值仍由 drawing 与 chart part 恢复。

这类优化不靠更大的内存限额,也不靠把 XML 循环改快一点。只需要元数据时,就不要展开主体内容。

回归测试要断言"某件事没有发生"

只验证"工作簿能打开、图表数量为 1"不够。重复读取即使重新出现,功能结果仍可能正确,内存风险却已经回来。

专项回归做了三件事:

  1. 动态生成带 16 MiB worksheet XML 的 XLSX;
  2. 监控 JSZip 对 sheet1.xmlasync('text') 调用,只要发生就抛出同类 RangeError
  3. 同时验证柱状图的位置、系列名、分类和数值。
ts 复制代码
expect(worksheetTextReads).toBe(0)
expect(charts['Large Sheet']?.[0]).toMatchObject({
  id: 'Large workbook chart',
  type: 'bar',
  series: [{
    name: 'Revenue',
    categories: ['Q1', 'Q2'],
    values: [10, 20]
  }]
})

16 MiB 是可重复的合成规模,不是对原始 70MB 文件的伪造复刻,也不是性能基准。这个 fixture 的职责很单一:只要图表路径重新读取 worksheet 文本,测试必须立即失败。

修复边界

这次修改只去掉图表发现阶段的一次重复展开,不能推出"所有 70MB Excel 都能在浏览器里稳定打开"。

大 XLSX 仍可能在共享字符串、样式、公式、图片或异常整表维度上消耗大量内存。设备内存、浏览器版本和 Android WebView 环境也会改变结果。

如果业务要求低端手机稳定处理任意超大表格,应该评估服务端预处理、分页数据、摘要视图或下载后交给专业工具。浏览器端优化能删除明确浪费,但不能替所有输入兜底。

可以复用的排查顺序

再遇到浏览器大文件问题,我会按下面顺序查:

  1. 统计压缩后和解压后的体积,不只看上传文件大小;
  2. 画出主解析、搜索、缩略图、图表和统计各自的读取路径;
  3. 优先使用索引、目录、关系文件或偏移表,避免为了入口扫描全文;
  4. 让图表、缩略图等增强能力可以失败,不拖垮仍可读的正文;
  5. 在测试里断言读取次数、对象创建次数或 Worker 边界,而不只断言最终 UI。

这套方法同样适用于 DOCX、PPTX、EPUB 和压缩包中的大文本。

完整源码与发布记录在 File Viewer

相关推荐
12.=0.1 小时前
【REVIEW_C】【持续更新】
服务器·前端·javascript
Hilaku2 小时前
为什么同一段代码在 Safari 上永远有 Bug?
前端·javascript·程序员
张元清2 小时前
React scrollIntoView + useRef:滚动到指定元素 (2026)
javascript·react.js
BreezeJiang2 小时前
别再背工厂模式了:NestJS 第一行代码就是它的工业级落地
前端·javascript
渣波2 小时前
深度解析工厂模式:从蜜雪冰城到 NestFactory,彻底搞懂“创建与使用分离”
前端·javascript
雪芽蓝域zzs3 小时前
新建前端pnpm(vue js ) 仿若依项目(一)
前端·javascript·vue.js
kaixin_learn_qt_ing3 小时前
Electron程序---初体验
javascript·electron
星夜夏空993 小时前
网络编程(5)—— Reactor实现(v1)
服务器·javascript·网络
YWL3 小时前
OpenLayers测距测面:完整测量工具
前端·javascript·vue.js·信息可视化·openlayers