简介:这份前端在线展示PDF功能的源码包,面向熟悉Vue 3的前端开发者、项目经理及设计师,解决在Web应用中无缝集成PDF查看器的需求。项目基于Vite+Vue3+JavaScript搭建,运行命令为npm i加npm run dev,并利用vue3-pdf-app组件完成核心展示能力,涵盖安装配置、组件用法、属性调整及国际化设置等完整流程。压缩包共17个文件,体积仅17KB,包含Vue组件、TypeScript配置、样式文件、SVG图标及package.json等,结构紧凑,适合快速嵌入在线教育、电子书阅读、企业内部文档管理等场景。目前已有1147人学习下载。通过该资源,开发者可掌握基于组件化的PDF展示方案,理解属性配置与国际化处理思路,同时兼顾内容安全与版权保护需求,为项目交付提供可直接参考的实现样板。
1. 前端在线展示 PDF:能打开只是起点,可控才是目的
「前端在线展示 PDF 功能源码」——接到这类需求时,很多人的第一反应是 iframe 一行代码,三分钟出「能打开」的效果。可真上线就会发现,起点是「能打开」,终点是「好用」:同一个 PDF 在 PC 上播放正常,到了手机上就只剩第一页;几十页的报告滚动起来一卡一卡;用户想缩放、跳页、选中复制一段合同条款,浏览器原生查看器几乎给不了。这篇笔记按我自己的落地顺序来讲:先选型,再给可抄的最小实现,然后工程化、排坑,最后补几个进阶能力。适合刚接手类似需求、又需要对效果负责的前端开发者。
2. 选型做不对,后期全白费:iframe 直出还是 pdf.js 自绘
2.1 iframe 一行代码的边界:演示没问题,上线就翻车
最省事的在线展示 PDF 代码只有一行:
<iframe src="/docs/report.pdf" width="100%" height="600"></iframe>本地开发时这个方案几乎挑不出毛病:主流桌面浏览器都内置了 PDF 查看器,点开就能翻页、缩放、搜索。但这个「顺」是浏览器给的,不是页面控制的,问题也随之而来。
第一,不同浏览器的内置查看器工具栏长得完全不一样,功能也参差不齐,页面上没法统一按钮,也没法统计用户到底看了第几页。第二,移动端是真坑:iOS 端的 iframe 对 PDF 的兼容策略和桌面端差异很大,经常出现只渲染第一页、空白或者手势完全失灵的情况,这个我在第 5 章会单独展开。第三是响应头问题——PDF 地址如果走 CDN,响应带了X-Frame-Options: DENY,iframe 直接白屏;带Content-Disposition: attachment时,不少浏览器会直接触发下载而不是展示。这些都不是前端改几行代码能绕过的。
2.2 pdf.js 自绘路线:canvas 分页渲染为什么更适合业务系统
pdf.js 是一个纯前端的 PDF 渲染开源库,思路是把 PDF 当作数据来解析,逐页画到 canvas 上,而不是依赖浏览器内置查看器。核心流程是这样的:加载文件拿到 PDF 文档代理对象,然后通过getPage()取某一页,用getViewport()把 PDF 的磅值坐标换算成屏幕像素,最后调用render()把这一页画进 canvas。
选择这条路线意味着页面交互完全由自己控制:翻页、缩放、懒加载、文本选中、目录跳转、甚至移动端手势都能按业务需求定制。代价也很明显——要写代码,要处理渲染性能,worker 配置错了会白屏,坑比 iframe 多。但对业务系统来说,可控性就是一切。合同预览要记录阅读进度,简历系统要按页展示,在线签约要定位到指定条款,这些需求靠 iframe 基本做不到。
2.3 选型判断:什么场景用哪个
我一般用下面这张表快速拍板:
| 维度 | iframe 直出 | pdf.js 自绘 |
|---|---|---|
| 接入成本 | 一行标签,零开发 | 需要引入库、配置 worker |
| 交互可控性 | 基本不可控 | 翻页、缩放、事件全部自定 |
| 移动端表现 | iOS 端兼容性差 | 可自己适配布局和手势 |
| 文本选中 | 部分浏览器支持 | 需要额外启用文本层 |
| 大文件性能 | 取决于浏览器实现 | 可做懒加载和内存回收 |
| 适合场景 | 内部工具、临时预览 | 业务系统、在线签约、报告管理 |
一句话判断:PDF 只是附件预览、用户看完就走,用 iframe 足够;PDF 是业务主流程的一环,用户要翻页、缩放、选中、跳转,就走 pdf.js。后面所有内容都围绕第二条路线展开。
3. 跑通最小实现:用 pdf.js 渲染第一屏的代码与参数
3.1 引入 pdf.js:在线展示 PDF 前先把 worker 配对
常见做法是两种:老项目用 script 标签引全局版,新项目走 npm 打包。先看 script 方式:
// 引入之后会暴露全局变量 pdfjsLib(老版本的常见形态) <script src="/lib/pdfjs/pdf.min.js"></script> // 必须设置 workerSrc,解析 PDF 的解析线程从这里加载 pdfjsLib.GlobalWorkerOptions.workerSrc = '/lib/pdfjs/pdf.worker.min.js';workerSrc 不配或者配错,最直接的结果是白屏,控制台抛错;有些版本不抛错,退化成在主线程解析 PDF,打开大文件时页面直接卡死。npm 方式在 Vite 等构建工具里常见这么写:
import * as pdfjsLib from 'pdfjs-dist'; // 用 new URL 让构建工具把 worker 文件当成静态资源处理 pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.mjs', import.meta.url ).toString();注意:不同版本的 pdfjs-dist 包内 worker 文件名、路径都不一样,有的版本是.worker.js,新版本换成了.worker.mjs。我自己的习惯是装完包先去 node_modules 里看一眼真实文件名再配路径,别凭记忆写。
3.2 渲染第一页:一个能跑通的核心片段
下面的代码是能直接跑的最小示例,逻辑上只做一件事:加载 PDF、取第一页、画到 canvas:
(async function () { const url = '/docs/report.pdf'; // 1. 加载 PDF 文件,getDocument 返回的是加载任务对象 const loadingTask = pdfjsLib.getDocument(url); const pdf = await loadingTask.promise; // 2. 取第 1 页,页码从 1 开始,不是 0 const page = await pdf.getPage(1); // 3. 计算这一页在屏幕上的尺寸 const dpr = window.devicePixelRatio || 1; const baseScale = 1.5; // 基础缩放:控制清晰度和体积 const viewport = page.getViewport({ scale: baseScale }); // 4. 准备 canvas,注意物理像素和逻辑像素要分开算 const canvas = document.getElementById('pdfCanvas'); const ctx = canvas.getContext('2d'); canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = Math.floor(viewport.width) + 'px'; canvas.style.height = Math.floor(viewport.height) + 'px'; // 5. 渲染这一页 await page.render({ canvasContext: ctx, viewport: viewport, // 高分屏下用 transform 做物理像素映射,防止发虚 transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null }).promise; })();逻辑说明:getDocument不只是拿文件,还负责解析 PDF 的结构,拿到的pdf对象是文档代理,后续所有页面操作都从它身上走;getPage返回的不是渲染结果,而是这一页的内容模型。getViewport计算的是 CSS 逻辑尺寸,真正画图时 canvas 的位图尺寸要乘devicePixelRatio,否则高分屏上一片糊。渲染完成后 canvas 里就已经有这一页的内容了。
3.3 关键参数说明:scale、dpr、renderTask 与 cMap
| 参数/配置 | 作用 | 常见取值与调整思路 |
|---|---|---|
GlobalWorkerOptions.workerSrc | Worker 脚本地址 | 必须和库文件版本匹配,配错直接白屏 |
getDocument.cMapUrl/cMapPacked | CJK 字体映射数据 | 中文 PDF 显示不全时配置,见下方示例 |
getViewport({ scale }) | 逻辑像素缩放 | 常用 1~2,按容器宽度动态计算 |
render({ transform }) | 物理像素映射 | 适配 devicePixelRatio,上限建议封顶 2 |
renderTask.promise | 渲染完成通知 | 翻页前必须 cancel 旧任务 |
中文场景最容易踩的是 cMap。很多 PDF 为了减小体积没有嵌入完整字体,渲染时依赖阅读器用系统字体去补,但 canvas 环境里没有这个补全机制,结果就是生僻字变方块。遇到这种情况,要在加载 PDF 时带上字体映射目录:
const pdf = await pdfjsLib.getDocument({ url: '/docs/report.pdf', cMapUrl: '/lib/pdfjs/cmaps/', cMapPacked: true, }).promise;cMap 相关文件一般在 pdfjs 发行包里自带,把整个 cmaps 目录部署到静态路径即可。判断要不要配 cMap 的办法很简单:PDF 里嵌入完整字体的通常没问题,字体被裁成子集或者完全没嵌入,就大概率需要。
4. 从 demo 到源码:翻页、缩放与懒加载的工程化结构
4.1 源码目录与页面骨架:把模块边界先划清楚
单页脚本只够演示,真要接到业务里,建议按下面的结构组织源码:
| 文件 | 职责 |
|---|---|
index.html | 页面结构:工具栏、滚动容器、canvas 挂载点 |
pdf-viewer.js | 初始化、渲染调度、事件绑定、内存回收 |
pdf-viewer.css | 布局、滚动、文本层样式 |
lib/pdfjs/ | 第三方库文件:主库、worker、cmaps 目录 |
HTML 里只需要放三个区域:顶部工具栏、滚动容器和页面挂载点:
<div class="pdf-toolbar"> <button id="prevBtn">上一页</button> <input type="number" id="pageInput" min="1" value="1" /> <span>/ <span id="pageCount"></span></span> <button id="nextBtn">下一页</button> <button id="zoomInBtn">放大</button> <button id="zoomOutBtn">缩小</button> </div> <div class="pdf-scroll" id="pdfScroll"> <!-- 动态生成的 canvas 会挂在这里 --> <div id="pdfPages"></div> </div>样式上有一个关键点:滚动容器必须是overflow: auto,内部每个 canvas 外层再包一层 div,方便后续做懒加载占位和文本层叠加。toolbar 用position: sticky吸顶,避免滚动时用户找不到操作入口。
4.2 连续滚动的分页渲染与懒加载:大 PDF 不卡的关键
一次性把几百页全部渲染成 canvas,内存直接爆掉。常见做法是只渲染可视区附近的页面,用一个占位 div 配合 IntersectionObserver 触发渲染,滚远了再回收。核心调度代码大致长这样:
const rendered = new Set(); // 已渲染完成的页码 const dpr = Math.min(window.devicePixelRatio || 1, 2); // 物理像素比封顶 2 // 观察所有占位节点,进入可视区附近才渲染 const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const no = Number(entry.target.dataset.page); scheduleRender(no); }); }, { rootMargin: '200px 0px' }); // 提前 200px 预加载 async function scheduleRender(no) { if (rendered.has(no)) return; rendered.add(no); await renderPageIntoContainer(no); releaseFarPages(no); // 渲染完成后顺手回收远距离页面 }rootMargin决定预加载的提前量:设成200px 0px,用户还没滚到那页,页面就已经在渲染了,视觉上无感知;设太大反而白白浪费带宽和 CPU。dpr封顶到 2 很重要,3 倍屏的 canvas 像素量接近 9 倍,很多移动端崩溃就是这么来的。
对应的渲染函数和内存回收函数是这套源码的灵魂:
async function renderPageIntoContainer(no) { const page = await pdf.getPage(no); const viewport = page.getViewport({ scale: currentScale }); // 每个页面独立容器,canvas 挂进去 const wrap = document.createElement('div'); wrap.className = 'pdf-page'; wrap.dataset.page = no; const canvas = document.createElement('canvas'); canvas.dataset.page = no; canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; wrap.appendChild(canvas); container.appendChild(wrap); await page.render({ canvasContext: canvas.getContext('2d'), viewport, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null }).promise; }回收函数的重点是「重置 canvas 尺寸」,这一步能真正释放 GPU 和后端存储,直接移除节点反而不彻底:
function releaseFarPages(currentNo, keepRange = 8) { container.querySelectorAll('canvas').forEach((canvas) => { const no = Number(canvas.dataset.page); if (Math.abs(no - currentNo) > keepRange) { canvas.width = 1; canvas.height = 1; // 重置尺寸以释放位图内存 canvas.remove(); rendered.delete(no); // 允许滚回去时重新渲染 } }); }keepRange是保留范围:当前页前后 8 页保留,其他全部销毁。这个数字既要保证滚动回来时不会白屏等待,又要控制总内存,8 在大多数业务场景里表现不错。
4.3 工具栏:页码跳转、缩放档位与下载按钮
翻页和缩放的核心是维护两个状态:当前页码和当前缩放比例。代码上注意一点——所有触发渲染的入口都要走同一个函数,避免各写各的逻辑导致状态分叉。
let currentPage = 1; let currentScale = 1.5; const scaleSteps = [0.5, 1, 1.5, 2, 3]; async function gotoPage(no, options = {}) { const total = pdf.numPages; currentPage = Math.min(Math.max(no, 1), total); // 边界钳制 document.getElementById('pageInput').value = currentPage; await renderPageIntoContainer(currentPage, options); }页码输入框的监听逻辑有个细节:用户可能还在输入中,别每个键盘事件都触发渲染。用change事件而不是input事件,只有回车或失焦时才跳页,能省掉一大半无效渲染。缩放按钮则按档位切换:
zoomInBtn.addEventListener('click', () => { const idx = scaleSteps.indexOf(currentScale); currentScale = scaleSteps[Math.min(idx + 1, scaleSteps.length - 1)]; clearAndRenderCurrentPage(); }); zoomOutBtn.addEventListener('click', () => { const idx = scaleSteps.indexOf(currentScale); currentScale = scaleSteps[Math.max(idx - 1, 0)]; clearAndRenderCurrentPage(); });缩放之后之前渲染的页面尺寸全部失效,这时候不能只重渲染当前页,要把整个容器清空,让所有页面按新尺寸重新走一遍懒加载逻辑。下载按钮反而最简单,直接放一个<a>标签指向原 PDF 地址,加上download属性,别自己拼文件流,浏览器原生下载更稳定。
5. 在线展示 PDF 的常见问题排查:五个高频坑从白屏到内存爆掉
5.1 白屏且控制台报 worker 相关错误
现象:页面打开一片白,控制台出现 worker 脚本加载失败或者 "The Worker was disabled" 之类的警告。原因有三类:workerSrc路径配错、库文件版本与 worker 文件版本不一致、worker 文件放在了跨域地址上,浏览器对 worker 脚本有同源限制。解决:把主库和 worker 放在同一个静态目录下,用相对路径或同源绝对路径引用;版本必须一一对应,升级主库时 worker 文件要一起换。部署后出现白屏,先打开控制台看 Network 里 worker 文件的请求状态,这一步基本能定位 80% 的问题。
5.2 iOS 端只显示 PDF 第一页或空白
现象:同一个 iframe 方案,桌面端正常,iOS 端打开后要么空白要么只有第一页,缩放手势也不对。原因:iOS 上浏览器对 iframe 内嵌 PDF 的渲染策略与桌面端差异很大,尤其是 PDF 文件超过一定大小或包含复杂字体时,Safari 内核会放弃渲染。解决:业务系统直接放弃 iframe 路线改用 pdf.js,这是最省心的方案;如果只是临时预览,用window.open(url)打开新页面,让系统查看器接管,也比 iframe 可靠得多。这个坑基本无解,只能换路线。
5.3 中文生僻字变方块或整体乱码
现象:PDF 大部分文字显示正常,个别字变成方块,严重时整段都是乱码。原因:PDF 没有嵌入完整字体,字形需要靠阅读器用系统字体补全,而 canvas 渲染环境没有这个机制;另一种可能是字体的 CID 编码需要 cMap 映射。解决:先给getDocument配上cMapUrl和cMapPacked,把 pdfjs 发行包里的 cmaps 目录部署到静态路径,多数中文场景这一步就能解决。如果还不行,需要让后端导出 PDF 时把字体完整嵌入,前端这边没有后悔药。
5.4 快速翻页白屏闪烁或残影
现象:连续点击上一页/下一页,canvas 区域偶尔全白,或者上一页内容残留。原因:page.render()是异步任务,上一次任务还没结束时又发起了下一次,两次渲染互相干扰;快速翻页时 canvas 尺寸变化导致内容清空也是诱因。解决:渲染前把上一次的渲染任务 cancel 掉,用标志位做锁:
let currentRenderTask = null; async function renderPage(no) { if (currentRenderTask) { currentRenderTask.cancel(); // 取消上一次还没完成的渲染 } const page = await pdf.getPage(no); currentRenderTask = page.render({ ... }); await currentRenderTask.promise; currentRenderTask = null; }取消任务后要等promise真正结束再发起下一次渲染,否则状态还是乱的。翻页按钮本身加个 200ms 的节流,也能减少这类闪烁。
5.5 大文件内存暴涨、标签页崩溃
现象:打开一个上百 MB 的 PDF,标签页慢慢卡死,任务管理器里内存涨到几个 GB。原因:高清屏下 canvas 位图尺寸是逻辑尺寸乘 dpr 的平方级放大;渲染过的页面全部挂在 DOM 里不释放,翻过几十页内存就积累起来了。解决:把 dpr 封顶到 2,超过 2 的按 2 算;严格走懒加载 + 远端回收,页面滚出一定范围就销毁 canvas;如果文件本身特别大,配合后端做按页切片或转图片兜底,前端硬扛不划算。
6. 进阶能力:文本选中、目录跳转与打印优化
到这里「能看、能翻、能缩放」已经达标,但业务系统往往还要更多。最后补三个高频进阶能力。
6.1 文本选中
canvas 渲染出来的是位图,用户想复制合同条款是做不到的。pdf.js 需要单独启用文本层:用getTextContent()拿文本及坐标,再渲染一个透明文本层覆盖在 canvas 上面:
const textLayer = document.createElement('div'); textLayer.className = 'textLayer'; const textContent = await page.getTextContent(); pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayer, viewport: viewport });文本层的 CSS 很关键:绝对定位盖在对应 canvas 上,文字颜色透明或半透明,让用户选中时看到高亮但不遮挡页面内容。文本层和 canvas 是两条渲染链路,缩放后两者要同步重建,这块交互细节多,建议先单独花半天时间调。
6.2 目录跳转
pdf.getOutline()能拿到 PDF 自带的大纲树,在线展示 PDF 时把它渲染成侧边栏,就是阅读器里的目录功能:
const outline = await pdf.getOutline(); // outline 是嵌套结构,每项里有 title 和 dest, // dest 指向目标位置的页引用,不同 PDF 生成器结构差异很大dest 的解析很玄学,有的结构是[pageRef, ...],有的直接带页码数组,我踩过几次坑,现在的习惯是先把一个真实 PDF 的 outline 打印出来看一眼再写解析逻辑,别赌某种格式。解析出页码后调用gotoPage(no)即可。
6.3 打印优化
最后一个习惯是:永远别让用户直接window.print()打印 canvas 页面。打印出来会糊、会把工具栏一起打进去、多页文件的页码还会乱。正确姿势是把原 PDF 地址交给浏览器原生打印:
// 先打开原文件,再触发打印预览 window.open(pdfUrl, '_blank');或者提供一个「打印原文件」按钮和「打印当前页」按钮,让用户自己选。这个细节看着小,客户现场演示打印时翻车过一次就长记性了。
我现在接到 PDF 展示需求,第一件事是问三个问题:文件多大、要不要文本选中、必须离线缓存吗。答案一出来,iframe 还是 pdf.js、要不要懒加载、要不要配 cMap 基本就定了。这套流程帮我绕开了不少返工,希望这篇笔记也能帮到你。
本文还有配套的精品资源,点击获取