简介:这份资源为Web前端开发者提供基于PDF.js的在线PDF预览实现方案,面向需要在浏览器中展示PDF文档的场景,适合希望掌握PDF.js库集成、多页渲染、缩放控制等核心思路的初级及中级开发者。压缩包共402个文件,大小3.06MB,包含4个JavaScript脚本、1个HTML示例页面、1个CSS样式文件、1个PDF测试文档,以及大量bcmap字符映射表和properties字体属性文件,另有png/svg图标资源,构成一套可直接运行的PDF预览Demo。已有3142人学习/浏览,实用性较强。通过阅读示例代码和页面结构,可以快速理解PDF.js的调用方式,并借助bcmap与字体资源支持中文等多语言PDF的正确渲染,为二次开发或集成到现有项目提供完整参考。
1. JS 在线查看 PDF:为什么直接丢<iframe>会卡在第一步
接到“在线查看 PDF”这个需求时,多数人第一反应是拿个<iframe src="xxx.pdf">塞进去,省事,几分钟就交差。可真上到业务里你会发现,这套方案在 PC 浏览器上还能靠内核自带的 PDF 查看器凑合,换到手机浏览器和 WebView 里就彻底失控:有的直接触发下载,有的黑屏,有的连滚动都费劲。再加上跨域、大文件、字体乱码这些隐藏问题,“一个 iframe 搞定”基本是纸上谈兵。这篇文章就基于 JavaScript 生态里最常用的 PDF 渲染库,拆一个可以实际落地的在线 PDF 查看方案:从选型、渲染管线、参数调优到踩坑记录,全走一遍。适合要在后台管理系统、网盘预览、电子合同场景里做 PDF 预览的前端和全栈开发,也适合想搞懂 PDF.js 渲染过程而不是只会抄代码的从业者。
2. 方案选型:原生 embed、PDF.js 渲染与后端转图片的取舍
2.1 原生 iframe/embed 的边界在哪
原生方案就是<iframe>或<embed>指向 PDF 文件地址,浏览器内核自己解析渲染。它的优点只有一个:零成本。PC 端 Chrome、Firefox 都内置了 PDF 引擎,点击后能显示能滚动,自带缩放和搜索,不需要写一行代码。但这套东西在真实业务里的边界非常明显。
第一,移动端表现不可控。Android WebView、iOS UIWebView 对 PDF 的处理策略各不相同,很多场景下系统会直接接管并且把文件下载到本地,用户根本看不到页面里的预览。第二,样式和交互完全不可控,工具栏是浏览器自己的,你想限制缩放、加水印、统计阅读页数都做不到。第三,跨域场景下,部分浏览器连白屏都敢给你看。第四,无法和前端 SPA 的状态管理打通:用户到底看到第几页、停留了多久、有没有翻页,这些统统拿不到。
我在选型时先列过一张对比表,帮你判断什么场景用哪套。
| 方案 | 实现成本 | 可控性 | 移动端表现 | 跨域/流式控制 |
|---|---|---|---|---|
| 原生 iframe/embed | 最低 | 弱 | 不稳定 | 受浏览器限制 |
| PDF.js 前端渲染 | 中 | 强 | 可定制 | 可统一接管 |
| 后端转图片 | 高 | 最强 | 稳定 | 无跨域问题 |
所以我一般不在方案上省事:只要业务方要求“打开就能看、最好能统计”,就直接上渲染引擎,而不是赌浏览器内核的行为。
2.2 PDF.js 渲染管线:从 ArrayBuffer 到 Canvas
PDF.js 的核心思路是把 PDF 解析放到 Web Worker 里,主线程只负责拿解析出来的页面对象画到 Canvas 上。这样做的原因很直白:PDF 是一个嵌套对象结构,有 xref 表、对象流、各种压缩算法,全部放主线程解析大文件,页面早卡死了。
整个渲染管线可以拆成四步:
获取文件二进制 →getDocument()解析出PDFDocumentProxy→getPage()拿到页面对象 →page.render()画到 Canvas。
这四步里最关键的分叉是:getDocument支持{ url }和{ data }两种输入。url模式内部走 XHR 或 fetch,文件源受 CORS 限制,跨域就报错。data模式传的是内存里的 ArrayBuffer,不跨域,但要先把整个文件拉回来。我实际项目中几乎不用裸url,都是先 fetch 再传data:
const resp = await fetch(pdfUrl); const buffer = await resp.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: buffer }); const pdf = await loadingTask.promise;这里把网络请求和 PDF 解耦开,好处是后面无论你加进度条、做超时控制、还是处理跨域,都在 fetch 这一段处理,不用去和渲染引擎扯皮。loadingTask返回的 Promise 只有在 worker 解析完 PDF 后才 resolve,如果文件损坏或加密,这里就会抛异常。
2.3 什么场景下才需要后端转图片
前端渲染不是万能的。有些场景下 PDF.js 的表现确实让人头疼,比如特殊嵌入字体解析不对、某些扫描版 PDF 的 OCR 文本层缺失、或者业务要求必须在每次预览时动态加水印。这时候后端把 PDF 转成一张张 PNG 图片再给前端展示,反而更省事。
后端转图片的价值是效果统一:同一份 PDF,在 Windows、安卓、iOS 上拿到的都是同一组图片,不会出现字体回退导致的换行差异。加水印也简单,服务端画完水印再输出。缺点是:文本不可选中、文件体积大、生成过程消耗 CPU,用户翻页只能按图片翻。
所以说到底,前端 PDF.js 渲染适合“要预览、要查正文、要复制文字”的场景;后端转图片适合“要防复制、要绝对统一”的场景。二选一还是混用,取决于需求是先“看得到”还是先“控得住”。
3. 基于 PDF.js 搭建在线查看器:核心步骤与参数
3.1 引入 PDF.js 与 worker 配置
这部分的坑最早、也最隐蔽。先看引入方式,常见有两种:npm 包里直接import * as pdfjsLib from 'pdfjs-dist',或者老项目用<script>标签引入pdf.min.js。无论哪种方式,都得设置 worker 路径。
pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/pdf.worker.min.js';我一般在项目根目录建一个vendor文件夹,把pdf.min.js和pdf.worker.min.js都放进去,然后用绝对路径指向 worker。这里不推荐直接引 CDN,原因后面避坑章节细说。workerSrc的路径必须是一个浏览器能直接访问的静态资源 URL,打包工具处理时要注意别被重命名。
提示:用 Vite 或 Webpack 时,可以写成
new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url)动态解析,避免手动拼路径出错。
3.2 加载文档、获取页数、渲染首页
核心逻辑其实不复杂。给定 ArrayBuffer 和容器元素,先初始化 PDF 对象,再取第一页渲染。这里我把所有关键参数一次写全:
async function initViewer(buffer, container) { const loadingTask = pdfjsLib.getDocument({ data: buffer }); const pdf = await loadingTask.promise; state.pdf = pdf; state.totalPages = pdf.numPages; // 取第一页做基准视口 const page = await pdf.getPage(1); const baseViewport = page.getViewport({ scale: 1 }); // 按容器宽度 + 设备像素比计算渲染比例 const dpr = Math.min(window.devicePixelRatio || 1, 2); const availableWidth = container.clientWidth - 32; const fitScale = availableWidth / baseViewport.width; const scale = fitScale * dpr; const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); container.appendChild(canvas); const ctx = canvas.getContext('2d', { alpha: false }); await page.render({ canvasContext: ctx, viewport }).promise; state.currentPage = 1; }这里两个参数最容易写错。canvas.width和canvas.height是物理像素,必须和 viewport 计算出的像素一致;如果你只给 CSS 尺寸,在视网膜屏上会糊成一片。scale按容器宽度自适应后再乘 DPR,但 DPR 要封顶,不然一页渲染几千万像素,低端安卓机直接崩溃。getContext('2d', { alpha: false })是为了让 canvas 不参与透明通道合成,能省一点性能,对阅读类应用足够。
3.3 缩放、翻页与 canvas 复用
翻页和缩放本质上同一个操作:重新计算 viewport,重新渲染当前页。关键在于两点:渲染任务要能取消,canvas 要复用。
let currentRenderTask = null; async function renderPage(pageNum, scale) { // 页码边界保护 if (pageNum < 1 || pageNum > state.totalPages) return; if (currentRenderTask) currentRenderTask.cancel(); const page = await state.pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.getElementById('viewer-canvas'); const ctx = canvas.getContext('2d', { alpha: false }); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); const renderTask = page.render({ canvasContext: ctx, viewport }); currentRenderTask = renderTask; try { await renderTask.promise; state.currentPage = pageNum; } catch (err) { // 主动取消也会走这里,属于正常流程 if (err.name !== 'RenderingCancelledException') throw err; } }复用 canvas 是性能关键。我见过有人每翻一页就document.createElement('canvas'),页面几十页翻下来,DOM 上挂了几十个 hidden canvas,内存直线上升。正确做法是只有一个显示 canvas,内容更新就行。renderTask.cancel()之后 Promise 会 reject,这个异常必须接住,不然控制台会被错误刷屏,而且它不该被当成故障上报。
3.4 文本选择与打印保留
如果只画 canvas,用户选中不了 PDF 里的文字,这在很多业务场景里是硬需求。PDF.js 提供了textLayer机制:解析每页文本内容后,生成透明的 div 覆盖在 canvas 上方,让文字可以选中复制。
const textContent = await page.getTextContent(); const layerDiv = document.createElement('div'); layerDiv.className = 'textLayer'; pdfjsLib.renderTextLayer({ textContentSource: textContent, container: layerDiv, viewport, textDivs: [] });renderTextLayer会把每个文本片段定位到对应的坐标,div 里的字是透明的、但可以被选中。CSS 上要给它绝对定位盖住 canvas,pointer-events保持默认即可,否则连滚动都会被挡住。
打印也要提前想好。直接window.print()打印页面,浏览器只输出当前 canvas,清晰度会受到缩放影响。稳妥做法是生成打印层:遍历当前已经渲染过的页面 canvas,放进一个隐藏的打印容器,再用@media print只显示这个容器。打印层生成完毕后再调用window.print()。
4. 参数调优:分辨率、渲染顺序与内存水位控制
4.1 scale 参数与模糊/发虚的权衡
PDF 本身是矢量文档,理论上任何比例都清晰,但渲染成 canvas 之后,清晰度就取决于你给 canvas 分配了多少物理像素。scale = 1对应 72 DPI,在绝大多数屏幕上看起来都偏小、偏糊,所以必须放大渲染。
但 scale 不能无脑往上加。canvas 像素量和渲染时间是线性增长的,到高倍数之后内存暴涨,低级 WebView 直接黑屏。我一般会把 DPR 封顶在 2,只在页面需要放大的时候才让 fitScale 继续往上走。
function calcScale(containerWidth, baseViewportWidth, padding = 24) { const dpr = Math.min(window.devicePixelRatio || 1, 2); const availableWidth = containerWidth - padding * 2; const fitScale = availableWidth / baseViewportWidth; // 页面本身小于容器时,保留 DPR 补偿;大于容器时就不再往上乘 return fitScale * (fitScale > 1 ? dpr : 1); }这里有个经验值:单张 canvas 的总像素数尽量控制在 2500 万以内,大约相当于 5000 × 5000。A4 纸默认 595 × 842 点,在 2 倍 DPR 下渲染也就 1190 × 1684,离上限还远。但如果是工程图纸、长表格页,宽度就很大,这时候再乘 DPR 很容易超限,所以封顶逻辑必须留。
4.2 异步渲染与任务队列
翻页时如果用户快速连点,前一个渲染任务尚未完成、后一个又开始,会出现页面内容闪烁、错乱,甚至卡死。PDF.js 本身没有内置渲染队列,所以业务层要自己控制:同一时间只保留一个正在执行的渲染任务,新任务进来就取消旧任务。
更稳的做法是在取消之外加一个低优先级预渲染队列。当前页渲染完成之后,把相邻页加入队列,等用户停下来时再依次渲染。这个队列里只排队首任务,而且新进来的高优先级任务要插到队首:
const renderQueue = []; function scheduleRender(task) { if (task.priority === 0) { renderQueue.push(task); } else { renderQueue.unshift(task); } drainQueue(); } async function drainQueue() { if (drainQueue.running) return; drainQueue.running = true; while (renderQueue.length > 0) { const task = renderQueue.shift(); await renderPage(task.pageNum, task.scale); } drainQueue.running = false; }这个方案的核心逻辑是保证同一时刻只有一个渲染循环,避免并发渲染抢占 CPU。预渲染任务放进队列尾部、优先级低;用户主动翻页的任务插到队首,立刻执行。实测下来,翻页延迟明显下降,连续翻页时白屏闪烁也消失了。
4.3 长文档的分页懒加载
一份 200 页的 PDF,如果初始化时把所有页面全部渲染成 canvas,内存直接失控。常规做法是:按需渲染当前页和相邻页,并用 LRU 缓存控制驻留页数。
const cache = new Map(); function getCachedPage(pageNum) { if (cache.has(pageNum)) { const entry = cache.get(pageNum); // 更新访问顺序:先删再插,保持 Map 尾部的访问最新 cache.delete(pageNum); cache.set(pageNum, entry); return entry; } return null; }Map 的插入顺序天然适合做 LRU,最多保留 5 个页面对象,超出就删最早的。翻页时如果命中缓存,直接把缓存 canvas 贴到显示层,能省一次完整渲染时间。同时还要注意page对象本身也占内存,渲染完后可以调page.cleanup()释放部分临时资源;整个组件销毁时别忘了pdf.destroy(),不然在 SPA 里反复打开预览,内存水位会一直往上走。
5. 避坑:文件流、跨域与 worker 的五个常见问题
5.1 worker 加载失败导致 getDocument 一直 pending
现象:页面空白,loadingTask.promise一直不 resolve,控制台可能报No GlobalWorkerOptions.workerSrc specified或 worker 文件 404。
原因:workerSrc路径没有设置,或者设置了但指向了 CDN,又被浏览器同源策略拦下了。Web Worker 本身就是同源限制,跨域 worker 需要有 CORS 响应头才能加载。
解决:把pdf.worker.min.js下载到自己的静态资源目录,用绝对路径设置。每次发版前检查网络面板里 worker 文件是否返回 200,而不是肉眼确认路径没写错。
提示:worker 版本必须和
pdf.min.js完全一致,混用两个大版本经常会莫名其妙地渲染错乱。
5.2 跨域 PDF 走 url 模式被 CORS 拦截
现象:PDF 文件在另一个存储域,getDocument({ url })报NetworkError或Unexpected server response,浏览器控制台有 CORS 报错。
原因:PDF.js 的url模式内部也是走网络请求,浏览器对跨域 XHR 有严格限制。存储服务没开Access-Control-Allow-Origin头,前端再怎么写都没用。
解决:用 fetch 先拉流,再传 ArrayBuffer 给渲染引擎。这样跨域问题在 fetch 层就解决了,后端只需要支持普通 GET 请求,不需要特殊响应头。
const response = await fetch(pdfUrl, { credentials: 'include' }); const arrayBuffer = await response.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;5.3 大文件内存暴涨与页签卡死
现象:打开一份 100 页以上、单页分辨率很高的 PDF,连续翻页后浏览器 Tab 内存占用冲到 1GB 以上,最后白屏或闪退。
原因:渲染后的 canvas 物理像素太大;页面对象一直持有未释放;用户快速翻页时多层渲染任务叠加。
解决:DPR 封顶 2;只保留最近 5 页 canvas;离开预览组件时调用pdf.destroy()释放整个文档。渲染完的临时 bitmap 也会被回收,但需要给 GC 一点时间,所以不要在同一事件循环里高频创建大量 canvas。
5.4 中文 PDF 显示空白或乱码
现象:同一份 PDF 在系统预览里正常,换到前端渲染后整页空白,或者只显示一半文字、部分中文字符变成方块。
原因:PDF 里嵌入字体的 CID 映射不完整,或 PDF.js 版本太老,拿不到 ToUnicode 映射。中文 PDF 常见问题集中在字体解析阶段,而且不同版本表现差异很大。
解决:把pdfjs-dist升级到最近稳定版,并确保 worker 同版本。仍不行就配置 cMap 资源目录,让解析器能读取字符映射表:
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer, cMapUrl: '/vendor/cmaps/', cMapPacked: true }).promise;cMap 目录可以从官方发布包里找到,拷到静态资源即可,路径必须指向目录而不是单文件。
5.5 cancel 触发的异常被当成故障处理
现象:快速翻页时控制台抛出一堆RenderingCancelledException,SPA 的错误监控平台也被这些异常刷屏。
原因:为了打断上一页渲染,调用了renderTask.cancel()。这个方法会让渲染 Promise 主动 reject,抛出的异常名固定是RenderingCancelledException。它不是 bug,是设计行为。
解决:catch 里判断错误名,这个异常直接放过,不上报、不提示:
try { await renderTask.promise; } catch (err) { if (err.name !== 'RenderingCancelledException') { reportError(err); } }做错误监控的团队尤其要注意,不然一周下来全是这种无效告警,真正的渲染错误反而被淹没。
6. 进阶:从 Blob 加载到错误兜底的完整加载管线
最后的进阶技巧,是把加载流程串成一条完整的流水线:fetch 拉流、读取进度、Blob 本地化、解析渲染、错误分类。直接裸传url给getDocument的做法我早就停用了,现在所有入口都走同一个函数。
async function loadPdfWithProgress(url, onProgress) { const response = await fetch(url); if (!response.ok) throw new Error('HTTP ' + response.status); const total = Number(response.headers.get('content-length') || 0); const reader = response.body.getReader(); const chunks = []; let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; if (total > 0) onProgress?.(received / total); } const arrayBuffer = await new Blob(chunks).arrayBuffer(); return pdfjsLib.getDocument({ data: arrayBuffer }).promise; }这段代码做了几件事:用ReadableStream读取 PDF 二进制,保证大文件不会一次性全塞进内存;content-length拿不到时不阻塞进度条,只显示“读取中”;最后用Blob统一转成 ArrayBuffer,屏蔽了不同浏览器对二进制流的差异。进度回调里可以直接驱动进度条 UI,用户面对大文件时等待感会小很多。
错误兜底按照异常类型分类,体验会专业很多:
try { const pdf = await loadPdfWithProgress(url, updateProgress); await initViewer(pdf); } catch (err) { if (err.name === 'InvalidPDFException') { showToast('文件不是有效的 PDF'); } else if (err.name === 'PasswordException') { showToast('PDF 已加密,需要密码'); } else if (err.name === 'NetworkError') { showToast('网络连接异常,请重试'); } else { showToast('预览加载失败,请稍后再试'); } }这个兜底逻辑值得放进每个项目里,它把“页面白屏”变成了“明确告诉用户发生了什么”,对客服和技术支持都有帮助。想起之前一个项目,就是把 PDF 地址直接塞给getDocument,结果客户内网环境里 PDF 文件和系统不在同一个域,CORS 没开,所有用户都打不开,我反反复查了整一下午才发现问题出在加载入口。从那以后,我每次接 PDF 预览都强制走一遍“先拉流、后 Blob、再解析渲染”的完整管线,worker 路径也固定在发布包里,不再交给运行时去猜。希望这些细节能帮你在做在线 PDF 查看时少走几步弯路。
本文还有配套的精品资源,点击获取