☰
Vue3项目中集成PDF.js:从worker配置到文本层复用的完整指南
2026/10/2 15:37:05 网站建设 项目流程

做了几年后台管理系统,前端预览 PDF 这个需求几乎每个项目都会遇到。早先我习惯直接甩一个<iframe>嵌浏览器自带预览器,后来发现移动端兼容差、样式没法定制、工具栏也不可控,直到把渲染内核换成 PDF.js 之后才算是真正“收放自如”。这篇文章就把我在 vue3 项目里接入 PDF.js 的完整过程写出来,从版本选型、worker 配置、首屏渲染链路,到翻页缩放、文本选中复制,再到我在实际项目里踩过的各种坑和排查思路,一次性讲透。

1. 集成 PDF.js 之前先想清楚这三件事

1.1 PDF.js 到底是个什么角色

PDF.js 是 Mozilla 维护的一个纯前端 PDF 解析与渲染引擎。它的核心思路是:浏览器本身不提供统一的 PDF 解析接口,PDF.js 就在 JavaScript 层面自己解析 PDF 文件结构,再通过 Canvas 2D 把每一页画出来。也就是说,你看到的 PDF 页面本质上是一张由 JavaScript 生成的 canvas 位图。

这个“自己解析文件格式”的做法,好处很明显:不依赖浏览器厂商的 PDF 插件,行为在 Chrome、Firefox、Edge 甚至 WebView 里都能保持一致。但也要接受一个现实——PDF.js 不是一个开箱即用的“预览组件”,它提供的是 API 级别的能力,页面的布局、工具栏、交互逻辑都需要你自己用 vue3 的响应式系统去组织和封装。

1.2 版本选择不能拍脑袋,先看 API 差异

pdfjs-dist 是 PDF.js 的 npm 包名。我用过 2.x、3.x,也用过 4.x、5.x,最大的体会是:这个库的大版本升级经常伴随“破坏性变更”,尤其是 worker 的引入方式和模块格式。

如果你在网上搜索教程,很容易搜到 2.x/3.x 时代的老写法,比如:

import pdfjsLib from 'pdfjs-dist' pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/build/pdf.worker.min.js'

或者 Webpack 项目里的:

import 'pdfjs-dist/webpack'

这些写法放在当前较新的 4.x/5.x 版本里不能说完全失效,但已经不再推荐,而且直接照抄往往会碰到 worker 找不到、构建报错、CORS 报错等一系列问题。

实战建议:如果是新项目,直接锁定 4.x 或 5.x 系列,并固定到具体版本号安装,不要用裸的latest。我当前的推荐版本是pdfjs-dist@5.3.0左右这一代,API 稳定,并且官方示例 Vite 写法可以直接套用。老项目保持原版本也可以,但下面讲到的代码逻辑,我会优先按 4.x/5.x 的写法来,遇到版本差异我会单独说明。

1.3 你需要的到底是“预览”还是“渲染内核”

在动手写代码前,还应该想清楚一点:你是只需要在页面上显示 PDF,还是需要嵌入到自己的业务逻辑里?

如果只是“能看就行”,那我建议你别用 PDF.js,直接用<iframe :src="pdfUrl">或者<object>,省事得多。但一旦你有下面这些需求,就必须考虑自建渲染链路:

  • 需要自定义上一页/下一页、缩放、旋转、页码跳转等操作
  • 需要让用户选中 PDF 里的文字并复制(文本层)
  • 需要深度定制加载失败、加载中、页数提示等 UI
  • 需要在移动端 H5 或 WebView 里保持一致体验
  • 需要统计页面阅读时长、上报当前页等业务数据

我的经验是:80% 的管理系统“PDF 预览”需求其实都有工具栏控制要求,所以直接用 PDF.js 的收益远大于维护成本。接下来的内容,我会按“可复用的 vue3 组件”标准来拆解整个接入过程。

2. 环境初始化和 worker 配置:新版写法与老写法的差异

2.1 创建 vue3 项目并安装 pdfjs-dist

我用的是 Vite 作为构建工具,这也是 vue3 生态最常见的组合。创建项目的过程不展开说了,直接进入安装这一步。

