用pdf.js打造在线PDF阅读器:渲染、缓存与跨域避坑指南

发布时间:2026/10/18 13:00:56
用pdf.js打造在线PDF阅读器:渲染、缓存与跨域避坑指南 简介面向Web前端开发者的PDF.js完整资源包用于在浏览器中无需下载即可实现PDF文档在线预览、翻页与渲染。资源围绕Mozilla开源的PDF.js库整理包含核心JavaScript库、HTML示例页面、CSS样式、示例PDF及配套字体映射文件bcmap并提供大量png/svg图标与界面素材。全包共402个文件、压缩后约3.06MB其中168个bcmap文件覆盖UniGB、UniJIS等字符集另有4个js、3个map及1个css用于调试与样式控制目录结构完整可直接部署或二次开发。目前已有3142人学习下载适合需要快速集成PDF预览功能、研究Canvas渲染原理或构建在线文档系统的中高级前端开发者。通过示例可掌握PDF.js的引入、文档加载、多页渲染、进度监听、缩放操作及Web Worker后台解析等关键用法并参考license文件了解商用注意事项。1. 用 JavaScript 在线查看 PDF先想清楚方案再写代码要在网页里直接查看 PDF很多人的第一反应是扔一个 iframe 完事。实际接需求时你会发现客户要的是类似阅读器的体验能翻页、能缩放、能知道加载到第几页甚至要记住上次看到的位置。这个用纯 JavaScript 解析渲染 PDF 的需求核心解法是围绕一个开源解析库pdf.js做封装它把 PDF 文件解码成页面对象再通过 canvas 把每一页画出来。下面这套方案从原理到踩坑拆开讲适合前端工程师、以及要给系统做文件预览功能的开发者。你会发现真正决定效果的往往不是渲染函数而是缓存、尺度处理和资源释放。2. 深入 pdf.js 的渲染管线从 URL 到 canvas 的每一步2.1 加载与解析从 URL 到 PDFDocumentProxy先用 script 标签引入解析库的时候全局会挂一个pdfjsLib对象。初始化 worker 和加载文档的代码通常长这样script src/vendor/pdf.min.js/scriptconst pdfjsLib window.pdfjsLib; pdfjsLib.GlobalWorkerOptions.workerSrc /vendor/pdf.worker.js; const loadingTask pdfjsLib.getDocument({ url: /files/contract.pdf, withCredentials: false, onProgress: (data) { if (data.total 0) { const percent Math.round((data.loaded / data.total) * 100); console.log(下载进度 ${percent}%); } } }); loadingTask.promise.then((pdf) { console.log(总页数:, pdf.numPages); }).catch((err) { console.error(解析失败:, err); });逻辑说明getDocument返回的是一个加载任务 loadingTask它内部会在独立 worker 线程里完成 PDF 的下载和解析包括交叉引用表、对象树和页面树。前端拿到的pdf其实是一个代理对象 PDFDocumentProxy它并不直接持有像素数据而是提供操作句柄。这个阶段最容易被误解的是promise resolve 之后PDF 内容并没有渲染出来还需要通过getPage一页一页取。onProgress回调里的loaded / total是字节下载进度不是解析进度如果服务器响应头没有Content-Lengthtotal会是 0页面上的进度条要做兜底不能直接算百分比显示成 NaN。参数说明url可以是相对路径或绝对路径跨域地址需要服务端正确返回 CORS 响应头。withCredentials表示跨域请求是否需要携带 Cookie只有 PDF 接口依赖登录态时才开true否则开着会增加一次 OPTIONS 预检小文件不明显大文件慢个一两百毫秒都能感觉到。GlobalWorkerOptions.workerSrc是必配项主文件负责 API真正的二进制解析在 worker 里做路径指向pdf.worker.js版本要与主文件一致否则后面会报版本不匹配。2.2 计算 viewportscale 与 devicePixelRatio 的关系渲染单页的核心是拿到 PDFPageProxy然后通过getViewport计算绘制窗口。这里有个高清屏的细节很多人第一次写都会踩async function renderPage(pdf, pageNumber, canvas, scale 1) { const page await pdf.getPage(pageNumber); const baseViewport page.getViewport({ scale: 1 }); const dpr window.devicePixelRatio || 1; const viewport page.getViewport({ scale: scale * dpr }); canvas.width viewport.width; canvas.height viewport.height; canvas.style.width ${baseViewport.width * scale}px; canvas.style.height ${baseViewport.height * scale}px; const context canvas.getContext(2d); try { await page.render({ canvasContext: context, viewport, background: #ffffff }).promise; } catch (err) { if (err.name RenderingCancelledException) return; throw err; } finally { page.cleanup(); } }逻辑说明PDF 内部坐标单位是磅getViewport({ scale: 1 })是把一磅换算成一个 CSS 像素得到原始尺寸。scale参数控制缩放倍率dpr是屏幕物理像素与逻辑像素的比值。canvas.width和canvas.height是位图缓冲区的像素尺寸应该用物理像素值也就是scale * dpr而canvas.style.width/height是 CSS 显示尺寸应该用逻辑像素值。这两个分清楚高分屏上文字边缘才不会发虚。我一般习惯把background: #ffffff固定加上因为部分 PDF 页面没有底色透明背景叠加在深色页面上会显得很脏。参数说明page.cleanup()在渲染结束后调用它会释放当前页面不再使用的图像和字体缓存但不会销毁整个 PDF 文档。翻页场景下这个调用不能省否则几百页翻过去内存里会堆满各种没用的字体子集和图片对象最终导致卡顿。2.3 渲染任务取消快速翻页的保命操作连续快速翻页时上一页的渲染可能还没结束新一页的渲染就开始排队用户会明显感到翻页越来越慢。正确做法是取消掉上一个 RenderTasklet currentRenderTask null; async function renderPageWithCancel(page, canvas, viewport) { if (currentRenderTask) { currentRenderTask.cancel(); } const renderTask page.render({ canvasContext: canvas.getContext(2d), viewport }); currentRenderTask renderTask; try { await renderTask.promise; } catch (err) { if (err.name RenderingCancelledException) return; throw err; } finally { page.cleanup(); } }逻辑说明renderTask.cancel()是异步的被取消的渲染任务最终会以RenderingCancelledException结束必须捕获并忽略这个错误否则控制台会多一条 unhandledrejection。发起新渲染前建议把旧的 canvas 宽高先改掉因为 canvas 的 width/height 重新赋值会清空画布这和取消任务是两个独立步骤。如果连续取消三四次旧的 promise 全部 rejectcatch 里只判断异常名不要顺手把page对象销毁下一页还需要复用同一个页面结构。3. 三种接入方式对比iframe、embed 与自定义渲染3.1 iframe 与 embed零代码实现的边界在哪用原生方式嵌入 PDF往往就是一行标签的事iframe src/docs/report.pdf#page3toolbar0 width100% height600px /iframe逻辑说明iframe 和 embed 本质上是把 PDF 交给浏览器内置的 PDF 查看器去渲染。加载、缩放、翻页都由浏览器接管网站代码拿不到任何事件和进度。#page3这类锚点参数不是标准Chrome 支持得很不错但 Firefox 对toolbar0经常无视移动端 Safari 根本不渲染内嵌 PDF直接变成下载行为。更隐蔽的是响应头里如果带了Content-Disposition: attachment即使 iframe 写得好好的浏览器也会强制触发下载而不是预览。embed 和 iframe 的差别只在于少了一层嵌套文档控制能力同样弱。要做阅读统计、关键词搜索、自定义水印、记住上次阅读位置原生插件方案全都做不到。所以 iframe 只适合内部管理系统这类环境固定、用户不挑体验的场景。3.2 自定义渲染把控制权拿回自己手里自定义渲染方案以 pdf.js 的解析能力为核心。HTML 只需要一个容器剩下的都是 JavaScript 在操作 canvasdiv idpdfContainer/div script src/vendor/pdf.min.js/script script pdfjsLib.GlobalWorkerOptions.workerSrc /vendor/pdf.worker.js; /scriptlet pdf null; const container document.getElementById(pdfContainer); pdfjsLib.getDocument(/files/report.pdf).promise.then(async (doc) { pdf doc; const page await doc.getPage(1); const viewport page.getViewport({ scale: 1.2 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; container.appendChild(canvas); await page.render({ canvasContext: canvas.getContext(2d), viewport }).promise; page.cleanup(); });逻辑说明渲染结果完全由自己的 canvas 元素承载DOM 结构、尺寸、位置可以随意控制。500 页的 PDF 首屏只画一页用户几乎感觉不到大文件的存在。代价是需要自己维护的细节变多了worker 版本、跨域响应头、canvas 位图尺寸、页面资源回收、渲染取消、加载进度。这些坑不是必然出现但出现一个就能让你排查半天。3.3 选型矩阵与参数边界维度iframe / embedpdf.js 自定义渲染开发量零代码需要维护渲染流程交互控制弱完全依赖浏览器逐页、缩放、进度全自主移动端表现iOS Safari 经常白屏全平台行为一致加载进度无可以拿到字节进度跨域限制受浏览器插件策略限制服务端配置 CORS 即可内存管理浏览器负责必须手动 cleanup 和淘汰缓存文本搜索依赖浏览器原生通过 getTextContent 自行实现选型建议只是后台管理端自己看一眼iframe 是够用的要做面向用户的合同预览、电子书、简历展示就选自定义渲染。项目里已经有完整前端框架时也别把 iframe 当组件复用后期想加搜索、记住阅读位置你会为当时图省事付出代价。4. 完整实现带进度、缩放、取消渲染的在线 PDF 查看器4.1 初始化解析库与加载进度先搭一个最简单的工具栏和容器div classviewer-toolbar button idprevBtn上一页/button span idpageIndicator- / -/span button idnextBtn下一页/button select idzoomList option value0.550%/option option value0.7575%/option option value1 selected100%/option option value1.5150%/option /select span idprogressText/span /div div idcanvasHost/divconst pdfjsLib window.pdfjsLib; pdfjsLib.GlobalWorkerOptions.workerSrc /vendor/pdf.worker.js; const state { pdfDocument: null, pageNumber: 1, scale: 1, renderTask: null }; async function loadPdf(url) { const task pdfjsLib.getDocument({ url, onProgress: (data) { const total data.total || 0; if (total 0) { const pct Math.round((data.loaded / total) * 100); document.getElementById(progressText).textContent 加载 ${pct}%; } else { document.getElementById(progressText).textContent 加载中…; } } }); state.pdfDocument await task.promise; document.getElementById(pageIndicator).textContent ${state.pageNumber} / ${state.pdfDocument.numPages}; document.getElementById(progressText).textContent ; await renderCurrentPage(); }逻辑说明onProgress在下载阶段反复触发适合给用户一个文件正在进来的反馈。如果文件已经命中了浏览器缓存loaded可能直接等于total也可能两个都是 0所以代码里要先把total || 0兜住。下载进度和解析进度是两回事解析期间这条回调不会再触发你可以在promise前后手动切换 loading 文案。4.2 渲染当前页并维护 canvasasync function renderCurrentPage() { const doc state.pdfDocument; if (!doc) return; if (state.renderTask) { state.renderTask.cancel(); } const page await doc.getPage(state.pageNumber); const baseViewport page.getViewport({ scale: 1 }); const viewport page.getViewport({ scale: state.scale * (window.devicePixelRatio || 1) }); let canvas document.querySelector(#canvasHost canvas); if (!canvas) { canvas document.createElement(canvas); document.getElementById(canvasHost).appendChild(canvas); } canvas.width viewport.width; canvas.height viewport.height; canvas.style.width ${baseViewport.width * state.scale}px; canvas.style.height ${baseViewport.height * state.scale}px; const renderTask page.render({ canvasContext: canvas.getContext(2d), viewport, background: #ffffff }); state.renderTask renderTask; try { await renderTask.promise; } catch (err) { if (err.name RenderingCancelledException) return; throw err; } finally { page.cleanup(); } document.getElementById(pageIndicator).textContent ${state.pageNumber} / ${doc.numPages}; }逻辑说明每次渲染前取消上一个renderTask是为了避免旧任务还在往同一个 canvas 画像素改完宽高后正好把画布清空两个动作叠加容易造成画面闪烁。取消不是立即结束所以 await 之后还在 try/catch 里兜住RenderingCancelledException。canvas.width和canvas.style.width分开设置保证高分屏清晰度的同时让 CSS 布局按逻辑尺寸走。page.cleanup()放在 finally 里无论渲染成功还是被取消都及时释放当前页的字体和图片缓存。参数说明state.scale是逻辑缩放比例我平时从 0.5 到 1.5 提供几个档位。工程图纸类的宽幅 PDF建议把最低档设成 0.2否则首屏渲染几百兆像素会直接卡死。容器高度建议固定不要跟随 PDF 高度变化否则每次翻页上下跳动很影响体验。4.3 翻页与缩放联动document.getElementById(prevBtn).addEventListener(click, () { if (state.pageNumber 1) { state.pageNumber--; renderCurrentPage(); } }); document.getElementById(nextBtn).addEventListener(click, () { if (state.pageNumber state.pdfDocument.numPages) { state.pageNumber; renderCurrentPage(); } }); document.getElementById(zoomList).addEventListener(change, (e) { state.scale parseFloat(e.target.value); renderCurrentPage(); });逻辑说明翻页只是修改state.pageNumber再整体重走渲染流程没有其他魔法。缩放也不涉及重新解析 PDF只是换了一个 viewport 重新绘制所以响应很快。这里要注意缩放之后 canvas 的位图和 CSS 尺寸都会变化如果外层容器的高度没跟着调整页面里会出现滚动条跳动要么固定容器高度要么缩放后手动把容器高度设置成viewport.height * scale。4.4 本地文件与 Blob URL 的加载差异用户上传 PDF 时最常见的错误是const file input.files[0]; pdfjsLib.getDocument({ url: URL.createObjectURL(file) }).promise.then(...);这样也能跑但 Blob URL 会占用一条内存引用使用完要及时 revoke。更稳妥的是直接转成 ArrayBuffer 走内存数据const file input.files[0]; const data await file.arrayBuffer(); const task pdfjsLib.getDocument({ data }); state.pdfDocument await task.promise;逻辑说明data参数直接接收二进制数据解析过程发生在 worker 内存里没有网络请求不存在跨域问题。大文件时要注意几十 MB 的 PDF 全部读进内存移动端可能吃力这种情况更适合后端转图片或返回流式 URL 让 pdf.js 自己拉取。5. 避坑与排查五个能让你在线上翻车的 PDF 预览坑5.1 跨域问题导致加载任务直接失败现象网络面板里 PDF 请求 200但页面报Failed to fetch或Access to fetch at ... has been blocked by CORS policy整片内容一直空白。原因pdf.js 内部用 fetch/XHR 去请求 PDF 文件浏览器同源策略拦截了没有 CORS 响应头的跨域响应。地址栏直接打开 PDF 触发的是导航请求不受 CORS 限制所以你会觉得文件明明能打开啊。解决如果 PDF 由自己的后端提供给响应加Access-Control-Allow-Origin并且正确处理跨域请求的 OPTIONS 预检。如果 PDF 来自第三方存储你改不了响应头就在自己后端做一层代理由服务端去拉取再转发给前端。代理时要保持流式读写不要response.text()完之后再返回大 PDF 会把代理服务内存吃爆。5.2 worker 文件 404 或版本不匹配现象控制台出现Failed to load pdf.worker.js或者The API version ... does not match the Worker version ...再或者一条Setting up fake worker的警告。原因workerSrc路径写错或者主文件跟 worker 文件来自不同构建版本。用 CDN 直引时最容易出这个问题一个地址是 3.x 版另一个是 2.x 版浏览器加载后才比对出来。解决固定同一版本、同一目录。我用 script 标签时会把pdf.min.js和对应的pdf.worker.js一起拷到静态目录版本靠文件名锁定。用 npm 打包时记得把pdfjs-dist/build/pdf.worker.mjs作为静态资源输出构建工具不同配置也不同别只导入 API 包而忘了 worker。5.3 canvas 渲染成功但显示空白现象page.render的 promise 正常 resolve控制台没有任何报错canvas 元素在页面上也有尺寸但内容全白或者只显示一个角落。原因最常见的是 canvas 的 CSS 尺寸和位图尺寸对不上。比如容器宽度是 0viewport.width算出来是 0渲染全画到零宽位图上或者 CSS 写了width: 100%; height: 300px但 canvas 位图只有默认的 300x150绘制结果被压缩或裁剪。解决渲染前用getBoundingClientRect()确认容器宽度大于 0再给canvas.width赋具体数值不要依赖默认值。调试时可以给 canvas 加一个stylebackground: red如果红色能显示但 PDF 内容没有问题就在绘制坐标或位图尺寸。另一个隐蔽原因是渲染刚结束就把 canvas 从 DOM 移走了这属于异步时序问题渲染 promise 没有 resolve 之前不要做任何 DOM 清理。5.4 长文档翻页卡顿内存只增不降现象几十页的 PDF 一直流畅翻到两百多页后每翻一页都有明显延迟任务管理器里浏览器内存稳步上涨最后直接白屏或闪退。原因每一页getPage后页面的字体、图片和资源都挂在文档对象上没有调用cleanup()。同时渲染过的 canvas 如果一直留在 DOM 里位图内存也不会释放。两者叠加越翻越卡。解决渲染当前页后立即page.cleanup()并且维护一个 LRU 缓存只保留当前页和前后一两页的 canvas。淘汰页面时把canvas.width 0这一步可以立刻释放位图内存再把 canvas 元素直接从 DOM 移除。如果是单页应用组件卸载时要调用pdfDocument.destroy()否则整个文档对象仍然持有大量内部资源。5.5 移动端 iframe 方案直接失效现象iOS Safari 和微信内置浏览器里iframe 打开 PDF 显示灰色或空白有些版本直接唤起下载。Android 各厂商 WebView 行为也不一致有的能显示有的直接跳到外部应用。原因iOS Safari 不支持在 iframe 内嵌渲染 PDF它把 PDF 资源当作文档导航处理但内置渲染器没有真正覆盖这个场景。加上服务端Content-Disposition或 MIME 类型不标准浏览器就会选择下载而不是预览。解决移动端不要再用 iframe/embed统一走 pdf.js 自定义渲染。如果项目实在不想引入这个库另一个稳健方案是服务端把 PDF 转成图片列表前端用图片轮播的方式预览虽然丢掉了文字搜索和选中复制但兼容性最好、开发量最小适合扫描件和合同快照。6. 进阶技巧预渲染与缓存管理把阅读体验拉满6.1 用去抖预渲染下一页用户停留在当前页超过 300ms 后可以预先渲染下一页让翻页看起来零等待let preloadTimer null; function schedulePreload() { clearTimeout(preloadTimer); preloadTimer setTimeout(() { const nextPage state.pageNumber 1; if (nextPage state.pdfDocument.numPages !pageCache.has(nextPage)) { renderToCache(nextPage, state.scale); } }, 300); }逻辑说明去抖的意义在于用户快速连翻三页时中间页根本没有必要预渲染等他停下来再按当前所处位置预读下一页。预渲染的 canvas 放在离屏容器里用户翻过去时直接替换 DOM省掉一次空白等待。移动端内存紧我会直接关掉这个功能。6.2 用 Map 实现简单的 LRU 页面缓存const MAX_CACHE 3; const pageCache new Map(); function setPageCache(num, canvas) { if (pageCache.has(num)) { pageCache.delete(num); } else if (pageCache.size MAX_CACHE) { const oldestKey pageCache.keys().next().value; const oldCanvas pageCache.get(oldestKey); oldCanvas.width 0; oldCanvas.remove(); pageCache.delete(oldestKey); } pageCache.set(num, canvas); }逻辑说明Map 的 key 按插入顺序排列每次命中时先delete再set就能把最近使用的项移到末尾。淘汰时从开头拿最久没用的项width 0让浏览器立即释放位图remove()从 DOM 移走。缓存上限我一般设 3当前页加前后各一页再多就失去按需渲染的意义了。6.3 全文检索与高亮定位pdf.js 提供getTextContent()能拿到页面文本项和位置信息const textContent await page.getTextContent(); const fullText textContent.items .map((item) item.str) .join( ); const found fullText.includes(keyword);逻辑说明文本搜索用于判断哪几页包含关键词很方便但要实现高亮定位还需要把文本项的坐标映射到 canvas 的显示坐标然后在 canvas 上层覆盖一个半透明高亮层。文本层和 canvas 必须使用相同的scale位置才能对齐。扫描件没有文本层items会是空数组这种情况只能靠 OCR 服务转换后再检索。有一回我接到一个特别大的 PDF 预览需求连续快速翻页把整个页面卡到无响应。后来我把预渲染改成翻页停下来 300ms 才触发并把页面缓存上限压到 3 张内存曲线立刻平稳了下来。从那以后我每次做在线 PDF 预览都会强制走一遍这套检查CORS 头、worker 版本、canvas 尺寸、页面 cleanup、缓存上限五步全过才敢说这个功能能上线。希望这些细节能帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询