做前端这么多年,最怕听到的需求永远是“放个PDF进去,但不能让人下载”——在线预览本身不难,难的是把“预览”和“安全”这一对矛盾揉进同一个组件。PDF.js 是绕不开的底座,但我们团队最后落地的并不是它的现成 viewer,而是一个从虚拟滚动到水印渲染全部自己控制的安全 PDF 预览组件。这套方案解决了公司单证系统里超过千页合同、内部标注文件在线预览的卡顿问题,也彻底告别了用户右键保存、拖拽下载的尴尬。这篇文章把我从选型、架构、实现到踩坑的过程完整写出来,适合所有正准备做文档预览或正在被 PDF 渲染性能折磨的前端同学。
1. 选型之前:为什么原生 PDF 方案在安全场景里寸步难行
1.1 “能打开 PDF”和“能安全预览 PDF”完全是两件事
浏览器里塞 PDF 最原始的办法就是 <iframe src="xxx.pdf">,两三行代码就能“跑通”。但你去点一下,浏览器自带的 PDF 查看器会直接给你展示一排工具栏:下载按钮、打印按钮、旋转、搜索、缩放,一样不缺。就算你藏了入口,用户只要在 iframe 上点右键,就能看到“在新标签页打开”,一旦绕出 iframe,浏览器原生查看器又会接管一切。
这还不是最麻烦的。原生查看器是典型的“黑盒”,你没法往渲染结果上叠加水印,也没法在翻页、滚动时注入自己的逻辑。对于内部资料场景,下载按钮和右键菜单基本就是致命伤。你去禁右键、拦快捷键,拦得再多也拦不住浏览器自身的“下载”菜单,更拦不住用户拿手机对着屏幕拍。所以在这个需求一开始,团队就达成了一致:原生方案只适合放公开文档,一旦涉及权限控制,必须自己做渲染层。
1.2 PDF.js 能做但 viewer 目录不能直接用
PDF.js 是 Mozilla 维护的纯前端 PDF 解析和渲染引擎,它的核心能力是把 PDF 里的每一页绘制到 canvas 上。渲染工作放在 Web Worker 中执行,主线程只负责拿页面对象和画布清空、提交渲染任务,所以页面本身卡顿的控制力比 iframe 强很多。
但我强烈不建议直接把 PDF.js 自带的 viewer.html 塞进项目里。官方 viewer 功能太全:目录树、搜索、缩放、旋转、下载、打印、文本选择都内置了。你如果要做一个面向内部的“安全预览组件”,得先把官方 viewer 里一大堆能力黑掉、白名单掉,而且它内部的 DOM 结构和样式体系已经完全自成一套,想再叠一层业务逻辑,维护成本并不低。真正务实的路线是放弃官方 viewer,只用 pdfjs-dist 暴露的底层 API,把“预览器 UI”和“安全策略”全部自己掌控,这才符合标题里说的“组件化”思路。
1.3 相关方案对比,帮大家少走弯路
| 方案 | 实现成本 | 页面渲染控制力 | 水印叠加能力 | 安全属性 | 适用场景 |
|---|---|---|---|---|---|
| iframe 嵌原生 PDF | 极低 | 几乎没有 | 基本做不到 | 极差,无法阻止下载 | 一次性原型、公开文档 |
| PDF.js 官方 viewer 嵌入 | 中 | 中,但需大量魔改 | 需二开 | 中,官方默认还带下载按钮 | 对 UI 无要求的内部预览 |
| PDF.js 自研组件 | 较高 | 完全可控 | 可深层定制 | 按需发挥 | 有权限、水印、审计要求的系统 |
| pdfium / WASM 重渲染 | 高 | 高 | 可做 | 高 | 要做全文切分、编辑等重度场景 |
从表格能看出,如果你只是展示一个 PDF,iframe 就够了;但一旦需求里出现“禁止下载”“动态水印”“双击事件打点”这类词,基本只有 PDF.js 自研一条路。pdfium 虽然渲染性能好,但它的解析渲染全部走 C++ 编译产物,和前端生态的集成成本高,而且社区资料少,人员上手时间很长。除非你有大量 PDF 详情页渲染性能诉求,否则没必要上这么重的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组件整体架构:渲染流水线和安全策略分开治理
2.1 模块划分与数据流
这个组件我没有按“一个大的 Vue/React 文件”去写,而是拆成了 5 个职责独立的模块:文档加载器、布局管理器、视口虚拟滚动器、页面渲染器、安全控制器。文档加载器只负责把 ArrayBuffer 交给 PDF.js,拿到 PDFDocumentProxy。布局管理器知道一共有多少页、每一页在当前缩放比例下的宽高和累计偏移量。视口虚拟滚动器监听滚动容器的 scrollTop,算出当前应该显示哪些名字在 DOM 上的页面。页面渲染器拿到具体的页面编号,从 PDF.js 的页面对象中渲染到 canvas 上。安全控制器则统一管理水印的生成、右键与拖拽事件的拦截、快捷键的兜底处理。
数据流是单向的:滚动事件进入虚拟滚动器,虚拟滚动器计算出需要渲染的页面区间,交给页面渲染器;只有真正在可视区的页面才会触发 PDF.js 的 render 方法。水印渲染发生在前置阶段和页面渲染完成之后两个时间点,这样可以保证不管用户翻到哪一页,水印层都已经就位。
2.2 worker 配置是第一次“安全落地”的拦路虎
PDF.js 的 API 看起来很简单,但配置 worker 是第一道翻车点。getDocument() 只有在 worker 里解析字节流,主线程才不会卡死。如果是 Webpack 或 Vite 工程,建议直接用模块方式引用 worker:
javascript复制import * as pdfjsLib from 'pdfjs-dist';
import PdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker';
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
这里有个特别坑的细节:如果你用的是 pdfjs-dist@4.x,构建文件变成了 .mjs,如果照抄老文章里的 pdf.worker.js 路径,大概率会 404。另外,老版本里 GlobalWorkerOptions.workerSrc 接受路径字符串,你可以把 worker 文件放进静态资源目录,我建议直接放到项目内静态目录并在部署时确认 MIME 类型是 application/javascript。曾经就有同事把 worker 丢到 CDN 上,CDN 返回了 application/octet-stream,结果 PDF.js 在部分浏览器里直接解析失败,页面白屏了五个小时才定位到,这种环境问题比业务代码问题更隐蔽。
2.3 权限配置入口:所有安全能力先收敛成一个 options
组件对外暴露时,我不建议让业务方直接传一堆布尔开关,而是收敛成一个权限配置对象。例如:
javascript复制const preview = createSafePdfPreview({
url: '/api/document/file?id=123',
permission: {
allowDownload: false,
allowPrint: false,
allowCopy: false,
allowOpenInBrowser: false,
},
watermark: {
text: `内部资料-工号${getCurrentUser()?.id}-${dayjs().format('YYYY-MM-DD HH:mm')}`,
opacity: 0.09,
angle: -25,
},
maxZoom: 2,
});
所有安全判断逻辑都读取这份配置,渲染层不关心具体规则。业务侧如果以后要基于部门维度动态开放“部分人可下载”,只改这个配置入口就能满足,组件内部不会因为这些需求被拆得七零八落。
3. 虚拟滚动实现:千页 PDF 不崩溃的关键不在渲染引擎
3.1 为什么不把每一页都渲染成 DOM
很多第一次做 PDF 预览的同学会把 PDF 的每一页都循环渲染成 <canvas>,再把它们挂到一个纵向容器里。100 页以内的 PDF 这样没问题,但超过 300 页以后,页面上的 canvas 数量、浏览器维护的位图内存、以及每次滚动触发的重排成本,都会让页面走向崩溃边缘。
我做过一个粗略估算:A4 页面在默认缩放比例下渲染到 canvas,大约每页会占用 5-12 MB 的 GPU/内存位图空间。如果 800 页全部渲染,即使懒加载不做,浏览器也会先被 canvas 宽度和高度限制卡住,接着内存飙升。所以必须引入“虚拟滚动”,即:不管文档多少页,DOM 里始终只保留当前视口附近几页的节点。用户滚走了,节点就回收;滚回来,再从 PDF.js 渲染。这相当于把 PDF 预览从“一次全量渲染”变成“围绕视口的流式渲染”。
3.2 建立页面布局表,用二分查找替代逐页计算
虚拟滚动的第一步是计算每一页在容器中的坐标和偏移量。PDF.js 的每个页面对象可以获取当前缩放下的 viewport,也就是页面渲染出的宽度和高度。默认 scale=1 时大小可能偏小,实际预览需要换算到适合阅读的 scale。我通常用 containerWidth | clientWidth / 2 之类的方式算出一个基础 scale,再对所有页面用同一个 scale 创建 viewport,从而得到每一页的宽高。
之后把所有页面信息放进一个纯数组:
javascript复制pageLayouts = [
// { pageNo, width, height, top, bottom }
];
每个元素记录页面的垂直 top 和 bottom 坐标。这样滚动容器高度全量撑开,滚动条位置真实可拖。每次滚动手势触发时,用二分查找找到 scrollTop 对应的 page 下标,省去遍历全部页面的开销。800 页文档滚动时,这样查下标几乎不会产生额外开销,这也是虚拟滚动能保持流畅的基础。
3.3 可视区窗口计算与“预渲染带”
当找到第一个可见页面后,再去接收下一个可视窗口,不能只渲染一页,通常视口内会有 1-2 页在当前屏幕内,但你滚动过程中如果只渲染屏幕内这一页,用户快速滚动时会出现大量白屏。
我的方案是在可视页基础上再增加一个“预渲染带”,前后各预留若干页,预渲染带的量取决于滚动速度。为了极致控制,我在 RAF 里更新虚拟列表的渲染范围。如果期间用户滚动又变快了,当前未完成的大量渲染任务会自动取消。你可以看下面的简化代码:
javascript复制function updateRenderQueue() {
const firstVisible = binarySearch(pageLayouts, scrollTop);
const lastVisible = binarySearch(pageLayouts, scrollTop + clientHeight);
const start = Math.max(0, firstVisible - preloadPages);
const end = Math.min(totalPages, lastVisible + preloadPages);
// 不在 [start, end] 区间内的页面做销毁
// 区间内未渲染的页面入队,按可视优先级排序
}
3.4 渲染任务调度与画布复用
虚拟滚动只是控制 DOM 上的节点数量,真正让渲染不卡的是 PDF.js 渲染任务调度。每一个 page.render() 调用返回一个 RenderTask 对象,它有 cancel() 方法。当页面的 canvas 即将被回收时,如果它还在排队或正在渲染,必须调用 cancel,否则 worker 里可能堆积大量任务。
canvas 的创建和销毁也很伤性能。建议做一个简单对象池:页面离开可视区时不立即 remove,先把 canvas 的 width/height 归零并放入队列;下一个页面需要显示时,优先从队列里取 canvas 对象,重新设置宽高并渲染。这样可以避免频繁创建和销毁 DOM 节点,页面切换时肉眼可见得更平滑。
javascript复制const canvasPool = [];
function acquireCanvas(width, height) {
const canvas = canvasPool.pop() || document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
return canvas;
}
function releaseCanvas(canvas) {
canvas.width = 0;
canvas.height = 0;
canvasPool.push(canvas);
}
这里面还有个容易被忽略的内存点:PDF.js 页面渲染完成以后,页面对象内部仍然持有大量字体、token 和其他数据。尤其是一个 page 渲染完成而长时间不再使用,应该调用 page.cleanup() 释放资源。但 cleanup() 不能全局频繁调用,否则每次重新渲染都要重新解析资源,开销更大。我在实际项目里做的是:当页面长时间离开可视区,比如超过 5 秒仍然没有再次进入可视区时才执行 cleanup,同时保留页面对象本身以便瞬时返回。
3.5 采样实测:千页单证 PDF 的体感
改造完成后我用一份 1026 页的公司单证 PDF 做了压测。没做虚拟滚动前,把所有页一次性塞进 DOM,浏览器直接卡到无法操作,页面崩溃前我在“页面统计”插件里看到 canvas 数量超过 1000 个,内存峰值到了接近 2 GB。接入虚拟滚动和 canvas 复用后,同一台电脑上 DOM 里的 canvas 始终不超过 10 个,内存峰值降到了 300 MB 左右,首屏白屏时间从不可用缩短到了 1.2 秒。快速拨动滚动条时虽然仍需要短暂等待,但已经属于正常渲染时序,不再出现“页面彻底死掉”的问题。
4. 水印渲染:从平铺水印到动态身份标识
4.1 水印的形式选择:平铺文本已经是及格线
安全预览组件里的水印,首先要求是“看得见”。常见做法是平铺文本水印,也就是把用户名、工号、时间等信息以一定角度重复铺在页面上。这个方案在信息泄露溯源上已经能覆盖大部分业务场景:一旦有人拿手机拍了屏幕,水印可以明确指出是谁在什么时刻访问了这份文档。
我见过有些团队用 CSS 背景图或一个全屏覆盖的 SVG 背景做水印,这些做法在纯静态页面没问题,但放到 PDF 预览中要考虑到文档滚动和缩放。如果水印层覆盖在整个滚动容器上,页面发生位移后水印对齐关系就会乱。正确做法是让水印层作为虚拟滚动列表中的普通页面节点,在单页容器内绝对定位,这样每一页的水印会跟随页面一起滚动,天然无限靠拢页面坐标。
4.2 用 Canvas Pattern 画平铺水印
简单且性能高的做法是先创建一个小画布作为“水印章”,把文本按旋转角度画到小画布上,再用 createPattern(canvas, 'repeat') 把重复铺开。这样做的好处是无论绘制多少页,重复纹理只需要生成一次。
javascript复制function createWatermarkPattern(text = '') {
const size = 320;
const tileCanvas = document.createElement('canvas');
tileCanvas.width = size;
tileCanvas.height = size;
const ctx = tileCanvas.getContext('2d');
ctx.clearRect(0, 0, size, size);
ctx.globalAlpha = 0.08;
ctx.font = '14px "Microsoft YaHei", sans-serif';
ctx.fillStyle = '#333';
ctx.translate(size / 2, size / 2);
ctx.rotate((-20 * Math.PI) / 180);
ctx.textAlign = 'center';
ctx.fillText(text, 0, 0);
return ctx.createPattern(tileCanvas, 'repeat');
}
在水印层渲染时只需要:
javascript复制waterCtx.fillStyle = pattern;
waterCtx.fillRect(0, 0, pageWidth, pageHeight);
需要注意的是字体大小、透明度、间距会直接影响水印的“视觉侵入度”。透明度太高,起不到溯源作用;透明度太低,会干扰文档本身的阅读。我的默认值在 0.06-0.1,字号在 14-18px,具体可以根据内部 PDF 内容密度微调。如果 PDF 页面本身背景偏灰,可以把水印颜色换成品牌色或深灰色,保证在任何浅色背景上都有辨识度。
4.3 动态水印与渲染时机
动态水印不要把用户信息写死在页面初始化时,因为水印内容本身可能就是由鉴权接口返回的。我把安全控制器设计成异步获取用户展示信息,等 watermark 内容 ready 后再初始化平铺纹理。业务逻辑里,水印的文本建议格式为:当前用户名 + 工号 + 访问时间 + 文档标识。
水印层渲染是在页面 canvas 渲染完成之后进行。可以监听 pageRenderTask.promise 完成后给同一容器添加一个绝对定位的 watermark canvas;如果用户调整页面缩放,水印画布尺寸也需要同步。最简单的做法是把水印节点和页面 canvas 一起放进同一个大小一致的容器中,坐标配置 top:0/left:0。
有一个容易踩的坑就是水印层拦截鼠标事件。必须给它设置 pointer-events: none,否则拖动选择、划词高亮、点击翻页都会失效。很多做水印功能的人都在这上面浪费过半天。
4.4 不吹不黑:水印解决不了截屏
把水印做得再完美,也必须向业务方讲清楚边界:水印防的是“截图外流后无法溯源”,但防不了“有心人用手在手机屏幕上拍照”。在浏览器环境中,根本没有办法绝对禁止截屏。你可以尝试隐藏 document、监听某些键盘事件、在 visibilitychange 时销毁渲染内容,但这些手段都会被更高权限的操作绕过。更合理的安全组合是:接口层做访问时效校验,文件本身不直接暴露可下载直链,配合动态水印做溯源。这几层叠加之后,泄密成本才真正提高,单靠某一个前端组件是撑不起整个安全体系的。
5. 落地过程的真实踩坑记录
5.1 高分屏下文字发虚:devicePixelRatio 必须参与渲染
第一版组件在普通显示器上一切正常,换到 MacBook 的 Retina 屏幕上,PDF 里的文字边缘发虚,严重的时候像蒙了一层雾。原因很简单:canvas 的内部像素尺寸如果等于 CSS 尺寸,在高 DPR 屏幕上像素不足,浏览器会做缩放插值,导致文字模糊。
解决思路是渲染前放大画布物理尺寸,再让 PDF.js 在渲染时通过 transform 做缩放补偿:
javascript复制const clientWidth = viewport.width;
const clientHeight = viewport.height;
const dpr = window.devicePixelRatio || 1;
const canvas = acquireCanvas(
Math.floor(clientWidth * dpr),
Math.floor(clientHeight * dpr)
);
canvas.style.width = clientWidth + 'px';
canvas.style.height = clientHeight + 'px';
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null;
page.render({
canvasContext: canvas.getContext('2d'),
viewport,
transform,
});
注意这个时候不能直接把 viewport = page.getViewport({ scale }) 里的 scale 改成 * dpr,否则页面 CSS 尺寸会跟着变大,布局全面失控。我第一版就是这么写的,结果页面间距错乱。后来才意识到,正确的做法是“CSS 布局用一份尺寸,canvas 内部像素用另一份尺寸,中间用 transform 桥接”。
5.2 组件卸载时任务不清理导致的内存泄漏
这类内存泄漏在单页应用里最容易发生。用户打开预览页,上下翻看了一会儿,直接跳转路由,但组件卸载时如果没做清理,PDF.js 的 worker 会继续在后台持有数据,再次进入预览页面时又新建 worker,内存成倍上涨。
排查下来主要是没处理好三点:一是没有在 onUnmount 时遍历所有渲染任务并调用 cancel();二是不停创建的 cancel 信号并没有被 PDF.js 的页面对象释放,比如页面对象的 destroy() 没有被调用;三是 worker 实例没有销毁。正确的卸载清理序列是:
javascript复制function destroyPdfViewer() {
Object.values(renderTasks).forEach((task) => task.cancel());
Object.values(pages).forEach((page) => page.destroy());
pdfDoc?.destroy();
pdfWorker?.terminate();
}
顺序不能颠倒,先 cancel 未完成的 canvas 渲染,再 destroy page 对象,最后销毁整个 PDFDocument 和 worker。如果先销毁了 page 对象再 cancel,某些版本的 pdfjs-dist 会直接抛异常。
5.3 中文 PDF 字体乱码与 cMaps
很多内部系统生成的 PDF 并不是标准字体,而是嵌入了 CID 字体的中文 PDF。这种文件在 PDF.js 中需要加载 CMap 和标准字体数据才能正确映射字符。如果没有配置,常见表现是部分汉字变成豆腐块或者根本不渲染。
配置方法是在 getDocument 时传入额外参数:
javascript复制await pdfjsLib.getDocument({
data: arrayBuffer,
cMapUrl: `${BASE_URL}/cmaps/`,
cMapPacked: true,
standardFontDataUrl: `${BASE_URL}/standard_fonts/`,
}).promise;
这两类资源都可以在 pdfjs-dist 包里找到。需要把它们单独拷贝到静态目录,因为打包工具不会默认把第三方包的资源文件搬进构建产物。这个坑很容易隐藏在生产环境:开发环境依赖 npm 路径能加载成功,一部署到 CDN 就白屏或乱码。
5.4 右键、拖拽和键盘事件的屏蔽清单
安全策略中有一项是阻止用户把 PDF 内容拖到其他窗口。draggable 图片,也就是 canvas 本身,默认会被浏览器当作图片拖拽。必须给页面节点增加 draggable="false",并在容器上监听 dragstart、drop 事件阻止默认行为。
右键菜单的拦截虽然不能阻止浏览器原生下载,但能减少普通用户随手保存的概率:
javascript复制container.addEventListener('contextmenu', (e) => e.preventDefault());
container.addEventListener('keydown', (e) => {
if (
(e.ctrlKey || e.metaKey) &&
['s', 'p', 'c', 'v'].includes(e.key.toLowerCase())
) {
e.preventDefault();
}
});
但这里我依然要强调:这种拦截只有提示意义,它挡不住技术熟练的用户。真正让文档不泄露,靠的还是服务端的权限校验。组件把可下载的原始文件地址隐藏成一串有时效的临时 token,到达前端就只给一次性读取的 ArrayBuffer,而不是留下一个可以反复下载的 URL,这个设计比任何前端拦截都重要。
6. 组件沉淀后的几点经验
这套安全 PDF 预览组件上线后,我的最大感受是:PDF.js 本身的能力边界其实很大,难的是不想把官方 viewer 的所有默认行为和数据结构直接继承过来。你一旦开始自研,就必须在虚拟滚动、canvas 生命周期、水印渲染和管理层权限治理上下狠功夫,任何一个环节漏了,都会在真实文件场景里暴露问题。
我也整理了一个自查清单,现在团队做同类需求都会照着过:第一,所有 PDF 文件是否都通过鉴权接口获取,网络层是否暴露了永久直链;第二,组件卸载时是否完整销毁 worker 与渲染任务;第三,canvas 的物理分辨率是否跟随 devicePixelRatio 变化;第四,水印层是否覆盖了所有页面,并且没有拦截鼠标事件;第五,虚拟滚动是否实现了任务取消和 canvas 对象池。只要这几项保持住,这套组件在项目里替换任何 PDF 预览需求都有稳的基础。
最后再说一个很多人忽略的小技巧:PDF.js 渲染是 CPU/GPU 密集操作,建议在组件初始化时加一颗“降级按钮”,对超大 PDF 自动提醒用户是否开启页面精简模式。虽然虚拟滚动已经能把千页 PDF 控制在合理内存内,但密集渲染在线路较慢的低功耗设备上仍会有明显热量和耗电,保留一个可选项对移动端体验非常友好。安全之外,体验本身的兜底,同样是我们做预览组件不能丢的东西。
