ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Hexo博客集成PDF.js:从failed to fetch到完美在线阅读

2026/10/5 9:57:01 拓冰建站 浏览量
Hexo博客集成PDF.js:从failed to fetch到完美在线阅读 博客里放PDF这件事我拖了很久才动手。原因很简单以前分享文档都是丢个下载链接结果读者在手机点开要么直接跳转App要么下载完找不到文件体验非常割裂。直到我决定把几篇论文原文和说明书做成网页内直接翻页的在线阅读才认真研究起了Mozilla的PDF.js方案。在Hexo里集成倒是容易真正折腾人的是那个全网都在问的报错pdf.js v2.16.105 (build: 172ccdbe5) 信息:failed to fetch我前后排查了两天走了不少弯路。这篇把整套集成流程、报错根源和最终落地的代码都整理出来给同样在Hexo上折腾PDF阅读的朋友一份能直接照着做的方案。1. 内容整体设计与思路拆解1.1 博客里塞PDF的典型场景做技术博客久了一定会遇到这些需求想把一篇领域经典论文的PDF直接放进文章里读者在页面内就能翻阅而不是下载后用本地阅读器打开想把自己的产品手册、简历模板、项目规范文档做成可在线阅读的版本或者想在教程里直接嵌入一份参考资料的扫描件配上自己的批注。这三种场景的共同诉求是PDF不能只是一个附件链接它要成为页面内容的一部分。也就是用户点进文章滚动到某个位置直接就能看到PDF并且翻页、缩放、甚至搜索文字。实现这个能力有很多路子但我最终把方案锁定在了PDF.js上。这是Mozilla出品的一个纯前端PDF渲染引擎Apache 2.0协议支持桌面和移动端核心API完整覆盖加载、渲染、翻页、缩放、文本选中这些阅读器功能。和直接用浏览器自带的PDF Viewer相比PDF.js最大的优势是你完全掌控渲染细节能做进度记录、自定义工具栏还能把PDF嵌进文章中间而不影响整体页面布局。1.2 为什么不用浏览器原生PDF插件很多人第一反应是用iframe直接内嵌浏览器自带的PDF查看器代码三行就搞定iframe src/files/manual.pdf width100% height600px/iframe这个方案在桌面Chrome上能用但问题很明显。第一每个浏览器内置PDF插件的UI完全不一样Chrome的预览页有一堆按钮样式和你的博客风格格格不入第二移动端体验几乎是灾难手机上iframe里的PDF渲染性能差双指缩放经常卡顿第三跨域场景下iframe加载PDF会被览器拦截你连页面都看不到第四也是最关键的浏览器原生Viewer没有暴露任何JS接口你没法知道用户看到第几页也没法做进度保存。这些限制对普通个人使用可能无所谓但对一个想认真做好阅读体验的博客来说都是不可接受的。1.3 PDF.js切入Hexo的天然优势Hexo是一个静态站点生成器你写的Markdown经过渲染变成纯静态HTML、CSS和JS。PDF.js同样是纯前端库不需要服务端配合不需要数据库把它当作静态资源放进Hexo的source目录即可。两者一拍即合没有任何架构冲突。而且PDF.js的加载流程本身就是为网页内嵌设计的先通过getDocument拉取PDF文件拿到一个PDFDocumentProxy对象再用getPage获取某一页最后用render把页面画到Canvas上。整个过程全在浏览器里完成请求一次PDF文件之后翻页都是本地渲染速度极快。加上Web Worker的加持渲染过程不阻塞UI线程翻页时页面不会卡住体验接近原生阅读器。2. 方案选型与核心细节解析2.1 三种集成姿势对比在Hexo里用PDF.js我调研下来主要有三种搞法各有取舍。第一种是直接引CDN的链接。在页面上加载BootCDN或unpkg上的pdf.min.js和pdf.worker.min.js然后调用API。这种方式代码量最小但缺点也明显CDN不可用或者被屏蔽时整个功能就废了而且CDN服务的文件版本你控制不了更新后可能连带出兼容性问题。对这种阅读核心功能依赖外部服务不太稳妥。第二种是走npm webpack在Hexo主题源码里把PDF.js作为依赖引入。这种方法适合用React或Vue重写的现代主题但对大多数用ejs/swig模板的Hexo主题来说构建链路会变得非常复杂而且每次改PDF相关代码都要重新编译主题调试成本高。第三种是把PDF.js的构建产物直接下载到Hexo的source目录下作为纯静态资源引用。这也是我最终采用的方式。它保留了静态站点的纯粹性不依赖外部CDN加载速度可控版本锁定代码侵入性也最低。你只需要下载两个核心文件pdf.min.js和pdf.worker.min.js就可以开工。2.2 版本选择为什么选v2.x而不是v3.x关于版本这件事我特别想多说几句。你现在去PDF.js官网看v4.x都已经发布了但如果你搜解决方案会发现大量文章、问答仍然基于v2.x特别是v2.16.105这个版本用的人特别多。原因不是大家跟不上新版本而是v3.x之后PDF.js做了几处破坏性改动模块系统从UMD全面转向ES module部分API改成了Promise异步风格连一些配置项都重命名了。网上基于v2.x写的教程拿到v3.x环境里经常报各种莫名其妙的错误。我自己最初就是从v2.16.105开始的因为Hexo社区里绝大部分教程、插件示例都是基于这个版本写的文档好找踩坑的人多解决方案也全。如果你的需求只是博客嵌入PDF、翻页、缩放、进度记录v2.x完全够用而且稳定得让人放心。等哪天你真的要深度定制阅读器UI再迁移到v3.x也不迟。版本升级的收益和付出的迁移成本不成正比。另外提一句v2.x的worker文件和主文件版本号必须完全一致混用会出问题。下载时记得找同一个build的两个文件别一个是v2.16.105的主文件一个是v2.15.346的worker那样加载必然失败。2.3 文件结构与加载机制设计把PDF.js放到source目录后你的Hexo项目里大概是这样的结构source/ ├── _posts/ ├── files/ # 存放PDF文档 │ └── manual.pdf ├── pdfjs/ # PDF.js的构建文件 │ ├── pdf.min.js │ └── pdf.worker.min.js └── js/ └── pdf-viewer.js # 自定义的初始化逻辑这个结构的核心逻辑是静态资源全部跟随Hexo构建部署到服务器后路径明确不存在动态拼接的问题。PDF.js主文件在页面加载时通过script标签引入worker文件则是在初始化时通过GlobalWorkerOptions.workerSrc指定路径由PDF.js在后台启动一个独立的Worker线程来处理渲染任务。这里要解释一下为什么必须有worker文件。PDF.js解析PDF文件内容是非常耗CPU的操作包括解压、解析、排版计算。如果这些工作全部跑在主线程上翻页时用户会明显感觉到页面卡顿滚动都一顿一顿的。Web Worker把渲染任务丢到后台线程主线程只负责接收结果并绘制CanvasUI就不会被阻塞。这也是PDF.js体验优于浏览器iframe内嵌的关键所在。3. 实操过程与核心环节实现3.1 第一步把PDF.js文件放进来先从官方发布渠道拿到v2.16.105版本的构建产物你需要的是pdf.min.js和pdf.worker.min.js两个文件都放到上面说的source/pdfjs/目录下。source目录里的东西Hexo会原样拷贝到生成的public目录里所以部署之后你的站点结构就是public/ ├── index.html ├── files/ │ └── manual.pdf └── pdfjs/ ├── pdf.min.js └── pdf.worker.min.js这一步没有任何魔法就是把静态文件放对位置。但要注意Hexo默认在构建时只拷贝source目录中未被文章占用的文件所以你的PDF文档也应该放在source/files/下而不是public/下手动塞否则下次hexo clean hexo generate之后就没了。3.2 第二步在Hexo里写一个自定义标签插件直接在Markdown里手写一堆HTML和script标签也能用但每次写很啰嗦而且容易出错。更好的做法是利用Hexo的标签插件机制写一个简短的短代码在文章里一行调用。在Hexo项目根目录建一个scripts/文件夹新建pdfviewer.js// scripts/pdfviewer.js use strict; function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); } hexo.extend.tag.register(pdf, function(args) { const url args[0]; const title args[1] || PDF文档; return [ div classpdf-wrap idpdf- url.replace(/[^\w\-]/g, _) , div classpdf-bar, button classpdf-btn>// source/js/pdf-viewer.js (function() { var pdfjsLib window[pdfjs-dist/build/pdf]; pdfjsLib.GlobalWorkerOptions.workerSrc /pdfjs/pdf.worker.min.js; var currentPage 1; var pdfDoc null; var rendering false; function renderPage(pageNum) { if (!pdfDoc || rendering) return; var wrap document.querySelector(.pdf-canvas-wrap); var canvas wrap.querySelector(canvas); var ctx canvas.getContext(2d); rendering true; pdfDoc.getPage(pageNum).then(function(page) { var viewport page.getViewport({ scale: 1.5 }); var dpr window.devicePixelRatio || 1; canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width viewport.width px; canvas.style.height viewport.height px; var renderContext { canvasContext: ctx, viewport: viewport, transform: dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null }; page.render(renderContext).promise.then(function() { rendering false; document.querySelector(.pdf-current).textContent pageNum; }); }); } function loadPDF(url) { var loadingTask pdfjsLib.getDocument(url); loadingTask.promise.then(function(pdf) { pdfDoc pdf; document.querySelector(.pdf-total).textContent pdf.numPages; renderPage(1); }).catch(function(err) { console.error(PDF加载失败:, err); }); } document.addEventListener(click, function(e) { var btn e.target.closest(.pdf-btn); if (!btn) return; var act btn.getAttribute(data-act); if (act prev currentPage 1) { currentPage--; renderPage(currentPage); } else if (act next currentPage pdfDoc.numPages) { currentPage; renderPage(currentPage); } }); // 初始化查找页面里所有带data-pdf-src的canvas var canvas document.querySelector(canvas[data-pdf-src]); if (canvas) { loadPDF(canvas.getAttribute(data-pdf-src)); } })();这段代码有几个细节值得展开讲。第一Canvas的高DPI适配。现代手机屏幕的像素密度一般是2甚至3如果你只按CSS像素设置Canvas宽高渲染出来的PDF文字会发虚。所以我用window.devicePixelRatio把Canvas的实际像素尺寸乘上dpr然后再用transform把坐标系同步放大。这是让PDF在Retina屏上清晰显示的真正关键点。第二render方法返回的Promise。PDF.js v2.x中page.render接收一个renderContext对象返回一个Promise对象你要等它完成后才能进行下一次渲染否则连续翻页时会报Cannot use the same canvas during multiple render operations的错误。我代码里的rendering标志就是干这个的防止快速点击时重复渲染同一个Canvas。第三用户可视区的自适应。我在CSS里给.pdf-canvas-wrap设了width: 100%; overflow-x: auto;然后Canvas宽度设置为viewport宽度。这样做的好处是窄屏设备上PDF会按页宽完整显示不会溢出页面桌面宽屏上如果你觉得1.5倍缩放不够可以做成放大缩小的按钮把scale参数动态改。3.4 第四步解决“阅读到哪一页”的记录需求热词里有个问题pdf.js如何把阅读到哪一页记录到数据库里。问的人估计是想做跨设备同步。但你要清楚Hexo是纯静态博客没有服务器、没有数据库你唯一能用的持久化存储就是浏览器的localStorage。我的建议直接放弃数据库方案用localStorage就够了。实现的思路很简单每当翻页成功就把页码、PDF标识、时间戳写入localStorage页面加载时读取记录恢复上次阅读位置。这里的关键是怎么给PDF做一个稳定的标识key。我用的是PDF文件的路径如果路径太长或含特殊字符可以先做个简单的哈希function getStorageKey(url) { function hash(str) { var h 0; for (var i 0; i str.length; i) { h ((h 5) - h) str.charCodeAt(i); h | 0; } return h.toString(); } return pdf_progress_ hash(url); } function saveProgress(url, page) { var key getStorageKey(url); localStorage.setItem(key, JSON.stringify({ page: page, time: Date.now() })); } function loadProgress(url) { var key getStorageKey(url); try { var data JSON.parse(localStorage.getItem(key)); return data data.page ? data.page : 1; } catch (e) { return 1; } }于是翻页逻辑里加一行保存if (act next currentPage pdfDoc.numPages) { currentPage; renderPage(currentPage); saveProgress(canvas.getAttribute(data-pdf-src), currentPage); }加载时判断var restored loadProgress(canvas.getAttribute(data-pdf-src)); // 加载成功后直接渲染restored页而不是从第1页开始如果你的博客真的需要跨设备同步阅读进度那意味着你要引入后端服务比如用serverless函数加一个轻量数据库把进度存到云端。这是另一个架构层面的工程对这个简单的博客嵌入需求来说性价比太低。localStorage的方案单设备、零成本、秒级完成对绝大多数个人博客已经是完美答案。4. 常见问题与排查技巧实录4.1 failed to fetch到底在报什么标题里那个报错pdf.js v2.16.105 (build: 172ccdbe5) 信息:failed to fetch实际是PDF.js在调用getDocument()时底层fetch请求失败后抛出的错误信息。它本质上是一个网络层错误不是PDF.js本身的解析错误。我第一次遇到时一直在改代码后来才意识到问题压根不在渲染逻辑。排查这个错误我建议按顺序做四件事打开浏览器的Network面板找到那个请求PDF文件URL的请求看状态码。如果是404说明路径错了。最常见的坑是你的博客挂在子目录下比如https://user.github.io/blog/但你请求的PDF路径写的是根路径/files/manual.pdf实际应该是/blog/files/manual.pdf。Hexo的配置里有root字段你需要在代码里拼上这个root前缀。确认你是在http://或https://环境里测试而不是双击HTML文件用file://协议打开。fetch请求在file://协议下会被浏览器直接拦掉报的错就是failed to fetch。这是开发阶段最容易让人抓狂的问题我一度以为是代码写错了。如果PDF文件在另一个域名上检查那个域名是否返回了正确的CORS头。PDF.js跨域读取PDF时服务端必须在响应头里带上Access-Control-Allow-Origin否则浏览器会拦截响应fetch同样报failed to fetch。自己博客的PDF放在同域名下就不会有这个顾虑。检查服务器是否支持Range请求。PDF.js为了优化加载默认用Range请求分段读取文件。某些CDN或不支持Range的静态托管服务遇到分段请求可能会返回200而不是206PDF.js这时候虽然不一定报failed to fetch但可能报Invalid PDF structure之类的解析错误。如果你遇到这种问题可以在getDocument的配置里显式禁用Range请求。4.2 部署到GitHub Pages后的路径问题把Hexo部署到GitHub Pages这是现在很多人免费托管个人博客的选择。GitHub Pages有两种托管方式项目站点的URL是https://用户名.github.io/仓库名/而个人站点的URL是https://用户名.github.io/。如果博客地址是带仓库名的子路径那么你在PDF.js代码里写的所有绝对路径都要带上这个前缀。解决办法有两个。最简单的是在Hexo的_config.yml里把root配置成/仓库名/然后代码里所有资源路径都用%- root %模板引擎变量拼接比如pdfjsLib.GlobalWorkerOptions.workerSrc %- config.root %pdfjs/pdf.worker.min.js;如果你的主题是纯前端写法比如一个独立的HTML文件塞进source/下那就把所有路径都写成相对路径并且保证HTML里定义了base标签指向实际部署的根目录。相对路径在子目录部署下也能正常工作但有一个风险如果某个页面的URL层级比较深比如文章URL带日期层级相对路径会算错。用绝对路径加root前缀是最稳妥的方式。另外提醒一下GitHub Pages对文件名的大小写敏感。你本地Windows或macOS上可能不区分大小写但推上去之后如果引入路径的大小写和文件名不完全一致就会出现404。这类问题分布得极其隐蔽因为本地构建完全正常。4.3 Worker线程加载失败的独立表现Worker加载失败和PDF文件加载失败是两回事。如果GlobalWorkerOptions.workerSrc路径写错PDF.js通常会报Setting up fake worker failed: Cannot load script at...或者干脆卡住不动。排查时同样先检查Network里worker文件的请求状态。还有一个坑是跨域。如果你把pdf.worker.min.js放在CDN或者另一个域上浏览器会以new Worker()的方式加载它跨域Worker会被拦截。最稳妥的做法是把worker文件和主文件放在同域下这样PDF.js会自动走正常的Worker线程路径性能最好。如果迫不得已要跨域需要在worker文件响应头里设置正确的CORS头并确保crossOrigin配置正确。对博客场景来说真的没必要两个文件放一起就能解决。4.4 Jekyll和Hexo怎么选部署PDF功能时的体会既然热词里有人问jekyll和hexo哪个好我在这件事上确实有发言权。我最初搭建博客时在两个方案之间纠结过最后选了Hexo主要考量有三个。第一Hexo基于Node.js生态主题开发和自定义插件的门槛低。JavaScript无疑是我最熟的语言写标签插件、改模板、调渲染逻辑全程不需要换语言。而Jekyll基于Ruby虽然生态也很成熟但对不熟悉Ruby的开发者来说遇到问题时的排查成本明显更高。第二从部署PDF阅读器这个需求来看Hexo的source目录拷贝机制比Jekyll更简单直接。PDF.js文件和PDF文档往source目录一扔静态资源就和站点一起构建发布全程不需要额外配置。Jekyll的static_files机制也能做但语法和规则要多学一层。第三主题风格上Hexo社区的中文主题资源更丰富Next、Butterfly这些主题的文档和解决方案搜索起来更方便。对这个博客嵌入PDF的需求我能在半小时内找到前人做好的标签插件示例省了很多试错时间。当然Jekyll也有自己的优势比如GitHub原生支持Jekyll构建你推代码上去GitHub自动帮你渲染不需要本地安装Node环境也不需要写CI。如果你是纯粹的Markdown作者不想折腾构建环境Jekyll更适合。但如果你像我一样喜欢折腾定制功能Hexo的灵活度和生态会更顺手。没有绝对的好只有合不合适。4.5 快速排查速查表把常见的报错症状和解决方案整理成一张表方便你直接查。症状可能原因解决方案failed to fetch文件路径404检查Network请求路径是否正确注意子目录root前缀failed to fetchfile://协议下测试改用本地HTTP服务hexo serverfailed to fetch跨域且无CORS头将PDF和PDF.js放到同域或配置Access-Control-Allow-OriginCannot use the same canvas during multiple render operations并发渲染同一Canvas翻页时加锁等待上一次render完成Setting up fake worker failedworkerSrc路径错误或跨域检查worker文件路径确保同域页面空白无报错Canvas宽高未设置或dpr适配错误检查canvas.width/height赋值逻辑中文字体显示方块PDF内置字体缺失换用支持字体内嵌的PDF生成工具重新导出5. 实测体验与调试心得整个方案跑通后我实际在博客里放了三份文档做测试一份10页的论文原文、一份带大量图表的设备说明书、还有一个自己做的简历模板。从实测效果来看首屏加载PDF文件的时间在这个部署下大概在几百毫秒到一两秒之间取决于文件大小。翻页流畅度很稳定连续点击没有卡顿Retina屏上文字边缘清晰锐利和本地阅读器的视觉效果几乎没有差距。进度记录的体验尤其让我满意。我特意把浏览器关掉再重新打开单页恢复功能正常工作直接跳回上次阅读位置。就是这个细节让PDF从一篇随手打开的附件变成了一个有阅读上下文的内容体验完全是两个档次。最后再分享一个调试时的小技巧在开发阶段把pdfjsLib.getDocument的Promise的catch回调写得详细一点打印出err对象的name和message字段比如err.name UnknownErrorException通常指向PDF文件本身损坏而TypeError: Failed to fetch才是网络或跨域问题。学会区分错误类型排查效率能提升一半。我在最开始就是被这个错误分类搞懵了浪费了整整一个晚上。这套方案的可扩展性也很好。你如果想要更完整的阅读器比如加一个缩放滑块、页面列表下拉、全文搜索、记住当前浏览页码并通过URL参数分享思路和代码结构完全一致在pdf-viewer.js里继续加方法就行。就算哪天你的读者量上来了需要统计每篇PDF的实际阅读时长和翻页记录也只是在这个前端方案之上对接后端分析的事底层的PDF渲染稳定性已经验证过了。