背景

最近在 H5 项目中做了一个 PDF 阅读器,需要同时满足两个需求:
- 在手机端正常渲染 PDF;
- 根据后端返回的坐标,在 PDF 页面上显示文本高亮。
最终我尝试了两种方案,底层都是基于 pdfjs-dist:
vue-virtual-scroller+pdfjs-dist,自己实现 PDF 页面的虚拟滚动;- 直接使用 PDF.js 官方
PDFViewer,让 PDF.js 自己管理页面的渲染和虚拟化。
两种方案都可以实现,但是实际使用下来,我更推荐第二种。
注意:
pdfjs-dist5.x 在 H5 环境中的兼容性相对更好,而 6.x 在部分低端手机环境下出现了 PDF 页面无法正常渲染。
方案一:选择 vue-virtual-scroller
最开始的想法其实比较简单:PDF.js 负责把每一页渲染到 Canvas,Vue 负责页面,高度和滚动交给虚拟滚动 vue-virtual-scroller。
因为实际项目中可能会遇到上百页的 PDF,如果一次性把所有页面都渲染成 Canvas,内存占用会比较高。
问题1. 不要假设 PDF 每一页都是 A4
最开始我直接按照 A4 的比例给 PDF 页面设置高度:
html
<div class="aspect-[3/4]">
后来发现这是不可靠的。PDF 的每一页都可以拥有不同的尺寸和比例,并不一定是 A4。如果直接使用固定的 aspect-[3/4],会造成虚拟滚动错乱渲染问题。
问题2. 虚拟滚动带来的竞态问题
vue-virtual-scroller 会复用组件,同一个 Canvas 可能上一秒显示第 1 页,下一秒就被复用去显示第 20 页。
由于 PDF 渲染是异步的,旧页面的渲染任务可能比新页面更晚结束,最终把已经渲染好的 Canvas 覆盖掉,造成页面内容错乱。
解决方式很简单:给每次渲染分配一个唯一的 renderId,只有最新的一次渲染才允许落到 Canvas,旧任务直接丢弃。
ts
let currentRenderId = 0;
const renderPage = async () => {
const renderId = ++currentRenderId;
const page = await props.pdfDoc.getPage(props.pageIndex + 1);
// 当前组件已经被复用,之前的请求作废
if (renderId !== currentRenderId) {
page.cleanup();
return;
}
// 开始真正的 Canvas 渲染
};
问题3. PDF 页面是纯图片时,虚拟列表可能出现高度问题
如果 PDF 的某一页是纯图片,在快速滑动渲染的时候,可能会出现图片显示不完整的问题。
排查下来是 vue-virtual-scroller 对于每项里面的高度的监听方式有问题,即使修改了 DynamicScrollerItem 内部元素的高度,它也不会触发重新计算。
最后用了一个比较 hack 的方式,通过增加一个临时的 1px 元素,让 DOM 的尺寸发生一次变化,从而触发 DynamicScrollerItem 重新测量:
html
<div v-show="sizeTrigger" class="h-px" />
<script setup type='tx'>
const triggerSizeUpdate = () => {
sizeTrigger.value = true;
requestAnimationFrame(() => {
sizeTrigger.value = false;
});
};
</script>
注意下面的触发还不能使用 nextTick,还必须使用 requestAnimationFrame
问题4. PDF 高亮如何定位?
后端返回的高亮数据如下:
ts
interface HighlightData {
bbox: [number, number, number, number];
pageNo: number;
pageSize: [number, number];
}
bbox 代表高亮的上下左右的位置,pageNo 是高亮的页码,pageSize 是页面的宽和高。
这里不建议直接使用 px。因为 PDF 页面会发生缩放,而高亮应该跟着 PDF 页面一起缩放。
所以使用百分比来定位:
ts
const getHighlightStyle = (hl: HighlightData) => {
const [left, top, right, bottom] = hl.bbox;
const [pageWidth, pageHeight] = hl.pageSize;
return {
left: `${(left / pageWidth) * 100}%`,
top: `${(top / pageHeight) * 100}%`,
width: `${((right - left) / pageWidth) * 100}%`,
height: `${((bottom - top) / pageHeight) * 100}%`,
};
};
问题5. 高亮层和 Canvas 必须严格保持一致
错误的结构类似:
html
<div class="relative pb-2">
<canvas />
<div class="absolute inset-0">
<!-- highlight -->
</div>
</div>
因为 padding-bottom 会参与父元素的尺寸计算,而 absolute inset-0 又是相对于父元素定位的,高亮的坐标就可能整体产生偏移。
解决方案是把 padding 独立出来:
html
<div class="pb-2">
<div class="relative">
<canvas class="block w-full"></canvas>
<div class="absolute inset-0">
<!-- highlight -->
</div>
</div>
</div>
问题6. 自己处理"滚动到高亮"
因为虚拟列表只负责页面,所以如果打开 PDF 后,希望自动定位到第一个高亮,还需要自己处理一个异步流程。
最终的流程是:
text
1. 加载 PDF
↓
2. 找到第一个高亮所在的页面
↓
3. DynamicScroller 滚动到该页面
↓
4. 等待第一个高亮的页面渲染完成
↓
5. 滚动到页面内部的具体高亮位置
方案二:直接使用 PDF.js 官方 PDFViewer
PDF.js 官方 Viewer 的整体结构和方案一有很大的区别。
1. PDFViewer 的基本使用
核心代码如下,其中 eventBus 是用来做 pdf 渲染生命周期的事件派发。
ts
const eventBus = new pdfjsViewer.EventBus();
const pdfViewer = new pdfjsViewer.PDFViewer({
container: viewerContainerRef.value,
viewer: viewerRef.value,
eventBus,
});
模板只需要提供 PDF.js 需要的容器:
html
<div ref="viewerContainerRef">
<div ref="viewerRef" class="pdfViewer"></div>
</div>
这里需要特别注意:
html
class="pdfViewer"
这个 class 是 PDF.js Viewer 需要的,不要随意修改。
2. 利用 pagesinit 设置初始缩放
PDF.js 提供了很多事件,其中一个比较重要的是 pagesinit:
ts
eventBus.on('pagesinit', function () {
//...
});
这个事件触发时,PDF 页面已经初始化完成,可以拿到第一张页面的 viewport。根据这个 viewport 可以计算一个刚好适应屏幕宽度的 scale。
3. 使用 Teleport 添加高亮
第二种方案中,PDF 页面由 PDF.js 的 PDFViewer 负责渲染,而高亮部分仍然希望使用 Vue 组件来实现。
这时候就需要解决一个问题:
如何把 Vue 的高亮组件添加到 PDF.js 动态创建的页面 DOM 中?
PDF.js 在 pagerendered 事件中,可以获取当前页面对应的 pageView,通过 pageView.div 就可以拿到当前 PDF 页面的 DOM:
ts
eventBus.on('pagerendered', (evt: PageRenderedEvent) => {
const pageView = evt.source;
const pageDiv = pageView.div;
});
然后在当前页面中创建一个专门用于挂载高亮的容器,有了这个容器以后,就可以使用 Vue 的 Teleport,把高亮组件直接渲染到 PDF.js 对应的页面中。
4. cMapUrl 不要直接写死根路径
执行以下命令把字体拷贝到 public 目录下:
bash
cp -r node_modules/pdfjs-dist/cmaps public/
使用 VITE_BASE_URL 来指定 canvas 字体加载的路径。
ts
cMapUrl: `${import.meta.env.BASE_URL}cmaps/`,
cMapPacked: true,
总结
这次做 H5 PDF 高亮,最大的体会是:
不要急着自己实现 PDF Viewer,先看看 PDF.js 官方已经帮你做了什么。
第一种方案:
text
vue-virtual-scroller
+
pdfjs-dist
优点是自由度高,可以完全按照自己的业务模型组织 PDF 页面。
但是需要自己解决:
text
虚拟滚动
页面高度
Canvas 生命周期
渲染竞态
页面清理
滚动定位
缩放
第二种方案:
text
pdfjs-dist
+
PDFViewer
+
Teleport
则可以把 PDF 相关的底层问题尽可能交给 PDF.js,业务层只需要在 pagesinit、pagerendered 等关键生命周期节点接入自己的逻辑。
对于普通的:
H5 PDF 阅读 + 缩放 + 高亮 + 定位
我最终更推荐 第二种方案。
而第一种方案也并不是没有价值------如果 PDF 页面本身需要和业务虚拟列表深度融合,或者每一页都有大量自定义业务组件,那么自己控制页面仍然是更灵活的选择。