Vue 项目纯前端解析预览 PDF、DOCX、XLSX 完整方案

发布时间:2026/9/30 16:21:46
Vue 项目纯前端解析预览 PDF、DOCX、XLSX 完整方案
本地预览 docx、xlsx、pdf 这类需求我这两年在后台管理系统、合同平台、报表工具里反复接到过。最有意思的一次是客户明确提要求文件不能上传到服务器必须全在浏览器里解析渲染。当时第一反应是这不是给自己找麻烦吗但真做下来发现纯前端解析这条路不仅能走通还能省掉一整套文件转换服务。这篇就把 PDF、DOCX、XLSX 三种格式在 Vue 项目里的完整落地方案拆开讲包含库的选型理由、构建工具的配合细节、性能取舍以及我实际踩过的那些坑。适合手里正卡在预览功能上的前端同学也适合想搞清楚浏览器到底能解析多少 Office 格式的技术负责人。1. 为什么我最后选了纯前端解析这条路接到预览需求时多数团队的第一反应是后端转 PDF 或转图片前端只负责显示一张图。这个方案听起来省事实际做起来账并不好算。1.1 服务端转换方案的三笔隐性成本第一笔是服务器资源。LibreOffice 无头模式转一份 20 页带图表的 docx在 2 核 4G 的机器上大概要 3 到 8 秒并发上来之后 CPU 直接打满你得单独部署一个转换服务还要做队列和超时控制。第二笔是保真度损耗docx 转 PDF 之后字体替换、行距偏移、表格错位是家常便饭用户拿着原始文件和预览页面对不上投诉就来了。第三笔是隐私和合规合同、体检报告、财务流水这类文件很多客户在合同里就写死了不得离开终端设备你一转就是违规。纯前端方案把这三笔账一次性抹平文件从头到尾留在内存里解析耗时花在用户自己的机器上服务器只承担静态资源分发。代价也很明确就是前端要引入几个体积不小的解析库以及要接受预览效果和 Office 原生打开有差异这件事。提示纯前端预览的核心价值在于不上传和即时反馈如果你的场景对像素级保真要求极高比如打印级排版校对那还是老老实实走服务端转换不要硬扛。1.2 三种格式对应三套完全不同的解析逻辑很多人以为预览文件是一件事其实 PDF、DOCX、XLSX 在浏览器里是三套独立的玩法。PDF 是渲染型的。它本质上是一份指令列表告诉你在这里画一条线、在那里贴一张图、用这个字体渲染这串字符。pdfjs-dist 做的事就是把这份指令在 Canvas 上重放一遍。它不关心内容语义只管画得像不像。DOCX 是结构型的。它其实是一个 zip 包里面塞了一堆 XMLword/document.xml存正文word/styles.xml存样式word/media/存图片。解析它等于要把 OOXML 那套复杂的样式继承体系翻译成 HTML 和 CSS。这个翻译过程注定有损耗因为 Word 的排版模型和浏览器的盒模型根本不是一回事。XLSX 是数据型的。它同样是 zip但重点是xl/worksheets/sheet1.xml里的单元格值和xl/sharedStrings.xml里的共享字符串。你要做的是把稀疏的单元格坐标还原成二维表格。这里最难的不是读数据而是处理合并单元格、列宽、日期格式这些看起来是样式、实际影响可读性的东西。理解了这三种差异选型思路就清楚了PDF 用 pdfjs-distDOCX 用 docx-previewXLSX 用 SheetJS三套库分工明确不要指望一个库通吃。1.3 输入形态统一成 ArrayBuffer 是省事的关键这三个库对输入的接受度不太一样pdfjs 接受 URL、Uint8Array、ArrayBufferdocx-preview 接受 Blob、ArrayBuffer、Uint8ArraySheetJS 的read接受 array、binary、base64、buffer 等类型靠type参数区分。我最后统一的做法是不管外界传进来的是File、Blob还是ArrayBuffer都在组件入口处转成ArrayBuffer然后按需切片。async function toArrayBuffer(input) { if (input instanceof ArrayBuffer) return input if (input instanceof Blob) return await input.arrayBuffer() throw new Error(不支持的文件输入类型) }这么做有两个好处。一是各个渲染分支的参数写法统一了不用在每个库里判断类型。二是ArrayBuffer是纯内存对象没有 URL 生命周期问题不会像URL.createObjectURL那样忘了 revoke 就泄漏。代价是同一份数据可能被多个库各持有一份引用超大文件要注意内存后面第 5 节会讲怎么释放。2. PDFpdfjs-dist 在 Vue 里的正确打开方式pdfjs-dist 是三个库里最容易出问题的一个因为它依赖 Web Worker而 Worker 的加载方式和你的构建工具强相关。我见过太多人卡在第一步就没往前走。2.1 Worker 加载失败是最高频的第一道坎装完库直接import * as pdfjsLib from pdfjs-dist然后调getDocument控制台大概率会甩一个Setting up fake worker failed或者 404。原因是 pdfjs 把解析逻辑放在pdf.worker.js里主线程只是调度Worker 文件必须能被浏览器单独请求到。Vite 项目里我推荐用?url后缀让构建工具把文件处理成静态资源import * as pdfjsLib from pdfjs-dist import PdfWorker from pdfjs-dist/build/pdf.worker.min.mjs?url pdfjsLib.GlobalWorkerOptions.workerSrc PdfWorkerWebpack 5 项目写法不同要用new URL语法pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.mjs, import.meta.url ).toString()这里有个版本坑必须提醒pdfjs-dist 3.x 的 worker 文件是.js4.x 之后改成了.mjsESM 模块。如果你从网上抄了一段 3.x 的代码装到 4.x 上路径对不上一样 404。另外 Vite 的预构建有时候会把 worker 也一起打包反而搞坏路径加一句配置更保险// vite.config.js export default defineConfig({ optimizeDeps: { exclude: [pdfjs-dist] } })2.2 Canvas 渲染的三个参数决定了清晰度Worker 配好之后渲染一页 PDF 的代码本身很短但有几个参数直接决定成品质量const pdf await pdfjsLib.getDocument({ data: buffer }).promise const page await pdf.getPage(1) const viewport page.getViewport({ scale: 1.5 }) const canvas canvasRef.value const ctx canvas.getContext(2d) const dpr window.devicePixelRatio || 1 canvas.width Math.floor(viewport.width * dpr) canvas.height Math.floor(viewport.height * dpr) canvas.style.width ${Math.floor(viewport.width)}px canvas.style.height ${Math.floor(viewport.height)}px await page.render({ canvasContext: ctx, viewport, transform: dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null }).promisescale控制逻辑尺寸缩放功能就靠它用户点放大就是scale * 1.2重新渲染。devicePixelRatio决定物理像素密度不处理的话在高分屏上文字边缘会发虚。transform是把 DPR 缩放应用到绘制上下文上很多人只改了canvas.width却忘了transform结果画布变大了但内容还是原尺寸右下角一片空白。注意缩放后必须重新调用render不能靠 CSStransform: scale()糊弄。CSS 缩放会让已经栅格化的位图被拉伸字迹立刻变糊而且文字层的位置也会跟着错位。2.3 多页渲染要做懒加载而不是一次性铺开一份 50 页的 PDF如果一进来就把 50 个 Canvas 全渲染出来内存占用轻松上 G页面直接卡死。我的做法是给每一页占一个容器容器高度按第一页的 viewport 预估然后用IntersectionObserver监听只有进入视口前后一定范围才真正渲染。const observer new IntersectionObserver((entries) { entries.forEach((entry) { const index Number(entry.target.dataset.page) if (entry.isIntersecting) { renderPage(index) } }) }, { rootMargin: 200px 0px })rootMargin给 200px 是为了让用户滑动时提前渲染避免看到白屏。同时我给已渲染的页做了数量上限比如同时只保留 10 页的 Canvas 内容超出范围的页调用page.cleanup()并把 Canvas 宽高置零把位图内存还回去。这一招在移动端特别管用不做的话 iOS Safari 很容易因为内存超限直接刷新页面。2.4 文字层不是可有可无的装饰只渲染 Canvas 的话PDF 里的文字是画上去的用户没法选中、没法搜索、没法复制。要恢复这些能力得额外渲染一个文字层用page.getTextContent()拿到每个文本片段的内容和变换矩阵然后生成一堆绝对定位的透明span盖在 Canvas 上。pdfjs 4.x 提供了TextLayer类比自己手写省事const textContent await page.getTextContent() const textLayer new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv.value, viewport }) await textLayer.render()对应的 CSS 里文字层容器要position: absolute; inset: 0;里面的 span 用color: transparent;选中时的背景色通过::selection自定义。字体大小需要用--scale-factor这个 CSS 变量来配合否则文字位置会和 Canvas 上的实际位置对不齐。这一块调试起来比较费劲我的经验是先把 Canvas 渲染对准了再弄文字层不要两个一起调。3. DOCXdocx-preview 的样式还原与它的脾气DOCX 预览的难点从来不是能不能显示内容而是显示得像不像。我试过三条路线最后稳定在 docx-preview 上。3.1 三条解析路线各自的适用场景第一条是mammoth.js。它走的是语义转换路线把 Word 的Heading 1转成h1、把Strong转成strong输出的 HTML 非常干净。但它会主动丢弃大量排版信息表格边框、段落缩进、页边距基本都没了。如果你的需求是把文档内容提取出来展示在移动端mammoth 是首选如果是让用户看到和 Word 里一样的效果它不合适。第二条是docx-preview。它走的是像素级还原路线直接解析 OOXML把每个段落、每个 run 的样式都翻译成内联 CSS连页眉页脚、分页符、脚注都能处理。代价是输出 DOM 非常庞大一份 10 页文档能生成几千个节点而且它对某些复杂排版比如文本框、艺术字支持有限。第三条是自研。用 JSZip 解开 docx自己解析document.xml再自己写渲染逻辑。我试过一周之后放弃了——OOXML 的样式继承链太深styles.xml里的样式可以被document.xml里的直接格式覆盖还有basedOn的继承关系工作量远超预期。除非你有非常特殊的定制需求否则不建议走这条。3.2 renderAsync 的 options 里哪几个必须调docx-preview 的主 API 是renderAsync第四个参数是一堆开关默认值并不适合所有场景import { renderAsync } from docx-preview await renderAsync(blob, containerRef.value, undefined, { className: docx-preview, inWrapper: true, ignoreWidth: false, ignoreHeight: true, ignoreFonts: false, breakPages: true, renderHeaders: true, renderFooters: true, renderFootnotes: true, ignoreLastRenderedPageBreak: true, useBase64URL: true, experimental: true })ignoreWidth我留成了false让 docx-preview 按文档里的纸张宽度设置元素宽度这样表格和页边距才是对的。但副作用是内容宽度可能超过容器需要外层包一个overflow-x: auto的滚动容器。ignoreHeight我改成了true。因为 Word 里的分页高度是固定的 A4 高度浏览器里按这个高度切页会出现大片空白或者内容被截断关掉之后让内容自然流动更合理。renderHeaders和renderFooters看需求。如果只是给用户快速看内容关掉能省不少 DOM。如果是合同类文件页眉里的合同编号、页脚里的页码是有意义的那就打开。useBase64URL建议开启。默认情况下 docx-preview 会把图片转成 blob URL文档切换频繁时容易泄漏用 base64 虽然会让 DOM 体积大一些但至少没有生命周期问题。如果文档里图片特别多特别大那还是用 blob URL 并在组件卸载时统一 revoke。3.3 中文文档跑版的两个真实原因中文 docx 预览最常被吐槽的是字变了、行距不对、换行位置不一样。这基本不是库的 bug而是两个客观原因。第一个是字体缺失。Word 文档里写的是宋体仿宋_GB2312方正小标宋这类字体名但用户的浏览器所在系统不一定装了这些字体浏览器只能回退到默认字体字形宽度一变换行位置全乱。我的处理方式是给预览容器指定一个字体栈兜底.docx-preview { font-family: SimSun, Songti SC, Microsoft YaHei, sans-serif; line-height: 1.6; }同时把ignoreFonts保持false让文档自己的字体设置生效——装了字体的用户能看到原样没装的用户至少有合理回退。第二个是行距计算基准不同。Word 的行距是按行这个排版单位算的浏览器是按line-height的倍数算的两者对同一份w:spacing的解读有细微差异。这个没法完美解决只能接受。我一般会在预览区顶部加一行小字说明预览效果与实际排版可能存在细微差异把用户预期先降下来。3.4 大文档的性能处理docx-preview 是同步解析整份文档的主线程会阻塞。一份 30 页带 50 张图的文档解析加渲染能卡住两三秒。我做了两件事缓解。一是渲染前先显示骨架屏并且用setTimeout让出一次事件循环保证 loading 状态能先画出来。二是用requestIdleCallback或者 Web Worker 做预解析——不过 docx-preview 内部依赖 DOM不能直接扔进 Worker所以我的做法是把 JSZip 解压和 XML 解析这部分提前放到 Worker 里拿到 XML 字符串之后再回主线程渲染。这个改造有点重只有在对大文档体验要求很高时才值得做。4. XLSX把二维数据还原成表格而不是一堆字符串Excel 预览我见过最多的错误实现是拿sheet_to_json转出对象数组然后v-for渲染成table。结果是日期变成一串 4 万多的数字合并单元格全散开列宽全一样。这里面的细节值得单独讲一节。4.1 read 和 sheet_to_json 的参数决定了数据质量import * as XLSX from xlsx const wb XLSX.read(buffer, { type: array, cellDates: true, cellStyles: true, cellNF: true, sheetStubs: true }) const ws wb.Sheets[wb.SheetNames[0]] const rows XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: , blankrows: true })cellDates: true是必须的。不开这个参数日期列读出来就是 Excel 序列号比如 45123因为 xlsx 存储日期用的就是从 1900 年 1 月 1 日起的天数。开了之后 SheetJS 会按单元格的格式判断转成 JS Date。raw: false配合cellNF: true也很有用。它让 SheetJS 按照单元格的数字格式输出字符串百分比显示成12.5%、货币带上符号、保留位数也按格式来。但要注意raw: false之后数字都变字符串了如果后续要做数值排序或者统计得单独再读一份raw: true的数据。我的做法是同时保留两份展示用格式化后的排序按原始值。header: 1表示按行输出数组而不是对象。这在预览场景下更通用因为很多表格第一行不是表头或者有合并的大标题按对象输出反而会丢数据。4.2 合并单元格必须自己算 rowspan 和 colspansheet_to_json完全不管合并单元格它只在左上角单元格返回值其余位置是空。要正确渲染得用ws[!merges]手动处理function buildMergeMap(merges []) { const spanMap new Map() const skipSet new Set() merges.forEach((m) { const key ${m.s.r}:${m.s.c} spanMap.set(key, { rowspan: m.e.r - m.s.r 1, colspan: m.e.c - m.s.c 1 }) for (let r m.s.r; r m.e.r; r) { for (let c m.s.c; c m.e.c; c) { if (r m.s.r c m.s.c) continue skipSet.add(${r}:${c}) } } }) return { spanMap, skipSet } }渲染时遇到在skipSet里的单元格直接continue跳过遇到在spanMap里的就给td加上对应的rowspan和colspan。这套逻辑不复杂但不写就是会错。列宽用ws[!cols]行高用ws[!rows]。列宽的字段是wch字符宽度或wpx像素宽度优先用wpx没有的话按wch * 7 5估算成像素。行高一般用hpx取不到就统一给 28px。4.3 大数据量下我放弃了虚拟滚动改用分页一万行的 Excel 太常见了。如果直接渲染一万个tr浏览器的布局计算会直接把页面冻住十几秒。我一开始上的是虚拟滚动但表格虚拟滚动有个硬伤行高不是固定的内容换行的行长不一样一旦行高动态变化虚拟滚动的定位就全乱了。后来我换成固定行高 分页。每页渲染 200 行用table-layout: fixed锁死列宽超出宽度的文本用text-overflow: ellipsis截断鼠标悬停用原生title属性显示完整内容。这个方案看起来土但实际体验非常好翻页几乎瞬间完成滚动顺滑还省掉了虚拟滚动那一堆边界情况处理。实测数据供参考在 2021 款 MacBook Pro 上一个 8MB 的 xlsx 文件约 6 万行、12 列XLSX.read耗时约 2.1 秒sheet_to_json转换约 0.4 秒渲染首屏 200 行约 60 毫秒。这两秒多的解析时间是省不掉的所以我一定会给一个明确的进度提示而不是转个圈让用户干等。4.4 样式还原不了那就舍掉必须坦白SheetJS 社区版的cellStyles拿不到完整的填充色、字体色、边框这些视觉样式。想要完整样式得用商业版或者换 exceljs 从底层 XML 里抠——但那会让整个解析流程复杂度翻好几倍。我的取舍是只还原影响可读性的结构性样式也就是合并单元格、列宽、行高、对齐方式、数字格式、加粗。填充色和边框统一用一套干净的表格 CSS 代替反而比原来花花绿绿的表看着舒服。提示对齐方式从ws[cell].s.alignment.horizontal取社区版能拿到一部分。如果取不到就按数据类型猜——数字右对齐、文本左对齐、布尔和错误值居中这个规则和 Excel 默认可视行为基本一致。5. 落地到 Vue 组件统一入口、按需加载与资源清理讲完三种格式各自的实现真正让代码可控的是把它们收进一个统一的 Vue 组件里。我用的是 Vue 3 的script setup但思路在 Options API 和 Vue 2 里同样适用。5.1 一个组件管三种格式的接口设计对外只暴露最少的接口内部自己做分派script setup import { ref, watch, onBeforeUnmount, nextTick } from vue const props defineProps({ file: { type: [File, Blob, ArrayBuffer], required: true }, fileName: { type: String, default: }, zoom: { type: Number, default: 1 } }) const emit defineEmits([loaded, error]) const wrapperRef ref(null) const loading ref(false) function detectType(name, buffer) { const ext name.split(.).pop()?.toLowerCase() if (ext pdf) return pdf if (ext docx) return docx if (ext xlsx || ext xls) return xlsx return sniffMagic(buffer) } /scriptsniffMagic是兜底用的魔数检测主要防两种脏情况用户把.doc改名成.docx或者文件名被后端处理丢了扩展名。PDF 的魔数是开头 5 个字节%PDF-docx 和 xlsx 都是 zip开头 4 字节都是PK\x03\x04要再深入一步——读[Content_Types].xml里的内容类型区分或者直接在字节流里搜word/和xl/这两个路径片段。后者简单粗暴但足够用。这里有个真实的坑input typefile拿到的File对象是只读的但ArrayBuffer可以被读取任意次而不消耗。所以我在组件内部第一步就把File转成ArrayBuffer存起来避免切换格式或者重渲染时文件已经被读走了。这个在 Streams API 场景下尤其重要。5.2 动态 import 把首屏体积压下来三个库的打包体积加起来接近 2MBpdfjs-dist 约 400KBdocx-preview 约 300KBxlsx 约 900KB。如果全塞进首屏 bundle页面加载会非常难看。我的做法是全部改成动态 import在真正需要渲染时才加载对应库async function renderPdf(buffer) { const pdfjsLib await import(pdfjs-dist) const workerUrl (await import(pdfjs-dist/build/pdf.worker.min.mjs?url)).default pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl // ...渲染逻辑 }配合 Vite 的build.rollupOptions.output.manualChunks把这三个库拆到独立 chunk浏览器缓存粒度也更细——用户第二次打开同一个页面这些 chunk 直接命中缓存。xlsx 这个库可以额外说一下体积优化。npm 上的xlsx包是完整版包含读写能力和所有格式支持。如果你只需要读 xlsx不需要写、不需要读 xls 老格式、不需要 ods可以用它的精简构建xlsx/dist/xlsx.mini.min.js体积能砍掉一大半。我一般会在项目里锁定版本号因为这个包在小版本之间偶尔会有 API 行为变化。5.3 卸载时的资源清理清单预览组件最容易出问题的场景是用户在一个列表页里连续点开十份文件。如果不清理内存会一路涨上去。我列了一份卸载清单每次切换文件或组件销毁时都执行资源类型释放方式不释放的后果PDF 文档对象pdf.destroy()Worker 常驻内存多份文档叠加PDF 页面对象page.cleanup()页面位图占用不回收Canvas 位图canvas.width 0; canvas.height 0大尺寸位图驻留显存Blob URLURL.revokeObjectURL(url)内存泄漏页面越用越卡未完成的渲染任务renderTask.cancel()切换文件时报错控制台刷屏IntersectionObserverobserver.disconnect()观察器持续持有 DOM 引用renderTask.cancel()这个特别重要。用户在渲染一份大 PDF 的过程中切换到另一个文件原来的渲染任务还在跑新任务又开始了两者共用一个 Canvas 上下文会抛出Cannot use the same canvas during multiple render() operations这样的错误。我在每次渲染前都先检查有没有未完成的任务有就取消掉。5.4 移动端的额外注意事项移动端有三个和桌面端不同的地方。一是iOS 的内存上限。Safari 对单个标签页的 Canvas 内存有硬限制超了会直接白屏刷新。所以移动端我把同时渲染的 PDF 页数从 10 页压到 3 页图片也做降采样。二是手势缩放冲突。用户双指缩放时浏览器会缩放整个页面同时我又在做 Canvas 重绘两者打架。解决办法是在预览容器上加touch-action: pan-x pan-y禁掉浏览器的默认缩放自己用按钮控制缩放比例。三是软键盘和横竖屏切换。Excel 预览的宽表格在竖屏下必然要横向滚动我会在检测到屏幕宽度小于 768px 时自动切到一个卡片式的降级展示——每行显示成一张小卡片字段名和值上下排列可读性反而比横向滚动的表格好得多。6. 我踩过的几个坑和对应的排查路径前面讲了方案这一节专门讲排错。我按现象 → 排查路径 → 根因 → 修复的结构写方便你照着复现。6.1 worker 404 的三层排查法现象控制台报Setting up fake worker failed: Cannot load script at ...PDF 渲染不出来。排查路径是这样的第一步打开 Network 面板过滤worker关键字看有没有一个 404 的请求。有 404 说明路径不对没请求说明配置压根没生效。第二步如果有请求但 404看请求的 URL 长什么样。如果是http://localhost:5173/node_modules/pdfjs-dist/...那是?url后缀没加Vite 把它当源码处理了。如果是一个带 hash 的路径但请求不到那是构建产物路径错了检查base配置。第三步如果压根没有请求检查GlobalWorkerOptions.workerSrc是不是在调用getDocument之前就赋值了。这个赋值必须放在最前面晚一步都会 fallback 到 fake worker。根因基本就是这三种路径没处理、构建配置干扰、赋值时序不对。我把这三步写成了一个团队内的排查清单新人踩这个坑基本五分钟能解决。6.2 打包后体积翻倍的重复打包问题现象本地 dev 环境正常npm run build之后产物里出现了两份 pdfjs 代码体积比预期大一倍。排查方式是跑npx vite-bundle-visualizer或者rollup-plugin-visualizer看 chunk 图里有没有重复模块。根因通常是同时用了静态 import 和动态 import。比如某个工具文件里import * as pdfjsLib from pdfjs-dist静态引入了组件里又await import(pdfjs-dist)动态引入Rollup 会把两份都留下。修复就是全局搜一遍把所有静态引入改成动态引入或者反过来全部静态引入但配置manualChunks拆包。注意optimizeDeps.exclude加上之后dev 环境的依赖预构建不处理这个包冷启动会慢一点这是正常代价。6.3 页面越用越卡一次 blob URL 泄漏的完整定位现象预览页面打开十几次文件之后切换文件明显变慢打开 DevTools 的 Memory 面板做堆快照发现有一堆Blob对象没被回收。定位过程先做一次 GC拍快照切换到下一个文件再拍一次对比两次快照之间新增且未释放的对象。找到引用链之后发现是 docx-preview 内部为图片生成的 blob URL组件卸载时没人 revoke。修复方案是加一个 URL 收集器const blobUrls new Set() function trackBlobUrl(blob) { const url URL.createObjectURL(blob) blobUrls.add(url) return url } function releaseAll() { blobUrls.forEach((url) URL.revokeObjectURL(url)) blobUrls.clear() } onBeforeUnmount(releaseAll)更省事的办法是前面提到的useBase64URL: true直接不用 blob URL但代价是 DOM 体积变大。我现在的策略是文档里的图片少于 20 张就用 base64超过就走 blob URL 主动回收。6.4 加密文件和损坏文件的优雅降级用户上传一份加了密码的 PDFgetDocument会 reject如果不处理就是控制台一片红加白屏。pdfjs 提供了密码回调const task pdfjsLib.getDocument({ data: buffer, onPassword: (callback, reason) { // reason 为 1 表示需要密码为 2 表示密码错误 const pwd window.prompt(reason 1 ? 请输入文件密码 : 密码错误请重新输入) if (pwd) callback(pwd) else task.destroy() } })自己做一个密码输入框比prompt体验好得多但思路一样把密码交给回调让 pdfjs 自己解。损坏文件更简单三个库都会抛异常用 try/catch 统一包住给用户一个文件无法解析请确认文件是否完整的提示就行。我额外做了一件事把错误对象打上用户可见的标记方便客服定位问题时能区分文件坏了和代码崩了。这两种情况给用户的提示文案应该完全不同——前者让用户换文件后者让用户刷新重试。最后分享一个我项目里用得最多的经验。浏览器端解析这件事最大的风险不是技术实现而是用户预期。用户拿着 Word 打开的效果来对比你的预览页面任何差异都会被当成 bug。所以我现在做这类功能一定会在第一版就把差异说明和下载原文件两个入口放在显眼位置——让用户随时能回到原生程序里查看比你在前端死磕像素级还原要划算得多。