1. 为什么要在Hexo博客里折腾PDF.js
先交代一下背景。我用Hexo搭个人博客有些年头了,主题换了几套,文章越写越多,直到某天需要上传几份PDF格式的文档——一份是项目技术方案,一份是产品手册,还有几篇带排版的论文扫描件。起初图省事,直接扔了个链接到文章里,让读者点进去用浏览器自带的PDF查看器打开。结果被朋友吐槽了好几次:有人用的是老版本浏览器,点击直接触发下载而不是预览;有人在手机上打开,页面缩放和翻页手感一言难尽;还有人想要边看文档边对照博客里的说明,却不得不在两个标签页之间来回切换。
这就是我决定在Hexo博客里集成PDF.js的起点。
PDF.js是Mozilla开源的一个纯前端PDF渲染方案,简单说就是让浏览器不依赖内置插件、只用JavaScript和Canvas就能解析并绘制PDF内容。它解决的核心问题有三个:一是跨浏览器兼容性,不管用户用的是Chrome、Firefox、Safari还是Edge,渲染效果保持一致;二是可控性,我可以自定义工具栏、翻页逻辑、缩放按钮,甚至可以拿到当前页码、总页数这些数据做二次开发;三是摆脱“下载再打开”的割裂体验,让PDF像一张网页那样直接在文章里展示。
这篇文章会把整个落地过程完整拆开,从部署方式选型、Hexo集成细节,到实际使用中必然会踩的坑——包括那个把不少人坑哭的“v2.16.105 (build: 172ccdbe5) 信息: failed to fetch”报错——再到如何给PDF阅读器加上“记住上次读到第几页”的功能。适合想在博客里优雅展示PDF的Hexo用户,也适合任何想在纯静态站点里嵌入PDF阅读能力的开发者。
2. PDF.js接入方案选型:CDN引入和本地托管怎么选
2.1 两种方式的对比
PDF.js官方提供了两种使用方式:直接通过CDN引入现成的构建产物,或者下载源码在自己服务器上托管。两种方式我都实际跑通过,说下差别。
CDN方式最大的优势是省事,几行标签就搞定,不用管那些viewer.js、pdf.worker.js文件从哪来。但缺点是稳定性不可控——CDN挂了或者被墙了,整个阅读器就白屏;另外CDN上的跨域配置、版本更新时机你都没法掌控。本地托管的好处是文件都在自己的服务器或代码仓库里,加载速度快慢自己心里有数,也不用担心外部依赖突然失效。对Hexo这种生成静态页面的博客来说,本地托管其实更契合“所有资源自包含”的部署哲学。
从实际维护角度来看,我更推荐本地托管。原因很简单:Hexo博客最终通常部署到GitHub Pages、Gitee Pages或者自己的VPS上,PDF.js的构建产物本来就只是一堆静态文件,完全可以直接塞进Hexo的source目录里,随博客一起打包发布。
2.2 版本选择的讲究
PDF.js的版本演进非常快,目前稳定版已经到3.x甚至更高。但我见到不少教程还在用2.x,尤其是2.16.105这个版本,在搜索热词里也出现了——这版本有个典型的报错“failed to fetch”,后面我会详细说排查思路。
版本选择上有两个原则值得记住。第一,在自己真正测试通过之前,不要盲目追最新版,新版本可能改了API或者依赖接口。第二,一旦选定版本并完成集成,除非有安全漏洞或功能需求,否则不要轻易升级——PDF.js的viewer.js初始化参数在不同版本间有小幅变化,升级可能连带改动模板代码。
我自己在用的组合是:PDF.js 2.16.105版本,配合Hexo 5.x、Next主题。这个组合验证下来兼容性没问题。
2.3 PDF.js的目录结构怎么看
不管用官方发布包还是从GitHub Release下载,解压后核心看这几个文件:
pdf.js:主库文件,提供渲染核心APIpdf.worker.js:在后台线程执行PDF解析任务,主线程和它通过postMessage通信viewer.js/viewer.html:官方自带的完整阅读器界面,开箱即用web/目录:包含了阅读器相关的所有资源
如果用官方viewer,直接把整个web目录拷过去就行。如果只想在自己页面里嵌入核心渲染功能,那只需要pdf.js和pdf.worker.js两个文件即可。我建议第一阶段先使用官方viewer,跑通了再考虑定制。
3. Hexo博客集成PDF.js实操全流程
3.1 准备静态资源目录
Hexo的source目录是博客静态资源的根目录,所有放在里面的文件或文件夹,在hexo generate之后都会被原样复制到public目录。这是集成PDF.js的基础前提。
我的做法是在source下新建一个lib目录,专门放第三方库:
cd your-hexo-blog mkdir -p source/lib/pdfjs然后把下载好的PDF.js构建产物解压,把web目录和pdf.js、pdf.worker.js都放到source/lib/pdfjs下。最终目录结构长这样:
source/lib/pdfjs/ ├── pdf.js ├── pdf.worker.js └── web/ ├── viewer.html ├── viewer.js ├── viewer.css └── ...还有一个容易被忽略的点:pdf.worker.js的路径问题。PDF.js主库在运行时需要加载worker文件,默认情况下它会从和pdf.js相同的目录去解析。如果你把pdf.js放在/lib/pdfjs/下,那worker文件的路径一般没问题。但如果你用Webpack等构建工具打包过,就必须显式指定worker路径,否则就会出现“Setting up fake worker”的降级提示,性能明显下降。
3.2 在文章中嵌入PDF阅读器
有几条路可以走,我逐一说明。
方式一:iframe加载官方viewer
这是最简单粗暴的方式,直接用一个iframe把官方viewer.html嵌入到博客文章中:
<iframe src="/lib/pdfjs/web/viewer.html?file=/pdfs/technical-doc.pdf" width="100%" height="600px"></iframe>这里的file参数是你要展示的PDF文件路径。官方viewer会读取这个参数,然后加载并渲染PDF。注意,file参数需要经过URL编码,尤其是当文件路径中包含中文名或特殊字符时。
方式二:用HTML标签引入PDF.js核心
这种方式更加灵活,可以完全控制阅读器外观。
<div id="pdf-container"></div> <script src="/lib/pdfjs/pdf.js"></script> <script> pdfjsLib.GlobalWorkerOptions.workerSrc = '/lib/pdfjs/pdf.worker.js'; var loadingTask = pdfjsLib.getDocument('/pdfs/technical-doc.pdf'); loadingTask.promise.then(function(pdf) { pdf.getPage(1).then(function(page) { var scale = 1.5; var viewport = page.getViewport({ scale: scale }); var canvas = document.createElement('canvas'); var context = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; document.getElementById('pdf-container').appendChild(canvas); page.render({ canvasContext: context, viewport: viewport }); }); }); </script>这段代码的核心逻辑是:先通过getDocument获取PDF文件对象,然后获取指定页面,最后通过render方法把页面画到canvas上。这是最基础的用法,后续做翻页功能就是在这个基础上迭代。
方式三:Hexo插件
Hexo社区也有一些现成的PDF插件,但老实说,成熟度参差不齐,有的已经几年没更新了。我更建议直接用原生方式,因为PDF.js的集成本来就不复杂,没必要为了“用插件”而用插件。
3.3 在Hexo主题模板中嵌入
上面说的都是把代码直接写在Markdown文章里。但如果你希望博客里每一个页面都能快速插入PDF,更优雅的做法是在主题模板中做一个短代码(shortcode)或者局部布局(partial)。
以Next主题为例,我创建了一个source/_data/body-end.swig文件(不同主题挂载数据文件的方式略有差异),里面放了一段初始化代码,然后在需要展示PDF的文章中直接使用HTML块。这样做的优势是:PDF.js的初始化逻辑只写一遍,后续文章只需要改file参数。
具体的模板嵌入逻辑,我会在下一节和阅读进度记录功能一起展示,因为这两件事实际上是放在同一段代码里的。
3.4 PDF文件本身放哪儿
PDF文件我建议单独建一个目录管理,不要在文章目录里和图片混在一起。我用的是source/pdfs/目录,所有需要展示的PDF都放在这里。
这里有一个必须注意的点:中文文件名和空格。如果你把文件命名为“产品需求文档 final.pdf”,URL里会出现编码问题,有些浏览器能自动处理,有些则不行。保险的做法是统一改成小写英文加连字符,比如product-requirement-final.pdf。这种改名虽然麻烦,但能避免后续一系列奇怪的表现。
4. 先搞清楚“v2.16.105 failed to fetch”是什么情况
4.1 报错出现的场景
搜索热词里有“pdf.js v2.16.105 (build: 172ccdbe5) 信息: failed to fetch”,这说明很多人遇到了同样的问题。我在集成过程中也碰到了,所以单独拿出来讲。
这个报错的典型场景是:本地用hexo server预览时一切正常,部署到GitHub Pages之后,打开博客,阅读器白屏,控制台输出:
PDF.js v2.16.105 (build: 172ccdbe5) 信息: failed to fetch翻译成人话就是:PDF.js尝试从file参数指定的路径去获取PDF文件,但请求失败了,没有拿到预期的数据。注意,这个报错本身并没有说明“为什么”失败,只告诉你“获取失败了”。所以排查的关键在于搞清楚HTTP请求到底经历了什么。
4.2 最常见的三个原因
原因一:文件路径错误
这是最普遍的问题。PDF.js获取PDF文件走的是标准的XMLHttpRequest或fetch请求,如果路径拼错了,服务器会返回404,最终就表现为“failed to fetch”。我在集成时犯过一个低级错误:把文件放在了source/pdfs/下,部署后的路径确实是/pdfs/xxx.pdf,但在本地预览时,Hexo开发服务器的根路径可能和你配置的url不一致。
检查路径的方法很简单:打开浏览器开发者工具的网络面板,看那个PDF文件请求的URL,直接把它粘贴到浏览器地址栏,看能不能正常返回文件。
原因二:跨域(CORS)限制
如果你把PDF文件放在另一个域名或端口下,而阅读器页面在另一个域名下,就会触发跨域问题。浏览器默认阻止跨域读取文件,PDF.js同样会受影响。GitHub Pages场景下,如果你的博客部署在username.github.io,PDF也放在同一个仓库里,一般是不会有跨域问题的。但如果你用的是自定义域名加CDN,或者把PDF放在了OSS上,就要检查响应头里有没有Access-Control-Allow-Origin。
原因三:部署平台的文件名大小写规则
有些静态托管平台的文件系统是区分大小写的,你的本地文件叫Technical-Doc.pdf,但在Markdown或配置里写的是/pdfs/technical-doc.pdf,在部署平台上就会404。这个问题在本地排查不出来,因为macOS和Windows默认不区分大小写,一部署到Linux服务器就原形毕露。
4.3 排查思路与实战步骤
遇到failed to fetch,我建议按照以下顺序排查:
- 打开开发者工具(F12),切到Network面板,刷新页面,找到那个PDF文件对应的请求
- 看请求状态码:404说明路径错或文件不存在;403说明权限问题;200但被CORS拦截,控制台会额外显示CORS错误
- 把请求的完整URL复制到新标签页访问。如果返回的是PDF内容,说明路径没问题,问题出在跨域或请求头
- 检查代码里
file参数的写法。如果使用的是相对路径,比如../pdfs/xxx.pdf,在Hexo的URL结构下很容易出问题。我强烈建议统一使用绝对路径,以/开头
另外一个容易忽略的点:如果你在iframe里用官方viewer,并且配置了file参数,而这个参数值本身经过了一层编码,那么编码后的字符串里可能包含%2F之类的内容,服务器端如果做了解码再重定向,可能会改变路径。这种情况下,直接用Base64编码file参数会更稳,格式如下:
iframe src="/lib/pdfjs/web/viewer.html?file=base64编码后的路径"PDF.js支持这种写法,可以避开很多特殊字符带来的坑。
5. 翻页记忆功能:从最简单方案到数据库方案
5.1 为什么需要阅读进度记录
搜索热词里有“pdf.js如何把阅读到哪一页记录到数据库里”,说明大家已经不满足于“能打开PDF”,还希望阅读体验更进一步。说实话,对于偏文档型的博客文章,阅读进度记录不是刚需,但对于长篇电子书、使用手册、论文合集这类内容,这个功能价值巨大——读者关掉页面,下次回来还能接着看,体验上会专业很多。
不过要先想清楚一个问题:你的博客是纯静态站点吗?如果是,那么“数据库”这个词就需要重新解读——纯静态托管平台(如GitHub Pages)没有后端,你没法直接操作数据库。所以实际的方案分两档:
- 简单档:把页码记录在浏览器的localStorage里,下次访问同一篇文章时自动读取,跳转对应页
- 进阶档:通过第三方后端服务(如Firebase、Supabase、LeanCloud等)存储阅读记录,实现跨设备同步
5.2 基于localStorage的实现思路
先说简单档。localStorage是浏览器自带的本地存储机制,键值对形式,不涉及服务器,刷新页面后数据还在。在PDF阅读场景下,只需要在翻页时保存当前页码,在加载时读取并跳转即可。
实际操作中,我是在官方viewer的基础上写了一段注入逻辑。核心思路是:
- 监听PDF.js的页码变化事件
- 把“当前PDF文件名 + 页码”存入localStorage
- 加载PDF时,检查localStorage里有没有这个文件的阅读记录,有则直接跳转
这个方案的实现成本很低,但对用户体验的提升非常直接。唯一需要注意的是localStorage的限制:每个域名大约有5MB的空间,存页码绰绰有余,不用担心撑爆。
5.3 数据库方案的可行路径
如果你坚持要存到数据库里,那意味着你需要有一个后端服务。纯静态站点也可以接第三方BaaS(后端即服务)平台。
以LeanCloud为例,它的免费版足够个人博客使用,接入方式是通过JavaScript SDK直接在前端调用云数据库API。每一次翻页就向云端发一个更新请求,把文件路径、用户标识、当前页码写入记录。页面加载时再查一次云端数据,拿到记录后跳转。
这个思路的优点是跨设备同步——你在电脑上看了一半,手机打开可以接着看。缺点是逻辑复杂度上升,还涉及用户身份识别(未登录用户怎么标识?用匿名ID还是IP?),以及请求频率控制(每翻一页发一次请求显然不现实,需要做节流)。
我的建议是:个人博客场景先用localStorage方案跑起来,等确实有跨设备需求再升级数据库方案。不要一上来就上重型方案,徒增维护成本。
5.4 阅读进度功能的完整实现代码
下面展示我给Hexo博客定制的PDF阅读页面代码。这段代码放在主题的body-end部分,或者自定义页面模板中均可。
先准备一个HTML结构:
<div id="pdf-reader-wrap"> <iframe id="pdf-viewer" src="/lib/pdfjs/web/viewer.html" width="100%" height="720"></iframe> </div>注意,iframe的src一开始不带上file参数,我选择在iframe加载完成后再设置,这样方便统一处理进度恢复逻辑。接下来是注入脚本:
<script> (function() { // 配置区 var CONFIG = { pdfPath: '/pdfs/technical-doc.pdf', // 你要展示的PDF路径 storageKeyPrefix: 'hexo_pdf_progress_' // localStorage键名前缀 }; var iframe = document.getElementById('pdf-viewer'); var storageKey = CONFIG.storageKeyPrefix + CONFIG.pdfPath; // 从localStorage读取历史进度 var savedPage = parseInt(localStorage.getItem(storageKey), 10) || 1; // 拼接file参数,注意encodeURIComponent处理特殊字符 var viewerUrl = '/lib/pdfjs/web/viewer.html?file=' + encodeURIComponent(CONFIG.pdfPath) + '#page=' + savedPage; iframe.src = viewerUrl; // 监听iframe内页面的页码变化事件 window.addEventListener('message', function(event) { // 对消息来源做校验,避免收到无关消息 if (event.source !== iframe.contentWindow) return; var data = event.data; if (data && data.source === 'pdf.js' && typeof data.pageNumber === 'number') { localStorage.setItem(storageKey, data.pageNumber.toString()); } }); // 兜底逻辑:如果iframe内PDF加载完成后第一次截图获取当前页码 iframe.addEventListener('load', function() { // 某些版本PDF.js不会主动postMessage页码,这里可以额外获取 try { var iframeDoc = iframe.contentDocument || iframe.contentWindow.document; // 通过viewer的API获取当前页 var currentPage = iframe.contentWindow.PDFViewerApplication.page; if (currentPage) { localStorage.setItem(storageKey, currentPage.toString()); } } catch (e) { // 跨域限制下访问iframe内容会报错,忽略即可 } }); })(); </script>这里有几个细节值得解释:
第一,#page=参数是PDF.js官方viewer支持的,它会在文档加载完成后自动跳转到指定页。这是实现“恢复进度”最省事的方法,不需要自己写渲染逻辑。
第二,页码变化监听用到了postMessage跨窗口通信。PDF.js官方viewer在页码切换时会向父窗口发送消息,但这个消息的格式在不同版本里略有差异。如果你用的版本不主动发消息,就需要在iframe内部再注入一段脚本,在里面监听页码变化后手动postMessage给父窗口。这也是为什么有些场景下直接写iframe内嵌脚本比纯外部监听更可靠。
第三,保存时机的选择。每翻一页都写一次localStorage成本很低,不用担心性能。但如果用数据库方案,必须做节流——至少间隔5秒或累计翻页超过3页才提交一次,否则请求量会很恐怖。
5.5 在Hexo中管理多篇PDF的阅读记录
如果博客里有多篇PDF文档,阅读记录需要按文件区分。我的做法是直接用PDF文件名作为存储key的一部分,这样互不干扰。比如:
var key = 'hexo_pdf_progress_' + encodeURIComponent(pdfPath);每个PDF的进度独立存储,读者在不同文档间切换时互不影响。这个设计在初期就做好,避免后期文档多了再迁移数据。
关于localStorage的健壮性,再补充一句:localStorage在隐私模式或某些浏览器设置下可能不可用。代码里在使用localStorage.getItem前最好做一下能力检测或者try-catch包裹,否则某些环境下会抛异常导致页面脚本中断。
6. 集成过程中常见的坑与排查经验
6.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| PDF.js报“failed to fetch” | 文件路径404、跨域、大小写不匹配 | 检查网络请求URL,用绝对路径,统一文件命名 |
| iframe空白没任何反应 | iframe被主题样式遮挡,高度为0 | 检查父容器高度和iframe样式 |
| 第一次加载正常,刷新后空白 | viewer缓存了旧的file参数 | 给iframe的src加时间戳参数绕过缓存 |
| 手机端显示错乱 | viewer的响应式样式未生效 | 确保web目录下的css完全加载,检查主题是否有全局样式冲突 |
| worker文件加载失败,控制台有警告 | pdf.worker.js路径配置错误 | 显式设置GlobalWorkerOptions.workerSrc |
| 中文PDF文件名乱码 | URL编码处理不当 | 使用encodeURIComponent处理,建议统一英文文件名 |
| 页面滚动时PDF区域卡顿 | canvas渲染尺寸过大 | 调整scale参数,或者开启PDF.js的renderingQueue |
6.2 关于Hexo部署到GitHub的补充
搜索热词里还有“hexo部署到github”,既然说到项目部署,就顺带提一嘴。集成PDF.js后,推送部署时一定要确认source/lib目录内容被打包进去了。Hexo默认会把source下所有文件复制到public,但如果你用了某些清理插件或主题的“排除目录”配置,就有可能把lib或pdfs目录排除了。
部署完成后,务必用浏览器的无痕模式访问一次博客,手动输入PDF阅读器的URL,确认资源能正常访问。很多人在本地跑得好好的,一部署就各种问题,十有八九是资源文件压根没上传成功。
另外,如果你是部署在GitHub Pages的仓库里,而项目仓库是username.github.io这种形式,那静态资源的根路径默认就是/,前面代码里的绝对路径写法是对的。如果你部署在子路径下(比如/blog/),那就必须修改Hexo的root配置,同时所有静态资源路径都要加上这个前缀。我见过不少人在这上面栽跟头,因为PDF.js内部加载的viewer.css、viewer.js也是绝对路径,根路径不对的话,阅读器整个就是白屏。
6.3 和Jekyll对比:为什么我留在Hexo
搜索热词里还有“jekyll和hexo哪个好”,这句话说明很多人还在选型阶段。我的观点是:如果你主要用Markdown写技术博客,中文社区资料多、主题丰富,Hexo的上手曲线更平滑;如果你更看重与GitHub原生的集成度,以及Ruby生态,那Jekyll也不错。但单就集成PDF.js这件事来说,两个平台毫无差别——因为PDF.js是纯前端方案,跟生成器类型无关。
不过Hexo有一个优势是hexo generate的产物结构非常干净,所有静态资源路径可控性强,方便做我今天说的这类定制集成。Jekyll也完全可以做,只是你需要在_includes和_layouts里自己组织类似逻辑,思路互通。
7. 后续还能怎么玩:PDF.js能力的进一步挖掘
集成完成、进度记录上线之后,PDF.js的能力其实还有很多可扩展空间。
第一个方向是界面定制。官方viewer的样式是通用的,如果你希望它和博客主题更统一,可以覆盖viewer.css里的颜色、字体、按钮样式。比如把顶部工具栏改成你博客品牌色,或者把默认的下载按钮隐藏掉。这不需要改JS,只改CSS就行。
第二个方向是搜索和目录。PDF.js支持文本层提取,所以可以实现在线搜索关键词;同时还可以从PDF元数据中提取目录(如果有书签的话),在侧边栏生成一个可点击的目录树。这个功能对于展示长篇文档特别有用,读者可以直接跳到感兴趣的章节。
第三个方向是统计和分析。既然已经能拿到页码数据,那也可以把读者读了哪几页、停留多久上报到自己的统计系统。这能帮你了解哪些文档内容最受欢迎、读者通常在哪一页流失,对于优化文档结构很有参考价值。不过做用户行为采集要谨慎,涉及隐私合规,需要评估后再决定。
对我个人来说,最实际的需求是把多份技术方案的PDF和对应的博客解读文章关联起来——读者先看解读,再打开原始PDF对照,阅读进度还能自动续上。目前这套方案已经在我博客上稳定跑了几个月,没有再被朋友吐槽过PDF打不开的问题。如果你也在Hexo上遇到过PDF展示的尴尬,不妨照这篇文章的思路试一下,尤其是那几条关于failed to fetch的排查路径,能帮你少走不少弯路。