基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染
在公司做内部文档中台的时候,接到一个挺头疼的需求:要在Web端预览PDF,但不是普通的预览——文件不能轻易被下载,页面要带用户信息水印,而且一份PDF可能是几千页的标书、几万页的合同附件,不能打开就卡死浏览器。当时第一反应就是用<iframe>直接嵌浏览器原生预览,但很快就发现这条路根本走不通,于是转向了PDF.js。整个方案从调研到落地花了大半个月,中间踩了不少坑,这篇就把实现思路和关键细节完整拆开讲清楚。
这篇文章的目标读者是那些需要做在线文档预览、企业网盘、审批系统、知识库的前端同学。如果你只是需要展示一个三页五页的PDF,其实用<pdf-viewer>之类的封装库就够了,但一旦涉及三个关键词——多页大文件、安全控制、水印溯源——你真的需要一套自己能掌控的渲染方案。PDF.js是Mozilla开源的PDF解析渲染库,基于它做二次开发是目前Web端最主流、也是能力边界最高的路线。我会从为什么非要自己封装、虚拟滚动怎么设计、水印怎么嵌、性能怎么调四个维度完整讲一遍,配合可运行的思路和代码片段,保证你照做能少走一半弯路。
1. 内容整体设计与思路拆解
1.1 为什么不再用iframe或浏览器原生预览
很多业务系统里,PDF预览最省事的做法是直接扔一个<iframe src="xxx.pdf">。第一版Demo我也这么干,界面确实出来了,但问题接二连三:首先样式完全不可控,不同浏览器、不同系统下的工具栏长得完全不一样,Chrome下那个下拉框和下载按钮根本关不掉;其次是安全功能零基础,右键就能另存为、直接拖拽下载、浏览器缓存里还能翻到原始文件,这对一个要按密级管理文档的系统来说完全不合格;更致命的是大文件支持,Chrome渲染几十MB的PDF也会卡顿,遇到几千页的文件直接内存爆炸。
于是目标很明确:用PDF.js拿到底层渲染能力,完全自己控制展示层。PDF.js的canvas渲染方案可以把每一页变成一张图片画到Canvas上,页面内容是我们画出来的,而不是浏览器解析的,这样下载按钮、鼠标手势、右键菜单全部可以自定义,而且最终用户看到的只是合成后的画面截图,原始文件可以做到只在内存中存活,从根本上掐断了“浏览器自带功能导致泄密”的路径。
PDF.js的原理简单说一下:它把PDF二进制文件解析成一个文档对象,里面包含页面、字体、图片、文本等各种资源,然后通过一个渲染任务(renderTask)把指定页面按一定的缩放比例绘制到Canvas上。整个过程不需要任何浏览器插件,纯JavaScript加上Web Worker做解析,所以兼容性很好,从IE11到最新的Chrome都能用。这套架构意味着它能塞进任何前端框架里,也能配合任何权限系统做集成。
1.2 安全预览的完整能力清单
在动手写代码前,我们先得明确“安全预览”到底要覆盖哪些场景。我和安全组的同事列了一份清单,总共有四层:
- 防下载:禁用右键、禁止拖拽、隐藏工具栏,这些都是最基础的,真正关键的是文件流不能直接暴露在浏览器网络面板里,要用带鉴权的临时地址,最好是直接拉二进制流到内存,再由PDF.js解析。
- 防打印/防拍照:现在的打印机没有几个能真正防止的,但至少要拦截掉window.print,再配一个禁用复制文本的选项。
- 水印溯源:屏幕上必须能看到当前用户是谁,真出了问题截个图传出去,我们拿着截图就能反查泄露源头。这个水印要同时包含用户ID、邮箱、操作时间,还最好是半透明动态交错排布的。
- 行为留痕:打开、翻页、缩放、打印这些关键动作都要埋点上报,这个属于服务端范畴,前端配合采集就好。
听上去功能不少,但落到前端代码上,本质就是三件事:加载PDF数据、渲染页面、叠加水印。只要架构设计得当,这三件事可以解耦,互不干扰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 PDF.js的加载方式:URL、TypedArray还是File
PDF.js支持多种数据源:直接传URL、传一个ArrayBuffer、传一个Uint8Array。官方文档说可以直接传url,但在安全预览场景下,我不推荐直接传URL。原因很简单:如果你传了一个URL,浏览器会在后台发一个GET请求,这个请求会出现在网络面板里,哪怕URL带了防盗链参数,用户还是可能通过一些手段拿到你这个文件的地址;另外直接传URL意味着你需要先有一个能公网访问的地址,这本身就增加了泄露面。
我采用的是“先请求二进制,再喂给PDF.js”的方式:
javascript复制async function loadPdfWithAuth(url, token) {
const resp = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
credentials: 'include'
});
if (!resp.ok) throw new Error('加载失败: ' + resp.status);
const buffer = await resp.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise;
return pdf;
}
这样PDF文件就只存在于内存里,网络面板里看到的是乱码的二进制流,下载下来的可能性大大降低。安全性上还有一个细节:请求头里的token要跟服务端约定短时效,一般不超过几分钟,用完即弃。如果服务端支持预签名URL,那也可以配合使用,但无论如何,不要直接把文件挂在静态目录下。
2.2 多页渲染的取舍:为什么要放弃整册直出
我的业务里最夸张的一份材料是一份四千多页的标书。如果用最简单的方案——一打开就循环渲染所有页到Canvas上——浏览器当场就会崩溃,因为每页Canvas保存的是位图,一个1920宽的画面,一页光像素内存就有十几MB,四千页那就是天文数字。
所以必须做虚拟滚动。虚拟滚动的核心思想是:只渲染用户当前可视区域附近的那几页,离屏的页面销毁掉,滚动回来再重新渲染。在PDF.js场景里,这个方案有两个基础前提:
第一,PDF.js的页面渲染不是一锤子买卖,它可以在任何时刻重新执行,底层的PDF文档对象在内存里本身就是棵解析树,页面数据是持久保存的;第二,Canvas是像素画布,不是文档流,所以销毁了重画并不会影响别的页面。基于这两点,虚拟滚动是可行的,代价是滚动时需要短暂的重新渲染时间,中间可以拿空白占位符过渡。
所以整体渲染架构是:视口是固定的——一个可以上下滑动的容器(比如高度为窗口高度),容器里面按PDF页面的实际渲染尺寸摆放若干个占位div,但只在其中的可视区域附近真正创建Canvas并渲染内容。这样页面数量再大,同时存在的Canvas最多只有四五个。
3. 实操过程与核心环节实现
3.1 渲染上下文和容器结构的正确姿势
一个健壮的PDF渲染组件,容器结构应该是这样的:
html复制<div id="viewer-container"> <!-- 滚动容器,overflow: auto -->
<div id="viewer-body"> <!-- 内部长条容器,height = pageCount * pageHeight -->
<div class="page-wrapper" data-page-index="0" style="top: 0px; height: 1100px;"></div>
<div class="page-wrapper" data-page-index="1" style="top: 1100px; height: 1100px;"></div>
<!-- ... 每个page-wrapper 绝对定位,计算top -->
</div>
</div>
这里有几个关键点:
page-wrapper使用绝对定位,通过top属性定位到对应位置,这样浏览器原生滚动天然支持几千个节点,不用自己模拟滚动条。viewer-body的height必须等于所有页面高度的总和,这是让滚动条能够滚动到正确长度的前提。- 每个
page-wrapper里放Canvas,Canvas的width和height要用渲染像素乘以设备像素比(devicePixelRatio),否则在Retina屏上显示模糊。 - 不能给所有页面都创建Canvas,只给进入可视区域的页面创建。可视区域外面用空白占位,再配一个Loading占位图。
拿一个标准A4页来说,如果渲染宽度是900px、缩放比例1.5,那么页面的实际渲染高度就是1273px左右。渲染时就要准确把握:容器高度、缩放参数、页面数量、每个页面的高度,这四个值全对,虚拟滚动才可能流畅。
3.2 虚拟滚动:IntersectionObserver与Canvas复用
我最开始用的是监听容器scroll事件,在回调里动态判断哪些页面应该渲染,但滚动事件有性能瓶颈,回调触发太频繁,而且还要自己算offsetTop,处理起来很繁琐。后来换成了IntersectionObserver,优雅得多:
javascript复制const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const idx = Number(entry.target.dataset.pageIndex);
renderPage(idx);
} else {
const idx = Number(entry.target.dataset.pageIndex);
releasePage(idx);
}
});
}, {
root: containerEl,
rootMargin: '300px 0px 300px 0px'
});
wrapperElements.forEach(wrapper => observer.observe(wrapper));
rootMargin设置为上下各300px的意思是把可视区域向外扩展300像素,也就是说当页面距离视口底部还有300px时就开始渲染,这样用户滚到那里的时候画面早就准备好了,肉眼感知不到等待。releasePage的逻辑是如果页面不在观察范围内,就把Canvas的宽高和绘制内容清掉,或者干脆把Canvas移除DOM,释放内存。
但是这里有个性能细节:当用户快速滚动时,IntersectionObserver的触发频率其实还是能跟上,但如果渲染任务太重(比如一页要100ms以上),就会产生“追不上”的堆积问题。所以每个页面渲染要做好防重入:渲染前把之前未完成的renderTask取消掉。
javascript复制async function renderPage(idx) {
if (renderingPages.has(idx)) return; // 已在渲染中
const task = currentTasks.get(idx);
if (task) { task.cancel(); }
const page = await pdf.getPage(idx + 1);
const viewport = page.getViewport({ scale });
const canvas = getCanvasFor(idx);
ctx.drawImage(placeholder, 0, 0); // 先画占位图
const renderTask = page.render({ canvasContext: ctx, viewport });
currentTasks.set(idx, renderTask);
try {
await renderTask.promise;
} catch (e) {
if (e.name === 'RenderingCancelledException') return;
}
}
给每个renderTask建立一个Map,在页面重新渲染或者销毁前调用cancel(),能避免Canvas被半路绘制出来的奇怪脏数据影响。
3.3 水印渲染:Canvas合成还是DOM覆盖层
水印是整个安全方案里最直观的一个功能。设计上有两种路线:第一种是在PDF每一页渲染完成之后,往Canvas上再绘制一遍水印文字,这样水印和页面内容合成为一张图;第二种是做一个DOM覆盖层,整个预览区上面铺一层半透明的水印div。
两种方案我都试过,最终选择了DOM覆盖层。原因有三:一是性能好,Canvas合成模式下,水印会跟着每页每次重绘都重新画一遍,页面滚动频繁时CPU开销炸裂;二是DOM覆盖层可以统一追加当前用户变化的动态信息,不需要重绘PDF;三是覆盖层水印是CSS样式可以控制的,改动即时生效,不需要清空重建Canvas。
但DOM覆盖层有个缺陷,稍微懂一点前端的人都能通过开发者工具把水印div删掉。不过别担心,水印本来就不是防内行渗透的,它防的是“截个图随手转给别人”这种场景。再加上现在的截图工具,水印一般在图上,删除DOM根本不解决问题,因为画面上显示的时刻已经带水印了。
具体实现上,我用的是一个绝对定位的覆盖层,里面铺满多个水印块:
css复制#watermark-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.watermark-item {
position: absolute;
color: rgba(180, 180, 180, 0.35);
font-size: 14px;
white-space: nowrap;
transform: rotate(-30deg);
user-select: none;
}
JS动态生成水印块,每个用户会话里水印文案像user-10086-2025-06-01-10:30,加上一个随机偏移量,让每个块的旋转角度和位置错开,这样一眼扫过去不会显得特别规则,防截图分析的效果更好。水印文案的生成规则要和服务端约定好,比如“用户ID|姓名|时间|随机token”,到追溯阶段凭token去日志里捞记录。
3.4 渲染速度优化:延迟加载、预加载与取消抖动
渲染速度直接决定这个组件好不好用。我做了三个层面的优化:
第一层是延迟:页面滚动到哪个位置才渲染哪一页,但对进入视口前300px的页面提前渲染;这个前面已经提到,rootMargin就是干这个的。第二层是预加载:当前页的上下各1页提前渲染好,比如当前在第10页,那第9页和第11页的Canvas已经准备好了,滚动体验会顺滑很多。第三层是取消抖动:如果用户快速滚动,中间的页面可能来不及渲染就要被清掉,那就不要渲染了,把任务取消,只渲染最终停留在视口里的那几页。
另外还有一个大技巧:canvas的复用。不要每次渲染都新建一个Canvas元素,而是在页面离开可视区时把Canvas放回一个对象池里,等下一个页面进入时直接拿出来用。这一步能减少很多DOM创建开销,对长文档尤其重要。实践中我给对象池预设了10个Canvas,实测下来完全够用,不管怎么滚动,可视区域内同时存活的任务量也就四五个。
4. 常见问题与排查技巧实录
4.1 Canvas图像模糊Retina屏适配
第一次上线测试,设计师立刻反馈预览的字发虚。原因很简单:Canvas的width和height属性是物理像素,但CSS里我用的是CSS像素,设备像素比是2的MacBook上,900px宽的Canvas会被拉伸到1800px物理宽度显示,自然糊。
解决方法是渲染前先算好backing store尺寸:
javascript复制const dpr = window.devicePixelRatio || 1;
const cssWidth = 900;
const cssHeight = Math.floor(cssWidth / viewport.width * viewport.height);
canvas.width = Math.floor(cssWidth * dpr);
canvas.height = Math.floor(cssHeight * dpr);
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
这里有个坑:如果每次渲染前都无条件setTransform,那在高分屏下没问题,但在普通屏上也是1倍,没区别,但如果你的canvas有缓存或复用,必须在重绘前重置ctx的变换矩阵,否则会出现图片错位甚至变形。
4.2 字体加载与CORS导致的空页/乱码
有一个比较隐蔽的问题:某些PDF里的字体是嵌入的,但嵌入字体解析失败,导致整页白屏。排查下来,多半是字体文件跨域问题或者PDF.js的字体缓存加载失败。解决办法是在初始化时给getDocument传cMapUrl和cMapPacked(如果是中文PDF,还需要设置对应的CMap资源),并且确保字体资源放在同域下或者配置了CORS头。
实际业务里,国内客户传上来的大量中文PDF,很多是扫描版/福昕工具栏导出,会用到一些非标准字库。为保证稳定,我们的服务端在上传时就做了一次“PDF规范化”处理,统一转成标准PDF再保存,这个操作对后端来说成本不高,但能把前端渲染的兼容性问题压到最低。如果你们没有这个预处理环节,前端至少要在getDocument配置里多加一个参数:
javascript复制const pdf = await pdfjsLib.getDocument({
data: buffer,
cMapUrl: '/cmaps/',
cMapPacked: true,
standardFontDataUrl: '/standard_fonts/',
});
/cmaps/和/standard_fonts/要下载PDF.js发行包里的对应目录,放到静态资源服务器上,缺失的话在部分扫描件或复杂字体PDF上会出现字符错位的问题。
4.3 内存泄漏排查:渲染任务没取消
组件跑了一个月后,运维反馈说后台系统打开过几份大PDF后,浏览器标签页内存持续飙升,切到别的页面也降不下来。定位思路有三步:第一步,确认监听器是否解除——组件卸载时必须observer.disconnect()、renderTask.cancel()、清空对象池;第二步,确认Canvas是否回收——如果Canvas一直挂在DOM里,只是移出可视区,它占用的GPU内存不会释放;第三步,确认PDF文档对象是否释放——pdf.destroy()要显式调用。
实际代码里我封装了一个dispose()方法,页面销毁时统一清理:
javascript复制dispose() {
this.observer?.disconnect();
Object.values(this.renderTasks).forEach(t => t.cancel());
this.renderTasks.clear();
this.pdf?.destroy();
this.container.innerHTML = '';
}
如果组件还要继续复用,记得把this.pdf置空,避免下次打开时调用已销毁的文档对象,那会抛异常。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| PDF能进页数但一直白屏 | 缺少CMap资源/字体解析失败 | 配置cMapUrl、standardFontDataUrl;规范化源文件 |
| 高分屏预览模糊 | Canvas物理像素小于CSS像素 | 设置canvas.width为cssWidth * devicePixelRatio |
| 快速滚动出现花屏/重影 | 渲染任务未取消,旧的绘制叠加 | 维护renderTask,重新渲染前调用cancel() |
| 大文件滚动卡顿 | 同时渲染页面太多 | 对象池限制Canvas数量,只渲染视口±1页 |
| 页面水印偶尔不显示 | 覆盖层z-index被组件内部样式覆盖 | 水印层z-index设为极大值且用style直接写 |
| 内存只增不减 | PDF文档对象未销毁 | 显式调用pdf.destroy(),移除对象引用 |
| 个别扫描版页面文字错位 | 非标准PDF规范 | 服务端预处理转标准PDF;前端升级PDF.js版本 |
5. 纵深安全防护的进一步扩展
5.1 禁止下载的几个实用手段
渲染层的防下载只是第一道门,用户不傻,知道绕过Canvas直接看网络请求。我再补几个常用的加固手段:
- 网络层加鉴权:文件接口必须带临时token,且token绑定IP和会话,5分钟内失效;防止有人拿到链接后分享出去。
- 禁止文本选择:页面上所有文字是Canvas画出来的位图,本身就没有DOM文本节点,天然无法复制。即使PDF.js在
getTextContent()能拿到文本层,也绝不渲染文本层DOM;一旦渲染了文本层,右键就能复制到内容了,这个要特别注意。 - 禁用开发者工具快捷入口:虽然只能防君子,但加一层无妨,比如监听
F12、Ctrl+Shift+I、Ctrl+U并弹提示。 - 打印拦截:监听
beforeprint事件,弹窗提示无权限;或者直接把打印样式表写成白屏。
javascript复制window.addEventListener('keydown', (e) => {
if (e.key === 'F12' || (e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
window.addEventListener('beforeprint', (e) => {
e.preventDefault();
alert('当前文档不允许打印');
});
说实话,这些手段对专门搞技术的人都可以绕开,但放到企业内网审计的语境下,它们是有意义的:增加操作成本、留下操作痕迹,本身就能劝退大部分“顺手下载”的人。
5.2 水印的追溯与效果评估
水印是否能起到追溯作用,关键是看水印里编码的信息是否能快速定位到人。我的方案里,水印文案包含员工ID + 会话ID + 时间戳,服务端把会话ID与登录态、IP、设备号绑定。一旦截图流出,管理员看图里水印,去后台查这个会话ID就能知道是谁在什么时间点的操作。整个过程要控制在1分钟内。这一套流程上线后,内部通报了两起截图外发事件,都是在当天就锁定了账号,威慑效果足够明显。
5.3 在预览组件里接入文档权限
还有一点值得提:预览组件的权限控制不要只在服务端接口做,前端也要做一层“权限状态机”。比如一份文档可能有“只读预览”“可评论”“可下载”“可打印”四档权限,前端拿到权限位后动态决定渲染哪些功能按钮、是否允许右键、是否监听打印事件。这样一套组件可以复用在不同的文档类型上,也便于统一升级。
真实落地的时候,权限状态最好放在一个PreViewConfig对象里,每次渲染前校验一下:
javascript复制const config = {
allowDownload: false,
allowPrint: false,
allowTextSelect: false,
watermarkText: 'u10086|2025-06-01 10:30',
token: '...',
onPageChange: (page) => report(page),
onPrintAttempt: () => report('PRINT_ATTEMPT')
};
组件内部只认这个配置,任何页面入口不能绕过它修改。服务端还要二次校验,防止有人直接改前端配置解锁功能。
6. 性能压测与上线效果
6.1 测试数据
我做了一份对比测试,在普通办公电脑(i5-8250U、8GB内存、Chrome)上,对一份600页、48MB的PDF进行了预览测试:
| 指标 | 普通整册渲染 | 虚拟滚动+水印覆盖层 |
|---|---|---|
| 首次可交互时间 | 6.8s(页面白屏) | 1.2s(仅首页) |
| DOM节点数 | 604+个Canvas | 最大7个Canvas |
| 内存峰值 | 1.2GB+ | 约680MB |
| 滚动到第500页耗时 | 卡死 | 约0.8s(预加载生效) |
| 水印CPU占用 | 无(未实现) | 约1% |
虚拟滚动的优势非常明显,尤其对水印这种覆盖层方案,成本几乎可以忽略。
后来进一步压测,把测试PDF换成5000页的建筑图纸PDF,整个方案依然能稳定运行,滚动到任意页都能在1秒内出图,内存峰值控制在2GB以内(因为Canvas尺寸变大)。对于办公场景来说,这个表现已经足够支撑日常使用。
6.2 上线后的真实反馈
部署到内部系统后,几个核心反馈:
- 财务同事说“以前预览标书要用PDF阅读器,现在浏览器打开就能看,也不用装东西了”。
- 安全管理员比较满意的是水印追溯功能,他之前最大的痛点就是截图外发找不到源头。
- 反馈最多的反而是“为什么打开时不能立刻看到第3页”——因为我们的权限系统限制只能从第一页开始看,不能跳页。这个问题后来通过放开了“跳转前N页”的权限配置解决了,权限粒度更细。
- 有几个同事问能不能记住上次阅读位置,这个用本地sessionStorage存页码即可,属于小迭代。
上线三个月,没有收到渲染崩溃或严重兼容性的工单,对比之前用iframe预览时每周都有若干“PDF打不开”的反馈,整体算是平稳落地。
7. 踩坑过的几个使用细节总结
回顾整个开发过程,有些东西看起来很小,但踩到是真的疼。列几个我认为最有价值的:
第一,PDF.js版本不要太旧。我最初用的是2.0.943,性能还行,但在处理较新的PDF规范时经常报一些奇怪的错,比如没有ToUnicode映射、CMap缺失。后来升到3.11.174,整体稳定多了,而且API变化不大,迁移成本能接受。最近PDF.js已经出了4.x版本,渲染速度和内存控制更好了,有条件建议直接用新版。
第二,不要轻易在pdf.js的外层再套一层滚动容器。虚拟滚动的难点之一是你必须让浏览器精确知道整个文档的总高度,如果外层容器有transform变换,很多浏览器的滚动定位会出偏差。我们曾为了弹窗动画给外层容器加了transform: scale(0.98),结果整个预览区的滚动位置全乱了。最终的做法是弹窗打开后不做scale变换,或者用zoom属性配合修正,这个真的是改了一下午才定位出来的。
第三,水印覆盖层的DOM数量要克制。如果每个水印块都做成一个独立DOM,一份A4纸大小可能要上百个块,预览区铺满几千个DOM节点,滚动时的性能会哗哗往下掉。我的优化方案是把水印做成一整张背景图,用background-repeat平铺,或者用CSS的linear-gradient叠加多个背景层模拟,这样只需要一个div就完了。前提是不同用户的背景图要单独生成,用户信息变了就重新生成。
第四,渲染任务一定要做超时保护。有些PDF页面特别复杂,单页渲染可能需要3秒以上,用户感觉像卡死。我的处理是:renderTask.promise超过1.5秒就在页面位置显示一个“渲染中”的小提示,并允许用户取消等待、继续滚动。这样即便个别页慢,也不影响整个文档的浏览体验。
最后再说一个安全细节:如果你在水印里放了用户真实姓名和手机号,要注意隐私合规。现在内部系统我们一般只用用户编号加会话ID,不带手机号。截图追责时,管理员通过后台查编号,而不是直接在水印上暴露全部个人信息。这种安全与隐私的平衡,越早考虑越好。
