做Web端内容平台的同学,大概率都接到过这样一个需求:给我们系统里的PDF加一个在线预览页面,要求不能下载、不能复制、还要带用户水印。听起来不难,但真做起来,里面全是细节。尤其当你的PDF文件动不动就几十上百兆、几百页时,直接用iframe内嵌浏览器预览会卡到怀疑人生,更别提安全控制基本为零。这篇文章就从我自己落地的一个基于PDF.js的安全预览组件出发,讲讲从虚拟滚动到水印渲染这条链路上,到底有哪些坑、哪些方案、哪些代码可以直接抄。
先交代一下背景:我们要做的是一个B端文档管理系统的预览模块,核心诉求有三个——支持大文件流畅预览、禁止下载和复制、能追溯到泄露源头的水印。技术栈是Vue3 + Vite,但这套思路换到React或者原生JS上也完全成立。适合谁看?后端想理解前端预览原理的、前端准备接PDF渲染的、以及所有被"安全预览"这个需求折磨过的同学,这篇应该能帮你省一周的调研时间。
1. 项目需求拆解:安全预览到底要解决哪些问题
1.1 需求清单与优先级划分
在动任何代码之前,我习惯先把需求拆成一个表格,逐条列清楚,特别是要跟产品经理对齐"什么能做、什么做不到"。这个环节能避免很多后期返工。
| 需求项 | 期望效果 | 实现难度 | 优先级 |
|---|---|---|---|
| PDF在线预览 | 浏览器内直接查看,无需下载 | 低 | P0 |
| 大文件流畅翻页 | 100MB、500页以上不卡顿 | 高 | P0 |
| 禁止下载 | 无法通过右键、按钮、快捷键保存文件 | 中 | P0 |
| 禁止复制文本 | 无法选中和复制内容 | 低 | P1 |
| 用户水印 | 页面上动态叠加用户信息水印 | 中 | P1 |
| 移动端适配 | 手机端可以正常查看和缩放 | 中 | P2 |
这里我想多说一句:"禁止下载"是一个绝对化表述,诚实地说,任何纯前端方案都无法100%阻止一个懂技术的人拿到PDF源文件,因为文件内容必然要通过网络传输到浏览器,用抓包工具就能抓到原始数据。我们能做的,只是增加获取门槛、提升追溯能力——配合后端权限控制和水印,让泄露者承担后果。这个边界一定要提前跟业务方讲清楚。
1.2 选型思考:为什么不直接用浏览器内置预览
最早有人提议,直接用<iframe src="xxx.pdf">加浏览器自带的PDF插件,简单省事。但实际测试下来,这个方案存在几个硬伤:
- 跨浏览器渲染不统一:Chrome有自己的PDF查看器,Firefox是PDF.js,Safari又不同,样式、工具栏、交互都不可控,你想隐藏下载按钮?做不到。
- 安全控制等于零:浏览器原生工具栏自带下载、打印、旋转按钮,用户一键就能把PDF存到本地,这跟"安全预览"的需求直接冲突。
- 性能表现差:打开一个100MB的PDF,浏览器原生插件的加载策略是懒加载页面还算能接受,但如果你要跟自己的业务系统做联动(比如显示自定义缩略图列表、记录阅读进度),完全没有可操作空间。
还有一个容易被忽略的问题:PDF文件如果不在同源域名下,iframe加载还面临着跨域限制和相关登录态Cookie的携带问题,光处理这个就够折腾的。
1.3 核心架构思路:渲染层、控制层、安全层分离
我最终确定的架构是三层分离:
- 渲染层:基于PDF.js的Canvas渲染,负责把PDF页面画到浏览器上。
- 控制层:组件内部管理翻页、缩放、滚动、缩略图等交互逻辑,不依赖浏览器原生行为。
- 安全层:通过全局事件拦截、样式禁用、水印叠加等手段,实现下载、复制的限制和用户身份的可追溯。
这三层各司其职,后期维护时思路非常清晰。比如安全策略升级了,只需要改安全层,渲染逻辑完全不用动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PDF.js的渲染机制:理解源码才能玩得转
2.1 PDF.js的关键概念:PDFDocument、Page、RenderTask
用PDF.js做二次开发,最核心的三个对象你要刻在脑子里:PDFDocument、PDFPageProxy和RenderTask。
PDFDocument:通过pdfjsLib.getDocument({ url })加载PDF后返回的文档对象,可以拿到总页数、元数据、目录等信息。PDFPageProxy:调用doc.getPage(pageNum)拿到某一页的代理对象,负责渲染、获取文本内容、位图数据等。RenderTask:page.render(params)返回的渲染任务,可以调用cancel()取消一个正在进行的渲染,对于滚动场景下的渲染调度至关重要。
一个典型的加载与渲染流程是这样的:
javascript复制import * as pdfjsLib from 'pdfjs-dist';
// 注意:高版本PDF.js需要指定workerSrc,否则无法工作
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.js';
async function initPreview(url) {
const loadingTask = pdfjsLib.getDocument({ url });
const doc = await loadingTask.promise;
// 总页数
const totalPages = doc.numPages;
// 渲染第1页
const page = await doc.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.getElementById('pdf-canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderTask = page.render({
canvasContext: context,
viewport,
});
await renderTask.promise;
console.log('第1页渲染完成');
}
2.2 渲染参数详解:viewport、scale、transform的配合
getViewport是PDF.js渲染的灵魂方法。PDF文件内部使用的单位是"PDF点"(PDF point),1个PDF点约等于1/72英寸。getViewport({ scale })的作用就是把这个内部尺寸转换成屏幕像素尺寸。
理解这个关系,你才能回答一个很常见的问题:为什么渲染出来的页面模糊或过大?
scale = 1:1个PDF点 = 1个CSS像素。在普通屏幕上,文字边缘会发虚。scale = devicePixelRatio:适配Retina屏,让文字清晰锐利,但渲染耗时成倍增加。scale = 某个固定百分比:比如scale = 1.25,就是把PDF放大到125%。
我的建议是在普通PC上使用scale = 1.5左右作为基础值,同时结合当前的缩放级别做浮动。下面这行代码是我常用的viewport适配逻辑:
javascript复制function computeViewport(page, customScale) {
const baseScale = window.devicePixelRatio || 1;
// customScale 是用户手动缩放时的倍数,比如0.8、1、1.5
const scale = baseScale * customScale;
return page.getViewport({ scale });
}
2.3 内存管理:page的加载与释放
PDF.js非常吃内存。大文件的每一页被getPage获取后,会在内部缓存渲染所需的数据,如果所有页面都加载一遍,1GB内存分分钟见底。
我测试过一个300页、200MB的PDF,连续加载完所有页面的数据后,页面直接白屏,Chrome内存占用飙到1.8GB。解决的思路是:按需加载 + 及时释放。
什么叫按需加载?就是只有滚动到某一页附近时,才去getPage这一页并渲染。什么情况下释放?渲染完成后,调用page.cleanup()释放该页占用的渲染资源;等滚远了,再调用page.destroy()彻底销毁。
javascript复制async function renderPage(doc, pageNum, canvas) {
const page = await doc.getPage(pageNum);
const viewport = computeViewport(page, currentScale);
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderTask = page.render({
canvasContext: canvas.getContext('2d'),
viewport,
});
await renderTask.promise;
// 渲染结束后主动清理,防止内存泄漏
page.cleanup();
}
同时,在组件切换页面或销毁时,记得调用renderTask.cancel(),否则未完成的渲染任务会继续霸占资源,甚至报错。
3. 虚拟滚动:让500页PDF也不卡顿的核心方案
3.1 为什么PDF.js会卡:整页渲染与DOM节点数量
先说个反常识的结论:PDF.js的性能瓶颈不在于渲染本身,而在于一次性往DOM里挂太多Canvas节点。
如果不用虚拟滚动,最直接的实现方式是在容器里放500个<canvas>组成的"页面列表",每个Canvas对应PDF的一页。问题立刻浮现:
- 首屏加载极慢:浏览器要一次性为500个Canvas分配GPU资源和内存,页面白屏好几秒。
- 滚动卡成PPT:每滚动一帧,浏览器都要计算500个节点的位置和绘制区域,主线程一会儿就跑满。
- 内存爆掉:500个Canvas叠加起来的内存开销非常吓人。
虚拟滚动的本质是:只渲染当前可视区内的几页,其他位置的"页面"用占位div撑高度,滚动时动态回收和重建真正渲染的Canvas。这跟当前前端框架里的虚拟列表原理一致。
3.2 虚拟滚动的核心设计:可视区、缓冲区与回收机制
我的实现里,虚拟滚动分为三个区域:
- 可视区:用户当前肉眼可见的页面范围,这个区域内的页必须渲染。
- 缓冲区:可视区上下额外多渲染2-3页,用于用户快速滚动时防止出现空白闪烁。
- 回收区:超出可视区+缓冲区范围的页面,回收Canvas节点,只保留一个高度占位div。
简单计算一下:假设一页在100%缩放下高度是800px,可视区高度1200px,那么可视区内有2页;如果上下各加3页缓冲,那一共活跃渲染的Canvas节点为2+6=8个。500页的PDF,只需要管理8个Canvas,性能自然好。
3.3 关键实现:IntersectionObserver + 可视化页面池
我推荐用IntersectionObserver而不是传统的scroll事件监听。为什么?因为scroll事件频率极高,每次触发都要手动判断哪些页进入了可视区,代码复杂且容易漏掉边缘情况;而IntersectionObserver是浏览器原生提供的"元素可见性变化"回调机制,性能更好,而且不会在滚动时反复触发回调,只有页面真正进入或退出可视区时才会通知你。
核心实现思路如下:
javascript复制const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const pageNum = Number(entry.target.dataset.pageNum);
if (entry.isIntersecting) {
// 进入可视区,渲染该页
ensurePageRendered(doc, pageNum);
} else {
// 离开可视区,回收该页Canvas
recyclePage(pageNum);
}
});
}, {
root: scrollContainer,
rootMargin: '600px 0px', // 预加载可视区上下600px的内容
threshold: 0.01,
});
// 为每个页面占位div绑定观察
function bindObserver(placeholders) {
placeholders.forEach((el) => observer.observe(el));
}
rootMargin这个参数非常关键,它相当于把可视区向外扩展了一段距离,用于预渲染。'600px 0px'表示在可视区上下的600px范围内就开始触发渲染,这样用户滚动时内容早就画好了,体验会顺畅很多。
3.4 渲染调度:如何避免滚动过程中频繁重绘
虚拟滚动还有一个隐藏问题:用户快速滚动时,如果每个进入可视区的页面都立刻开始渲染,会同时启动好几个RenderTask,CPU直接拉满,反而导致滚动更卡。
解决思路是引入一个渲染队列调度器:同一时间只允许1个渲染任务在执行,后来的渲染请求排队,如果排队过程中又来了更新的请求,就把旧请求扔掉。
javascript复制class RenderScheduler {
constructor() {
this.currentTask = null;
this.queue = [];
}
requestRender(pageNum, renderFn) {
// 如果当前页正在渲染,忽略重复请求
if (this.currentTask && this.currentTask.pageNum === pageNum) return;
// 把新请求放入队列,如果队列里已有相同页,去重
if (!this.queue.some(item => item.pageNum === pageNum)) {
this.queue.push({ pageNum, renderFn });
}
}
async processQueue() {
if (this.currentTask || this.queue.length === 0) return;
const { pageNum, renderFn } = this.queue.shift();
this.currentTask = { pageNum };
try {
await renderFn(pageNum);
} finally {
this.currentTask = null;
this.processQueue(); // 处理下一个任务
}
}
}
这样处理后,无论滚动多快,同一时刻最多只有一个页面在渲染,其他请求按顺序排队执行,用户感知到的卡顿会显著改善。
4. 安全控制:权限校验、禁用下载与防复制
4.1 权限控制:后端鉴权与前端水印的双层配合
预览功能的安全防线必须先在后端站稳,前端只是辅助。我的实现里,预览PDF的URL不是一个公开的静态文件地址,而是一个带有短期token的鉴权接口:
code复制GET /api/preview/pdf/{id}?token=xxxx&expires=xxx
后端接收到请求时,校验token有效性,校验该用户对该文档是否有预览权限,全部通过后才返回PDF文件流。token一般设置5-10分钟过期,预览页面加载完成后,后续的翻页、缩放操作不再走网络请求,所以短token不会影响体验。
另外,我还加了URL防泄露的补充:PDF实际存储路径使用不可猜测的UUID或哈希值作为文件名,即使token泄露,攻击者也无法直接拼出源文件的下载地址。
4.2 阻止下载与保存:屏蔽右键、快捷键与打印
前端能做的"防下载",本质是增加操作成本。我在组件里实现了以下几层拦截:
第一层:CSS禁用选中与右键菜单。
css复制.pdf-preview-container {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
第二层:全局拦截contextmenu事件,禁止右键菜单。
javascript复制container.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
第三层:快捷键拦截。 这块需要细心,Windows下Ctrl+S、Ctrl+P、Ctrl+C,Mac下Command+S、Command+P、Command+C都要拦。另外还有一个很容易漏的组合键——Ctrl+Shift+S(另存为)。
javascript复制document.addEventListener('keydown', (e) => {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const ctrlOrCmd = isMac ? e.metaKey : e.ctrlKey;
if (ctrlOrCmd && ['s', 'p', 'c'].includes(e.key.toLowerCase())) {
e.preventDefault();
e.stopPropagation();
}
});
第四层:防止拖拽文件到本地。 用户可能直接把Canvas拖到桌面上(实际上拖拽的是图片数据),所以要拦截拖拽事件:
javascript复制container.addEventListener('dragstart', (e) => e.preventDefault());
4.3 防截图的现实思考:水位不可见但可追溯
很多人会问,防截屏怎么做?这里必须直面现实:Web前端无法阻止用户使用系统截图工具,甚至无法可靠地探测到截屏行为。与其追求"防截",不如把重点放在"可追溯"——通过可见水印和隐藏水印让泄露者找到源头。
可见水印的作用就是让截图上带着用户ID和操作时间,让拿到截图的人知道这是谁泄露的。后面第三节的水印渲染就是干这个的。至于隐藏水印(比如把用户ID编码到页面上不可见的像素点中),原理上可行但实现复杂,且会被截图软件的压缩算法破坏掉,我这里没有采用。
5. 水印渲染:前端水印的多种方案与最终落地
5.1 水印方案选型:Canvas水印 vs DOM水印 vs SVG水印
做水印渲染前,我调研了三种主流方式,直接拉个表对比:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| Canvas水印 | 先把水印画到Canvas上,再转成DataURL设为背景图 | 性能好、不占DOM节点、水印不会被DOM结构干扰 | 样式调整需重绘,不能单独控制单个水印 |
| DOM水印 | 渲染大量带文字的小div,铺满全屏 | 实现简单、可单独控制 | DOM节点过多导致页面卡顿,易被用户删除 |
| SVG水印 | 用SVG作为背景图平铺 | 代码简洁、支持矢量缩放 | 老浏览器兼容性一般 |
考虑到容器里本身已经有虚拟滚动的大量Canvas,DOM水印再增加几十上百个节点显然不明智。最终我选择的是Canvas水印,而且用了一个很妙的技巧——把水印生成成Base64的DataURL图片,作为容器的background-image平铺,一行CSS搞定整个页面的水印层,不占用额外DOM节点。
5.2 动态水印实现:用户信息与时间戳的注入
动态水印意味着每次预览都根据当前登录用户和操作时间生成不同的水印内容。通常的格式是:
code复制用户名:张三
工号:EMP00123
时间:2025-01-15 14:23:45
IP:10.24.5.67
实现步骤分三步:
第一步:生成水印画布。
javascript复制function createWatermark(config) {
const canvas = document.createElement('canvas');
canvas.width = 300;
canvas.height = 240;
const ctx = canvas.getContext('2d');
// 设置透明背景
ctx.clearRect(0, 0, 300, 240);
// 文字样式
ctx.fillStyle = 'rgba(128, 128, 128, 0.25)';
ctx.font = '14px Microsoft YaHei, sans-serif';
// 旋转45度,让水印倾斜更显眼
ctx.translate(150, 120);
ctx.rotate((Math.PI / 180) * 45);
// 按行绘制水印文字
const lines = [
`用户:${config.userName}`,
`时间:${config.time}`,
];
ctx.textAlign = 'center';
lines.forEach((line, index) => {
ctx.fillText(line, 0, (index - 0.5) * 20);
});
return canvas.toDataURL('image/png');
}
第二步:把水印图作为背景平铺到容器。
javascript复制container.style.backgroundImage = `url(${watermarkDataUrl})`;
container.style.backgroundRepeat = 'repeat';
container.style.backgroundSize = '300px 240px';
第三步:水印层与内容层分离。 这里有一个细节容易踩坑——如果用background-image直接放在容器上,用户滚动内容时水印会跟着内容一起滚动,看起来不自然。更好的做法是建立一个固定定位的水印层,盖在预览内容之上,同时设置pointer-events: none让鼠标事件穿透到下面的内容层:
css复制.watermark-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background-image: url('data:image/png;base64,...');
background-repeat: repeat;
}
这样水印始终悬浮在页面之上,用户滚动、缩放都不会影响水印的稳定显示。
5.3 水印防移除:MutationObserver与样式加固
既然水印的意义是追溯,那必然有人试图删除水印。其中最常见的操作是在开发者工具里找到水印层元素,直接display: none或者删除节点。针对这个,我加了一个守卫机制:
- 给水印层设置超高
z-index和!important样式,防止被普通样式覆盖。 - 用一个
MutationObserver监听水印层节点的删除和样式变更,一旦检测到被篡改,立刻重新挂载。
javascript复制const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// 如果水印层被删除
if (mutation.removedNodes.length > 0) {
restoreWatermark();
}
// 如果水印层样式被修改
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const display = getComputedStyle(watermarkLayer).display;
const visibility = getComputedStyle(watermarkLayer).visibility;
if (display === 'none' || visibility === 'hidden') {
restoreWatermark();
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style'],
});
不得不承认,MutationObserver也不能防住真正的高手,但作为基础防护足够了。安全对抗永远是一条升级路,我们的目标是让90%以上的普通用户没有删除动机,让10%的技术用户删除成本大于收益。
6. 完整实现流程:从加载到渲染的组件链路
6.1 组件初始化流程
把前面的模块整合起来,一个完整的初始化流程是这样的:
- 组件挂载后,先创建容器DOM结构:外层滚动容器、水印层、内部页面列表。
- 调用后端鉴权接口,获取PDF文件的临时URL。
- 用
pdfjsLib.getDocument加载PDF,拿到PDFDocument实例。 - 读取总页数,创建对应数量的占位div,设置每个div的高度为预设高度。
- 创建
IntersectionObserver,监听占位div的可见性。 - 渲染可视区内的第一屏页面。
- 挂载安全拦截事件(右键、快捷键、拖拽)。
- 生成并挂载动态水印层。
6.2 虚拟滚动与水印的整合
这里必须注意一个顺序问题:水印层必须在滚动容器之上,但生成水印的DOM层级不能被子元素的滚动影响。我的实现是在最外层套一个position: relative的容器,滚动容器在它内部,水印层设置为position: fixed并固定在视口。
为了确保缩放页面时水印依然有效,我用了resize事件监听,窗口大小变化时重新生成水印的平铺尺寸。注意这里要用requestAnimationFrame或debounce做节流,否则窗口拖拽时水印会疯狂重绘。
6.3 组件销毁与资源释放
前端组件最常见的隐藏Bug就是内存泄漏。预览组件销毁前,必须做这几步清理:
javascript复制function destroyPreview() {
// 1. 取消所有未完成的RenderTask
renderTasks.forEach((task) => task.cancel());
// 2. 销毁PDFDocument,释放底层资源
pdfDoc?.destroy();
// 3. 断开IntersectionObserver
observer?.disconnect();
// 4. 移除全局事件监听
document.removeEventListener('keydown', keydownHandler);
container.removeEventListener('contextmenu', contextmenuHandler);
// 5. 移除MutationObserver
mutationObserver?.disconnect();
// 6. 清空容器DOM
container.innerHTML = '';
}
特别是第2步pdfDoc.destroy(),很多人会漏掉。不调用它的话,即使页面跳转了,PDF.js底层维护的worker线程和缓存依然存活,长期操作后浏览器内存会以肉眼可见的速度增长。
7. 常见问题与排查技巧实录
7.1 大PDF加载时间过长怎么办
如果文件体积100MB以上,即使用虚拟滚动,首屏加载依然可能超过5秒。排查后发现瓶颈不在渲染,而在getDocument阶段——PDF.js需要解析整个文件的目录结构、字体资源、元数据,这个过程是串行的。
优化手段有两个方向:
- 服务端转码:把PDF在服务端预处理成分页图片(如WebP格式),前端直接加载图片,速度飞快,但丧失了文本选择和矢量缩放的特性,适合纯展示场景。
- PDF.js分片加载:利用HTTP Range请求,让PDF.js只加载所需的字节范围。需要服务端支持Range请求,且PDF内部结构允许分区解析,不是所有PDF都适用。
我实际项目中用的是第一种方案变体——对超大型PDF,服务端自动转码为图片流,普通大小PDF依旧走PDF.js矢量渲染,各取所长。
7.2 水印在Retina屏上模糊怎么办
背景平铺的水印在普通屏看着还行,一放到MacBook上就发虚,因为Canvas只按CSS像素绘制,没有考虑devicePixelRatio。解决办法:创建水印Canvas时,把实际像素尺寸乘以devicePixelRatio。
javascript复制function createWatermarkWithDpr(config) {
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
// 物理像素放大
canvas.width = 300 * dpr;
canvas.height = 240 * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// 后面的文字绘制逻辑与之前相同,只是坐标系从逻辑像素开始
}
这样水印在Retina屏上就是物理像素级别的清晰。同时记得background-size对应的尺寸也要改成逻辑像素值(300px 240px),否则水印会异常放大。
7.3 滚动频繁导致闪烁或白屏怎么解决
虚拟滚动中,如果用户快速拖动滚动条,常常能看到一片白色区域一闪而过。这通常是两个原因导致的:
一是rootMargin设置得太小,预渲染范围不够。解决:把rootMargin从'600px'增加到'1200px'。代价是活跃Canvas节点数会略微增加,但体验平滑度会明显改善。
二是IntersectionObserver的回调触发机制。如果滚动太频繁,回调可能来不及触发就已经滚过去了。这里我加了一个兜底:监听滚动容器的scroll事件,用一个requestAnimationFrame节流器,在滚动停止后的200ms内,主动检查当前可视区是否有未渲染的页面,有就立刻渲染。
javascript复制let scrollTimer = null;
container.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
// 滚动停止后,强制检查可视区页面
checkVisiblePagesAndRender();
}, 200);
});
7.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
控制台报Worker was destroyed |
组件销毁后文档还在被渲染 | 销毁前取消所有RenderTask |
| PDF内容不显示 | GlobalWorkerOptions.workerSrc未配置 |
设置workerSrc为本地pdf.worker.min.js路径 |
| 页面字体错乱 | PDF内嵌字体加载失败 | 检查CORS设置,确保字体资源跨域可访问 |
| 水印不显示 | 背景图为空DataURL | 检查Canvas绘制后toDataURL()是否被浏览器拦截 |
| 手机端滚动卡顿 | Canvas过多或过大 | 降低devicePixelRatio放大倍数,限制最大Canvas尺寸 |
| 无法打印预览 | @media print样式未设计 |
添加打印样式表,隐藏工具栏水印重新排版内容 |
写在最后的几点心得
这个组件从原型到上线,前后花了大概两周时间,踩的坑大多不在功能实现本身,而在于对细节的思考。比如安全层的边界在哪、虚拟滚动的预渲染范围应该设置多大、水印层如何防篡改——每一个单拎出来都不难,难的是组合在一起还能保持稳定。
如果让我重新做一次,我会在一开始就跟后端约定好"文件服务+鉴权"的接口规范,而不是前端孤军奋战。安全预览从来不是纯前端能独立完成的事,它需要后端鉴权、存储服务、前端渲染三层协同。
最后分享一个小技巧:开发调试PDF.js时,建议在浏览器控制台里先手动执行一遍getDocument、getPage、render三个步骤,确认你拿到的PDF本身没有损坏或加密,再去排查组件代码的问题。很多时候问题出在文件而不是代码上,先排除这个变量,能省下大量排查时间。
