简介:这是一份面向网页前端开发者的 PDF.js 集成示例包,适合需要在网站中嵌入 PDF 查看、翻页与缩放功能的入门及进阶学习者。资源共 6 个文件,包括 3 个 HTML 页面、2 个核心 JavaScript 文件以及 1 个测试 PDF 文档,压缩包约 601KB。主入口页面搭建基本渲染框架,两个附加示例分别演示页面缩放与自定义导航等不同场景;js 目录中的主库文件负责 PDF 解析,worker 文件处理耗时计算以避免阻塞主线程。部署时需放入 IIS 或 Apache 等 Web 服务器,避免浏览器对 file:// 协议的跨域限制。开发中可重点关注配置项、PDF 加载方式、事件监听、错误处理与性能优化,按需求定制在线文档预览方案。目前已有 1791 人学习下载,示例结构清晰,适合对照代码快速上手。 我直接说结论:如果你要在网页里做PDF预览,pdf.js基本是绕不开的选择。它是Mozilla官方维护的开源项目,解析PDF的核心逻辑全在JavaScript里,不需要服务端配合,更不依赖浏览器原生插件。这些年我在好几个项目里用过它,从最简单的展示到复杂的批注、分页缩略图都折腾过,有些坑属于“官方文档没写透,不看源码根本发现不了”那种级别。这篇就把一个能直接跑的demo从零拆开讲,顺便把那些网上搜不到明说、但实际开发中必然会踩的问题一并交代清楚。
1. 需求分析和方案选型:为什么demo要这么设计
1.1 搞清楚"demo"到底要解决什么问题
很多人上来就急着去npm装包,然后照着一个老demo抄,结果连PDF都渲染不出来。我建议第一步先别碰代码,把需求捋清楚。
一个“pdf.js使用demo”至少要回答这几个问题:
- 用哪一版的pdf.js?官方API在2.6版本和3.x、4.x变化很大,很多老代码直接跑不通新版本
- 用CDN还是npm打包?这决定了你的构建方式和worker怎么配置
- 你只需要渲染第一页,还是要翻页、缩放、缩略图?
- 是否需要兼容CORS(跨域读取PDF)?本地文件怎么读?
这次demo以官方最新稳定版(4.x)为主,实现一个带页码导航、翻页、缩放、全屏宽自适应、加载进度提示的完整预览组件。把基础功能跑通了,你后续的任何定制都是在这些骨架上做加法。
注意:网上大量教程用的还是2.x的写法,比如用
window.pdfjsLib.getDocument()、PDFJS.workerSrc,这些在4.x部分还能用,但新版官方推荐用ES module方式引入,且worker的注册方式也变了。
1.2 为什么仍然要选pdf.js而不是其他方案
这个选择值得展开说。市面上做网页PDF预览的方案不外乎这么几类:
- iframe+浏览器内置预览:零代码,但对PDF版本兼容差,在部分浏览器里直接变成下载或黑屏,且无法定制UI
- 第三方SaaS服务(如Google Docs Viewer等):有跨域和国内访问限制,生产环境基本不可控
- PDFObject.js:轻量封装,本质还是做iframe嵌入,能力有限
- pdf.js:能把PDF页面渲染成Canvas,等于你完全掌控了展示形态
pdf.js最大的优势就是“可控”:可以嵌入到任何页面作为组件,可以自定义样式和交互,可以配合Canvas做截图标注,还能做文本提取。代价就是API复杂度和体积摆在那,核心文件加worker加CMap目录至少几百KB。
demo阶段不用纠结体积,先把能力跑通,后续用webpack做代码分割,按需加载,体积问题可以优化。
2. 搭一个能跑的demo:从零到第一页渲染
2.1 目录结构和引入方式选择
我推荐用Vite来搭这个demo。不是非要用框架,而是Vite的dev server天然支持跨域代理,省去本地调CORS的环境搭建时间。
pdfjs-demo/ ├── index.html ├── main.js └── pdfjs/ ├── pdf.min.mjs ├── pdf.worker.min.mjs └── cmaps/直接用npm安装官方包:
npm install pdfjs-dist@4.10.38装完以后去node_modules里找一下pdfjs-dist/build/目录,里面有pdf.min.mjs和pdf.worker.min.mjs,这两个文件建议拷贝到项目的public目录下,后面worker注册要用。
为什么不直接在代码里import?因为worker需要单独的URL来加载,打包工具对worker的路径处理各有一套,容易出现路径错乱。最保险的做法就是把这两个文件放在静态目录,像远古时代用script标签那样,从根路径去定位。
2.2 worker注册:90%的人第一道坎
worker是pdf.js解析PDF的“后台线程”,必须单独注册,而且注册时机要在getDocument之前完成。
import * as pdfjsLib from 'pdfjs-dist'; // 从node_modules包名引入 const workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString(); pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;如果你没有设置workerSrc,pdf.js会尝试加载一个相对于当前脚本路径的默认worker文件,如果路径不对,控制台会报错:“Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of application/octet-stream”。
这个MIME type错误在Vite里经常出现,原因是本地dev server给.mjs文件返回了错误的content-type。把server.headers配置加上就好:
// vite.config.js export default { server: { headers: { 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Resource-Policy': 'cross-origin' } } };但更省事的方式是像我上面那样,直接new URL指定node_modules里的源文件,格式正确、路径也不会迷。
2.3 核心渲染流程:getDocument、getPage、render
先写一个最小可运行的demo,把三件事做出来:加载文档、拿页面、画到Canvas上。
const loadingTask = pdfjsLib.getDocument({ url: './sample.pdf' }); const pdf = await loadingTask.promise; console.log('PDF页数:', pdf.numPages); const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdf-canvas'); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: context, viewport: viewport }).promise;这段逻辑看起来很直白,但背后有几个细节值得展开:
getDocument返回的是PDFDocumentLoadingTask,它既是Promise-like对象,又有自己的promise属性和destroy()方法。demo里你直接await loadingTask也可以,但如果是需要中途取消加载的应用,必须持有loadingTask然后调用loadingTask.destroy()。
getViewport里的scale是什么意思?它表示渲染的物理像素与PDF逻辑坐标的倍数关系。如果PDF是72dpi的坐标系统,scale为1就是按原尺寸渲染,在普通屏幕上会显得很小。实际经验是:先取容器的CSS宽度,除以viewport的逻辑宽度,得出自适应scale。
const containerWidth = document.getElementById('pdf-container').clientWidth; const baseViewport = page.getViewport({ scale: 1 }); const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale: scale * devicePixelRatio });注意最后又乘了个devicePixelRatio(DPR)。高分屏下Canvas如果不按DPR放大,渲染出来是糊的。这个细节在普通桌面浏览器还看不明显,放到MacBook上对比特别直观。
2.4 完整demo:能翻页、能缩放、能看进度
把上面这些组合成一个可交互的单页应用。核心HTML结构:
<div id="pdf-container"> <canvas id="pdf-canvas"></canvas> </div> <div class="toolbar"> <button id="prev">上一页</button> <span id="page-num">1 / 10</span> <button id="next">下一页</button> <select id="scale-select"> <option value="0.5">50%</option> <option value="1" selected>100%</option> <option value="1.5">150%</option> <option value="2">200%</option> <option value="auto">自适应</option> </select> <div id="loading-bar">加载中...</div> </div>完整脚本逻辑:
let pdfDoc = null; let currentPage = 1; let currentScale = 1.5; let renderingTask = null; async function loadPdf(url) { const loadingTask = pdfjsLib.getDocument({ url }); loadingTask.onProgress = (progress) => { const percent = (progress.loaded / progress.total * 100).toFixed(0); document.getElementById('loading-bar').textContent = `加载中 ${percent}%`; }; pdfDoc = await loadingTask.promise; document.getElementById('page-num').textContent = `1 / ${pdfDoc.numPages}`; renderPage(); } async function renderPage() { if (renderingTask) { await renderingTask.cancel(); } const page = await pdfDoc.getPage(currentPage); const baseViewport = page.getViewport({ scale: 1 }); let scale = currentScale; if (scale === 'auto') { const container = document.getElementById('pdf-container'); scale = container.clientWidth / baseViewport.width; } const viewport = page.getViewport({ scale }); const canvas = document.getElementById('pdf-canvas'); const context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; const renderContext = { canvasContext: context, viewport: viewport }; const task = page.render(renderContext); renderingTask = task; await task.promise; } document.getElementById('next').addEventListener('click', () => { if (currentPage < pdfDoc.numPages) { currentPage++; document.getElementById('page-num').textContent = `${currentPage} / ${pdfDoc.numPages}`; renderPage(); } }); document.getElementById('prev').addEventListener('click', () => { if (currentPage > 1) { currentPage--; document.getElementById('page-num').textContent = `${currentPage} / ${pdfDoc.numPages}`; renderPage(); } });这个demo已经具备可用性了,但代码还有很大优化空间。比如翻页的时候没有取消上一页的渲染任务,如果用户快速连点“下一页”,会出现Canvas画面错乱,旧的任务把画布覆盖了。我在生产代码里是加了一个renderId自增标识,每次渲染结束后检查ID是不是最新的,不是就丢弃结果。
3. 核心API细节与常见功能扩展
3.1 文本层:让PDF可选中、可搜索
如果只是把PDF画成图片,用户没办法选中文字、复制内容,在文档类产品里完全是灾难。pdf.js提供了单独的文本层渲染机制,需要把每个文本项放到绝对定位的span里,和Canvas重叠显示。
思路是这样的:Canvas负责画图形和背景,文本层负责覆盖文字,因为Canvas本身不能承载交互,文本层的span可以天然被浏览器选中。
const textLayerDiv = document.getElementById('text-layer'); textLayerDiv.innerHTML = ''; const textContent = await page.getTextContent(); const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: viewport, textDivs: [] }); await textLayer.render();但有个细节特别容易出错:文本层渲染出来的每个文本span必须有transform样式来对齐Canvas坐标系里的位置,而Canvas的viewport和文本层viewport必须完全一致。如果你先渲染Canvas,然后又调了page.getViewport获取一个不同的scale来渲染文本层,所有文字位置都会错位。
正确做法是:用同一个viewport对象渲染Canvas和文本层,或者至少保证scale和rotation参数一致。
3.2 搜索高亮:pdf.js内置的findController
搜索功能听起来高大上,其实pdf.js把大部分工作封装好了。老版本用PDFFindController,4.x版本改名成PDFFindController照样存在,只是需要通过eventBus来驱动。
基本用法是创建一个事件总线实例,然后让渲染器和查找控制器都监听这个总线:
import { EventBus, PDFFindController } from 'pdfjs-dist/web/pdf_viewer.mjs';这里必须引入pdf_viewer.mjs,因为查找功能是在viewer层实现的,核心库pdf.min.mjs里只有渲染能力,不包含UI层逻辑。
demo阶段做搜索可能会走弯路,因为你得自己维护事件通知,比如搜索时需要触发updatefindcontrolstate事件来更新高亮状态。我的建议是:如果只是搜索高亮,可以直接调findController.setQuery,然后dispatchFindEvent,不用把整个viewer的UI层引进来。
const eventBus = new EventBus(); const findController = new PDFFindController({ linkService: { // 需要自己实现最小接口 get page() { return currentPage - 1; }, set page(v) { currentPage = v + 1; renderPage(); }, get pagesCount() { return pdfDoc.numPages; }, }, eventBus, updateMatchesCountOnProgress: true, }); findController.setDocument(pdfDoc); findController.setQuery('关键词');这里有个大坑:PDFFindController的page是从0开始的,而getPage方法是1开始的,不一致导致高亮始终差一页,排查了半天。如果遇到这个情况,记得在linkService里做转换。
3.3 缩略图:带预览的页码导航
缩略图其实不是单独的功能,它只是用很小的scale渲染每一页然后横向排列。但性能问题就来了,一个100页的PDF如果全量渲染缩略图,首屏要加载很久。
业界通用的做法是:只渲染视口附近的缩略图,离得远的懒加载,甚至直接放一个灰色占位块。我自己的项目里是配合IntersectionObserver做的,缩略图容器挂进视口才渲染,下面是一个精简的实现片段:
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const pageNum = entry.target.dataset.pageNum; renderThumbnail(pageNum); observer.unobserve(entry.target); } }); }, { root: document.getElementById('thumbnail-list') }); // 为每个页面创建占位容器 for (let i = 1; i <= pdfDoc.numPages; i++) { const div = document.createElement('div'); div.dataset.pageNum = i; div.className = 'thumbnail-item'; thumbnailList.appendChild(div); observer.observe(div); }IntersectionObserver的回调在初次观察挂载的元素时会立刻触发一次,这意味着视口附近的缩略图会马上开始渲染,体验上几乎无感。
3.4 性能优化:超高清PDF或超大页面的渲染策略
有几次遇到单页尺寸大得离谱的PDF,比如工程图纸,逻辑宽度超过1万像素。如果你直接照常渲染,Canvas会被浏览器限制大小——Chrome和Firefox对Canvas面积有上限,超出后Canvas会默认为空白,什么也不画。
两种解法:
第一种是降低渲染比例,比如把viewport的scale设为(目标宽) / (页面原始宽) * 0.5,牺牲清晰度换取可用性;
第二种是做“瓦片渲染”,把页面切块,每块独立渲染,然后拼接展示。这个复杂度较高,需要精确计算每块在原始坐标系中的位置。如果只是demo,建议走第一种,把scale限制在合理范围:
const MAX_CANVAS_WIDTH = 4096; let scale = desiredScale; let viewport = page.getViewport({ scale }); if (viewport.width > MAX_CANVAS_WIDTH) { scale = MAX_CANVAS_WIDTH / viewport.width; viewport = page.getViewport({ scale }); }4. 踩坑记录与排查速查表
4.1 最常被问到的6个问题
整理了一下这几年在技术群里被问烂的问题,直接做成排查表:
| 症状 | 根本原因 | 解决方式 |
|---|---|---|
控制台报Failed to fetch或NetworkError | 跨域读取PDF | 服务端配置CORS;或本地用Vite代理;或以ArrayBuffer形式传入 |
| 渲染出来是空白Canvas | Canvas尺寸超过浏览器上限 | 限制scale;或瓦片渲染 |
| 文字不显示但图形正常 | 文本层viewport和Canvas不一致 | 统一使用同一个viewport对象 |
| 中文PDF文字乱码 | 缺少CMap目录,或者字体解析失败 | 配置cMapUrl和cMapPacked: true |
| PDF带表单,填的字段不显示 | 需要在viewer层启用annotationLayer | 引入AnnotationLayer并创建注解层容器 |
| 快速翻页画面错乱 | 渲染任务没有取消 | 在render前先cancel()旧任务,或使用renderId丢弃旧结果 |
每个买过“空白页”教训的人都知道,第3个问题尤其隐蔽。我在一个老项目里遇到过一次,最后是拿Chrome的Profiler逐帧对比Canvas和文本层的render调用,才发现两个viewport不是同一个实例。
4.2 CMap和字体:中文PDF的真正难点
上面表格里提到CMap,单独拎出来说。CMap是把PDF内部编码映射到Unicode的表,pdf.js在解析有中文、日文、朝鲜文嵌入的PDF时,基本都离不开它。
设置方法:
const loadingTask = pdfjsLib.getDocument({ url: './sample.pdf', cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/cmaps/', cMapPacked: true, });cMapPacked表示使用压缩后的bcmap文件,体积小一些。生产环境建议把cmaps目录放到自己的CDN上,不管国内国外访问都稳定,依赖jsdelivr有被墙或者限速的风险。
另外,某些PDF如果内嵌了自定义字体子集,pdf.js无法通过CMap直接解析,这时候你需要的是启用FontFace的下载:
const loadingTask = pdfjsLib.getDocument({ url: './sample.pdf', // 允许通过Font Loading API加载内嵌字体 useSystemFonts: true, });useSystemFonts: true表示优先使用操作系统的字体,如果系统里恰好有同名字体,渲染速度会大幅提升,否则就等字体下载完再渲染。
4.3 移动端适配:一个必须提前准备的细节
移动端的坑主要是手势缩放和横竖屏切换。PDF内容本身宽高比是固定的,Canvas是固定像素,在手机上通常需要允许用户双指缩放,但不要直接放大整个DOM元素,而是去改变render的scale。
我试过最简单的方法是把多点触控的scale值监听一下,然后同步到Canvas。但你很快会发现,每次手势变化都重新渲染整页会非常卡顿。
比较务实的移动端方案:手势缩放时,用CSS transform对Canvas做临时缩放,手势结束(touchend或gestureend)时再以新的scale真正渲染一次。这样临时阶段只是浏览器做图像变换,计算压力小很多。
let gestureScale = 1; canvas.style.transformOrigin = '0 0'; canvas.style.transition = 'transform 0.15s ease-out'; canvas.addEventListener('gesturestart', () => { gestureScale = currentScale; }); canvas.addEventListener('gesturechange', (e) => { const newScale = gestureScale * e.scale; canvas.style.transform = `scale(${newScale / currentScale})`; }); canvas.addEventListener('gestureend', () => { currentScale = currentScale * e.scale; canvas.style.transform = ''; renderPage(); });需要注意的是,Android上还有部分旧浏览器不支持gesture事件,此时用touch事件自己算两点距离更通用。demo阶段先把iOS上跑通,Android主流浏览器也基本都有一定支持,实在不行再补touch方案。
5. 扩展思路:demo还能往哪个方向长
很多朋友的demo跑通后就止步了,其实pdf.js的能力边界比你想象的大得多。我列几个自己在业务里验证过、值得再挖一挖的方向。
一个是PDF导出和合成。pdf.js做渲染是主业,但它的姊妹库pdf-lib可以创建和修改PDF,两者搭配就能做“PDF水印工具”或“PDF合并拆分器”——在前端全流程完成,不依赖Node服务。
另一个是批注系统。Canvas渲染后你在页面上覆盖一层SVG或DOM层,用鼠标绘制矩形、高亮、便签,再把坐标以“页面逻辑坐标+页码”的方式存下来。下次加载时根据坐标换算回viewport坐标,批注就落到正确的位置了。这里的关键就是坐标格式要用viewport的逻辑坐标(除以当时的scale),而不是Canvas的物理像素,否则缩放后批注就飘了。
还有一个是文本报告或结构分析工具。用getTextContent提取PDF里的文本,再做正则匹配或语义分析,可以做到合同关键信息抽取、论文参考文献提取等实用功能。pdf.js甚至给出了不用渲染,只加载文本内容的API,性能很好,处理几百页内容不在话下。
最后,我觉得做这种工具类功能时,比起单纯的“能用”,更值得花心思的是交互上的顺滑程度。很多人在PC端的demo里觉得pdf.js体验非常好,但在移动端就会觉得缩放手感、点选响应速度都差了一截,原因往往不是引擎的问题,而是你没为触控场景专门调优。把Canvas的pointer-events属性、被动事件监听、触摸事件节流都处理好,体验会有本质提升。
我在实际项目中还有个习惯,就是把pdf.js封装成一个Web组件(比如基于Lit或原生Custom Element),把里面这些API细节全部藏住,外部只暴露src属性和几个事件回调。这样做的好处是,以后换版本或者调整渲染逻辑时,业务层完全不用动,改动只发生在组件内部。如果你要在这个demo上继续做业务扩展,建议也早点做这个封装。
本文还有配套的精品资源,点击获取