有小伙伴往官网 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.xmlsheet1.xml 不再为了“发现图表”被读成字符串。
这是我很喜欢的一类性能优化。没有玄学参数,也没有把循环硬抠快 10%。只是先问清楚:这一步到底需不需要正文?
只需要元数据,就别碰主体。只需要关系,就别展开内容。
我最担心的,是半年后它又被写回来
只修代码还不够。
如果测试只检查“文件能打开、图表数量是 1”,旧逻辑以后即使回来,功能结果可能仍然正确,内存风险却已经复活。
所以这次回归样本故意做了三件事:
动态生成一个带 16 MiB worksheet XML 的 XLSX; 盯住 JSZip 对 sheet1.xml的文本读取,只要发生就主动抛出同类RangeError;同时检查柱状图的位置、系列名、分类和数值,确认优化没有顺手把图表删掉。
真正重要的断言是这两个:
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] }]})我重新跑了一遍专项测试,两个测试文件都通过了。
这才是我想要的结果:重复读取是 0,图表还在。

先别把它理解成“70MB Excel 从此随便开”
这次修复解决的是一个很具体的问题:图表发现不再把大型 worksheet 重复读成文本。
它不是一张“所有 70MB Excel 都没问题”的保证书。
XLSX 里还可能有巨大的共享字符串、样式、公式、图片,或者一个被错误标成整表范围的维度。设备内存、浏览器版本和 WebView 环境也会改变结果。
如果业务要求低端手机稳定打开任意超大表格,服务端预处理、分页数据、摘要视图,甚至直接下载后交给专业工具,仍然可能更合适。
少一次重复展开,能去掉一块明确的浪费;它不能替所有大文件问题兜底。
再遇到大文件,可以按这个顺序查
这次问题不只属于 Excel。DOCX、PPTX、EPUB,甚至普通 ZIP 里的大文本,都可能踩到相似的坑。
我把排查顺序压成了五步:
别只看压缩包大小。 先估算解压后的正文、字符串、DOM/AST 和渲染缓存。 画出读取路径。 同一份主体是否被主解析、搜索、缩略图、图表或统计各读了一遍? 先找元数据。 有索引、目录、关系文件或偏移表时,不要为了找一个入口扫描全文。 让增强能力可以退让。 图表、缩略图或目录失败时,别轻易拖垮仍然可读的正文。 测试“禁止发生的工作”。 除了结果正确,还要断言某次读取是 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
夜雨聆风