一份 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"不够。重复读取即使重新出现,功能结果仍可能正确,内存风险却已经回来。
专项回归做了三件事:
- 动态生成带 16 MiB worksheet XML 的 XLSX;
- 监控 JSZip 对
sheet1.xml的async('text')调用,只要发生就抛出同类RangeError; - 同时验证柱状图的位置、系列名、分类和数值。
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 环境也会改变结果。
如果业务要求低端手机稳定处理任意超大表格,应该评估服务端预处理、分页数据、摘要视图或下载后交给专业工具。浏览器端优化能删除明确浪费,但不能替所有输入兜底。
可以复用的排查顺序
再遇到浏览器大文件问题,我会按下面顺序查:
- 统计压缩后和解压后的体积,不只看上传文件大小;
- 画出主解析、搜索、缩略图、图表和统计各自的读取路径;
- 优先使用索引、目录、关系文件或偏移表,避免为了入口扫描全文;
- 让图表、缩略图等增强能力可以失败,不拖垮仍可读的正文;
- 在测试里断言读取次数、对象创建次数或 Worker 边界,而不只断言最终 UI。
这套方法同样适用于 DOCX、PPTX、EPUB 和压缩包中的大文本。

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