npm create vite@latest pdf-preview-demo -- --template vue cd pdf-preview-demo npm install npm install pdfjs-dist@5.3.0

安装完之后,先不要急着写组件,我建议先确认一下安装的版本,因为后续很多 API 细节跟版本强相关:

npm list pdfjs-dist

如果你的 package.json 里出现了 4.x 或者 5.x,那么接下来这套配置就是给你准备的。如果显示的是 3.x,说明你可能是历史遗留项目或 install 时没锁版本,请额外留意 worker 配置的差异。

2.2 最新版 worker 配置:new URL 方式

PDF.js 的解析逻辑由主线程发起,但真正消耗 CPU 的解析计算是在 Web Worker 里完成的。官方文档要求你提供一个 worker 文件的地址,让库内部去创建 Worker。如果不配置,PDF.js 会回退到“fake worker”模式,在主线程模拟 Worker 行为,轻则卡顿,重则直接报错。

在 Vite 项目里,最标准的没用之一是用new URL让构建工具识别并打包 worker 资源:

import * as pdfjsLib from 'pdfjs-dist' pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString()

这行代码我建议放在独立模块里,比如src/utils/pdf.ts,因为多个组件都要用到,避免重复定义。

为什么是这样写而不是直接import worker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'?这两种方式在 Vite 里都能跑,但?worker方式会把 worker 作为一个独立的 chunk 输出,逻辑上更“包工”;而new URL(..., import.meta.url)方式会生成一个资源 URL,PDF.js 内部拿这个 URL 去new Worker(),兼容性更稳。我实测过,在部署到子路径的项目里,new URL方式能自动按 base 调整路径,踩坑概率更小。

2.3 旧版 3.x 的配置方式:GlobalWorkerOptions 时代的做法

如果是 3.x 的历史项目,通常你会看到这样的配置:

import * as pdfjsLib from 'pdfjs-dist' pdfjsLib.GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.entry')

或者:

import 'pdfjs-dist/webpack'

原因在于 3.x 之前 utils 模块是 CommonJS,pdf.worker.entry会按当前模块系统自动选择 worker 入口。而 4.x 之后包体改成纯 ESM,build/pdf.worker.min.mjs是显式路径,配合import.meta.url是官方示例的标准做法。这个差异,本质上是模块体系变迁带来的配置方式变化。

如果你在升级一个老项目,遇到控制台报Setting up fake worker或Failed to fetch dynamically imported module,大概率就是 worker 路径没有正确指向 4.x/5.x 的.mjs文件。等一下我在踩坑章节再详细展开。

2.4 Vite 构建时的额外建议

因为 PDF.js 体积不小,web worker 文件又必须单独加载,我建议在vite.config.js里适当调高 chunk 大小警告阈值,避免打包时被警告刷屏:

export default defineConfig({ build: { chunkSizeWarningLimit: 2000 } })

这不会影响构建结果,只是让输出日志更清爽。另外,如果项目部署在 CDN 或子路径,要注意 Vue Router 的base和 Vite 的base要一致,否则new URL生成的 worker 路径可能会 404。这个坑我在部署阶段踩过,后面会专门讲。

3. 第一页渲染链路:从 PDF 文件到 Canvas 的完整实现

3.1 getDocument 加载流程:一次异步的解析协议

PDF.js 对外的主入口是pdfjsLib.getDocument(),它接收url、data或file参数,返回一个PDFDocumentLoadingTask对象。这个对象上有promise,await之后拿到PDFDocumentProxy,这才是整个 PDF 文档的“句柄”。

const loadingTask = pdfjsLib.getDocument({ url: pdfUrl }) pdfDoc = await loadingTask.promise console.log('总页数:', pdfDoc.numPages)

这里我习惯用对象参数{ url }而不是直接getDocument(pdfUrl),因为后面想加httpHeaders、withCredentials、cMapUrl等配置时,对象形式更清晰。PDFDocumentProxy上有numPages、getPage()、destroy()等方法和属性,它不直接参与绘制,但所有页面操作都要先经过它。

