前阵子给一个政务类的后台管理系统做文件预览模块,需求听起来很常规:把服务器上的PDF、Word、Excel在页面上直接打开看,点击下载按钮才进入下载流程。我一开始也没当回事,觉得PDF随手就能弄,Word和Excel顶多找两个库的事。结果真东西做下来,从选型到兼容性再到性能,折腾了小半个月。这篇文章就当一次复盘,把我在Vue项目里集成 PDF、Word、Excel 预览的完整思路、核心代码片段和踩坑经历梳理出来,给同样被这个需求砸中的朋友一点参考。不管你是刚接到这种需求的半小白,还是已经踩过几个坑的初级前端,按这条路线走,至少能少走几段弯路。
1. 接到"页面里直接预览文件"需求,我先做了三件事
1.1 摸清浏览器到底能白嫖哪一步
很多人第一反应是:PDF 浏览器自己就能开啊,<iframe>或者直接给个链接不就完事了?这话对了一半。桌面端 Chrome、Edge、Firefox 确实原生支持 PDF 预览,你只要丢一个.pdf地址给浏览器,它自己就会调起内置阅读器。但这个方案有三个致命问题:
- 移动端体验完全不可控。安卓手机浏览器对 PDF 的处理策略五花八门,有的直接唤起下载,有的甩给第三方App,有的白屏没反应。
- 无法做精细控制。你要实现"只能看正文、不能下载"基本没戏,用户随手一按 Ctrl+S 文件就跑走了。
- 样式和交互不可控。你不会想让用户停留在浏览器的内置阅读器里,翻页、放大缩小、页码跳转全都不可控,跟业务页面的设计语言完全割裂。
Word 和 Excel 就更不用说了,浏览器原生压根不认。所以实际情况是:PDF 必须上 pdf.js 这类渲染库,Word 和 Excel 必须借助前端解析库转成 HTML 或数据表格,这是绕不过去的第一步认知。
1.2 确认文件来源:接口流、静态URL、本地上传,预览逻辑完全不同
我在动手前先问了后端同事一个问题:文件从哪来?这直接决定了整个组件的输入设计。
常见的三种场景:
- 后端返回一个可访问的静态URL,比如
https://files.example.com/contract/2024/xxx.pdf。这种情况最简单,直接把 URL 丢给预览组件就行。 - 后端返回二进制流,前端用固定接口地址去请求,一般是通过
/api/file/download?fileId=xxx这样的接口,返回application/octet-stream或对应 MIME 类型。这种场景下,前端 fetch 或 axios 拿到的是Blob/ArrayBuffer,需要自己拼接对象地址或直接传给解析库。 - 用户本地选择文件后本地预览,比如用户先上传到服务器前想先确认内容对不对。这种直接用
FileReader,把File对象转成ArrayBuffer再喂给解析库。
我最后的设计是:预览组件同时支持传入 URL 和本地 File 对象,内部统一处理。组件接收的文件地址先走一次fetch,如果是本地文件就直接转ArrayBuffer,这样前端调用方只需要关心"我有个地址"还是"我有个文件"。
1.3 技术选型:pdf.js + docx-preview + SheetJS 的组合怎么确定的
选型的时候我列了一个对比表格,逐个过了一遍每个方案在 Vue3 + Vite 项目里的接入成本:
| 文件类型 | 方案 | 优点 | 缺点 |
|---|---|---|---|
| pdf.js(官方库) | 渲染质量高、可精细控制交互、社区庞大 | 需要自己封装组件,配置 Worker 有门槛 | |
| vue-pdf | 上手快,API简单 | 更新慢,底层 pdf.js 版本旧,多页渲染需要循环处理 | |
| iframe / embed | 0成本 | 可控性差,移动端兼容差,不能防下载 | |
| Word | docx-preview | 还原度高,支持页眉页脚、表格、图片 | 只认 .docx,文件大时渲染较慢 |
| Word | mammoth.js | 轻量,输出 HTML 干净 | 还原度一般,复杂版式会丢 |
| Excel | SheetJS(xlsx) | 社区版免费,解析能力强 | 样式还原有限,大数据量需优化 |
| Excel | exceljs | 读写都强 | 偏向数据处理,渲染表格仍需自建 |
| Excel | Luckysheet / Univer | 接近在线表格体验 | 体积大,重依赖,仅限 Excel 场景 |
最后定下来的组合是:PDF 部分用 pdf.js 官方库自己封装,Word 用 docx-preview,Excel 用 SheetJS 解析成二维数组后配合 el-table 渲染。这套组合的通用性最强,不依赖任何 UI 框架,换到 Element 之外的其他组件库也能用。
2. PDF预览:放弃iframe,用pdf.js做到可翻页、可缩放
2.1 pdf.js 的 Worker 配置是第一个坑
pdf.js 是 Mozilla 团队维护的官方 PDF 渲染库,核心思路是用 Worker 线程解析 PDF 文件,主线程负责把每一页画到 Canvas 上。直接安装依赖:
npm install pdfjs-dist装完以后第一个坑就来了:必须告诉 pdf.js 去哪里找 Worker 文件。
import * as pdfjsLib from 'pdfjs-dist' import workerUrl from 'pdfjs-dist/build/pdf.worker.min.js?url' pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl在 Vite 里,?url后缀会把这个 worker 文件作为静态资源打包,并返回最终部署路径。这一步千万不能省,忘记配置 Worker 的话,pdf.js 会退回到主线程解析,轻则卡顿,重则直接白屏报错。
如果你的项目用的是 Webpack,要写成import workerUrl from 'pdfjs-dist/build/pdf.worker.min.js',然后配合file-loader或者worker-loader。内网部署环境特别要注意:别用 CDN 链接,一定要把 worker 文件放到自己的静态资源目录,不然生产环境一旦访问不了公网 CDN,整个预览功能就挂了。
2.2 两段核心代码:加载文档 + 渲染页面
pdf.js 的 API 设计得很清晰,核心就是getDocument加载 PDF 文件,然后逐页渲染。下面是一个最小可用的封装:
import * as pdfjsLib from 'pdfjs-dist' import workerUrl from 'pdfjs-dist/build/pdf.worker.min.js?url' pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl export async function loadPdf(urlOrBuffer) { const loadingTask = pdfjsLib.getDocument({ data: urlOrBuffer instanceof ArrayBuffer ? urlOrBuffer : undefined, url: typeof urlOrBuffer === 'string' ? urlOrBuffer : undefined }) const pdf = await loadingTask.promise return pdf }拿到pdf对象后,渲染指定页码到 Canvas:
export async function renderPdfPage(pdf, pageNumber, canvas) { const page = await pdf.getPage(pageNumber) const baseViewport = page.getViewport({ scale: 1 }) // 适配高清屏 const dpr = window.devicePixelRatio || 1 const scale = dpr const viewport = page.getViewport({ scale }) canvas.width = viewport.width canvas.height = viewport.height canvas.style.width = `${baseViewport.width}px` canvas.style.height = `${baseViewport.height}px` const context = canvas.getContext('2d') const renderContext = { canvasContext: context, viewport } await page.render(renderContext).promise return baseViewport }这里有个细节值得说明:Canvas 的物理像素和 CSS 像素要区分开。如果直接用viewport.width设置 Canvas 宽度,在 Retina 屏上会模糊。我上面的写法是用devicePixelRatio放大 Canvas 的物理尺寸,再用style.width固定 CSS 显示尺寸,这样在高分屏上渲染的 PDF 文字边缘是清晰的。
还有一个更隐蔽的问题:渲染大页面时,Canvas 支持的最大面积是有限制的(尤其移动端)。如果 PDF 页面尺寸异常,比如画报类的超宽页面,Canvas 可能直接报错。我通常会在渲染前加一个保护:
const maxCanvasArea = 16384 * 16384 if (viewport.height * viewport.width > maxCanvasArea) { // 缩小 scale,或提示用户该页面过大 }2.3 翻页、缩放、页码显示,一个最小封装就够了
把上面两个函数组合起来,就能做一个简单的 PDF 预览组件。核心状态就是pageNum和totalPages:
<template> <div class="pdf-preview"> <div ref="canvasWrap" class="pdf-canvas-wrap"> <canvas ref="canvas"></canvas> </div> <div class="pdf-toolbar"> <button :disabled="pageNum <= 1" @click="pageNum--">上一页</button> <span>{{ pageNum }} / {{ totalPages }}</span> <button :disabled="pageNum >= totalPages" @click="pageNum++">下一页</button> <button @click="zoomOut">缩小</button> <span>{{ Math.round(scaleRatio * 100) }}%</span> <button @click="zoomIn">放大</button> </div> </div> </template>缩放逻辑我建议用重新渲染当前页的方式,而不是用 CSStransform去缩放 Canvas。因为 CSS 缩放会让人觉得字是糊的,重新用page.getViewport({ scale: newScale })渲染出来的才是矢量级清晰度。
需要提醒的是,翻页时最好加一个"渲染中"的状态,因为 pdf.js 每一页渲染都是异步任务,用户快速点击翻页时容易产生竞态——上一次渲染还没结束,下次渲染就开始了,画面会闪跳。一个简单的处理是在渲染前用Promise判断当前渲染任务是否是最新的,或者直接用一个renderId标记,渲染完成后只有最新的一次才更新 UI。
3. Word预览:docx-preview 的实现和它解决不了的.doc
3.1 为什么选 docx-preview 而不是 mammoth.js
Word 预览的方案,我在 mammoth.js 和 docx-preview 之间犹豫了很久。mammoth.js 主打的是"把 docx 转成干净的 HTML",它的设计哲学是只保留内容结构,丢掉复杂的版式。如果你的 Word 文档主要是纯文字报告,mammoth 生成的 HTML 非常轻量,解析速度也快。但一旦文档里有页眉页脚、批注、修订、复杂表格、嵌入图片,mammoth 基本就力不从心了。
docx-preview 的思路更贴合"预览"这个需求——它尽力去还原 docx 在 Word 里的排版效果,包括分页、页边距、表格边框、段落间距等。虽然做不到 100% 像素级还原,但对用户来说,看到的东西和本地打开 Word 差不多,这就足够了。
最终我选 docx-preview,原因就一句话:预览工具的首要目标是"像",而不是"轻"。我们的用户里有很多是业务部门的同事,他们判断预览功能好不好用的第一标准就是"跟我电脑上打开像不像"。
安装命令:
npm install docx-preview3.2 renderAsync 从拿到 Blob 到页面展示的完整链路
docx-preview 的使用方式和 pdf.js 完全不同,它只需要一个容器 DOM 和一个Blob/ArrayBuffer,剩下的渲染全自动完成。完整代码如下:
import { renderAsync } from 'docx-preview' export async function previewWord(fileSource, container) { let blob if (fileSource instanceof Blob) { blob = fileSource } else if (fileSource instanceof ArrayBuffer) { blob = new Blob([fileSource]) } else if (typeof fileSource === 'string') { const res = await fetch(fileSource) if (!res.ok) throw new Error('Word 文件加载失败') blob = await res.blob() } else { throw new Error('不支持的 Word 文件来源') } await renderAsync(blob, container, null, { className: 'docx-preview-wrapper', inWrapper: true, ignoreWidth: false, ignoreHeight: false, ignoreFonts: false, breakPages: true, experimental: false }) }渲染时几个配置项值得理解:
breakPages: true表示按分页符切分,这样容器里能看到文档的分页效果,否则所有内容会连成一长条。ignoreWidth: false表示尊重 Word 文档里设定的页面宽度,如果想强制占满容器宽度,可以设成true。inWrapper: true会在容器外包裹一层,带上默认的内边距样式,方便做预览卡片效果。
还有一个很容易被忽略的点:docx-preview 不是数据驱动,而是直接往 DOM 里塞内容。这带来的问题是,如果同一个容器渲染了两次,前一次渲染的内容不会自动清空,会叠加在一起。所以在渲染前一定要先清空容器:
container.innerHTML = '' await renderAsync(blob, container)这个问题看似不起眼,但我在实际开发里真的遇到过——用户先预览了一个 A 文档,再点另一个 B 文档,页面上 A 的残留内容没清掉,新版内容直接堆在了下面,看着跟排版错乱一样。
3.3 .doc 老格式的三个处理方向
这里必须说清楚一个残酷的事实:docx-preview 只支持 .docx,不支持老版本的 .doc。.doc 是 Word 97-2003 时代的二进制格式,解析难度比 OpenXML 的 .docx 高一个数量级,前端几乎没有成熟的开源解决方案。
我在项目里处理 .doc 的方案有三个方向,按优先级排序:
- 后端转换(推荐):后端用 LibreOffice 或 OnlyOffice 把 .doc 转成 .docx 或 PDF,再返回给前端预览。这是最稳妥的方案,转换质量高,而且后端可以缓存转换结果。缺点是依赖后端同事配合。
- 前端降级提示:检测到文件后缀是 .doc 时,直接提示"该文件为旧版 Word 格式,暂不支持在线预览,请下载后查看",并给一个明显的下载按钮。
- 转存为 .docx:引导用户用 WPS 或 Word 把 .doc 另存为 .docx 再上传。
我最后项目里做的是组合拳:前端识别 .doc 后缀,先尝试请求后端转换接口,转换接口不可用或者超时就降级为下载提示。这样既保证了对老文件的基本可用性,又不会把前端搞成一个笨重的格式转换器。
4. Excel预览:SheetJS 解析成数据再渲染,比"原样还原"更实用
4.1 sheet_to_html 和 sheet_to_json 两条路线的取舍
Excel 的预览方案比前两类复杂,因为 Excel 文件本质上是一个"容器",里面装的是工作表、单元格、公式、样式、合并单元格等各种元素。到底该怎么在网页里呈现它,业界分两派:
一派是"原样还原"路线,代表是 Luckysheet 和 Univer 这类在线表格引擎。它们在网页里模拟 Excel 的完整交互,能看公式、能拖拽、能合并单元格,体验确实好。代价是体积巨大、依赖复杂、上手门槛高,而且如果只是做"只读预览",这个重武器有点杀鸡用牛刀。
另一派是"数据转化"路线,代表就是 SheetJS(xlsx库)。它先把 xlsx 解析成 JSON、二维数组或 HTML 字符串,然后再用前端表格组件渲染。这条路的好处是灵活、轻量,数据可以加工、可以做搜索排序,组件风格也可以跟项目 UI 统一。坏处是原样还原能力有限,带复杂样式的 xlsx 可能显示得不够"像"。
我的取舍逻辑很简单:后台管理系统的用户看 Excel 主要是看数据内容,比如核对报表、查看名单、确认导入模板,而不是在网页里做表格编辑。所以"数据可读、性能稳定、结合 UI 框架"比"像素级还原"重要得多。最后选了 SheetJS 解析 + el-table 渲染的方案。
4.2 解析 xlsx 到二维数组的完整封装
安装 SheetJS 社区版:
npm install xlsx核心解析逻辑如下:
import * as XLSX from 'xlsx' export async function parseExcel(fileSource, { maxRows = 500 } = {}) { let data if (fileSource instanceof ArrayBuffer) { data = fileSource } else if (typeof fileSource === 'string') { const res = await fetch(fileSource) if (!res.ok) throw new Error('Excel 文件加载失败') data = await res.arrayBuffer() } else { throw new Error('不支持的 Excel 文件来源') } const workbook = XLSX.read(data, { type: 'array', cellDates: true, cellNF: false }) const sheetName = workbook.SheetNames[0] const sheet = workbook.Sheets[sheetName] const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', range: maxRows }) return { sheets: workbook.SheetNames, activeSheet: sheetName, rows, totalRows: sheet['!ref'] ? XLSX.utils.decode_range(sheet['!ref']).e.r + 1 : 0 } }这里有几个参数我必须解释,因为它们直接影响解析结果:
type: 'array':告诉 SheetJS 输入的原始数据是二进制数组。cellDates: true:让日期单元格解析成 JS 的Date对象,否则会得到一个 Excel 序列号数字,比如45293这样的值,前端还得自己换算成日期。header: 1:让sheet_to_json返回二维数组,而不是对象数组。二维数组的好处是渲染成表格时每一行都好处理。defval: '':空白单元格默认值设为空字符串,避免后续渲染出现undefined。range: maxRows:只读取前 N 行,这是性能优化的关键。一个几十万行的 Excel 文件如果你全量解析,浏览器直接卡死。限制行数后,解析速度和渲染速度都能保持在秒级。
拿到二维数组后,在 Vue 模板里渲染就简单了:
<template> <el-table :data="tableData" border stripe size="small"> <el-table-column v-for="(_, colIndex) in columns" :key="colIndex" :prop="`col_${colIndex}`" :label="columnLabel(colIndex)" min-width="120" show-overflow-tooltip /> </el-table> </template>需要特别处理的是列数据。因为sheet_to_json返回的二维数组长度可能参差不齐,某行有 10 列,另一行只有 5 列是常态。我在渲染前会先遍历所有行,算出最大列数,然后每一行再补齐到统一长度。这样可以避免 el-table 渲染时某行少了一列而错位。
4.3 大数据量预览的降级策略
SheetJS 的解析速度本身很快,但真正卡的是浏览器渲染表格 DOM。3万行数据全量塞进 el-table,页面基本等着白屏。我的降级策略是分三档:
| 总行数 | 策略 |
|---|---|
| < 500 行 | 直接渲染全部 |
| 500 - 5000 行 | 只渲染前 500 行,页面提示"仅展示前 500 行,完整内容请下载查看" |
| > 5000 行 | 不解析文件内容,直接弹窗提示下载 |
也就是说,上面的maxRows参数,我用的是一个动态值。先根据文件大小和 SheetJS 读取到的总行数判断档位,再决定传给sheet_to_json的行数上限。实际体验下来,500 行以内渲染毫无压力,用户看着也不会觉得"怎么才这么点数据"。
还有个细节是合并单元格。如果 Excel 里有合并单元格,sheet_to_json解析出的二维数组只在左上角单元格有值,其余都是空字符串。如果业务上需要保留合并表现,可以读取sheet['!merges']拿到合并区域信息,再映射到 el-table 的span-method里。但这一步会显著增加代码复杂度,我的建议是:先跟需求方确认有没有"需要看清合并单元格"的场景,没有就不用做。
5. 统一封装成 FilePreview 预览组件,按扩展名自动分发
5.1 props 设计:url、file、扩展名
整个功能开发到这里,我发现一个问题:三个预览模块(PDF、Word、Excel)的加载状态、错误处理、组件入口是互相独立的,如果直接在业务页面里各用各的,调用方要写三份判断逻辑。所以我决定做一个统一的FilePreview组件,对外只暴露两个核心 props:
<script setup> defineProps({ // 文件访问地址,二选一 src: { type: String, default: '' }, // 本地 File 对象,二选一 file: { type: File, default: null }, // 可选,不传时根据文件名后缀自动识别 ext: { type: String, default: '' } }) </script>ext这个 prop 很多人可能觉得多余——从src里截取后缀不就行了?但实际项目中,有些后端返回的文件 URL 不带后缀(比如/api/file/download?id=123),或者后缀被?token=xxx污染了。这种情况下就必须给组件一个显式的文件类型提示。所以我的逻辑是:优先用ext,没有ext再从文件名或 URL 里解析后缀。
5.2 内部按 type 分发到三个渲染模块
组件内部的逻辑核心是确定预览类型。我把确定类型的逻辑独立成一个函数:
function resolvePreviewType(src, ext, file) { if (ext) return ext.toLowerCase() const fileName = file ? file.name : (src || '') const match = fileName.match(/\.([^.?#]+)($|[?#])/) if (match) return match[1].toLowerCase() return '' }然后根据类型分发到不同的子组件。模板部分长这样:
<template> <div class="file-preview"> <div v-if="loading" class="file-preview-loading">文件加载中...</div> <div v-else-if="error" class="file-preview-error"> <p>{{ errorMessage }}</p> </div> <template v-else> <PdfPreview v-if="previewType === 'pdf'" :url="resolvedSrc" @load="onInnerLoad" @error="onInnerError" /> <WordPreview v-else-if="previewType === 'docx'" :url="resolvedSrc" :file="file" @load="onInnerLoad" @error="onInnerError" /> <ExcelPreview v-else-if="['xlsx', 'xls', 'csv'].includes(previewType)" :url="resolvedSrc" :file="file" @load="onInnerLoad" @error="onInnerError" /> <div v-else class="file-preview-unsupported"> 暂不支持 {{ previewType || '该' }} 文件类型的在线预览 </div> </template> </div> </template>这里有个交互细节值得说一下:每个子组件都是用自己的方式加载文件流的(pdf.js 直接接受 URL,docx-preview 需要先 fetch 成 Blob,SheetJS 需要 ArrayBuffer),所以父组件不提前统一 fetch,而是把原始src或file传给子组件,让子组件自己处理。这样做的好处是职责清晰,坏处是每个子组件都要写一遍加载逻辑。我的折中方案是封装一个loadFileSource的公共函数,放在子组件里共用。
5.3 加载态、错误态、空态的统一治理
预览组件最容易被忽视的就是异常反馈。一个文件预览不了,如果只是白屏,用户会以为功能坏了,实际上可能是网络问题、文件损坏、格式不支持、跨域被拦。我在这版组件里把状态机统一成三个:loading、error、success。
子组件加载成功后发load事件,失败后发error事件,父组件负责切换状态并展示错误信息。错误信息尽量做到人话可读:
// PDF.js 加载失败 'PDF 文件解析失败,文件可能已损坏或不是合法的 PDF 格式' // docx-preview 渲染失败 'Word 文件渲染失败,请检查文件是否为有效的 .docx 格式' // fetch 网络层错误 '文件加载失败,请检查网络连接或稍后重试'有一个隐藏的状态也必须处理:加载超时。大文件在弱网环境下,fetch 可能要等很久。我给 fetch 包了一层AbortController,设置 30 秒超时,超时后主动中断并提示用户重新尝试。这个体验优化虽然只是一个小细节,但在实际项目里避免了"用户盯着转圈等两分钟"的尴尬。
6. 从内网环境到生产环境:我踩过的一些坑
6.1 文件流没拿到,先检查 responseType
先说一个前端新人特别容易踩的经典坑:用 axios 请求文件接口,忘记设置responseType: 'blob',导致拿到的数据是一串乱码字符串。因为 axios 默认把响应当成 JSON 解析,二进制流被强行转成了文本,后面无论是传给 pdf.js 还是 docx-preview,都会解析失败。
正确的写法是:
const res = await axios.get('/api/file/download', { params: { fileId: 'xxx' }, responseType: 'blob', timeout: 30000 })如果用原生 fetch,不需要设置 responseType,但要注意拿到的res.blob()和res.arrayBuffer()只能用一次,不能先用 blob 再转 arrayBuffer,流只能消费一次。有些同事会写const data = await res.arrayBuffer()之后又调用res.blob()去拿 Word 渲染用的 Blob,结果后者直接报错。正确的做法是:按需取一次,如果要同时用 Blob 和 ArrayBuffer,就const buf = await res.arrayBuffer(); const blob = new Blob([buf])。
6.2 iframe 和 pdf.js 的跨域表现完全不同
如果你的 PDF 地址是跨域的,iframe方案大概率会翻车。跨域情况下 iframe 和父页面之间的交互是受限的,虽然单纯展示 PDF 内容可能能看,但如果你在父页面上调 iframe 的contentWindow去控制翻页,会被浏览器拒绝访问。
pdf.js 的getDocument({ url })本身是走fetch加载的,受 CORS 策略限制。如果后端没配 CORS 或者内网网关有安全策略,跨域请求一样会失败。我在项目里遇到过一种典型场景:前端部署在 A 域名,文件存储在 B 域名的对象存储上,B 域名没有开 CORS,导致 PDF、Word、Excel 全部加载失败。
这种问题有三个解决方向:
- 让后端网关加一个反向代理接口,前端通过同源路径访问,由后端转发。这个最省事。
- 对象存储或文件服务配置 CORS 白名单。适合文件和前端域名都相对固定的内网场景。
- 前端在后端接口返回文件时,用 URL.createObjectURL 生成一个临时对象地址,这样子组件拿到的就是一个同源地址,不涉及跨域。这个方案我在项目里用了很多次,前提是文件接口本身在同域。
6.3 文件名中文乱码与 Content-Disposition 的解析
当文件接口返回的响应头里有Content-Disposition时,前后端配合容易出问题。后端如果不做处理,直接返回filename="文件名.pdf",中文会被编码成%E6%96%87%E4%BB%B6.pdf,前端拿到以后不能直接用解码后的字符串当文件名。
我写了一个比较健壮的解析函数:
function getFileNameFromDisposition(disposition) { if (!disposition) return '' // 处理 filename*=UTF-8''xxxx 的格式 const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i) if (utf8Match) { try { return decodeURIComponent(utf8Match[1]) } catch (e) { // ignore decode error } } // 处理 filename="xxxx" 的格式 const asciiMatch = disposition.match(/filename="?([^";]+)"?/i) return asciiMatch ? asciiMatch[1] : '' }需要注意,decodeURIComponent可能因为编码格式不标准而抛异常,所以必须包一层try/catch。这个函数我一般在"用户点击下载"和"预览成功后展示文件名"两个场景都会用到。
6.4 大文件内存控制与移动端注意点
最后说两个生产环境才暴露的问题。
第一个是内存暴涨。pdf.js 渲染 PDF 时,每一页都会生成一个巨大的 Canvas 对象,如果一个 PDF 有 200 页,用户全部翻一遍,内存占用会非常可观。我的处理方式是用一个WeakMap或普通对象缓存当前页和前后两页的渲染结果,翻页时主动释放更远页码的 Canvas 引用,而不是把 200 页全部渲染出来放内存里。Excel 那边同理,上面说了限制行数就是最直接的手段。
第二个是移动端 Canvas 面积限制。某些安卓浏览器的 Canvas 最大尺寸有限制,超过之后渲染会静默失败。我的处理是在渲染前计算viewport的宽高,当超过一个安全阈值时(比如单边超过 8000px),直接缩小渲染scale,让 Canvas 尺寸降下来,然后用style.width撑大显示。虽然清晰度会略降,但至少页面不会白屏。
从最初的"这需求不难"到最终交付,前后经历了选型调研、方案推翻、内网环境联调、生产环境踩坑几个阶段。回过头看,预览 PDF、Word、Excel 这个功能没有银弹,核心就是"按文件类型选对库 + 把交互状态管好 + 提前想清楚异常降级路径"。我在这套方案里最大的体会是:先搞清楚你的用户到底要"看到内容"还是"还原原貌",这决定了整个技术路线的走向。如果你是做合同、公文类预览,"还原原貌"优先级高,docx-preview 和 pdf.js 是不错的选择;如果你只是让用户快速核对表格数据,SheetJS 加 el-table 就足够了,别把系统做成一个重型的 Office Online。最后再分享一个小建议:给预览组件加上统一的文件大小限制提示,超过 50MB 的文件一律引导下载,别指望浏览器在前端能无压力处理这种巨型文件,这是真实环境里最能保平安的一条规则。