乐于分享
好东西不私藏

一个 70MB 的 Excel,为什么会在浏览器里被解压两次?

一个 70MB 的 Excel,为什么会在浏览器里被解压两次?

有小伙伴往官网 Demo 里放了一份 70MB 的普通 .xlsx

官网打不开,他回到自己的 2.2.5 项目里又试了一遍,结果还是一样:Worker 先报错,自动回退主线程,紧接着控制台冒出这几行:

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

第一眼看,很容易把它归结为一句话:文件太大,浏览器扛不住。

可再看一眼堆栈,事情就有点奇怪了。表格不是先倒在单元格、公式或样式上,而是倒在了 图表发现 这一步。

我顺着这条路径往下找,最后发现:主解析已经处理过工作表,图表解析却又把同一份 worksheet XML 展开成了一次完整字符串。

这篇不讲“把内存调大一点”这种临时办法。我想把这次重复解压怎么出现、怎么删掉,以及怎样防止它以后悄悄回来,完整拆开。

70MB 的 Excel,打开以后可不止 70MB

.xlsx 其实是个穿着 Excel 外衣的 ZIP 包。

把它解开,会看到 workbook、worksheet、样式、共享字符串、drawing、chart,以及一批负责把它们串起来的关系文件。

其中一张工作表通常就在这里:

xl/worksheets/sheet1.xml

所以,磁盘上 70MB,只是压缩包进门时的体积。worksheet 解压后可能膨胀很多;再把它变成 JavaScript 字符串、XML DOM 和渲染模型,内存还会继续往上走。

这也是为什么“限制 100MB 以内”经常让人产生错觉。它只检查了文件有多大,却没有回答更关键的问题:打开之后有多大,同一份内容又被复制了几次。

真正浪费内存的,是两条链路各干了一遍重活

我先把两条解析路径叠在一起看。

主链路会调用 SheetJS 读取工作簿,建立单元格模型。为了把 Excel 里的图表也还原出来,旁边还有一条 OOXML 图表发现链路。

旧实现是这样的:

const [worksheetDocument, worksheetRelationships] = awaitPromise.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 的字符串边界。

说得直白一点:我们只是想找门牌号,却又把整栋楼搬了一遍。

答案其实藏在旁边那个小文件里

图表发现真的需要整张 worksheet 吗?

不需要。

OOXML 已经把 drawing 的位置写进了关系文件:

xl/worksheets/_rels/sheet1.xml.rels

它负责告诉解析器:这张工作表连着哪个 drawing;drawing 自己的关系文件再继续指向 chart XML。

换句话说,图表发现要的是一条关系链,不是几百万个单元格的正文。

所以修复没有去“加速 XML 解析”,而是把不该发生的解析直接删掉:

const worksheetRelationships = awaitloadRelationships(  zip,  worksheetRelationship.target)const drawingParts = Array.from(newSet(  worksheetRelationships    .filter((relationship) =>      relationship.type.endsWith('/drawing')    )    .map((relationship) => relationship.target)))

新的路径短了很多:

workbook.xml  -> workbook.xml.rels  -> sheet1.xml.rels  -> drawing1.xml  -> chart1.xml

sheet1.xml 不再为了“发现图表”被读成字符串。

这是我很喜欢的一类性能优化。没有玄学参数,也没有把循环硬抠快 10%。只是先问清楚:这一步到底需不需要正文?

只需要元数据,就别碰主体。只需要关系,就别展开内容。

我最担心的,是半年后它又被写回来

只修代码还不够。

如果测试只检查“文件能打开、图表数量是 1”,旧逻辑以后即使回来,功能结果可能仍然正确,内存风险却已经复活。

所以这次回归样本故意做了三件事:

  1. 动态生成一个带 16 MiB worksheet XML 的 XLSX;
  2. 盯住 JSZip 对 sheet1.xml 的文本读取,只要发生就主动抛出同类 RangeError
  3. 同时检查柱状图的位置、系列名、分类和数值,确认优化没有顺手把图表删掉。

真正重要的断言是这两个:

expect(worksheetTextReads).toBe(0)expect(charts['Large Sheet']?.[0]).toMatchObject({id'Large workbook chart',type'bar',series: [{name'Revenue',categories: ['Q1''Q2'],values: [1020]  }]})

我重新跑了一遍专项测试,两个测试文件都通过了。

这才是我想要的结果:重复读取是 0,图表还在。

先别把它理解成“70MB Excel 从此随便开”

这次修复解决的是一个很具体的问题:图表发现不再把大型 worksheet 重复读成文本。

它不是一张“所有 70MB Excel 都没问题”的保证书。

XLSX 里还可能有巨大的共享字符串、样式、公式、图片,或者一个被错误标成整表范围的维度。设备内存、浏览器版本和 WebView 环境也会改变结果。

如果业务要求低端手机稳定打开任意超大表格,服务端预处理、分页数据、摘要视图,甚至直接下载后交给专业工具,仍然可能更合适。

少一次重复展开,能去掉一块明确的浪费;它不能替所有大文件问题兜底。

再遇到大文件,可以按这个顺序查

这次问题不只属于 Excel。DOCX、PPTX、EPUB,甚至普通 ZIP 里的大文本,都可能踩到相似的坑。

我把排查顺序压成了五步:

  1. 别只看压缩包大小。 先估算解压后的正文、字符串、DOM/AST 和渲染缓存。
  2. 画出读取路径。 同一份主体是否被主解析、搜索、缩略图、图表或统计各读了一遍?
  3. 先找元数据。 有索引、目录、关系文件或偏移表时,不要为了找一个入口扫描全文。
  4. 让增强能力可以退让。 图表、缩略图或目录失败时,别轻易拖垮仍然可读的正文。
  5. 测试“禁止发生的工作”。 除了结果正确,还要断言某次读取是 0、某个对象只创建 1 次,或者任务始终留在 Worker。

这五步即使不用 File Viewer,也适合任何在浏览器里处理压缩文档和大文本的项目。

最近这两次更新,主线其实是“别打断用户”

这次重复解压修复是在 2.2.6 里发布的,npm 上当前能安装的是 2.2.7

这两次更新没有急着再堆几个格式,而是继续处理那些会让用户突然出戏的小问题。

Excel 不再为了找图表重复展开大 worksheet,也开始防住异常整表维度和窄容器首屏;PDF 在翻页、目录跳转、旋转和缩放后,尽量留在用户刚才看的位置;OFD 补上了完整的翻页与键盘导航;DOCX 里的链接不会再突然带走当前页面;一些装着 HTML 内容的旧 .doc,也不会再被误判成普通二进制文档后直接报错;PPTX 的连接线则回到了正确的起终点。

这些修复看起来分散,背后其实是一件事:文件已经打开了,就别再因为一次多余计算、一次错误跳转或一个错位图形,把用户赶出阅读状态。

最后

这次修复没有发明新算法,也没有找到什么神奇配置。

它只是承认了一件很朴素的事:图表解析根本不该再读一遍整张表。

很多大文件性能问题也是这样。看起来像机器不够强,最后真正有用的答案,却是少做一次本来就不需要做的工作。

想看完整修复和回归测试,可以顺着 Issue #173 查看源码。

github.com/flyfish-dev/file-viewer/issues/173