如果是本地文件上传后预览,可以直接把File对象转成 ArrayBuffer,然后用data传入:

const buffer = await file.arrayBuffer() const loadingTask = pdfjsLib.getDocument({ data: buffer })

不建议用URL.createObjectURL(file)生成的临时 URL 传给getDocument,虽然也能跑,但内存生命周期管理更麻烦,尤其是组件卸载时要记得revokeObjectURL,少做一步就容易泄漏。

3.2 依据 scale 计算 viewport,设置 canvas 尺寸

拿到PDFPageProxy之后,要调用page.getViewport({ scale })得到页面在指定缩放比下的视口信息。这个viewport包含页面宽高、缩放矩阵等绘制所需参数。

const page = await pdfDoc.getPage(pageNum) const viewport = page.getViewport({ scale: 1.5 })

这里的scale可以理解为“渲染清晰度”和“显示尺寸”的乘积。scale = 1.5意味着 PDF 页面按 1.5 倍输出尺寸绘制到 Canvas。实际项目中我建议基准值设为 1.0~1.5 之间,后续再根据屏幕和用户操作动态调整。

canvas 的width和height属性是物理像素尺寸,style.width和style.height是 CSS 显示尺寸。如果直接都用viewport.width/height,在高 DPI 屏幕上会显得模糊。正确做法是把 devicePixelRatio 考虑进去:

const dpr = window.devicePixelRatio || 1 canvas.width = Math.floor(viewport.width * dpr) canvas.height = Math.floor(viewport.height * dpr) canvas.style.width = `${viewport.width}px` canvas.style.height = `${viewport.height}px`

同时,渲染前要把 canvas 的 2D context 做一个 setTransform 复位,否则在重复渲染时会出现“越画越糊”或内容偏移:

const ctx = canvas.getContext('2d') ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

这一步等同于告诉绘制系统:接下来我以 dpr 倍的物理像素来画,但坐标逻辑仍然按 CSS 像素走。很多教程不写这一行,你会发现放大页面时 canvas 模糊,或者每次渲染文字边缘发虚,其实就是没处理 dpr。

3.3 用 page.render 绘制到 Canvas,注意 renderTask 的取消与等待

渲染动作由page.render(params)触发,参数里最重要两个就是canvasContext和viewport,它返回一个RenderTask对象。这个对象上有promise和cancel()方法:

if (renderTask) { renderTask.cancel() } renderTask = page.render({ canvasContext: ctx, viewport }) await renderTask.promise

cancel()在快速翻页时特别关键。如果不做取消,用户点击下一页后,上一页的渲染任务仍然在跑,渲染结果返回后可能覆盖到新页面,造成画面闪烁、串页甚至白屏。正确姿势是:每次开始新渲染之前,先把上一次的renderTask.cancel()掉。

一个容易被忽略的细节是:renderTask.promise被 cancel 之后会 reject 一个RenderingCancelledException。如果你用await等待它,又没有 catch,控制台会冒红。所以我把渲染函数设计成 try/catch 包裹,专门吞掉取消异常:

