☰
pdf.js 2.2.228 部署与避坑指南:兼容旧浏览器的 PDF 预览方案
2026/10/14 1:11:58 网站建设 项目流程

简介:这是一份 PDF.js 2.2.228 的发行版压缩包,由 Mozilla 团队打造,专供 Web 前端开发者在浏览器中无插件渲染和预览 PDF 文档。压缩包共 402 个文件,整体仅 3.73MB,包含核心库文件 pdf.js 与 pdf.worker.js、渲染所需的字符映射表(bcmap)、界面资源(png/svg/css/html)及示例文档等,结构清晰,方便直接部署或二次开发;目前已有 1286 人浏览/学习。使用该资源可快速搭建支持跨浏览器、跨操作系统的在线 PDF 阅读器,实现高质量渲染、分块加载、文本搜索、书签与表单支持等功能,并且支持文本层选择、注释等交互特性;尤为重要的是,其 bcmap 文件覆盖中文、日文等多语种字符映射,能确保 CJK 等复杂字体在 PDF 中正确显示。加上自带的 web 目录提供默认阅读器 UI、build 目录提供编译后的库文件,开发者可配合 API 快速嵌入到项目中,特别适合需要集成 PDF 预览能力的在线文档系统、知识库或管理后台使用,无论是前端新手还是资深研发,都能借助这套发行版快速落地稳定、安全的 PDF 预览方案,并可按需定制界面与交互。

1. pdf.js 2.2.228:一个“过时”版本反而解决了我最头疼的兼容问题

某企业的业务系统要嵌入PDF预览,一线浏览器环境却停留在老版本内核,升级要走大半年的审批流程。新版pdf.js的构建产物在那些内核上直接白屏,退回2.2.228的dist压缩包后,打开、缩放、打印全部正常。这个版本用ES5构建,不依赖现代浏览器的新特性,天然适合旧环境;同时API与2.x主线差异不大,后续迁移成本也低。适合三类人:给存量系统做预览、要离线部署不想走CDN、被PDF字体和文本层折腾过的前端。如果你也卡在这种环境里,这个版本值得先试一把。

2. 部署与集成:先用viewer跑通,再做API接入

拿到压缩包不要急着写代码,先搞清楚这一份dist包里到底有什么,以及为什么它不需要构建就能直接进生产。

2.1 解压后看目录:build与web不是摆设

解压后是一个完整的构建产物。dist包最大的价值就是省掉构建环节:不需要Node环境,不需要webpack打包,直接把文件丢进静态目录就能用。目录里两个核心部分:

路径作用备注
build/pdf.js库主体,暴露全局pdfjsLib生产文件,已压缩
build/pdf.worker.js后台解析线程必须与pdf.js同版本
web/viewer.html完整的PDF阅读器页面含工具栏、缩略图、文本层
web/viewer.jsviewer页面逻辑按需修改配置时用
web/viewer.css阅读器样式文本层样式也在里面
web/cmaps/字符映射表,中文和生僻字渲染靠它千万别删
web/standard_fonts/标准14字体资源缺失时字形替换会出错

从这张表你能直接得到第一个结论:版本一致性比什么都重要。pdf.js和pdf.worker.js必须来自同一份构建,混用不同版本会导致解析任务静默失败。有的项目图省事,从CDN引了2.2.228的pdf.js,worker却用本地的2.4.x文件,结果页面一直转圈,控制台没有任何报错,最后才发现是版本错位。这是这个资源里最容易踩的第一个坑,而且属于那种“看起来像玄学、查下来是路径”的问题。

2.2 先用viewer.html跑通:最直接的一条验证路径

viewer.html是现成的完整阅读器,不需要写一行代码就能验证这份资源在当前环境是否可用。先起一个本地HTTP服务,注意不要直接双击打开:

# 在解压目录的上一层执行,让web目录成为站点根目录 python -m http.server 8080 # 或者用Node npx serve .

然后访问:

http://localhost:8080/web/viewer.html?file=/sample.pdf#page=3&zoom=page-width

这里有两个前提要说清楚。第一,测试用的PDF文件要放在你能访问到的路径下,我一般会拿自己生成的测试PDF丢到web目录里,避免外链文件过期。第二,URL中file参数指向要预览的PDF路径,page指定初始页码,zoom指定缩放策略,page-width表示适应页面宽度。viewer.html能打开,说明库本身没问题;打不开再看控制台的具体报错,大部分集中在worker加载或跨域。

