接口返回 200 OK。
下载按钮保存出的文件名是 合同.docx。
浏览器 Network 里甚至还能看到 Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document。
但预览仍然失败。
这类问题最容易把排查带到错误方向:先换 DOCX 解析器,再关 Worker,然后怀疑 Vue 组件生命周期。折腾一圈后才发现,接口真正返回的是一段鉴权过期 XML,只是被业务层继续命名成了 .docx。
我在本地构造了一个 175 字节的脱敏样本。它的文件名是 auth-expired.docx,内容却是对象存储常见的错误结构:
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>The preview token has expired.</Message><RequestId>demo-redacted</RequestId></Error>把它交给当前 File Viewer v2.2.5 的同一条校验链路后,执行会在 DOCX 运行时加载前停止,并直接指出:文件不是有效的 DOCX/OOXML 压缩包,可能下载不完整或被服务端错误内容替换。下图是这道防线最初落地时,在 v2.2.4 上采集的脱敏复现截图;当前版本的错误边界和文案保持一致。

这篇文章不讨论“怎样吞掉异常”,而是用这个样本拆开一条文档预览链路:网络响应、文件容器、Worker、文档解析、页面渲染,每一层都应该回答不同的问题。
第一层:200 只说明请求完成,不说明文件正确
业务接口返回 200,可能只是网关把上游错误包装成了成功响应。文件名和 MIME 也可能来自数据库字段,而不是响应体本身。
所以排查远程 DOCX 时,我先记录这些信息:
最终 URL 是否发生重定向; HTTP 状态、 Content-Type和Content-Length;响应体前 16 至 32 个字节; 实际下载大小是否与业务记录接近; 同一个请求在令牌过期、权限不足和跨域失败时分别返回什么。
这里最有用的不是扩展名,而是响应体本身。
本次合成错误文件的前四个字节是 3c 3f 78 6d,对应 ASCII 的 <?xm;正常样本是 50 4b 03 04,即 ZIP 本地文件头常见的 PK 签名。

下面这段业务层检查不依赖任何预览库,适合放在鉴权下载之后:
functionlooksLikeZipPackage(buffer: ArrayBuffer) {if (buffer.byteLength < 4) returnfalseconst view = newUint8Array(buffer, 0, 4)return view[0] === 0x50 && view[1] === 0x4b}asyncfunctiondownloadDocx(url: string) {const response = awaitfetch(url, { credentials: 'include' })if (!response.ok) {thrownewError(`附件下载失败:HTTP ${response.status}`) }const buffer = await response.arrayBuffer()if (!looksLikeZipPackage(buffer)) {thrownewError('响应体不是 DOCX/OOXML 包,请检查鉴权、重定向和文件源') }returnnewFile([buffer], '合同.docx')}它只是第一道快速门禁,不是完整的 DOCX 校验器。PK 只能说明“像 ZIP”,不能证明中央目录完整,也不能证明包内一定存在 Word 文档需要的部件。
第二层:DOCX 不是一段 XML,而是一组有关系的部件
一个可读取的 DOCX 至少要通过三类检查:
ZIP 容器能否完整解压,中央目录有没有截断; [Content_Types].xml、根关系和word/document.xml等核心部件是否存在;文档、样式、图片、页眉页脚之间的 Relationship 能否解析到真实目标。
因此,“首字节不是 PK”可以立刻判定文件源有问题;“首字节是 PK”则应该继续进入包完整性与 OOXML 关系检查。
不要把所有失败都改写成“文件损坏”。对象存储返回错误页、代理截断下载、业务层解密失败、ZIP 本身损坏、关系目标缺失,修复责任完全不同。
第三层:只有容器有效,才值得启动 Worker
DOCX Worker 解决的是把重解析移出主线程,以及分批把解析结果交回页面。它不能把 XML 错误页变成 Word 文档。
File Viewer 当前的顺序是:
assertValidDocxPackage(buffer, context)const docxOptions = createDocxOptions( target, context, notifyProgressiveRender)const [{ defaultOptions, renderAsync }, pageBackgroundImage] = awaitPromise.all([loadLibrary(),resolveDocxPageBackgroundImage( buffer,() =>createTargetXmlParser(target) )])包签名检查发生在加载 DOCX 运行时之前。这个顺序看似只省了一次无效 Worker 启动,实际更重要的是保住错误归属:网络或文件源错误不会被包装成 WorkerError、ZIP 中央目录异常,或解析器内部堆栈。

如果走到 Worker 层,再检查另一组问题:
Worker URL 是否命中同版本脚本,而不是 SPA 的 HTML 回退页; Worker、JSZip 和主包版本是否一致; CSP、跨域和子路径部署是否允许加载资源; 超时是解析时间过长,还是 Worker 根本没有启动。
这些检查必须排在“响应体确实是 DOCX”之后。
第四层:解析降级不能吞掉不相关错误
真实 DOCX 也会包含不完整的关系。例如某些生成器保留了页眉引用,却没有生成可解析的页眉根节点。
项目里对这一类已知错误采用窄降级:只在堆栈明确落到页眉页脚渲染、且错误特征匹配时,关闭页眉页脚重试一次,保留正文;ZIP 错误、无效响应和其他渲染异常继续原样抛出。
对应回归测试固定了两个边界:
已知页眉页脚根节点异常会重试,正文仍然出现; Invalid central directory之类无关错误不会被吞掉,也不会无限重试。
截至 2026 年 8 月 6 日,本地 v2.2.5 的三组相关测试共 14 项通过;页眉页脚定向验证在重新构建 core 和 word renderer 后为 2 项通过。
这比“遇到异常就关掉所有高级能力再试一次”更保守,也更容易维护。
第五层:渲染成功仍不等于验收完成
同一套本地环境下,正常 DOCX 样本通过 ZIP 检查后进入解析与渲染,正文、表格、图片和工具栏正常出现。下面同样保留修复初次落地时的 v2.2.4 实测截图;当前 v2.2.5 的回归验证继续覆盖这条正常路径。

但页面能出现还不够。回归至少要覆盖:
正文、表格、图片、页眉页脚和目录字段; 连续阅读与分页模式; Worker 开启、关闭和资源加载失败; 快速切换文件时的旧任务取消; 打印、HTML 导出和缩放; 中文错误信息是否保留了可行动的故障层级。
这套判断什么时候不适用
首字节门禁适合快速排除 HTML/XML 错误页和明显截断文件,但不能替代完整 OOXML 校验。
需要统一版式、服务端全文检索、病毒扫描或归档固化时,自建转码服务更合适。文件公开、团队不想维护 Worker 与字体资源时,在线 SaaS 可能更省事。需要编辑和协同时,应选择 Office 类编辑器,而不是只读预览器。
浏览器本地预览的价值,是让文件在已有业务权限内完成解析和展示;它不意味着网络、内存、运行时资源和兼容性测试可以消失。
真正可靠的错误处理也不是把所有异常变成一句“预览失败”,而是让每一层只解释自己能解释的问题。
关注后续故障与修复记录。
夜雨聆风