做了几年前端,接手的后台管理系统没有哪个能躲开PDF预览这个需求的。合同、报告、发票、电子书……只要涉及文档展示,必然有一句“最好在浏览器里直接看,不要下载”。早期的做法简单粗暴,<iframe src="xxx.pdf">一挂就完事,但很快你就会碰壁:有时候是浏览器直接下载了,有时候是白屏,最要命的是你没法控制翻页、获取当前页、做水印或记录阅读进度。后来项目里统一换成了PDF.JS,才算是把在线预览这件事彻底理顺了。
这篇内容我打算一次讲透,用 PDF.JS 同时搞定两种最常遇到的场景:本地文件的在线预览(比如用户上传PDF后立即回显)和服务器文件的在线预览(比如加载服务器上的合同附件)。除了基础集成,还会把我踩过的坑一起放出来,包括 failed to fetch、跨域拦截、带token的文件怎么加载、页码记录到数据库怎么设计。对照着自己的项目就能直接用。
1. 为什么PDF预览我最终选了PDF.JS
1.1 iframe都没解决的需求,PDF.JS都能接住
很多人习惯用原生方式预览PDF,代码量确实少,浏览器基本也都支持。但你会很快遇到几个绕不开的痛点。
首先是iframe和embed的兼容问题。同一个URL,在Chrome下能正常展示,到了某些版本的Edge或移动端浏览器就直接弹出下载框,或者显示一堆乱码。因为这本质上是把渲染工作交给了浏览器内置的PDF阅读器,浏览器觉得不合适,就会走下载逻辑。你几乎没有任何干预手段。
其次是定制能力几乎没有。你想隐藏工具栏?做不到。你想默认跳到第10页?做不到。你想统计用户看了多少秒、停在哪一页?统统做不到。iframe只能提供一个完整封闭的PDF浏览器页面,像是一个“别人的产品”,你只能接受它的一切默认行为。
PDF.JS是Mozilla团队维护的开源项目,它最大的特点是:PDF在网页上的解析和渲染完全由JavaScript完成,不再依赖浏览器内置插件。PDF文件会被当作数据源读取,然后通过Canvas逐页绘制出来,你可以只用它的viewer页面,也可以完全脱离自带的UI,用API自己画一套。PDF出字、翻页、缩放全部可编程控制,这就解决了iframe方案的所有短板。
1.2 实际项目中,PDF.JS帮我省了哪些事
这里直接说我实际项目里的感受。有一个合同管理模块,合同附件全部是PDF,需求明确要求:在线预览、水印覆盖、阅读进度记录、只能看不能下载。用iframe方案,水印就是天方夜谭;用PDF.JS,我在渲染每一页Canvas之后叠加一行水印文字,几行代码就搞定了。页码记录更直接,监听currentPage变化,节流提交到后端,下次打开直接跳转——这个在后面的章节我会展开写。
另外一个好处是性能可控。PDF.JS提供了Canvas渲染模式,也可以测试SVG渲染模式。缩放、旋转、打印都能自己控制,配合离屏Canvas可以做到预加载下一页,翻页体验比浏览器自带的PDF阅读器还要顺滑。如果你的场景只是“能看就行”,那PDF.JS确实是重了;但只要涉及定制、统计、防下载、权限控制任何一个点,它就是最合适的选择。
2. 5分钟跑通:PDF.JS的最小可运行集成
2.1 两种引入方式怎么选:CDN还是npm
PDF.JS有两种主流引入方式,看你的项目环境。
如果你是在纯HTML页面、后端模板渲染这种非工程化场景里用,建议直接用CDN:
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>工程化项目(Vue、React)则建议npm:
npm install pdfjs-dist@2.16.105版本选择上我的建议是:不要无脑追新,优先用2.x的最新版,比如2.16.105。为什么?因为3.x分支把部分API改了,同时目录结构调整比较大,很多老教程里的写法拿到3.x直接报错,且3.x对旧浏览器兼容性也弱一些。PDF预览这种功能讲究稳定,2.16.105是2.x线里非常成熟的版本,网上资料也最全。我自己两个版本都用过,生产环境至今跑的是2.16.105。
2.2 Worker配置,最容易翻车的环节
PDF.JS默认解析PDF文件是在Worker线程里进行的,好处是不阻塞主线程UI渲染。但这个Worker文件需要单独指定,不配置的话通常会报:
No GlobalWorkerOptions.workerSrc specified这应该算是新手遇到的第一个必备坑。两种配置方式:
CDN方式:
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';npm方式:
import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';也可以直接引node_modules里的文件:
pdfjsLib.GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.entry');注意一个细节:Worker文件名必须和主库版本一致。如果你引入的是2.16.105的pdf.min.js,Worker却指向了2.15.349,大概率会出现一些识别不了的“幽灵报错”,比如Unsupported feature或silent failure。记得主库和Worker使用同一版本号。
2.3 第一份PDF的完整渲染示例
配好了Worker之后,渲染第一份PDF的核心代码就这么几段。
先准备一个Canvas容器:
<canvas id="pdfCanvas"></canvas>然后写渲染逻辑:
const url = '/files/sample.pdf'; // 1. 加载PDF文件 const loadingTask = pdfjsLib.getDocument(url); const pdf = await loadingTask.promise; // 2. 获取第一页 const page = await pdf.getPage(1); // 3. 计算视口,拿去适配容器宽度 const containerWidth = document.getElementById('pdfContainer').clientWidth; const baseViewport = page.getViewport({ scale: 1 }); const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale: scale }); // 4. 把页面画到Canvas上 const canvas = document.getElementById('pdfCanvas'); canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext('2d'); const renderTask = page.render({ canvasContext: ctx, viewport: viewport }); await renderTask.promise;这段代码背后的逻辑是:getDocument负责解析PDF文档对象,解析完成之后pdf实例包含pages、metadata、指纹等信息。每一页需要单独用getPage(pageNumber)获取,拿到Page对象之后通过getViewport计算绘制视口,最终交给page.render在Canvas上绘制。
scale的计算是适配屏幕的关键。PDF页面本身有一个固定的尺寸,屏幕适配就是缩放这个尺寸让它能完整显示出来。我习惯先按1倍拿到原始宽度,再根据容器宽度计算缩放比例,这样在高分屏下显示清晰,在小窗口下也不会溢出。每页渲染前都重新算一次,多页切换时大小保持一致。
3. 本地文件预览:从File对象到Canvas渲染
3.1 FileReader读文件,拿到ArrayBuffer是关键
本地文件预览最常见的需求是:用户从本地上传一个PDF文件,文件还没传到服务器之前,就先把内容显示在页面上。这种“先预览后上传”的交互方式,用户体感非常好。
核心思路是把本地文件通过FileReader转换成PDF.JS能识别的数据格式。PDF.JS的getDocument支持多种数据源:URL字符串、TypedArray(Uint8Array)、ArrayBuffer。本地文件最合适的就是转成ArrayBuffer。
附一段可运行的上传预览代码:
<input type="file" id="pdfFileInput" accept="application/pdf" /> <canvas id="previewCanvas"></canvas>document.getElementById('pdfFileInput').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file || file.type !== 'application/pdf') { alert('请选择PDF文件'); return; } const fileReader = new FileReader(); fileReader.onload = async function(event) { try { // 关键点:转成Uint8Array喂给PDF.JS const typedArray = new Uint8Array(event.target.result); const loadingTask = pdfjsLib.getDocument(typedArray); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('previewCanvas'); canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport: viewport }).promise; // 这里拿到了pdf对象,后续继续用 window.currentPdf = pdf; } catch (err) { console.error('PDF渲染失败', err); alert('PDF文件解析失败,请确认文件没有损坏'); } }; fileReader.readAsArrayBuffer(file); });这里有两个容易犯的错误。第一个是没有判断文件类型,用户选了个损坏文件或非PDF文件,解析直接抛异常。第二个是忘了释放FileReader的引用,大文件读取时可能造成内存浪费。FileReader本身是一次性的,读完之后建议把引用置空。
3.2 大文件预览的内存问题处理
本地文件预览有一个隐性风险——大文件。用户传一个几百MB的PDF,直接用ArrayBuffer方式加载会把整个文件塞进内存,浏览器当场卡顿甚至崩溃。PDF.JS在加载大文件时会把大部分内容转移到内存,确实吃紧。
我遇到过一次500MB的设计稿PDF,当时用户的浏览器直接白屏。处理方案分几个维度:
第一是加文件大小限制。上传前主动检查file.size,超过指定阈值(比如100MB)时提示用户,建议拆分成多个文件或走服务端转换。这个属于产品层面保护,但非常有效。
第二是可以利用Uint8Array的subarray做切片加载,但PDF.JS对切片的支持并不透明,需要配合后端Range请求。如果PDF在服务器上,优先用URL方式加载而不是全量下载到本地ArrayBuffer,因为URL方式可以触发浏览器/服务器端的Range请求,做到了按需加载。
第三是渲染完成之后及时释放资源。PDF.JS没有提供页面级别的unload API,但你可以把不再使用的canvas清除,或者把不再需要的page对象置空:
// 手动释放单页对象 page.cleanup();大PDF分页渲染时,翻页前把当前页cleanup()一下,能明显减少内存增长。
3.3 一个可直接抄走的本地预览组件(Vue2版)
热词里很多人搜的是“vue2 txt在线预览”“vue2 pdf预览”,说明工程化场景下大家还是需要框架级封装。这里给一个Vue2组件写法的小示例,思路完全可以在React里平移:
<template> <div> <input type="file" accept="application/pdf" @change="handleFile" /> <div ref="container" class="pdf-container"></div> </div> </template> <script> import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/build/pdf.worker.min.js'; export default { data() { return { pdfDoc: null, currentPage: 1, pageNum: 0 }; }, methods: { handleFile(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (ev) => { const typedArray = new Uint8Array(ev.target.result); this.pdfDoc = await pdfjsLib.getDocument(typedArray).promise; this.pageNum = this.pdfDoc.numPages; this.renderPage(this.currentPage); }; reader.readAsArrayBuffer(file); }, async renderPage(pageNo) { const page = await this.pdfDoc.getPage(pageNo); const viewport = page.getViewport({ scale: 1.5 }); // 在容器里创建/复用canvas let canvas = this.$refs.container.querySelector('canvas'); if (!canvas) { canvas = document.createElement('canvas'); this.$refs.container.appendChild(canvas); } canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; }, nextPage() { if (this.currentPage < this.pageNum) { this.currentPage += 1; this.renderPage(this.currentPage); } }, prevPage() { if (this.currentPage > 1) { this.currentPage -= 1; this.renderPage(this.currentPage); } } } }; </script>这个组件保留了最核心的“选择文件—读数据—渲染—翻页”闭环。真实项目里还会加上缩放控件、页码输入、loading遮罩等,这些都是在renderPage外面套壳子的活。
4. 服务器文件预览:URL、鉴权与跨域
4.1 直接加载和withCredentials,多数人不知道的选项
服务器的PDF文件预览,最基本的方式就是传一个URL给getDocument:
const loadingTask = pdfjsLib.getDocument({ url: 'https://your-api.com/files/contract_2024.pdf' }); const pdf = await loadingTask.promise;看起来很简单,但这里藏着一个跨域问题。PDF.JS的底层是用Fetch或XMLHttpRequest去拉取这个URL的,受到同源策略限制。你前端跑在http://localhost:8080,PDF地址在http://api.example.com,浏览器就会拦截响应。
解决办法是让服务端在响应头里加上:
Access-Control-Allow-Origin: *或者指定你的前端域名。如果涉及Cookie认证(比如服务器要求带session),还得注意withCredentials的配置:
const loadingTask = pdfjsLib.getDocument({ url: 'https://your-api.com/files/contract_2024.pdf', withCredentials: true });这里有个和多后端联调时踩过的坑:withCredentials一旦打开,服务端Access-Control-Allow-Origin就不能写*了,必须写明具体域名,否则浏览器一样会拦截。这个经常会成为“本地好好的、一部署就报跨域”的元凶。
4.2 带Token的私密文件怎么加载
大部分后台系统的文件接口都用了Token鉴权,但PDF.JS的getDocument直接接收URL时,无法像axios那样在请求头里带Authorization。偏偏浏览器在<canvas>或<img>里加载资源时不支持自定义请求头,很多人在这卡住。
几种可行的方案:
方案一:URL上带token参数。后端从queryString里校验:
const loadingTask = pdfjsLib.getDocument({ url: `/api/files/xxx.pdf?token=${token}` });简单粗暴,但token会留在服务器日志里,安全性稍弱。适合内网系统或token短期有效的场景。
方案二:httpHeaders方式。PDF.JS本身支持在加载参数里传请求头:
const loadingTask = pdfjsLib.getDocument({ url: '/api/files/xxx.pdf', httpHeaders: { Authorization: `Bearer ${token}` } });这是最正规的方案,但前提是后端接口允许CORS并暴露Authorization头,否则浏览器会先发OPTIONS预检请求,预检不通过就直接failed to fetch了。前后端都要配合。
方案三:先fetch文件流,再转为ArrayBuffer:
const response = await fetch('/api/files/xxx.pdf', { headers: { Authorization: `Bearer ${token}` } }); const data = await response.arrayBuffer(); const loadingTask = pdfjsLib.getDocument(new Uint8Array(data));这个方案绕开了PDF.JS的请求逻辑,由你手动控制鉴权,获取到完整文件内容后再喂给PDF.JS。缺点是文件全量进内存,超大文件不推荐。但配合上文章后面讲的内容,这个方案能解决95%的“服务器文件预览带鉴权”问题。
4.3 failed to fetch这个报错到底怎么排查
热词里的那串报错我见过太多次了:
pdf.js v2.16.105 (build: 172ccdbe5) 信息:failed to fetch这个报错信息非常隐晦,它没有告诉你具体是跨域、404还是网络断了,排查起来全靠经验。我整理一个清单,按顺序排查:
看Network请求。打开开发者工具,找到对应PDF请求,看HTTP状态码。如果CORS报错,Console里会有明确的Access-Control-Allow-Origin提示。
确认请求确实发出了。如果请求根本没发,检查网址是否被你手滑写成了相对路径,导致拼接到一个不存在的地址上。
后端是否返回了200。有些后端的鉴权中间件对无token的请求直接返回302跳转到登录页,PDF.JS跟随重定向之后拿到的是HTML而不是PDF,解析失败就报了failed to fetch。
Worker是否正常加载。Worker文件挂了也会有类似的失败提示,检查Network里有没有
pdf.worker.min.js的请求。如果你用的是
httpHeaders带token,确认后端CORS配置暴露了你的自定义头。任何自定义请求头都会触发预检,预检不通过就是failed to fetch。
我见过最刁钻的一个案例:服务端网关对文件接口做了压缩,响应头里Content-Encoding: gzip但实际没有压缩,PDF流损坏,PDF.JS解析失败。这种只能用面具法排查:先用curl手动请求一次文件接口,把响应保存下来,用本地PDF阅读器打开。如果本地打开也报错,就是服务端文件流的问题。
5. 阅读进度怎么记:页码记录到数据库的方案
5.1 获取当前页与监听翻页
PDF.JS如何记录“用户读到哪一页”这个需求,后台阅读类系统基本都会提。实现起来分两块:前端监听页码变化获取当前页,后端记录页码,下次打开时跳转对应页。
PDF.JS的viewer组件里自带页码状态管理,如果你用的是完整viewer模式,可以通过事件监听获取当前页:
document.addEventListener('pagechange', function(e) { const currentPage = e.pageNumber; console.log(currentPage); });但如果你像我一样用的是自定义渲染(自己去调page.render),那页码本质上就是你自己维护的一个变量。每次renderPage调用时,都知道当前页是第几页。想要监听,直接在nextPage/prevPage或者jumpToPage方法里保存即可。
需要说明的是:页码记录到数据库和纯前端localStorage是两级方案。如果只要求单浏览器记住进度,localStorage就够了。但如果用户在A电脑阅读到一半,去B电脑继续读,那就必须走数据库。
5.2 落库策略:什么时候存、存什么字段
落库的时间点是个需要设计的细节。一种做法是每次翻页就发一个AJAX,简单但请求量太大,一个快速翻页的用户能瞬间刷几十个请求。另一种做法是节流保存,比如翻页后1秒内没有继续翻页,就提交一次。还有更稳的:页面隐藏或卸载时提交一次,比如visibilitychange和beforeunload事件。
我个人推荐“双保险”:节流提交 + 页面关闭时补交一次。节流时间设定在3~5秒比较合理,用户在普通阅读场景下单页停留时间远大于这个数值,不会造成频繁请求。
数据库表结构很简单,一张表存用户ID、文档ID、页码、时间戳即可:
CREATE TABLE pdf_read_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, file_id BIGINT NOT NULL, page_number INT NOT NULL, total_pages INT DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_file (user_id, file_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;前端提交时建议同时带上totalPages,这样以后能在列表页展示阅读进度百分比。保存逻辑的代码大致是这个样子:
// 节流保存当前进度 function saveProgress() { if (!this.pdfDoc || !this.currentPage) return; const payload = { fileId: this.fileId, pageNumber: this.currentPage, totalPages: this.pdfDoc.numPages }; // 通过axios或fetch提交到后端 fetch('/api/read-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }打开文档时,先请求一次接口,拿到已记录的页码:
const res = await fetch(`/api/read-progress?fileId=${this.fileId}`); const data = await res.json(); if (data && data.pageNumber) { this.currentPage = data.pageNumber; renderPage(this.currentPage); }这里有一个体验细节:跳转到记录页之前,最好先展示一个提示条“已为你自动定位到第X页”,给用户一个预期。用户从进度续读时的“突然翻页”感会小很多。
6. 常见问题速查与避坑清单
6.1 报错对照表
很多问题都是重复出现的,我直接把这几年遇到过的高频问题整理成了一张表,方便你照着排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| No GlobalWorkerOptions.workerSrc specified | 没配置Worker路径 | 配置GlobalWorkerOptions.workerSrc指向对应版本的worker文件 |
| failed to fetch | CORS跨域、网络拦截、后端非200、文件流损坏 | 按第4.3节的顺序逐步排查 |
| PDF解析成功但Canvas空白 | canvas尺寸没按照viewport设置 | 检查canvas.width和canvas.height是否用了viewport.width/height |
| 只显示第一页,后续页面白屏 | 渲染时正在绘制上一页,页面上下文冲突 | 每次渲染前renderTask.cancel()上一个任务 |
| PDF字体显示乱码或方块 | PDF使用特殊嵌入字体,部分字体解析失败 | 升级到最新版PDF.JS,或设置disableFontFace为false(默认) |
| 高DPI屏幕下图片字模糊 | 未做devicePixelRatio适配 | 按window.devicePixelRatio乘以scale之后再设置canvas宽高 |
| 加载过程中页面卡顿 | 大文件 + 主线程解析 | 开Worker、分段渲染、减少单次渲染页数 |
| 打印不生效或打印空白 | 打印样式未包含canvas | 用CSS@media print指定canvas打印区域,或调用pdf.getData()传给打印插件 |
6.2 几个容易踩但不写在文档里的细节
这里说一些在官方文档里不太会提示、但是实际写代码时很容易犯的错误。
Canvas绘制必须在文档加载完成之后。PDF.JS的API几乎全是Promise + async的,新手最容易犯的错误就是getDocument还没resolve,就急着去拿page,导致拿到一个undefined。这也是为什么必须写await loadingTask.promise的原因。千万别因为看着例子简单就省略await。
版本混用是隐性炸弹。有些人直接从CDN引的是3.x的主库,但是网上抄来的代码是2.x的API,运行起来报错多半跟Destructor、CMapReader等新概念有关。一旦锁定了版本,主库、Worker、类型定义全部统一,不要混用。
本地文件预览建议保留原文件名。预览本地上传文件时,除了渲染PDF,最好把file.name单独存起来。后面如果要做“下载原文件”功能,直接用这个名字去构造a标签的download属性,命名体验会好很多。
显隐切换时记得清理渲染任务。在SPA应用里,组件销毁或弹窗关闭时,如果之前还有渲染任务没有结束,可能触发不可预期的报错。建议在beforeDestroy(Vue2)或者useEffect的清理函数里执行:
if (this.renderTask) { this.renderTask.cancel(); }页面数量特别多的PDF,比如上千页,做“逐页渲染”的性能几乎是无法接受的。我遇到过一本1200页的技术书PDF,用getPage逐张拿Page对象会把内存吃掉好几个G。这种场景下合理策略是:先加载文档,拿到numPages之后只渲染当前页和前后两页,翻页时按需渲染,其他页的数据不预加载。就这一改动,加载时间从几十秒降到3秒以内。
最后多分享一个个人经验:如果你的项目只是内部系统,对UI定制要求也不高,可以优先考虑直接使用PDF.JS自带的viewer.html页面,通过URL参数传文件地址:
viewer.html?file=/files/xxx.pdf它开箱自带工具栏、页码显示、缩放、打印按钮,省时省力。只有当你要做深度定制、嵌入水印、记录进度、防下载这些场景时,才值得去写自定义渲染逻辑。我做过的项目里,大概有三分之二的场景先用viewer.html撑起来了,剩下的三分之一用上面讲的API方案精雕细琢。先把基础预览跑通,再评估需要什么功能,按需开发,这个节奏最舒服。