从去年开始,我陆续给好几个后台管理系统加过“附件在线预览”的功能,这次要聊的是纯前端方案。需求背景很常见:运营上传了一批 word、excel、pdf、ppt 文件,老板要求能在浏览器里直接看,不能下载、不能编辑、最好还能带水印。一开始我想的是走后端转换,把 docx、xlsx 都转成 pdf 再渲染,但客户的部署环境是内网,系统本身也是私有化交付,后端根本不想再引入 OpenOffice 这类重型依赖。于是我把目光完全放在前端,最终用 js 把这四种格式的在线预览全跑通了。这篇就把整个方案的选型思路、核心代码和踩过的坑完整记录下来。
1. 为什么会被“纯前端预览”逼到墙角:先看传统方案的死穴
1.1 后端转换方案到底贵在哪
很多人一提到文档预览,第一反应是“后端转成 pdf,前端 iframe 打开”。这个方案本身没错,但如果你真的在项目里落过地,就会知道它藏着多少隐形成本。
最典型的做法是部署 OpenOffice 或者 LibreOffice,用 Java 的 POI 或者 Python 的库把 docx、xlsx、pptx 转成 pdf,然后前端拿 pdf 地址直接渲染。这套链路的问题不是“能不能跑通”,而是“维护起来有多痛”。首先,内网环境里装这些服务本身就是运维负担,版本升级、字体缺失、转换进程崩溃都是常见事。其次,转换质量是玄学:一个 100 多页的 pptx,转出来的 pdf 里图片可能错位,字体全变成宋体,表格列宽乱掉,老板截图发群里你连解释的勇气都没有。最要命的是,转换通常要排队,用户点预览后要等 3 到 5 秒,体验相当糟糕——尤其是小文件,明明前端一秒就能渲染完,非要去后端绕一大圈。
当时我评估了一下,发现需求里有一个关键限制:文件都在内网,且数量可控,核心诉求是“能看,别崩,也别太丑”。如果一个方案能省掉后端所有工作,哪怕前端多写两千行代码,也是值得的。
1.2 纯前端方案的真实边界
我先把丑话说在前面:纯前端预审不等于万能,它的能力边界必须在一开始就搞清楚。
- pdf:最成熟,pdf.js 就是为浏览器渲染 pdf 而生的,能做到接近原生阅读器的效果。
- word(.docx):可以用 docx-preview 这种专门解析 docx 的库,支持分页、表格、图片、页眉页脚,还原度能到 80% 以上。但要注意,它只能处理 .docx,老式 .doc 纯前端基本无解。
- excel(.xlsx):SheetJS 的社区版解析能力很强,但渲染能不能还原样式,取决于你的思路是“转 HTML 表格”还是“用 Canvas 自绘”,两者的差距非常大。
- ppt(.pptx):这是最难啃的骨头。pptx 本质上是一个 zip 包,里面每一页幻灯片都有独立的 XML 描述,纯前端有开源库能做,但复杂的动画、母版、SmartArt 基本都会渲染失败。
一句话总结:前端方案的核心价值在于“轻量、零服务端依赖、秒开”,代价是“格式兼容性要降级处理,复杂样式的还原度只能尽力而为”。在对样式要求不极端的管理后台场景里,这完全够用。
1.3 统一预览协议:文件流才是唯一入口
既然决定了纯前端,接下来要做的第一件事就是把预览入口统一。我不管文件是存在对象存储里,还是后端接口返回的二进制流,前端需要的是同一个东西:File对象或者Blob。所以工程上我会先封装一个fetchFileAsBlob的方法,所有预览文件都先拉成 Blob,再根据扩展名分流到不同的解析器。这一步是整个方案的地基,后面所有类型预览的坑,都是在这个基础上展开的。
2. PDF 预览:一切都要从 pdf.js 的 viewer 模式说起
2.1 为什么不用 iframe 直接打开 pdf
最简单粗暴的 pdf 预览方式是拼接一个iframe.src = 'xxx.pdf',浏览器原生就支持渲染 pdf。但这个方案有三个绕不过去的问题:第一,它要求 pdf 必须能通过 URL 直接访问,如果接口有鉴权头(比如 token 在 header 里),iframe 根本带不上;第二,不同浏览器原生的 pdf 阅读器长得很不一样,Chrome 和 Firefox 的工具栏、右键菜单都不同,交互无法定制;第三,也是最重要的一点,它没办法做水印、禁止下载、禁止打印这些管控需求,用户右键就能把 pdf 保存下来。
所以 pdf 要预览,我基本只用 pdf.js。
2.2 两种用法:API 模式 vs 完整 Viewer 模式
pdf.js 提供两种集成方式。一种是它自带的完整 viewer(就是用web/viewer.html那种打开方式),里面有工具栏、缩略图、搜索、翻页,功能齐全,但界面风格是 pdf.js 自己的,想改样式得动它的源码或者覆盖一堆 css 变量。另一种是只引入 pdf.js 的核心 API,自己基于 canvas 渲染每一页,自己画翻页按钮。我项目里用的是后者,因为管理后台的风格是 element plus,我宁可在组件内部自己写一个极简的工具栏,也不想塞一个风格突兀的完整阅读器进来。
核心思路是这样的:用getDocument加载文件数据,然后用getPage拿到每一页,再通过render方法把页面画到 canvas 上。关键代码如下:
import * as pdfjsLib from 'pdfjs-dist'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; export async function renderPdf(blob, canvas, pageNum, scale = 1.5) { const data = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data }).promise; // 这里的 scale 直接决定清晰度,2 倍屏建议至少 1.5 const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: context, viewport, }).promise; }2.3 分页懒加载:100 页大文件不卡死的关键
很多人初看这段代码会觉得简单,但你千万别在用户翻到第 3 页的时候就把 100 页全渲染出来。canvas 是极其消耗内存的东西,一张 A4 纸大小的 canvas,一页就可能吃 10MB 内存,100 页全渲染,浏览器直接崩给你看。
正确做法是:只渲染当前页和相邻的上一页、下一页,翻页的时候销毁掉远处的 canvas。我在组件里维护了一个Map存pageNum -> canvas,每次翻页先清理掉距离超过 2 的 canvas 实例,再渲染新的页面。配合一个简单的预加载:当前页渲染完成后,主动去渲染下一页,这样用户翻页时几乎感觉不到白屏。
2.4 PDF 预览最容易踩的坑:范围请求和字体加载
用 pdf.js 接接口流的时候,我踩过一个大坑:getDocument直接传入url时,pdf.js 默认会发起“范围请求”(Range 请求),也就是分段拉取数据。如果后端接口对 header 做了过滤,或者没有正确处理 Range 请求头,pdf.js 就会一直报 loading 失败,但同样的文件用浏览器直接打开地址又是好的。排查了半天,最后解决办法是把 pdf 拉成arrayBuffer再传给getDocument,绕开 Range 请求。如果你的 pdf 文件有几十 MB,这个方案反而会一次性占用内存,但换来的是“不依赖后端配置”的确定性,内网场景下这是值得的。
字体加载也会坑人。pdf 里如果嵌了特殊字体,pdf.js 渲染时会去字体文件里解析字形,这个过程比较慢。遇到“页面空白但 pdf 明明没问题”的情况,十有八九是字体加载超时。我的经验是把disableAutoFetch设为 true,减少不必要的网络请求,同时给渲染过程加一个 loading 状态,避免用户以为是 bug。
3. Word 预览:docx-preview 与 mammoth 的取舍
3.1 先把基础讲清楚:docx 就是一个 zip 包
很多人对 docx 有误解,以为它是个二进制文件。实际上,docx 是一个 zip 压缩包,里面是一堆 XML 文件和媒体资源,word/document.xml描述正文结构,word/media/放图片,word/styles.xml定义样式。正因为这个结构,前端解析 docx 才能在浏览器里做到。
知道这一点后,选型逻辑就很清晰了:谁能把这些 XML 还原成 HTML,谁就是好的 docx 预览库。
3.2 还原度优先:我推荐 docx-preview
我对比过 mammoth 和 docx-preview,最终在“还原原版式”这个需求下选了 docx-preview。原因很简单:mammoth 追求的是“把语义转成干净的 HTML”,它的输出更像是重新排版过的网页文档,适合内容型预览;而 docx-preview 更忠实于原文件的页面结构,分页、页边距、表格列宽、页眉页脚都是按原样还原的,这正好满足管理后台里“我要看到用户上传时的那份文件”的诉求。
docx-preview 的使用方式很直接,把 Blob 传进去,它会把内容渲染到指定容器里:
import { renderAsync } from 'docx-preview'; export async function renderDocx(blob, container) { await renderAsync(blob, container, null, { // 这个 className 会被挂到渲染出的容器上,方便覆盖样式 className: 'docx-preview', inWrapper: true, ignoreWidth: false, ignoreHeight: false, ignoreFonts: false, breakPages: true, // 保留原分页,关键词:分页 ignoreLastRenderedPageBreak: false, experimental: true, trimXmlDeclaration: true, useBase64URL: true, useWorker: true, // 开启 worker 解析,大文件不卡主线程 }); }useWorker: true是个值得单独说的配置。docx 的 XML 解析是纯计算任务,如果文件里有几十张图片、几百段带样式的文字,主线程会被阻塞 1 到 2 秒,用户滑动页面会明显卡顿。开 worker 之后,解析过程被移出主线程,UI 依旧流畅。
3.3 mammoth 在什么时候更好用
虽然我最终没在核心方案里用 mammoth,但它也不是没有价值。mammoth 输出的 HTML 非常干净,适合做“内容抽取型”预览,比如你要把 docx 转成富文本编辑器里的内容,或者要做全文检索。它的调用也很简单:
import mammoth from 'mammoth'; mammoth.convertToHtml({ arrayBuffer: blob.arrayBuffer() }) .then(result => { container.innerHTML = result.value; });但如果需求是“所见即所得”,我劝你别用 mammoth。它把 word 的分页概念彻底丢弃了,表格样式也做了简化,用户会觉得“这根本不是原来的文件”。
3.4 Word 预览中的真实痛点:表格列宽、分页与空页
docx-preview 也不是完美的,最常见的问题是表格列宽错乱——尤其是那些在 word 里手动拖拽过列宽的表格。热搜词里“word 表格列宽无法拖动”说的就是这类文件。docx-preview 解析出来的列宽偶尔会和原文件不一致,我的处理办法是在渲染完成后,遍历表格里的td元素,把原 XML 里的列宽信息取出来重新赋值。这个操作比较 hack,但实际效果立竿见影。
分页问题也很折磨人。docx 的分页是基于“文本流”概念模拟出来的,如果字体在用户机器上没有(比如用了微软雅黑但用户是 Linux 环境),行高就会变化,docx-preview 的分页位置会和 word 原版对不上,出现空页或者文字被截到下一页。这个坑没法彻底消除,只能靠统一字体渲染环境来缓解,我在项目里是强制让预览区域使用一套固定的中文字体,尽量减少字体缺失导致的偏差。
3.5 老式 .doc 文件的死局
务必在功能上线前想好这一点:纯前端解析 .doc(2003 那种老格式)基本无解。doc-preview 社区没有成熟的方案,mammoth 也只支持 docx。我最后的处理是:识别到 .doc 后缀,直接弹提示“暂不支持预览,请下载后查看”,同时提供下载按钮。这个降级策略虽然不是万无一失,但至少不会让用户卡死在一个白屏上。
4. Excel 预览:xlsx 库的解析与渲染边界
4.1 SheetJS 能拿到什么,不能拿到什么
excel 的纯前端预览,绕不开的库是 SheetJS(社区版叫 xlsx)。它的核心能力是把 xlsx 文件的二进制数据解析成一个 workbook 对象,每个 workbook 里有多个 sheet,每个 sheet 可以被转换成二维数组或者 HTML。
但社区版有一个非常关键的边界:它拿不到单元格的完整样式。比如背景色、字体颜色、边框粗细,社区版只能拿到一小部分(!cols、!merges这些基本信息有,s样式索引虽然存在,但样式表不公开)。这导致一个直接后果:如果你直接用sheet_to_html把 sheet 转成 HTML 表格,出来的样式会非常朴素,合并单元格倒是能保留,但你在 excel 里精心设置的列宽、行高、颜色全部丢失。
所以你要先明确自己的需求:是要“数据准确、结构清晰”的预览,还是“像素级还原 excel 的样式”?如果是后者,纯前端 SheetJS 社区版做不到,得考虑用 Canvas 自绘或者后台转换。
4.2 把 sheet 转成可交互表格的正确姿势
我的做法是弃用sheet_to_html,改用sheet_to_json拿到纯数据后,自己用组件库的 table 组件渲染。这样有几个好处:第一,表格的交互(排序、筛选、横向滚动)可以直接复用 element plus 的能力;第二,不用去处理 SheetJS 生成的那些奇怪的内联样式,兼容性更好;第三,遇到大表格时,可以搭配虚拟滚动。
核心代码大致是这样的:
import * as XLSX from 'xlsx'; export function parseExcel(blob) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); const sheetNames = workbook.SheetNames; const firstSheet = workbook.Sheets[sheetNames[0]]; // 拿到二维数组 const rows = XLSX.utils.sheet_to_json(firstSheet, { header: 1, defval: '', // 空单元格统一返空字符串,避免出现 undefined }); resolve({ rows, sheetNames, merges: firstSheet['!merges'] || [] }); }; reader.readAsArrayBuffer(blob); }); }这里有个细节:defval: ''很重要。如果缺了这个配置,空白单元格会被解析成null或者直接缺失,你在表格里渲染时就要做一堆判空处理,还容易在排序时把空值排到奇怪的顺序。
拿到rows之后,第一行通常是表头,后面的行是数据。合并单元格的信息在!merges里,但这个处理起来比较麻烦,我的做法是:表头区域如果遇到合并单元格,手工给第一列加rowSpan或colSpan;数据区域的合并直接忽略,因为大多数管理后台的预览场景,合并单元格在数据区出现的概率不高,强行还原反而会破坏表格的行列对齐。
4.3 多 sheet 切换与公式的坑
excel 文件经常有多个 sheet,如果只预览第一个,用户会以为文件数据缺失。我在解析结果里把sheetNames一并返回,然后在预览顶部加一个 tab 切换栏,点击不同 sheet 时重新对那个 sheet 调sheet_to_json。这里需要注意的是,不要一开始就把所有 sheet 全部解析,数据量大的文件,解析多个 sheet 会导致内存暴涨。懒加载的思路和 pdf 分页一样:用户切到哪个 sheet,才解析哪个。
公式是另一个细节。SheetJS 读取公式单元格时,如果单元格的f属性存在(表示公式),默认会取计算后的值v。这本身没问题,但如果你的文件是从别的地方导出来的,公式单元格的缓存值可能不存在,这时v就是undefined,渲染出来就是空单元格。我的处理是,遍历数据时检查undefined和null,统一显示为--,至少用户知道这个格子有内容只是没有缓存值,不会误以为是文件坏了。
4.4 大数据量 sheet 的性能优化
后台系统里经常有人把几万行的数据表传上来。如果用普通 table 渲染几万个tr,浏览器会直接卡死。我的方案分两层:第一层是限制预览行数,默认只渲染前 200 行,超过的部分给用户提示“数据量过大,已展示前 200 行,请下载查看完整内容”;第二层如果项目有富余时间,我会引入虚拟滚动组件,但说实话在预览场景里很少有必要,因为用户的诉求是“看一眼数据内容”,不是拿着预览界面做数据分析。
这里要特别提醒:SheetJS 解析大文件本身也会耗内存,一个 20MB 的 xlsx,解析后的 JS 对象可能占 100MB 内存。如果系统里经常有人传超大 excel,强烈建议在服务端做文件大小限制,或者在预览前先检查文件大小,超过 30MB 直接走下载提示,别硬抗。
5. PPT 预览:最不省心的一种,但也不是没有路
5.1 PPT 预览为什么这么难
pptx 和 docx 一样,本质也是 zip 包,每一页幻灯片对应ppt/slides/slide1.xml、slide2.xml这样的文件。解析思路理论上和 docx 类似,但为什么前端 ppt 预览的生态这么弱?因为幻灯片的信息密度远高于文档:位置、大小、层级、动画、渐变、母版、占位符、SmartArt、图表……这些元素在 XML 里的描述极其复杂,一个成熟的开源库需要处理的海量边界情况,远超 docx。
所以做 ppt 预览,第一原则是:不要试图完美还原所有视觉细节,要保证页面主体内容能看清。
5.2 方案一:pptx2html,轻量但对文件有要求
pptx2html 是老牌的纯前端 pptx 转 html 库,思路是解析 pptx 里的 XML 和媒体文件,然后用绝对定位的 div 把文字、图片“摆”到对应位置。对于简单的图文页,效果还不错;但只要稍微复杂一点,比如背景图、半透明遮罩、多个形状叠放,渲染结果就会和原文件差很远。
我会把它作为“没有 ppt 预览功能时的保底方案”,但不会作为主推方案。因为它的代码多年没怎么更新,遇到新版 Office 生成的 pptx,解析出错率比较高,经常整页空白。
5.3 方案二(更推荐):前端拼装,把 pptx 转成 PDF 流再预览
ppt 预览我自己目前用的是这个思路:在浏览器里读取 pptx 内容,但只取每页的文本和图片信息,渲染成一个带页码的“阅读版”,而不是还原成一页页幻灯片。听起来有点取巧,但实现逻辑反而更可靠。
具体做法是:解压 pptx -> 遍历每页 slide 的 XML -> 提取a:t标签里的文本(这些是真实文字内容)-> 提取p:pic里的图片引用 -> 用简单的卡片布局把文字和图片按顺序展示出来,左上角标注页码。这样虽然丢了动画、丢了精确排版,但用户在预览时能快速了解“这个 ppt 讲的重点是什么”,配合一个“下载原文件”按钮,日常办公场景足够用了。
如果你连这个都嫌麻烦,还有一条稳的路子:把 pptx 上传后,用服务端的 LibreOffice 转成 pdf,然后复用第 2 章的 pdf 预览逻辑。这不是纯前端,但它在“还原度”和“开发成本”之间是最平衡的。如果部署环境允许,哪怕只在后端开一个转换接口,都值得考虑。
5.4 动效、母版与 SmartArt 的兼容性红黑榜
我在实测中发现,纯前端 ppt 预览对文件内容是相当挑剔的。按我的经验排个序:纯文字 + 图片的旧版 pptx 兼容性最好;新版 Office 默认模板、带母版占位符的次之;带 SmartArt、图表、复杂动画的最差。遇到最后一种,与其解析到一半报错,不如在解析失败时直接降级到“原始 XML 文本预览”或者提示下载。我在组件里专门加了一个 try-catch,解析异常时自动走降级路线,绝不能让用户盯着一个空 loading 转圈。
6. 把四种预览串进统一入口:协议、加载态与内存回收
6.1 根据扩展名路由到对应解析器
四种格式的解析器都做好了,接下来的工程问题是:拿到一个文件,怎么知道该调哪个组件?我的做法是写一个PreviewFactory组件,根据扩展名分发到不同的子组件。分发逻辑很简单:
const PREVIEW_MAP = { pdf: PdfPreview, docx: DocxPreview, xlsx: ExcelPreview, pptx: PptPreview, }; // 不支持的格式走下载兜底 export function getPreviewComponent(fileName) { const ext = fileName.split('.').pop().toLowerCase(); return PREVIEW_MAP[ext] || UnsupportedPreview; }doc、xls、ppt 这些老格式会走到UnsupportedPreview,组件里显示“暂不支持在线预览”,并提供下载按钮。这里还有个业务细节:文件名里有可能带多个点,比如“2024.年度总结.docx”,所以取扩展名时一定要用split('.').pop()而不是slice(-4)这种土办法。
6.2 用 Blob URL 统一预览协议,解决鉴权与传参问题
我把所有预览文件的获取统一封装成了一个loadPreviewFile函数:内部用fetch带上鉴权 header 请求文件接口,拿到响应后转成 Blob,再通过URL.createObjectURL(blob)生成一个临时 URL。这样做有几个好处:
- 文件的下载权限完全由前端控制,后端不需要额外开放一个免鉴权的静态资源地址;
- 下载接口可以加防盗链逻辑,因为预览用的都是内存 Blob,不会留下可被直接访问的 URL;
- 后续如果要加水印,可以在 Blob 层面做二次处理,不会影响原文件。
这个方案也顺带解决了另一个高频需求:预览弹窗和父页面之间的联动。比如预览页里点“关闭”时要刷新列表,很多人会去折腾 iframe 之间的postMessage,但如果预览弹窗本身就是应用内的一个 vue 组件,那所有状态都在同一个 vue 实例里,根本不需要跨 iframe 通信。这也是我强烈建议“不要用 iframe 打开预览,而要在应用内组件渲染”的原因,省掉一整个通信层面的复杂度。
6.3 内存回收:大文件预览完必须主动释放
纯前端预览的内存问题,比想象中严重得多。pdf 每一页都是 canvas,docx 渲染会生成一大堆 DOM 节点,xlsx 解析会让 JS 堆暴涨。如果不做回收,用户连续预览 5 个文件,浏览器 tab 可能占用 2GB 内存,页面直接变卡。
我的统一回收策略是:所有预览组件在onUnmounted钩子里做三件事:
URL.revokeObjectURL(blobUrl),把临时 URL 释放掉。canvas.width = canvas.height = 0,把 pdf 组件里残留的画布强制置空,让浏览器回收显存。- 把 dom 容器的
innerHTML清空,docx-preview 和 excel 表格渲染出来的节点全部移除。
onBeforeUnmount(() => { if (blobUrl) URL.revokeObjectURL(blobUrl); if (canvasRef.value) { canvasRef.value.width = 0; canvasRef.value.height = 0; } containerRef.value && (containerRef.value.innerHTML = ''); });这段代码看着简单,但它是保证“连续预览不卡死”的最重要防线。很多纯前端预览方案在 demo 里表现良好,一上生产就被人骂,就是因为没人关心销毁时的内存回收。
6.4 加权限水印的前端思路
预览和权限是绑在一起的。我在纯前端预览里做了三层管控:
- 禁止下载:预览 UI 里不提供下载按钮,同时给预览容器绑定浏览器右键菜单禁用。
- 禁止复制:可以通过 CSS 属性
user-select: none实现,但要注意,pdf.js 的 canvas 本身不能复制文字,docx-preview 渲染出的 HTML 则要额外加这层限制。 - 动态水印:用 canvas 画一个斜向排布的字符串(当前用户姓名 + 时间戳),作为背景图平铺在预览容器上。水印要基于用户的实时会话生成,不能写死在前端代码里,否则截图后追查没有任何意义。
从安全角度看,前端水印是防君子不防小人的。真想防止数据泄露,必须在后端做下载审批、审计日志这些机制,前端水印只是让“泄露后能追到人”而已。
6.5 降级策略:预览不了,也要给用户一个好出路
纯前端方案的兼容性问题再洗也还是有漏洞,我最后加了一个全局降级策略。组件加载时先做能力检测,比如URL.createObjectURL是否存在、浏览器是否支持async/await(其实这年头都能支持);解析过程中如果抛异常,直接 catch 住,展示一个统一的错误面板:“该文件暂不支持在线预览,可下载到本地查看”,并附上下载按钮。
不要把异常想得太罕见。我上线第一个月,光遇到的情况就有:pdf 被损坏、docx 是从 WPS 导出的特殊版本、xlsx 的 sheet 名包含非法字符、pptx 里嵌入了视频,等等。每种情况都值得一个优雅的失败页面,而不是控制台里一段输出“这是坑”的红色报错。
最后再分享一个小心得
做完这套纯前端预览后,我的一个直观感受是:纯前端方案从来不是“代码写得有多聪明”,而是“边界处理得有多稳”。你花在 pdf.js 分页懒加载上的时间,和花在处理 docx 表格列宽上的时间,最终都会转化成用户感知到的“这个系统靠谱”。如果你也要做类似功能,我建议先把“哪些格式不支持”“哪些样式还原不了”这些边界清单写进需求文档,让产品和老板提前做好心理预期,这才是避免最后难堪的关键。至于选型,pdf 无脑用 pdf.js,docx 用 docx-preview,xlsx 用 SheetJS 自己渲染表格,pptx 要么降级成阅读版要么走后端转换,按这套组合往下走,基本不会出大乱子。