
在线预览 excel、word、pdf这几个词在后台管理系统的需求里出现的频率基本十个项目里九个会问。最近我在一个 Vue3 TypeScript 项目里正好被安排了这活儿从方案选型、组件封装到各种兼容性处理整个链路走了一遍。这篇不聊虚的把我真实的实现方式、踩过的坑、沉淀下来的代码写出来给同样被这个需求卡住的朋友做个参考。无论你是初学 Vue3还是已经在做中后台系统只要涉及文档预览前几节的设计思路和建议可以直接拿来用。1. 整体设计思路与方案选型1.1 三个文件类型三种处理思路先说结论Excel、Word、PDF 这三个格式没有一个前端方案能“一个库通吃”。PDF 的本质是固定版式文档浏览器可以直接渲染Word 的 docx 本质是一个 zip 压缩包里面有大量 XML 和资源文件Excel 解析起来更麻烦除了单元格数据还有公式、样式、合并单元格、图表这些复杂结构。所以正确的做法是三个文件分开处理再封装成统一组件。我最终采用的方式是PDF优先用浏览器原生iframe或embed渲染兼容要求高时引入 PDF.js。Word使用 mammoth.js 将 docx 转成 HTML再注入到页面容器里预览。Excel使用 SheetJS 解析 workbook转为 HTML Table 或 JSON 后自行渲染。另外还有一个更“偷懒”但更稳妥的路子后端把 Office 文件统一转成 PDF前端只负责预览 PDF。这个方案对前端压力最小兼容性也最好但需要部署转换服务通常用在企业级产品中。我这篇文章主要讲纯前端方案因为很多项目后端不配合只能自己扛。这三个方案并不是互斥的。我建议项目里做两层设计第一层直接前端解析第二层遇到复杂样式或解析失败时调用后端转换接口兜底。用户感知是先快后慢但能保证核心流程不中断。1.2 为什么选择 Vue3 TypeScript 而不是轻量框架可能有人觉得一个预览功能而已用 jQuery 或者原生 JS 不就行了但放在真实项目里预览不是孤立页面它要嵌入到工单管理、合同审批、课程资料库这类业务中组件需要复用状态需要共享还会不断加需求。Vue3 的组合式 API 天然适合把预览逻辑拆成usePdfPreview、useWordPreview、useExcelPreview这样的 hook而且模板逻辑清晰性能也好。TypeScript 的价值在预览功能里非常明显。比如文件类型判断如果直接用字符串到处传写久了就乱成一锅粥。定义一个PreviewFile接口上传组件、预览组件、API 返回值都约束好调用端写错字段马上就会报错。尤其在一个团队协作的项目里这能省下大量联调时间。这套组合还有一个实际优势Vue3 生态里已经有大量基于 TS 的组件库和工具函数比如我们后面要用的 mammoth.js 有官方类型定义SheetJS 自身也带类型。整个项目安装依赖时能自动推导省去了自己写.d.ts的麻烦。2. 核心细节解析与实操要点2.1 文件信息数据结构设计别把预览参数写死很多新手会这样写previewPdf(url, name)、previewWord(url)函数一堆参数后期加一个签名 token 就要改十几个地方。更好的方式是先定义一个统一的文件信息模型所有预览入口都收一个对象。我项目的src/types/preview.ts长这样export type FileType pdf | word | excel | image | other; export interface PreviewFile { name: string; url: string; type: FileType; size?: number; token?: string; headers?: Recordstring, string; extra?: Recordstring, any; } export interface PreviewResponse { success: boolean; message?: string; data?: PreviewFile; }为什么要这样设计因为一个在线预览组件往往要同时支持“当前要看的文件”和“上一个/下一个文件”。用一个refPreviewFile管理当前预览对象切换文件本质只是改变这个响应式对象组件内部根据type字段自动选择渲染分支不需要中途改函数签名。token和headers这两个字段尤其重要。很多文件接口是带鉴权的直接iframe srcfileUrl拿不到数据需要把 token 放到请求头里然后再生成 Blob URL 才能预览。这个细节我在后面章节单独说。2.2 文件类型判断不能只看扩展名文件类型判断是整个预览流程的第一道关卡。最直接的方法是根据扩展名判断export function detectFileType(fileName: string): FileType { const ext fileName.split(.).pop()?.toLowerCase() ?? ; if ([pdf].includes(ext)) return pdf; if ([doc, docx].includes(ext)) return word; if ([xls, xlsx, csv].includes(ext)) return excel; if ([png, jpg, jpeg, gif, webp].includes(ext)) return image; return other; }但在真实场景里用户上传的文件名可能是中文、带空格甚至混合大小写所以扩展名一定要toLowerCase()。同时不要完全相信扩展名最好再用Content-Type做二次校验。例如一个.docx文件正常的 MIME 类型是application/vnd.openxmlformats-officedocument.wordprocessingml.document如果后端返回的 Content-Type 改成了application/octet-stream就需要手动纠正类型。更重要的是表单上传时很多框架会把文件包装成File对象我们应该优先用file.type判断而不是 name。比如if (file.type application/pdf || fileName.endsWith(.pdf)) { // ... }这两种条件结合能覆盖绝大多数情况还能顺便拦截掉“把 word 后缀改成 pdf 后缀”这类恶意文件。2.3 安全细节防止 XSS 与越权下载在线预览很容易忽视安全这里必须强调三点第一如果是管理员才能看到的合同、订单文件预览接口不能用简单的公网 URL否则用户把 URL 复制出去就能下载。我项目里的做法是前端请求一个getPreviewUrl(fileId)接口后端返回一个带签名的临时链接附带过期时间和 IP 限制。前端拿到的临时链接只在预览时用不能直接传给第三方。第二用 mammoth 把 docx 转成 HTML 后内容里可能包含脚本或者恶意链接。虽然 mammoth 默认会移除大部分脚本但还是要在插入页面之前做一层清洗。我一般会先把 HTML 放到一个template里去掉所有script、iframe、object、embed标签然后再渲染。有洁癖的可以用DOMPurify库统一处理。第三iframe预览 PDF 时应该给 iframe 加上sandbox属性限制它的脚本执行权限同时用allow-same-origin控制同源访问。这样即使 PDF 里藏了脚本也不会影响母页面的数据。这三个点做到位基本能挡住绝大多数安全测试。2.4 大文件预览的性能预案浏览器直接解析 100MB 的 Excel 或者 Word页面大概率卡死。我的经验是前端预览只处理 20MB 以内的文件超过这个阈值提示用户使用下载或转 PDF。如果业务上一定要在线看大文件则优先走后端转换方案让服务器把 Office 转成 PDFPDF 浏览器解析是按页懒加载的压力小很多。前端解析也需要控制内存。所有 Blob URL 用完必须URL.revokeObjectURL()释放。如果你反复预览同一批文件不释放标签页内存会越涨越高最终崩溃。日志里看到Out of memory的报错十有八九就是这里没清理。3. 实操过程与核心环节实现3.1 搭建 Vue3 TypeScript 预览组件骨架预览组件我拆了两层底层是FilePreview.vue负责根据previewFile.type决定渲染哪个子组件上层是PreviewModal.vue负责全屏弹窗、工具栏、加载状态和错误状态。这样列表页只关心openPreview(file)不用理会内部实现。FilePreview.vue的骨架代码template div classfile-preview div v-ifloading classpreview-loading span正在加载请稍候.../span /div div v-else-iferror classpreview-error p{{ errorMessage }}/p button clickloadFile重新加载/button /div iframe v-else-iffileType pdf :srcpdfSrc classpreview-frame / div v-else-iffileType word classword-container v-htmlwordHtml / div v-else-iffileType excel classexcel-container v-htmlexcelHtml / /div /template script setup langts import { ref, computed, watch } from vue; import type { PreviewFile, FileType } from /types/preview; import { loadPdfBlob, parseWord, parseExcel } from ./previewService; const props defineProps{ previewFile: PreviewFile; }(); const loading ref(false); const error ref(false); const errorMessage ref(); const pdfSrc ref(); const wordHtml ref(); const excelHtml ref(); const fileType computedFileType(() props.previewFile.type); async function loadFile() { loading.value true; error.value false; errorMessage.value ; try { if (fileType.value pdf) { const blob await loadPdfBlob(props.previewFile); pdfSrc.value URL.createObjectURL(blob); } else if (fileType.value word) { wordHtml.value await parseWord(props.previewFile); } else if (fileType.value excel) { excelHtml.value await parseExcel(props.previewFile); } } catch (e: any) { error.value true; errorMessage.value e?.message || 文件预览失败; } finally { loading.value false; } } watch( () props.previewFile, () { if (pdfSrc.value) { URL.revokeObjectURL(pdfSrc.value); pdfSrc.value ; } wordHtml.value ; excelHtml.value ; loadFile(); }, { immediate: true } ); /script这个骨架看起来简单但很多细节决定了线上表现切换文件时必须先revokeObjectURL释放旧的 PDF 地址否则内存泄漏。如果 Word 或 Excel 解析失败不能把旧内容暴露在页面上所以重设wordHtml和excelHtml为空字符串。错误信息用中文提示并且提供“重新加载”按钮减少用户直接关弹窗的概率。3.2 PDF 预览浏览器能力还是 PDF.js最省事的 PDF 预览是直接用 iframeiframe :srcpdfSrc stylewidth: 100%; height: 100% /这样浏览器会自带工具栏、缩放、滚动、打印开发成本几乎为零。但缺点是很难定制比如要隐藏打印按钮、加自定义水印或限制用户复制内容基本做不到。正规企业系统通常要求预览页不能直接下载这时候就要用 PDF.js。PDF.js 的基本用法npm install pdfjs-dist组件里import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl; async function renderPdf(url: string, canvasId: string) { const pdf await pdfjsLib.getDocument(url).promise; const page await pdf.getPage(1); const scale 1.5; const viewport page.getViewport({ scale }); const canvas: HTMLCanvasElement document.getElementById(canvasId) as HTMLCanvasElement; const context canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; await page.render({ canvasContext: context, viewport }).promise; }这里有个坑pdfjs-dist各版本的 worker 资源路径不一样如果用 Vite 构建需要借助?url把 worker 文件作为静态资源导出。同时getDocument如果直接传带鉴权的 URL 会 401必须先用fetch拿到ArrayBuffer再传给getDocument({ data })。按我自己实测Vite 配置下用?url的方式最稳。如果项目里只需要简单预览我会直接走 iframe 方案毕竟快。只有当需求明确要求“禁止下载”“加自定义工具栏”时才上 PDF.js。3.3 Word 预览mammoth.js 转 HTML 后样式处理Word 的预览我踩过最大的坑是“样式还原”。mammoth.js 能把 docx 转成干净的 HTML但它默认转换出的样式和你 Word 里的排版还是有差距尤其是中文文档里的首行缩进、行间距、分页线。我最终的方案是mammoth.convertToHtml之后再在外面套一层自定义 CSS尽量贴近原文档。先安装npm install mammoth预览核心代码import mammoth from mammoth/mammoth.browser; export async function parseWord(previewFile: PreviewFile): Promisestring { const response await fetch(previewFile.url, { headers: previewFile.token ? { Authorization: Bearer ${previewFile.token} } : undefined, }); if (!response.ok) { throw new Error(Word 文件获取失败); } const arrayBuffer await response.arrayBuffer(); const result await mammoth.convertToHtml( { arrayBuffer }, { convertImage: mammoth.images.imgElement((image) { return image.read().then((imageBuffer) { const base64 btoa( new Uint8Array(imageBuffer).reduce( (data, byte) data String.fromCharCode(byte), ) ); return { src: data:${image.contentType};base64,${base64}, style: max-width: 100%; height: auto;, }; }); }), } ); return wrapWordHtml(result.value); } function wrapWordHtml(html: string): string { return div classword-document ${html} /div ; }重点说一下convertImage。如果不配置这个选项mammoth 默认会把图片转成 base64 内嵌但转换时可能会丢失扩展名和 MIME 类型导致图片加载不出来。我这里是手动读取图片二进制重新拼一个带contentType的 data URL亲测各种 docx 里的 JPG、PNG 都能正常显示。另外 Word 使用div v-html插入内容如果文档里包含外部链接建议给容器设置pointer-events避免误点。我通常还在 CSS 里加入“仿 A4 纸”样式.word-document { background: white; max-width: 900px; margin: 0 auto; padding: 48px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); } .word-document p { margin: 0 0 12px; line-height: 1.7; }3.4 Excel 预览SheetJS 渲染成 HTML Table 并处理合并单元格Excel 的解析我直接选 SheetJS。它的sheet_to_html能一行代码把 sheet 转成带样式的 HTML Table当然这个 HTML 只能还原基础样式颜色、对齐、边框会保留但图表和复杂公式需要额外处理。安装npm install xlsx核心代码import * as XLSX from xlsx; export async function parseExcel(previewFile: PreviewFile): Promisestring { const response await fetch(previewFile.url, { headers: previewFile.token ? { Authorization: Bearer ${previewFile.token} } : undefined, }); if (!response.ok) { throw new Error(Excel 文件获取失败); } const arrayBuffer await response.arrayBuffer(); const workbook XLSX.read(arrayBuffer, { type: array }); const sheetName workbook.SheetNames[0]; const worksheet workbook.Sheets[sheetName]; const html XLSX.utils.sheet_to_html(worksheet, { id: preview-excel-table, }); return div classexcel-toolbar span当前工作表${sheetName}/span /div div classexcel-scroll ${html} /div ; }实际使用中如果只用默认的sheet_to_html预览几百行数据时 DOM 节点太多页面会卡。我的优化思路是先取前 200 行数据做个“快速预览”同时提供“查看完整文件”按钮下载原文件。如果你想在前端完整渲染建议开启虚拟滚动但虚拟渲染 Excel 的复杂度不小非必要不碰。还有XLSX.read在处理大文件时会阻塞主线程几秒甚至十几秒体验很糟。我后来把解析逻辑放进Web Worker页面只负责接收渲染结果。这个优化比想象中简单而且效果立竿见影。3.5 统一封装带取消功能的预览弹窗预览弹窗在业务里通常是全局组件可能同时服务列表页、详情页还要能连续切换文件。封装一个基于el-dialog的PreviewModal.vue会省很多事。代码如下template el-dialog v-modelvisible width80vw top4vh destroy-on-close :close-on-press-escapefalse template #header div classpreview-header span{{ currentFile?.name }}/span el-button link clickclose关闭预览/el-button /div /template FilePreview :preview-filecurrentFile / template #footer el-button :disabled!hasPrev clickprevFile上一个/el-button el-button typeprimary :disabled!hasNext clicknextFile 下一个 /el-button /template /el-dialog /template script setup langts import { ref, computed, watch } from vue; import type { PreviewFile } from /types/preview; import FilePreview from ./FilePreview.vue; const props defineProps{ modelValue: boolean; fileList: PreviewFile[]; currentIndex: number; }(); const emit defineEmits([update:modelValue, change]); const visible computed({ get: () props.modelValue, set: (val) emit(update:modelValue, val), }); const currentFile refPreviewFile | null(null); watch( () props.currentIndex, (val) { currentFile.value props.fileList[val] ?? null; }, { immediate: true } ); const hasPrev computed(() props.currentIndex 0); const hasNext computed( () props.currentIndex props.fileList.length - 1 ); function prevFile() { if (hasPrev.value) emit(change, props.currentIndex - 1); } function nextFile() { if (hasNext.value) emit(change, props.currentIndex 1); } function close() { visible.value false; } /script这个弹窗的“取消”体现在哪里一种是关闭对话框时调dispose释放资源一种是 Excel/Word 解析过程中用户关闭我们通过v-if销毁子组件让异步代码失去 DOM 目标。不过如果有真实的中断需求更稳的方式是在FilePreview里加一个abortControllerlet abortController: AbortController | null null; async function loadFile() { abortController?.abort(); abortController new AbortController(); try { const response await fetch(file.url, { signal: abortController.signal }); // ... } catch (error: any) { if (error.name AbortError) return; // ... } }这样用户连续切换文件时旧请求会被取消不会出现“先打开 A又打开 B最后 A 的结果回来了覆盖 B”这种竞态问题。4. 常见问题与排查技巧实录4.1 中文文件名与 Base64 图片显示问题预览时文件名带中文直接作为 iframesrc会乱码甚至请求失败。解决方法是统一用encodeURIComponent包裹文件名。如果文件是通过 Blob 预览文件名只在下载时用到不影响渲染但 Word 内部的图片通过convertImage转 base64 时要注意btoa不能处理汉字相关的二进制流其实btoa是处理二进制数组的不会有问题。真正的问题是超大图片 base64 后字符串太长页面渲染变慢所以我会在convertImage里加一个大小判断超过 300KB 的图片先用图片压缩再转 base64。4.2 大文件导致页面卡死这个问题基本发生在 Excel 或包含大量图片的 Word 上。我实测一份 10MB 带 50 张图的 docxmammoth 解析需要 3-5 秒期间页面完全不可操作。最终优化手段是解析放在 Worker 里主线程展示 loading。解析结果先渲染前 5 页后续内容滚动到可视区域再插入。如果文件超过 30MB直接提示走“转 PDF 后端预览”。在线预览不是本地 Office流畅度上不可能完全对标。提前告诉用户“大文件请下载或稍候”比让用户傻等要好得多。4.3 在若依等后台框架里集成时遇到的坑最近很多人用若依的 Vue3 TypeScript 版本做后台集成预览组件时容易遇到这几个问题若依的 Axios 请求拦截器默认在响应code ! 200时弹统一错误提示如果预览组件内部直接fetch文件地址不走 Axios 就不会有这个问题但也要手动加请求头里的 token。若依框架的路由和静态资源前缀是vite.config.ts里的base配置。如果你把预览生成的 Blob URL 直接给v-html的图片 src不受影响但如果 Word 里使用了相对路径的图片需要手动拼接base。还有 TypeScript 编译时提示option baseUrl is deprecated这个在vue-tsc和 TypeScript 5.5 之后特别常见。解决办法是在tsconfig.json里把baseUrl去掉改用paths相对路径。我在实际项目里因为这个问题排查了两个小时所以放这里提醒一下。4.4 打印与下载的隐藏问题在线预览场景里用户最常用的操作就是打印。如果直接用浏览器 iframe打印按钮在 iframe 外面用户只能右键打印体验差。更麻烦的是iframe 内嵌 PDF 时打印可能整页都是白的尤其是 Chrome 对沙箱 iframe 的限制很严格。解决办法在弹窗工具栏放一个“打印”按钮点击后打开新窗口function printPdf(src: string) { const printWindow window.open(, _blank); printWindow.document.write( iframe src${src} stylewidth:100%;height:100%;border:0;/iframe ); printWindow.document.close(); printWindow.focus(); printWindow.print(); }不过我这里有一个更稳的心得如果用户需要在预览页直接下载原文件不要用a标签指向临时 URL因为某些浏览器会直接打开而不是下载。推荐用fetch拿到 Blob再通过URL.createObjectURL生成下载链接同时设置download文件名属性。这样文件名、扩展名都可以准确控制不会被Content-Disposition头干扰。问题现象常见原因解决思路预览白屏请求 401 / 跨域 / 文件过大检查 token、fetch headers、文件大小Excel 只显示 100 行SheetJS 默认只转换激活区域手动设置sheet_to_json的 rangeWord 图片不显示图片源是外部 URL 被拦截用 convertImage 转 base64PDF 打印空白iframe 沙箱权限不足新窗口打印或配置 sandbox中文文件名乱码URL 编码不对encodeURIComponent5. 从实际项目再聊几点经验东西跑通是一回事真正上线前你还要留意几个容易被忽略的点。第一个是文件来源的安全性如果预览组件的前置列表是用户上传的文件那么在上传时就应该校验真实文件类型而不仅是扩展名。第二个是兼容性目前主流浏览器对 iframe 的 PDF 预览表现尚可但 Safari 在某些版本里会直接把 PDF 下载而不是内嵌预览这时需要降级到 PDF.js。第三个是样式统一Word 转出的 HTML 默认没有任何类名你必须在外部容器上做约束否则页面会“乱成一片”。做这套功能的时间线我最开始先写了个 demo用 iframe 预览 PDF然后加 mammoth最后加 SheetJS。真正花时间的不是写代码而是处理各种文件样本的差异。所以我建议你在测试阶段一定要准备一份“样本文件库”里面至少要包含这几个类型带密码的 PDF、扫描件 PDF、带图片的 docx、有合并单元格的 xlsx、超过 20MB 的大文件分别跑一遍。我这个做法帮我提前发现了不少线上才会爆炸的 bug。最后分享一个小习惯所有预览相关的工具函数都写成纯函数不要在里面访问组件实例。这样后续无论是从弹窗调用、从列表页调用还是在 Worker 里复用都毫无压力。比如parseWord、parseExcel我都放在previewService.ts里组件只管传PreviewFile和收结果。后面如果再遇到要支持 wps 或者 txt只需要新增parseWps、parseTxt组件模板里加两个分支就行整个代码还是清晰的一坨而不是越改越烂。