Vue3 PDF预览弱网白屏:缓存命中引发的组件竞态排查与修复
做PDF预览功能的那几天我一直有个错觉只要在普通网络下把组件跑通弱网环境无非就是慢一点。后来我发现完全不是这么回事。某次我把浏览器开发者工具里的网络切到节流模式再刷新页面vue3-pdf-app这个内部组件直接卡在加载中第一页始终不出来。更诡异的是第一次刷新必现第二次刷新又大概率恢复正常。这个偶发问题耗费了我整整一个下午最后定位下来既不是PDF文件本身损坏也不是组件代码写错而是节流模式下的本地缓存命中恰好戳破了组件初始化顺序里的一个竞态。这篇文章就把整个排查过程、根因分析、修复方案和通用排查思路完整记录下来。内容适合正在做Vue 3 PDF预览功能的开发者也适合所有遇到过弱网下缓存资源导致组件不渲染问题的人。我不打算从框架原理开始讲就直接按照我当时排查的顺序来。1. 白屏现场从正常网速切到节流模式后的第一个异常1.1 最简复现路径三步必现这个问题的复现路径意外地简单不需要任何特殊操作。我自己验证了很多遍基本是三步必现把开发者工具切到Network面板把网络节流模式改成Slow 3G也就是模拟弱网。保持DevTools打开刷新页面。观察组件区域页面其余部分渲染正常但PDF容器一直显示loading文案控制台没有任何红色报错。注意一个前提如果DevTools没有打开这个规律不一定每次都能复现。打开DevTools之后页面请求的转发路径会有细微差别缓存命中行为也不一样。这也是它容易被当成偶发Bug的原因之一。如果你是在远程调试、或者用手机模拟器弱网复现可能也需要留意类似因素。总之只要能稳定复现问题就成功了一半。1.2 网络面板读出来的三条异常线索第一次出现白屏时我先看Network面板而不是急着去看组件代码。因为我清楚组件不渲染通常是数据没到位数据请求的异常基本都能在网络面板里看出来。打开Network面板我注意到三条比较反常的线索第一PDF资源的请求状态码是200但Size列显示的是(memory cache)不是正常的网络体积数字。这说明这次请求根本没走到远端服务器而是直接命中了浏览器本地缓存。在Slow 3G节流模式下按理说所有网络请求都会被限速但缓存命中的请求不受节流影响体感就是瞬间返回。第二点开这条请求的响应信息发现响应头里带有明显的强缓存字段比如Cache-Control: max-age31536000。这类针对静态资源的默认缓存策略很常见但它完全没考虑我们的PDF预览组件对数据到达时机有特定要求。第三也是最让我困惑的请求体的字节数看起来正常不是0KB也不是截断的半个文件。这说明缓存里存的是一份完整PDF。问题是为什么组件拿到数据后没有渲染这条线索很重要它把我从数据损坏的方向引开转向数据到达时序的方向。1.3 让我误判的二次刷新恢复更让人头疼的是页面第二次刷新就正常了。第一次刷新白屏第二次刷新PDF正常显示这非常容易让人认为是偶发网络抖动。其实第二次刷新正常恰好暴露了根因的一部分特征。第一次刷新时页面是冷启动组件刚挂载就发起了加载请求而请求又因为强缓存被瞬间命中组件内部的状态还没来得及流过完整的初始化流程。第二次刷新时虽然请求还是命中缓存但组件的运行时状态、监听器、渲染环境都已经准备得差不多了再收到缓存数据就能正常走完渲染。所以这不是什么偶发而是固定模式下的初始化竞态。你越是在网速快的时候测试越难发现它一旦切到节流模式浏览器对缓存资源的本地直达特性就把它暴露出来。2. 深入组件内部为什么缓存命中反而让状态卡住2.1 组件加载PDF的四段链路要理解这个Bug先得知道vue3-pdf-app组件内部加载PDF的基本链路。我们内部把这条链路拆成了四段第一段是资源请求。组件根据传入的URL发起fetch拿到PDF文件的二进制数据。第二段是数据校验。组件检查拿到的数据是不是合法PDF比如文件头是否是%PDF字节长度是否大于0。第三段是解析初始化。PDF解析内核被创建开始加载数据并生成文档结构。第四段是渲染准备。解析器返回第一页页面对象组件把它挂到DOM容器上loading状态关闭用户看到内容。这个四段链路可以类比成买书 - 验书 - 翻书 - 看书。任何一段没走完最后都不会出现内容。2.2 请求完成与渲染就绪是两个独立事件正常情况下链路是顺序执行的请求完成然后校验然后解析然后渲染。但在我们组件里存在一个容易忽略的设计请求完成事件和渲染就绪事件不是绑定在一起的。组件内部把加载动作拆成了两个并行的模块。一个模块负责从网络层拿二进制数据数据处理完成后它会更新一个store另一个模块负责监听解析器状态解析器准备就绪后再去渲染。两个模块各自维护自己的状态只有两个状态都满足时组件才隐藏loading。问题就出在这里如果请求模块先完成、store先更新但渲染模块的初始状态没有被正确触发组件就会一直等。你可以想象成一个简单的与门逻辑isDataReady isRendererReady才显示内容。数据到了但渲染模块没就绪门就一直关着。2.3 缓存瞬时返回对这两个事件的影响在普通网络下这两个模块的完成时间天然存在一个时间差。网络请求要经过DNS、TCP、服务端处理通常需要几十毫秒到几百毫秒。这个时间差反而给两个模块提供了缓冲窗口数据还没到的时候渲染模块已经把监听器注册好了等数据到达时事件能被正确接收。但在节流模式本地缓存的情况下请求不再经过网络栈。浏览器从本地缓存里直接取出数据Promise几乎在同一个事件循环里就resolve了。这时候组件内部的监听器可能还没注册完毕或者渲染模块的初始化Promise还挂在队列里数据就已经回来了。事件丢给了空气组件自然就卡死了。说难听一点平时测试之所以顺利不是因为代码严谨而是网络慢掩盖了时序缺陷。一旦缓存把中间延迟抹平缺陷就暴露无遗。3. 根因确认节流模式下缓存响应绕过了时序保护3.1 正常模式下时序保护为何生效在详细阅读代码之后我定位到组件内部的一个具体现象。加载逻辑大概长这样// 这里的代码是简化后的模拟结构 async function loadPdf(url) { const cached pdfStore.get(url) if (cached) { // 命中缓存直接使用缓存数据 renderPdf(cached) return } const resp await fetch(url) const data await resp.arrayBuffer() pdfStore.set(url, data) renderPdf(data) }问题出在renderPdf(cached)这个分支上。renderPdf内部会先等待一个rendererReadyPromise这个Promise是在组件挂载完成后的异步任务中才会resolve的。正常网速下fetch(url)还在等待网络响应异步任务早就跑完了rendererReadyPromise也已经准备好了。等renderPdf被调用自然能顺利走下去。缓存命中时就不一样了。pdfStore.get(url)返回缓存数据renderPdf(cached)在同步代码中几乎立刻就被调用。但此刻rendererReadyPromise可能还没进入resolved状态renderPdf里面又在await这个Promise于是整个流程被卡在等待队列里。3.2 缓存命中时为什么不会等待网络延迟可能有同学会问本地缓存命中也是要读磁盘、解数据的为什么就不会延迟这里要区分两类缓存。我们项目里有两层缓存一层是浏览器HTTP缓存一层是应用内pdfStore的JS对象缓存。HTTP缓存命中时浏览器从本地存储读取资源虽然也要消耗一点时间但这个时间远小于网络往返通常在毫秒级。所以在节流模式下它体现为瞬间返回。而应用内缓存更极端pdfStore.get(url)就是在内存里拿一个对象连I/O都没有。它跟网络节流没有半点关系。结果就是节流模式只是让真正的网络请求变慢却让应用缓存绕过网络限制产生了一个快得不正常的路径。这里最反直觉的点在于平时网络请求慢我们还会刻意加一些loading状态来覆盖等待期但应用缓存这条路不需要等待代码里反而没有做任何时序保护。慢路径没问题快路径出问题了。3.3 用最小实验验证根因为了确认判断不是猜测我做了两个最小实验。第一个实验是绕过应用缓存。我在pdfStore.get(url)前临时加了一行日志并且直接禁用缓存分支强制走网络请求。切到节流模式刷新页面PDF功能完全正常只是加载时间变长了。第二个实验是反过来强制走缓存分支。我修改了代码故意让renderPdf在组件挂载后的第一个微任务里执行模拟缓存命中时的瞬时调用。结果在普通网络下也复现了白屏。这两个实验一对照根因就非常清晰不是缓存数据有问题而是缓存分支的调用时机破坏了renderPdf内部的等待机制。3.4 顺带发现的Cache-Control隐患在排查过程中我还发现了另一个隐患虽然它不是这次白屏的直接原因但值得记录。后端返回PDF文件时响应头里配置的是强缓存策略max-age长达一年。这本来是为了让重复查看同一份PDF时省流量但对一个经常更新的合同预览系统来说这个策略很可能导致用户在PDF文件更新后仍看到旧版本。特别是我们切到节流模式测试场景时开发者工具不会给用户带来额外帮助真实弱网用户遇到强缓存资源时同样可能拿到过期版本。所以修复白屏问题之外我建议后端同学重新评估PDF资源的缓存头至少对动态生成的PDF使用Cache-Control: no-cache或者加入版本号查询参数。4. 从请求层和组件层同时加固的修复方案4.1 请求层PDF资源URL改为强制无缓存第一个修复动作是把PDF资源请求从尽量用缓存改成不使用缓存。这一步操作很简单但对解决白屏问题最直接。在fetch请求里我们可以通过cache选项控制浏览器缓存策略async function loadPdfBuffer(url) { const resp await fetch(url, { cache: no-store, }) if (!resp.ok) { throw new Error(PDF资源请求失败HTTP ${resp.status}) } const buffer await resp.arrayBuffer() // 做一个基础校验空数据直接判失败 if (!buffer || buffer.byteLength 0) { throw new Error(PDF数据为空请重新检查资源地址) } return buffer }cache: no-store的作用是让本次请求完全不写入HTTP缓存也不读取HTTP缓存。这样即使用户在弱网下反复刷新页面PDF资源每一次都会真正走网络请求不会命中瞬间返回的本地缓存。当然这里的代价是同一份PDF在短时间内多次打开时会重复下载。考虑到PDF预览通常不是高频操作我认为这个取舍很合理。如果你确实想保留缓存也可以改成cache: no-cache意思是使用时校验缓存、过期后再下载但这就需要在请求头或查询参数里带上版本标识实现复杂度会更高。4.2 组件层数据就绪和渲染就绪双条件合并只改请求层还不够。因为我们内部的renderPdf还有一个独立的rendererReadyPromise等待逻辑即便强制走了网络请求弱网场景下请求耗时较长组件状态流也可能被某些原因打断。所以组件层也要加固。我的做法是把数据就绪和渲染就绪两个条件合并成一个统一的门槛。具体地说渲染前必须同时满足两个条件第一PDF二进制数据已经拿到第二渲染器已经完成初始化。两个条件都满足时才允许真正执行渲染。代码层面可以这样封装let rendererReady false const rendererReadyWaiters [] function signalRendererReady() { rendererReady true rendererReadyWaiters.forEach((resolve) resolve()) rendererReadyWaiters.length 0 } async function waitForRendererReady() { if (rendererReady) return await new Promise((resolve) { rendererReadyWaiters.push(resolve) }) }这样的设计可以保证无论数据先到还是渲染器先就绪都会等另一个条件补齐后才执行渲染不会出现事件丢给空气的问题。4.3 增加请求序号解决连续刷新竞态除了上面两个修复我还考虑到了用户快速刷新页面的场景。如果组件同时发起多个加载请求后一次请求的结果可能覆盖前一次的结果导致显示内容错乱。所以我在加载函数里加了一个简单的序号校验。let loadSeq 0 async function loadPdf(url) { const currentSeq loadSeq try { const buffer await loadPdfBuffer(url) // 如果加载期间被新的请求打断直接丢弃这次结果 if (currentSeq ! loadSeq) return await waitForRendererReady() // 渲染前再做一次序号校验 if (currentSeq ! loadSeq) return renderPdf(buffer) } catch (error) { // 只有最新的一次请求才允许弹错误 if (currentSeq loadSeq) { showPdfError(error) } } }这个方案解决的是请求竞态跟缓存问题不冲突。弱网下用户最容易反复刷新每次刷新都会产生新的请求如果不对旧请求做抛弃处理旧的慢请求可能在新的请求已经渲染完成后才返回导致页面被重新覆盖成旧内容。4.4 修复后的运行结果修复完成后我重新按照复现路径做了多轮验证。在节流模式下刷新十次没有再出现白屏。每次加载的耗时稳定在几秒到十几秒之间loading状态会持续到渲染真正完成。第二次刷新也没有出现偶发恢复的情况因为请求层已经把缓存路径堵死每次加载都走同一个稳定流程。另外我把Network面板切回普通网速测试发现修复并没有给正常场景带来额外负担。PDF文件体积如果较大fetch仍然会正常下载loading状态也更准确了。整体体验反而比之前玄学式成功稳定得多。5. 排查节流缓存组件不渲染问题的通用Checklist5.1 从网络面板先判断缓存状态这类问题将来你可能还会遇到不只是PDF组件任何弱网模式下资源被缓存导致组件不渲染的问题排查思路都类似。我把自己沉淀的一套检查清单放在下面。第一步先打开开发者工具的Network面板看故障资源的请求详情。重点看三列Status Code、Size、Time。如果Status是200但Size显示(memory cache)或(disk cache)说明命中了HTTP缓存。如果Time接近0ms说明数据不是从网络来的。如果请求头里带了Cache-Control相关字段且允许缓存基本可以确认缓存参与。第二步在Network面板里勾选Disable cache再刷新页面看问题是否复现。如果取消勾选后问题消失说明HTTP缓存就是关键路径之一。第三步在Application面板查看缓存存储特别是Service Worker Cache Storage。有时候问题不止来自HTTP缓存还可能来自Service Worker的预缓存策略这个要单独排查。5.2 从组件状态判断时序问题网络面板只是第一步。如果资源请求本身没有报错数据也完整那就要把视角切到组件内部。重点关注三件事请求完成的回调、渲染器初始化的回调、以及组件loading状态的切换代码。问自己三个问题请求完成回调是不是有可能在渲染器初始化回调之前执行组件loading状态的关闭是不是同时依赖这两个回调如果有多个请求并发会不会出现旧请求覆盖新请求的情况台账式地把每个回调触发条件写出来通常就能很直观地看到竞态窗口在哪里。我遇到过不少类似的坑表面看是缓存导致问题实际是某个Promise在错误时机resolve了。缓存只是改变了事件的到达顺序真正需要修的是组件对事件顺序的假设。5.3 常见误判和我的建议最后一个部分说说我经历过的误判。最常见的误判是看到缓存命中就直接断定是缓存头配置错了改成不缓存就完事。但实际上如果组件内部时序本身就是脆弱的即使你强制无缓存在复杂的弱网环境下还是可能复现其他症状。所以真正的修复应该是请求层防缓存 组件层防竞态双管齐下。第二个常见误判是用setTimeout延时绕过问题。比如有人发现缓存命中后加一个setTimeout就能正常于是加了个500ms延迟。我非常不建议这种写法。因为延时是拍脑袋的无法覆盖不同设备、不同网络、不同浏览器之间的差异只会把问题压制到一个没那么容易复现但依然存在的角落。第三个误判是把问题归咎于PDF解析库不稳定。实际上底层解析库在绝大多数情况下都是无辜的。如果数据能正常到达解析器通常都能正常工作。排查时优先怀疑自己的封装层再怀疑底层库顺序不能反。我的核心建议是所有涉及异步初始化的组件都应当把数据来源和渲染就绪做成显式的依赖关系而非隐式的心存侥幸。缓存也好节流也好它们会让代码里所有不清晰的地方加速暴露。与其每次靠运气不如把时序设计得严谨一些这才是这类问题真正的解决之道。这次之后我还顺手把我们项目里其他几个涉及大资源加载的组件也都过了一遍凡是存在类似先拿数据再初始化渲染器模式的全部补齐了双条件等待逻辑短期内应该不会再被这类问题困扰了。