为什么必须用HTTP服务而不是直接双击?因为pdf.js的worker和fetch都遵循浏览器同源策略,file://协议下会直接报跨域错误,这是初学者最常碰到的“下载下来却打不开”的原因。我用本地服务跑通之后,再把同样路径搬到服务器上,行为基本一致,所以本地验证这一步能过滤掉一大半环境问题。

2.3 API方式接入:在自己的页面里渲染第一页

viewer.html适合直接使用,但多数系统的预览功能要做成内嵌模块,这时候走API。2.2.228在经典脚本方式下,会向全局暴露pdfjsLib对象:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>PDF 预览</title> </head> <body> <canvas id="pdf-canvas"></canvas> <!-- 注意保持版本一致 --> <script src="/web/build/pdf.js"></script> <!-- 指定worker路径,否则会退回“假的worker” --> <script> pdfjsLib.GlobalWorkerOptions.workerSrc = '/web/build/pdf.worker.js'; var url = '/web/sample.pdf'; var loadingTask = pdfjsLib.getDocument({ url: url, cMapUrl: '/web/cmaps/', cMapPacked: true, standardFontDataUrl: '/web/standard_fonts/' }); loadingTask.promise.then(function (pdf) { return pdf.getPage(1); }).then(function (page) { var canvas = document.getElementById('pdf-canvas'); var ctx = canvas.getContext('2d'); var viewport = page.getViewport({ scale: 1.5 }); canvas.width = viewport.width; canvas.height = viewport.height; return page.render({ canvasContext: ctx, viewport: viewport }).promise; }).catch(function (err) { console.error('渲染失败: ', err); }); </script> </body> </html>

这段代码的逻辑线很清楚:先通过GlobalWorkerOptions告诉库去哪个路径加载解析线程,再调用getDocument创建加载任务,拿到pdf文档对象后getPage(1)取第一页,最后把页面渲染到指定canvas上。三处参数需要特别留意:

  • workerSrc必须指向同版本的pdf.worker.js。路径写错时,pdf.js会在主线程上模拟worker,功能还能用但大文件会明显卡顿,控制台会打出一行不以error开头的警告,很容易被忽略。
  • cMapUrl和standardFontDataUrl指向解压包里的cmaps和standard_fonts目录,如果不配,英文没问题,中文字符或某些嵌入字体的字形会变成方块。这个坑后面专门展开。
  • viewport的scale决定渲染分辨率,显示清晰度按这个值来。用1倍、1.5倍还是2倍,取决于你的目标显示区域尺寸和屏幕dpr,不要盲目拉高,因为canvas的像素数直接决定内存占用。

到这里,部署和接入就通了。先viewer后API的顺序,能帮你在排查问题时快速缩小范围:viewer打不开,问题在资源或环境;viewer能打开但你的页面不行,问题在接入代码。这也是一种排查策略,比一上来就写几十行渲染逻辑要节省时间。

3. 参数与配置:把Viewer调成你要的样子

跑通只是开始,真实业务里还要处理初始页码、工具栏权限、字体资源、文本选择这些细节。这一章把参数逐个拆开讲。

3.1 通过URL参数控制Viewer:一个URL解决80%的展示需求

Viewer支持通过location.hash传参,无需改代码就能控制初始状态。比如这样拼URL:

http://localhost:8080/web/viewer.html?file=/docs/a.pdf#page=5&zoom=200

常用参数整理成表:

参数示例值说明
file/docs/a.pdf要打开的PDF路径,支持相对路径和完整URL
page5初始页码,从1开始
zoom150 / page-width / page-fit初始缩放级别
nameddest章节名跳转到PDF中的命名目的地

我常用这个特性做“从列表点进去直接定位到指定页”的跳转:列表页拼URL,viewer页读参数,不需要维护两套状态。这种方式的局限是参数由viewer.js自己解析,升级版本时如果解析逻辑有变,行为可能跟着变,但2.2.x这条线一直稳定。

为什么要用这个方案而不是自己写一套?因为viewer.html内部已经实现了工具栏、缩略图、翻页、打印、下载、搜索全套功能,直接传参复用最省事。自己用API从零实现,至少多写几百行才能达到同等交互程度。

3.2 禁用打印、下载与右键:两种常见做法

业务系统里通常不允许终端用户下载PDF或直接打印,viewer默认的工具栏又自带这两个按钮,需要按场景处理。有两种做法,目的相同,适用情况不同。

第一种走样式,简单直接,适用于只是隐藏外观、不防逆向的场景。在部署页面的样式里覆盖:

/* 覆盖默认工具栏样式 */ #print, #download { display: none !important; }

第二种走配置,在viewer.js初始化前修改选项。2.2.x提供了解析URL参数外的编程式选项入口:

