
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 width100% height600px/iframe这里的file参数是你要展示的PDF文件路径。官方viewer会读取这个参数然后加载并渲染PDF。注意file参数需要经过URL编码尤其是当文件路径中包含中文名或特殊字符时。方式二用HTML标签引入PDF.js核心这种方式更加灵活可以完全控制阅读器外观。div idpdf-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.ioPDF也放在同一个仓库里一般是不会有跨域问题的。但如果你用的是自定义域名加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?filebase64编码后的路径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 idpdf-reader-wrap iframe idpdf-viewer src/lib/pdfjs/web/viewer.html width100% height720/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的renderingQueue6.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的排查路径能帮你少走不少弯路。