async function renderPage(pageNum) { if (!pdfDoc || !canvasRef.value) return try { const page = await pdfDoc.getPage(pageNum) const viewport = page.getViewport({ scale: scaleRef.value }) // 尺寸设置、dpr 适配… if (renderTask) renderTask.cancel() renderTask = page.render({ canvasContext: ctx, viewport }) await renderTask.promise } catch (err) { if (err?.name === 'RenderingCancelledException') return console.error('渲染失败:', err) } }

判断RenderingCancelledException时,不同版本异常名可能叫RenderingCancelledException,也有的版本是DOMException。稳妥做法是看err.name里是否包含cancel字样,或者干脆在 catch 里判断renderTask是否处于 cancelled 状态。这个细节我在踩坑章节还会提。

3.4 一个能跑起来的 vue3 单组件示例

把上面的逻辑串起来,就是一个最简单可用的 vue3 组件。我习惯用<script setup>组合式 API 来组织:

<template> <div class="pdf-container"> <canvas ref="canvasRef"></canvas> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import * as pdfjsLib from 'pdfjs-dist' pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString() const props = defineProps({ pdfUrl: { type: String, required: true } }) const canvasRef = ref(null) let pdfDoc = null let renderTask = null let ctx = null const scale = 1.5 async function loadPdf(url) { const loadingTask = pdfjsLib.getDocument(url) pdfDoc = await loadingTask.promise const totalPages = pdfDoc.numPages await renderPage(1) } async function renderPage(pageNum) { if (!pdfDoc) return const page = await pdfDoc.getPage(pageNum) const viewport = page.getViewport({ scale }) const canvas = canvasRef.value ctx = canvas.getContext('2d') const dpr = window.devicePixelRatio || 1 canvas.width = Math.floor(viewport.width * dpr) canvas.height = Math.floor(viewport.height * dpr) canvas.style.width = `${viewport.width}px` canvas.style.height = `${viewport.height}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) if (renderTask) renderTask.cancel() renderTask = page.render({ canvasContext: ctx, viewport }) try { await renderTask.promise } catch (err) { if (err?.name === 'RenderingCancelledException') return console.error(err) } } onMounted(() => { if (props.pdfUrl) { loadPdf(props.pdfUrl) } }) onBeforeUnmount(() => { if (renderTask) renderTask.cancel() if (pdfDoc) pdfDoc.destroy() }) </script>

这个组件是后面所有扩展的地基。接下来我讲的翻页、缩放、文本层,都是在它基础上加状态和方法。

4. 实操扩展:翻页、缩放、文本选中与复制

4.1 翻页操作与状态管理的正确姿势

实现上一页/下一页按钮很简单,核心是维护currentPage和totalPages两个状态。但这里有个 vue3 特有的坑:不要把pdfDoc、renderTask、ctx这些非纯数据对象放进reactive或ref,否则 Vue 会给它们做响应式代理,徒增性能开销,甚至在某些版本下触发奇怪的报错。

我实测下来的经验是:currentPage、totalPages、scale这些页面状态用ref管理;pdfDoc、renderTask用普通let变量。页面 DOM 需要绑定状态时,模板里只依赖ref变量。

翻页的逻辑里加一个边界判断,防止用户狂点按钮导致页码越界:

async function nextPage() { if (currentPage.value >= totalPages.value) return currentPage.value++ await renderPage(currentPage.value) } async function prevPage() { if (currentPage.value <= 1) return currentPage.value-- await renderPage(currentPage.value) }

每次 querycurrentPage变化,会触发页面状态更新,canvas 重绘。这里还可以顺便做一个加载态:翻页过程中把按钮置灰,避免重复触发。不过用了renderTask.cancel()之后,即使连点按钮也不会出大问题,因为新渲染会立刻取消旧渲染。

4.2 缩放适配:scale 计算与高清屏细节

缩放看似是改一个scale变量重新渲染,但要做得顺手,必须理解 viewport 和 CSS 尺寸的联动。我的做法是把scale放进ref,用户点放大按钮时修改它,然后重新调用renderPage(currentPage.value):

const scaleRef = ref(1.5) function zoomIn() { scaleRef.value = Math.min(5, scaleRef.value + 0.25) renderPage(currentPage.value) } function zoomOut() { scaleRef.value = Math.max(0.5, scaleRef.value - 0.25) renderPage(currentPage.value) }

渲染函数内部引用scaleRef.value而不是外部固定常量,这样缩放和翻页共用一套逻辑,不会出现“缩放了翻页又变回原样”的问题。

如果你做的不是按钮缩放,而是像地图那样“以鼠标位置为锚点缩放”,那么还需要在缩放前记录鼠标相对页面内容的位置,缩放后调整滚动条偏移,保证用户盯着的文字不跑偏。这个逻辑不复杂,但比较繁琐,普通业务用按钮缩放就够了,我暂时不展开。

还有一个容易忽视的点:PDF.js 渲染出的页面宽度是固定的,如果外层容器宽度小于 canvas 宽度,会出现横向滚动条。在管理系统里,通常希望 PDF 自适应容器宽度。这时候可以通过容器宽度反推 scale:

const containerWidth = containerRef.value.clientWidth scaleRef.value = containerWidth / viewport.width

相当于“让页面刚好铺满容器”。但要注意,这么做之后用户再手动放大缩小,scale 就会被固定值替换。更好的方案是维护一个baseScale(容器自适应时的 scale)和userScale(用户手动缩放倍率),实际渲染 scale 等于两者相乘。篇幅关系,我提一下思路,实战中按这个结构设计状态就很清晰了。

4.3 文本层实现:让用户能选中和复制 PDF 里的文字

canvas 渲染出来的页面是一张位图,浏览器默认无法选中文字。如果你做过合同预览、论文阅读类的项目,肯定知道“不能复制文字”是个致命体验缺陷。PDF.js 的解决方案是用page.getTextContent()解析页面里的文本和位置,然后用 DOM 元素叠加在 canvas 上方,实现“看起来是 PDF,文字却能选中复制”的效果。

文本层的基本结构是:一个绝对定位的容器包裹在 canvas 外层或旁侧,容器内每个文本片段是一个<span>,通过绝对定位设置left、top、fontSize、transform等样式。下面是简化版实现:

async function renderTextLayer(page, viewport, container) { const textContent = await page.getTextContent() container.innerHTML = '' container.style.width = `${viewport.width}px` container.style.height = `${viewport.height}px` textContent.items.forEach((item) => { if (!item.str || !item.transform) return const span = document.createElement('span') span.textContent = item.str span.className = 'pdf-text-span' // 关键:把 PDF 内容坐标转换为屏幕坐标 const tx = pdfjsLib.Util.transform(viewport.transform, item.transform) const fontHeight = Math.hypot(tx[2], tx[3]) const fontScale = fontHeight ? fontHeight / 1000 : 0.01 span.style.left = `${tx[4]}px` span.style.top = `${tx[5] - fontHeight}px` span.style.fontSize = `${fontHeight}px` span.style.transform = `rotate(${Math.atan2(tx[1], tx[0])}rad)` span.style.fontFamily = item.fontName ? `"${item.fontName}"` : 'sans-serif' container.appendChild(span) }) }

这里的viewport.transform是渲染时的坐标变换矩阵,item.transform是 PDF 内部为这个文本项定义的变换矩阵,两者通过pdfjsLib.Util.transform合成,得到屏幕上每个字的位置。我这个实现里fontSize直接取变换后的高度,是因为 PDF 里字体高度按抽象单位存储,需要经过变换矩阵映射到屏幕像素。

样式上,文本层容器要放在 canvas 正上方,并用 CSS 保证透明和穿透:

.pdf-text-layer { position: absolute; top: 0; left: 0; overflow: hidden; line-height: 1; user-select: text; pointer-events: none; color: transparent; } .pdf-text-span { position: absolute; white-space: pre; transform-origin: 0% 0%; }

pointer-events: none让鼠标事件穿透文本层,不影响页面自身的点击交互;user-select: text则保证文字仍然可以被选中。这个组合看起来矛盾,实际在浏览器里的表现是:可以用鼠标拖选文字,但不会拦截 canvas 上的点击事件。

如果你的项目需要滚动条或缩放文字层,记得在每一次renderPage成功后都重新调用renderTextLayer,并先container.innerHTML = ''清掉旧 span,否则旧文字会叠在新的上面。

4.4 文本层的局限与替代方案

手动实现文本层有一个绕不开的局限:文本位置和样式只能做到“近似还原”,遇到旋转文字、跨行、复杂字体时,可能出现位置偏移或者被 canvas 内容盖住一半。如果你做的是 PDF 批注、文字搜索高亮这类对坐标精度要求很高的功能,更推荐用官方在新版本里封装的TextLayer,它接收textContentSource、viewport等参数,帮你处理了更多边界情况。不过它 API 变动幅度较大,我用过的版本迁移成本不低。

如果你的项目实际上不做文字交互,只想屏蔽选中效果,那可以直接不做文本层,也能少很多适配工作。我的原则是:按需实现,别为了“炫技”给每个 PDF 组件都配一层文本。

5. 高频踩坑记录:worker 报错、白屏、内存泄漏的排查链路

5.1 worker 报错:从“Setting up fake worker”说起

很多第一次在 vue3 + Vite 里接入 PDF.js 的朋友,控制台会看到这样的提示:

Setting up fake worker Failed to fetch dynamically imported module: http://localhost:5173/pdf.worker.min.mjs

这个报错的本质是:PDF.js 尝试创建真正的 Web Worker,但拿不到 worker 脚本地址,于是回退到主线程模拟 Worker。在 4.x 之后的版本里,fake worker 模式下连基础功能都可能直接白屏,而不是像 3.x 一样只是性能差。

排查路径我建议按顺序走:

  1. 检查GlobalWorkerOptions.workerSrc是否设置。如果没设置,补上new URL(...)那段代码。
  2. 检查路径字符串是否是pdfjs-dist/build/pdf.worker.min.mjs。有些教程写的是.js,在 4.x/5.x 里就不对。
  3. 在浏览器 Network 面板搜索pdf.worker,看请求是否成功、状态码是否为 200。如果 404,多半是资源路径没被正确打包,检查 Vite 的base配置或文件位置。
  4. 确认没有其他页面代码覆盖GlobalWorkerOptions.workerSrc。

真实项目中我还遇到过一种情况:项目里同时引用了老版本pdfjs-dist的某个插件,它内部自己又设置了一遍workerSrc,把路径覆盖成了错误地址。排查时可以在设置之后立即打印:

console.log(pdfjsLib.GlobalWorkerOptions.workerSrc)

确认无误再继续。

5.2 本地 file:// 协议与 CORS 跨域的限制

如果你用npm run dev起本地服务,通过http://localhost访问页面,加载本地 PDF 或同源 PDF 是没问题的。但如果你直接把打包后的dist目录用双击 index.html 的方式打开,也就是file://协议,PDF.js 的 worker 加载和 PDF 文件读取都会因为跨域策略失败。

一条让人崩溃的报错长这样:

Access to fetch at 'file:///.../pdf.worker.min.mjs' from origin 'null' has been blocked by CORS policy

原因很简单:file://协议下,页面源是null,任何同目录文件请求都视为跨域。解决方式也不是去改什么 worker 配置,而是正确起一个本地静态服务器:

npm run preview

preview 模式会按生产构建启动一个本地服务,既能验证打包产物,又不会踩 file:// 的坑。如果你负责的是一些“发送 HTML 压缩包给别人打开”的内部项目,我强烈建议把静态服务器作为标准交付手段,而不是发一个双击打开的文件。

另一个常见场景是:PDF 文件存储在 OSS/CDN 上,页面域名和文件域名不同。这时要求 OSS 配置正确的 CORS 规则,允许页面源跨域读取。如果对方没有配,浏览器会拦截响应。排查跨域问题最快的方式是看 Network 面板里 PDF 文件请求的 response header 有没有Access-Control-Allow-Origin。

5.3 快速翻页导致白屏或画面闪烁

白屏通常发生在快速连点翻页时,根因不是渲染失败,而是“渲染任务被 cancel 之后错误没有被正确处理”。前面我说过,renderTask.cancel()会让当前渲染的 promise reject,如果这个 reject 没有 catch,它会作为 unhandled rejection 抛到全局,React 或 Vue 的错误边界如果配置不当,可能直接导致组件卸载或页面空白。

完整处理方式是所有await renderTask.promise的地方都包一层 try/catch,并且把 cancel 场景单独放行。另外,我还遇到过一个诡异现象:页面 A 渲染完成后,页面 B 的渲染任务开始前,canvas 上显示的是旧内容,视觉上出现“上一页残影”。解决办法是在渲染前清空画布:

ctx.clearRect(0, 0, canvas.width, canvas.height)

这样在取消旧任务到新任务完成之间的空档,canvas 是干净的,视觉上只会闪一个空白,不会出现残影叠加。如果业务上不能接受空白闪烁,那就再加一个 loading 占位层,等renderTask.promise完成后再隐藏。

5.4 组件卸载后仍然继续渲染:内存泄漏的完整处置

这个问题最容易出现在 SPA 里:用户从“合同预览”页跳到“订单列表”页,组件实例已经卸载,但之前发起的 PDF 渲染任务还没有结束。渲染完成后的回调会尝试操作已经卸载的 DOM,轻则报错,重则内存持续上涨。

我做这类组件时,onBeforeUnmount里一定按顺序做三件事:

onBeforeUnmount(() => { // 1. 取消尚未完成的渲染任务 if (renderTask) renderTask.cancel() // 2. 释放 pdfDoc 内部资源 if (pdfDoc) pdfDoc.destroy() // 3. 如果有临时 URL,需要 revoke if (tempUrl) URL.revokeObjectURL(tempUrl) })

pdfDoc.destroy()会关闭 PDF 文档,释放内部缓存的字体、图片等资源。这一步不做,你连续打开几十个不同 PDF 后,内存占用能轻松飙到几百 MB。我遇到过一个真实项目,就是漏了 destroy,导致后台系统用一两个小时后就卡到无法操作。

还有一点容易忽略:在组件内部监听window事件(比如缩放窗口自适应、键盘翻页)后,记得在卸载时移除监听。否则用户切换路由后,旧组件的监听器还在跑,每次都会触发一次渲染,白耗性能。处理办法是用onMounted里 addEventListener,onBeforeUnmount里 removeEventListener。

6. 性能优化:大文件和多页场景下的渲染策略

6.1 一次渲染一页还是多页:按场景选方案

上面的示例组件是“当前页模式”,一次只渲染一页,翻页时切换 canvas 内容。这种方案对内存最友好,实现也简单,适合大多数后台系统的单页预览。但如果你的业务要求像浏览器预览那样“滚动长页、连续显示”,就得引入多页渲染。

多页渲染最简单做法是用一个容器按顺序放多个 canvas,渲染当前视口附近的页面,而不是把所有页面一次性渲染完。假设一份 PDF 有 80 页,如果上来就渲染 80 个 canvas,页面直接卡死是必然的。正确的做法是参考虚拟滚动思想:只渲染可视区域和预渲染缓冲区的页面,滚动时动态挂载和卸载 canvas。

我实现过一个简化版:维护一个currentRange,根据滚动位置计算 当前应该渲染第几页到第几页,每次只创建范围内的 canvas。离开范围的 canvas 直接移除,并调用pdfDoc.destroyPage或复用page.cleanup()释放内存。这个方案在大文件场景下能保持流畅滚动,但代码量明显更大,我只建议在真正需要“连续滚动阅读”的项目里做。

6.2 页面缓存策略:把渲染结果复用到极致

如果用户在一页和下一页之间来回切换,每次都重新渲染,显然很浪费。一个常见的优化是用 Map 缓存已经渲染出的 canvas 位图:

const pageCanvasCache = new Map() async function renderPageWithCache(pageNum) { if (pageCanvasCache.has(pageNum)) { // 直接用缓存的 canvas 替换到容器 return pageCanvasCache.get(pageNum) } // 渲染并存入缓存 }

这里要控制缓存上限,一般只缓存当前页的前后 2~3 页,超过上限就删除最早缓存的页面。因为 PDF 页面位图是很吃内存的,A4 页面在高分屏下渲染出一张图动辄几 MB,缓存十几张就是几十 MB。用 LRU 思想清理才能保证长时间翻阅不出问题。

我自己通常只做前后两页的缓存辅助,不做全局缓存。原因很简单:现代浏览器保留 canvas 位图的成本并不比重新渲染低太多,而且缓存多了,翻到后面页面时,早期的缓存就成了纯浪费。

6.3 大 PDF 文件的加载优化:按需加载与进度反馈

公司内部系统经常会上传几百 MB 的标书、图纸,这种文件用getDocument({ url })直接加载,等待时间非常长,而且用户没有反馈,直观感受就是“白屏了点不动”。处理办法有两个方向。

一是给loadingTask加进度回调。getDocument返回的PDFDocumentLoadingTask上有onProgress事件:

const loadingTask = pdfjsLib.getDocument({ url }) loadingTask.onProgress = (progressData) => { const loaded = progressData.loaded const total = progressData.total || 0 progressPercent.value = total ? Math.round((loaded / total) * 100) : 0 }

这样 UI 上可以显示“加载中 xxx%”。实测下来对超大型文件,进度条能大幅降低用户的焦虑感。

二是考虑使用服务端拆分上传或预压缩,但这属于后端配合范畴,前端要做的就是在渲染前判断文件页数,如果页数太多,提示用户“该文件共 N 页,可能加载较慢”,而不是默默卡着。给用户预期,比任何优化都重要。

6.4 渲染清晰度与性能的平衡

很多开发者在追求“清晰”时会把 scale 调很大,比如scale = 3。我见过有人为了让 PDF 在高分屏上更清楚,直接设 3,结果打开一个 50 页的 PDF,每页渲染耗时接近 1 秒,翻页卡顿明显。

实际上,屏幕显示只需要达到 devicePixelRatio 对应的物理像素就够了。我的建议是先用scale = 1.5起步,结合window.devicePixelRatio做适配,用户在需要看清细节时再通过缩放按钮主动放大。这样既保证了清晰度,又不会让默认渲染变成性能负担。

如果确实需要加载后立即展示高质量页面,可以在renderPage时先用低 scale(比如 0.7)快速出图,再在后台用高 scale 重新渲染替换。这种“渐进式增强”在移动端比较常见,但实现复杂度高一些,普通项目不是必须。

7. 组件化封装与项目落地建议

把上面这些逻辑全部揉进一个.vue文件里也能跑,但一旦业务变多,代码会很难维护。我会建议按层拆分:

  • src/utils/pdf.ts:统一导出GlobalWorkerOptions.workerSrc配置、加载 PDF、渲染页面、文本层等纯函数
  • src/components/PdfViewer.vue:负责 UI 交互层,模板里有 toolbar、canvas、文本层,逻辑上调用 utils 里的函数
  • 业务页面:只负责传pdfUrl和监听页面跳转等业务事件

这样的好处是:如果以后要做“PDF 批注”“PDF 转图片”等功能,可以直接复用 utils 里的底层函数,不必改动 UI 组件。我在组件开发里一直坚持“UI 与逻辑分离”,在 PDF.js 这种强逻辑场景下尤其值得。

状态管理上,如果多个业务页面需要共享“当前 PDF 打开的文件、当前进度”,可以考虑把pdfDoc放到 Pinia 里。但我要提醒:Pinia 的 state 会自动做响应式代理,而pdfDoc内部包含大量非可序列化对象和方法,放进 store 之后虽然用起来方便,但性能和维护性都要打折扣。我的习惯是,pdfDoc仍然放在组件内部管理,Pinia 只存 URL、页码、页码列表这些纯状态。

如果你团队里已经有成熟的组件库,比如 Element Plus、Ant Design Vue,建议把按钮、进度条、空状态这些 UI 都用组件库的控件,不要自己重复造轮子。PDF.js 负责的是“渲染内核”,那些外围交互交给现成的 UI 组件,开发效率会高很多。

最后分享一个部署上的细节:打包上线后,在 Nginx 静态资源目录下确认assets里确实有pdf.worker.min-xxxx.mjs这个文件。有些 CDN 上传工具会忽略.mjs后缀,或者 MIME 类型配置不对,导致 worker 加载时报 MIME 错误。最简单的验证方式是用生产地址直接访问这个.mjs文件,如果浏览器能正常下载,说明资源发布没问题;如果返回了 HTML 错误页,就说明 CDN 或 Nginx 对.mjs的 Content-Type 配置有误。这类问题在本地一般测不出来,上线前一定要检查。

我在实际项目里从 3.x 一路升到 5.x,每一次升级都会碰到 API 变化带来的问题,尤其是 worker 路径和文本层这两个点改过好几次。如果你正卡在某个版本报错上,别急着改业务代码,先确认版本号,再按对应版本来查,很多问题其实都是“教程版本”和“实际版本”不匹配造成的。

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

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

立即咨询