// 在viewer.js之前执行,或用script标签顺序前置 PDFViewerApplicationOptions.set('disablePrint', true); PDFViewerApplicationOptions.set('disableDownload', true);

这里的取舍是:样式方案改动小、升级时不易冲突;配置方案能同时禁用快捷键和菜单内的打印入口,行为更彻底。我会在纯展示场景用配置方案,在只影响观感的场景用样式方案。两种都要求在部署前验证一遍,因为viewer.js的判断逻辑在不同小版本可能有差异,没有“一定生效”的说法。

注意:disablePrint只是禁用UI入口,通过开发者工具手动调用打印API仍然可以触发打印,这在业务上叫“防误操作”,不是防泄漏。真有泄密顾虑得走权限系统和后置水印,不要把前端参数当成安全边界。

3.3 getDocument参数详解:这几个参数决定渲染成败

抛开viewer,直接走API时getDocument的配置参数才是关键。下面是一个典型的完整配置:

var loadingTask = pdfjsLib.getDocument({ url: '/docs/high-res.pdf', // 远程URL或本地路径 // data: arrayBuffer, // 或者传入已经读好的ArrayBuffer,二选一 cMapUrl: '/web/cmaps/', // 字符映射目录 cMapPacked: true, // 2.x的cmaps是压缩格式,必须打开 standardFontDataUrl: '/web/standard_fonts/', disableWorker: false, // 默认使用worker线程 isEvalSupported: true, // 允许内部使用eval优化 disableAutoFetch: false, // 是否禁用自动预取后续数据 rangeChunkSize: 65536 // 流式读取时的分块大小 });

参数逻辑说明:cMapUrl和standardFontDataUrl解决字体与字符映射问题;disableWorker置为true会在主线程跑解析,仅用于调试和无法启用worker的环境;isEvalSupported的默认值是true,pdf.js内部会用eval做部分渲染优化,如果你的部署环境有CSP禁止eval,就要把它设为false,否则渲染会触发安全策略报错;disableAutoFetch在只看第一页的极端场景下可以设为true,减少网络请求,但翻页时会有明显等待。

这些参数一旦配错,不是渲染报错,而是“显示看起来不对”:字符变方块、部分页面空白、页面加载极慢。所以遇到这类问题,先核对这份配置,比去翻业务代码高效得多。我自己踩过的教训是,项目从2.0升到2.2时,cMapPacked这个字段一度没传,结果中文全部变成“口”字,排查了两小时才想起来是配置掉了。

3.4 渲染细节:用textLayer做可选中文本

canvas渲染出来的只是位图,用户无法选中文本,搜索结果也无法高亮。要文本可选择,必须叠加textLayer。2.2.x的textLayer依赖viewer.css里的样式,需要在页面里引入:

<link rel="stylesheet" href="/web/viewer.css">
// 页面渲染完成后,创建文本层容器并挂载 var textLayerDiv = document.createElement('div'); textLayerDiv.className = 'textLayer'; textLayerDiv.style.left = canvas.offsetLeft + 'px'; textLayerDiv.style.top = canvas.offsetTop + 'px'; textLayerDiv.style.width = canvas.style.width; textLayerDiv.style.height = canvas.style.height; document.body.appendChild(textLayerDiv); // 从页面对象提取文本内容,绑定到文本层视图 page.getTextContent().then(function (textContent) { pdfjsLib.renderTextLayer({ textContent: textContent, container: textLayerDiv, viewport: viewport, textDivs: [] }); });

这里有个细节:textLayer的viewport必须与canvas渲染用的viewport完全一致,scale、rotation、transform都要对得上,否则选中的文本位置和视觉文字错位,看起来像“文字漂在空白处”。我一般是把viewport对象同时传给render和renderTextLayer,保证两者共享同一个实例,而不是各自计算。

文本层的性能也很敏感:页面文本节点多时,textLayer的DOM数量会很大。老内核浏览器上滚动会变卡,这是pdf.js已知的消耗点,2.2.228也不例外。如果只是做只读预览,给textLayer容器加一个CSS属性pointer-events: none,能显著降低交互开销,代价是不能再选中文本,取舍看业务。

4. 避坑指南:Worker、跨域、字体与老内核的五个典型翻车现场

这一章全是血泪。五个问题按出现频率排序,每一条都按“现象→原因→解决”写,可以直接当排查手册用。

4.1 Worker报错与“假worker”:workerSrc路径不正确

现象:控制台打印“Setting up fake worker”,页面能出但帧率低;大文件直接阻塞主线程,缩放时页面卡死。

原因:pdf.js找不到指定路径下的worker文件,回退到主线程模拟worker。workerSrc路径写错、文件版本缺失、或者用打包工具时worker文件没被拷贝到目标目录,都会触发。

解决:严格检查workerSrc是否是能直接访问的静态文件路径;不要在路径前加动态拼接的变量;确保同版本worker文件存在。生产环境用相对路径或经过构建系统统一注入的绝对路径。建议在加载逻辑前加一行显式输出workerSrc,避免在部署目录结构变化后排查半天。

4.2 中文出现方块或空白:cmaps和standard_fonts缺失

现象:中文、日文、韩文或某些嵌入式字体渲染成方块,但英文完全正常;切换字体模式后偶发空白。

原因:cMapUrl未配置、路径404,或standardFontDataUrl指向空目录。英文走标准字体,不依赖cmaps,所以表现正常;而中文的CID映射需要cmap表参与,缺失时字形替换逻辑找不到对应字符。

解决:把getDocument的参数指向web/cmaps和web/standard_fonts两个目录,并保持cMapPacked: true。同时注意服务器对.cmap文件返回的Content-Type不能为空,某些CDN会把未知后缀静态资源拦截,建议在nginx配置里对cmaps目录单独设置default_type application/octet-stream。部署完后,专门找一个包含中文生僻字和嵌入字体的PDF做回归。

4.3 file://打开崩溃:跨域不是后端问题

现象:双击打开viewer.html,页面一片空白;F12看到SecurityError或NotAllowedError。

原因:file://协议下页面和PDF都视为同源限制之外的资源,fetch逻辑被浏览器直接阻断,worker线程也无法正常实例化。

解决:在本地起一个HTTP服务,不要直接双击。部署到生产环境时保证viewer页面与PDF文件在同一域名下,或后端正确配置CORS头。CORS是配置项,不是改前端代码能解决的。这个问题在新手阶段出现频率极高,但我见过不少线上项目也这样发布——运维直接把压缩包解压拖到内网某个共享目录,然后用file://打开,最后来找前端“库不兼容”,其实是访问方式错了。

4.4 渲染一半白屏或切换页面错乱:renderTask不取消

现象:快速翻页时,前一页内容一闪而过,或渲染任务完成后新页面被旧页面覆盖;偶尔连续翻页后整个canvas区域空白。

原因:多次page.render返回的renderTask只有一个被await,旧的渲染任务没有调用cancel,之前的老页面在返回时抢先绘制,覆盖了最新页面。

解决:维护一个全局renderTask引用,每次新渲染前先cancel旧的:

if (currentRenderTask) { currentRenderTask.cancel(); // 取消后旧任务不会再绘制 } currentRenderTask = page.render({ canvasContext: ctx, viewport: viewport });

注意:cancel之后renderTask.promise会reject,别把取消当成错误上报到监控系统,要判断异常的name是不是RenderingCancelledException。这个异常名在2.2.x里是稳定的,不要把它当成bug处理。

4.5 打包后worker路径被hash破坏:静态资源被误处理

现象:webpack/vite构建产物部署后,控制台出现worker加载404;或构建时提示worker动态导入失败,打开PDF一直转圈。

原因:pdf.js的workerSrc如果被当成模块打包,构建工具可能把pdf.worker.js重新转写或改名,运行期拿不到原始文件;而带hash的静态资源文件名只在构建期写入manifest,运行期拼接出来的路径对不上。

解决:构建时把web/build/pdf.worker.js作为静态资源原样拷贝到输出目录,workerSrc指向一个不带hash的固定路径;或者用构建工具提供的worker加载方式,保持worker文件内容不被转写。关键是:worker是这个库的“黑匣子”,不要试图把它打进业务bundle,也不要让它经过转译。

5. 二次开发:从功能到组件,封装一个可复用的PDF预览模块

viewer能用,API能调,接下来是把它变成自己业务里可控的一部分。这一章讲职责边界和封装套路。

5.1 先用viewer还是API:一个判断标准

判断标准很简单:如果交互需求集中在“打开、翻页、缩放、打印、下载”,viewer.html直接用,改URL参数和样式就能满足;如果要嵌入到业务页面内、布局由我方控制、需要自定义工具栏和事件,走API封装。两者可以同时存在:viewer给人工预览用,API给嵌入模块用。

不要重复造轮子是原则,但也不要为了少写代码把整个viewer塞进一个高度定制化的弹窗里——viewer自带大量DOM结构和样式,后面想改弹窗布局时会非常痛苦,没有后悔药。我见过一个项目把viewer.html塞进iframe再裁切样式,改了三版之后放弃了,最后还是用API重写了一个轻量预览。

5.2 封装一个极简预览器类:ES5语法以兼容老内核

封装一个类,把加载、渲染、销毁收敛在一起。刻意用ES5语法,是为了和2.2.228面向的老内核环境保持一致,配到旧浏览器时不至于因为语法兼容再踩一层坑。

function PdfPreview(canvas, options) { options = options || {}; this.canvas = canvas; this.options = options; this.pdf = null; this.currentPage = 1; this.renderTask = null; } PdfPreview.prototype.load = function (url) { var self = this; var task = pdfjsLib.getDocument({ url: url, cMapUrl: '/web/cmaps/', cMapPacked: true, standardFontDataUrl: '/web/standard_fonts/' }); return task.promise.then(function (pdf) { self.pdf = pdf; return self.renderPage(self.currentPage); }); }; PdfPreview.prototype.renderPage = function (num) { var self = this; if (this.renderTask) { this.renderTask.cancel(); } return this.pdf.getPage(num).then(function (page) { var viewport = page.getViewport({ scale: self.options.scale || 1.5 }); var canvas = self.canvas; var ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; self.renderTask = page.render({ canvasContext: ctx, viewport: viewport }); return self.renderTask.promise; }); }; PdfPreview.prototype.destroy = function () { if (this.renderTask) { this.renderTask.cancel(); } if (this.pdf) { this.pdf.destroy(); // 释放解析出来的页面数据 } this.pdf = null; };

封装思路:load负责创建文档,renderPage负责渲染,destroy负责销毁。类把所有异步状态收敛进内部,业务代码只需要三步:new、load、destroy。参数解释:scale存进options里方便调用方独立控制;renderTask在renderPage开头cancel,保证快速翻页时旧渲染不会覆盖;destroy里pdf.destroy释放内存,这一步在单页应用里特别重要,不调用会造成页面切换后内存只增不减。

在组件化框架(比如React或Vue这类)里使用时,只需要在组件的挂载钩子里new一个实例,卸载钩子里调用destroy,就能做到“进来渲染、离开清理”,不再需要关心pdf.js内部的Promise链。

5.3 销毁与释放:页面不卡顿的关键

从性能角度说明为什么cancel和destroy缺一不可。老内核环境下渲染一个大的PDF页面,canvas本身可能占到几十MB内存,页面对象在解析后也会保留图片数据和字体数据。正确的释放顺序是:先cancel未完成渲染,再destroy文档对象。

如果用了textLayer,还要手动移除DOM节点,并解除viewport引用,否则这些DOM节点即使不可见也占用样式计算成本。我见过一个后台系统,切到下一个列表页时预览组件只是隐藏而没有销毁,结果连续打开十几个PDF后浏览器直接崩溃。这个组件的destroy方法只有三行,但少了它就等于内存泄漏。

6. 上线前的验证:从能打开到稳定打开的七个检查点

能让PDF显示出来,只是第一步。真正上线前的验证,我习惯按一套固定清单走,大概两三分钟能做完:

  • 清空缓存后首屏打开时间是否在可接受范围
  • 快速翻页20次,观察是否有白屏或渲染错页
  • 一个50MB以上的大文件:滚动缩放是否卡顿
  • 一个包含中文生僻字和嵌入字体的文件:字形是否完整
  • 禁用开发者工具的网络面板后打开:是否依赖了未加载的资源
  • 连续开关同一个PDF三次:内存曲线是否回落
  • 打印和下载按钮:是否按业务要求隐藏

前六项面向功能,第七项面向合规。这里面最容易翻车的是“中文生僻字”和“内存持续增长”。

验证时我有一个用惯的技巧:用浏览器自带的任务管理器看内存。在Chrome的任务管理器里添加JavaScript内存列,连续开关同一个PDF三次,如果内存曲线不回落到初始值附近,说明有对象没释放,优先检查renderTask有没有cancel,再检查textLayer的DOM是否被移除。这个技巧看似原始,但比任何profiler都直观,尤其是在老内核环境下,profiler本身都可能打不开。

这份2.2.228的dist包我已经放到下载区,解压后按第二章的步骤即可复现;所有参数和踩坑记录都基于这个版本,直接照做即可。

以前我接手过一版预览功能,看起来能打开,上线后用户反馈“翻几页就白屏”。当时排查了三天,最后发现是两个问题叠加:渲染任务没取消,加上worker路径在打包后被改成了带hash的路径,页面在第三个大文件时必定白屏。从那以后,我每次部署前都强制走一遍这套清单,尤其是worker完整性检查和内存曲线验证。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询