pdf.js网页PDF预览从零实现与踩坑指南 简介这是一份面向网页前端开发者的 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-dist4.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;如果你没有设置workerSrcpdf.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 });注意最后又乘了个devicePixelRatioDPR。高分屏下Canvas如果不按DPR放大渲染出来是糊的。这个细节在普通桌面浏览器还看不明显放到MacBook上对比特别直观。2.4 完整demo能翻页、能缩放、能看进度把上面这些组合成一个可交互的单页应用。核心HTML结构div idpdf-container canvas idpdf-canvas/canvas /div div classtoolbar button idprev上一页/button span idpage-num1 / 10/span button idnext下一页/button select idscale-select option value0.550%/option option value1 selected100%/option option value1.5150%/option option value2200%/option option valueauto自适应/option /select div idloading-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把大部分工作封装好了。老版本用PDFFindController4.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形式传入渲染出来是空白CanvasCanvas尺寸超过浏览器上限限制scale或瓦片渲染文字不显示但图形正常文本层viewport和Canvas不一致统一使用同一个viewport对象中文PDF文字乱码缺少CMap目录或者字体解析失败配置cMapUrl和cMapPacked: truePDF带表单填的字段不显示需要在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-dist4.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上继续做业务扩展建议也早点做这个封装。本文还有配套的精品资源点击获取

相关新闻

最新新闻

大风天气安全避险指南:从风力等级到户外防护全解析

大风天气安全避险指南:从风力等级到户外防护全解析

大风天到底有多危险?平时我们看新闻、刷短视频,总能看到大风把广告牌吹翻、树木连根拔起、楼体外墙脱落,甚至行人被吹倒的画面。但很多危险并不是“看到了才发生”的,而是你出门之后才意识到:原来风这么大、原来这里这…

2026/9/2 22:09:25
我的世界1.12.2原版生存服务器开荒全攻略:入服准备与服主配置指南

我的世界1.12.2原版生存服务器开荒全攻略:入服准备与服主配置指南

如果你以为“MC 玩新版才算跟上时代”,那大概率会错过一类非常特别的游戏体验:老版本原版生存服务器。最近看到 CST 这个 1.12.2 原版生存服务器的开荒公告,我反而觉得这是一个观察《我的世界》多人玩法的好机会——为什么一个发布多年的版本…

2026/9/2 22:09:25
1.12.2原版生存服开荒指南:从搭建到运营全流程解析

1.12.2原版生存服开荒指南:从搭建到运营全流程解析

“1.12.2 还有人开服?”这是很多人听到老版本生存服的第一反应。但真正经历过那个版本的玩家都明白,1.12.2 从来不是一个“过时”的版本,而是一个规则稳定、生态成熟、对硬件友好的联机时代坐标。CST 服务器选择在这个节点做原版生存开荒&…

2026/9/2 22:09:25
实测微信小程序去硬字幕:应急可用,专业交付不足

实测微信小程序去硬字幕:应急可用,专业交付不足

微信小程序里的去字幕工具,在短视频圈子里传得挺神。我的第一反应是不太相信,因为“硬字幕”这三个字决定了一件事:字幕不是可隐藏的文本层,而是已经被烧进画面里的像素。去掉它,等于要猜出被字幕遮挡的背景长什么样&a…

2026/9/2 22:09:25
Hadoop WordCount词频统计实战:从环境搭建到MapReduce作业运行

Hadoop WordCount词频统计实战:从环境搭建到MapReduce作业运行

简介:一套基于Hadoop 2.2.0的完整词频统计MapReduce解决方案,面向正在入门分布式计算、希望掌握MapReduce编程流程的开发者。该方案覆盖从原始文本读取到最终结果输出的完整链路,能够解决在集群环境下统计单词出现次数的经典问题,…

2026/9/2 22:09:25
无纸化报销之后,档案怎么办?这3件事一定要做对

无纸化报销之后,档案怎么办?这3件事一定要做对

无纸化报销只是起点,档案管不好反而会埋下新隐患很多企业把"无纸化报销"当成数字化转型的一个里程碑,报销流程跑通了、员工不贴票了、审批在线化了,就以为大功告成。但报销无纸化之后,真正的难题才刚刚浮出水面&#xf…

2026/9/2 22:04:24