示例

介绍
首先使用的是 vue-pdf-embed渲染pdf,前段时间测试测出一个bug,当pdf文档页码过多(500页,7M)时,会出现页面卡死现象。首先尝试使用loading解决,css position解决,worker但是无济于事。然后开始研究vue-pdf-embed,发现内部是基于的pdfjs封装了一层,vue-pdf-embed内容是一次性渲染全部pdf。其实也是可以使用翻页的方式,那么就是一页一页的渲染,不会同时渲染很多页pdf。
技术栈
vue3 pdfjs
解决方案
有一个重要的点是:pdfjs 可以获取到pdf的总页数,以及每一页的pdf的高度。那么先使用一个div撑起父容器,然后滚动父容器,根据scrollTop/pdfHeight 获取到当前视口该展示哪一页 currentPage。
要解决的问题:向下滚动(相对简单),向上滚动,快速下拉,快速上拉。
以下有5种方案,为什么会有这么多种方案呢,都是做着做着 发现更好的方案,以及一些pdfjs的限制问题。最终方案是最后2种。
- 1个canvas渲染,分别渲染前一张pdf的上半部分和后一张pdf的下半部分(放弃,pdfjs不允许使用同一个canvas重复渲染)
- 2个canvas渲染,例如先绘制第1页和第1页,然后currentPage到2的时候 删除2个canvas,第1个canvas绘制第2页,第2个canvas绘制第3页。以此类推。(放弃每次切换页数的时候,页面都会一闪一闪的)
- 多个canvas 弊端 向上滚动一页 全部要重新绘制
- 每滚动一页 将最上面的canvas删除 在最后面添加新的canvas,其中还有边界值需要处理,比如pdf本来就只有1页,pdf 到最后一页和首页。
- 维护一个数组 数组长度为3(或者5),数组中的每个值对应pageindex,currentPage变化后,更新数组,然后和dom对比,做diff操作。
方案5的好处,无须考虑那么多其他的,第4种方案还要解决快速拉动的问题。每次切换pageIndex,前面的canvas被删除掉了(其实不做删除操作也是可以的,因为每个canvas所在的位置是固定的,且不会重叠),后面的canvas新增了,不会让用户看见一闪的现象。
全部代码
js
<template>
<!-- Canvas 显示区域 -->
<div
v-loading="loading"
class="canvas-container"
ref="canvasContainer"
@scroll.passive="handleScroll"
:style="{ overflow: 'auto' }"
>
<div class="mark" :style="{ height: totalHeight + 'px' }"></div>
</div>
</template>
<script setup>
import * as pdfjsLib from "pdfjs-dist/build/pdf";
import PdfWorker from "pdfjs-dist/build/pdf.worker.mjs?url";
import { onMounted, watch } from "vue";
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker; // 使用woker
const props = defineProps({
url: {
type: String,
required: true,
default: "",
},
});
const totalHeight = ref(0); // 总高度
const totalPages = ref(1); // 总页码
const pageHeight = ref(1); // 一页高度
let currentPage = 1; // 当前page
const loading = ref(false);
let pdfDoc = null; // 不能使用ref 会报错
let renderIndex = 0;
onMounted(() => {
console.log(props.url, "props.url");
if (props.url) {
loadPdf(props.url, renderIndex);
}
});
watch(
() => props.url,
(newVal, oldVal) => {
if (canvasContainer.value) {
canvasContainer.value.scrollTop = 0;
let canvases = canvasContainer.value.querySelectorAll("canvas");
canvases.forEach((item) => {
canvasContainer.value.removeChild(item);
});
}
if (newVal) {
// console.log("newVal", newVal);
renderIndex++;
currentPage = 1;
allCanvasIndex = [];
loadPdf(newVal, renderIndex);
}
}
);
const emit = defineEmits(["error"]);
const canvasContainer = ref(null);
let allCanvas = [];
let allCanvasIndex = [];
// 一次性渲染多少张pdf
let canvasMaxNum = 2 + 1;
let t = 0;
let lastScrollTop = 0;
let renderPartialIndex = 0;
function getCurrentIndexs(nindex) {
let currentIndexs = [];
for (let i = 0; i < canvasMaxNum; i++) {
if (nindex + i <= totalPages.value) {
currentIndexs.push(nindex + i);
}
}
return currentIndexs;
}
// 新增单个canvas
function addCanvas(npageIndex) {
let ncanvas = document.createElement("canvas");
ncanvas.classList = ["canvasshow"];
canvasContainer.value.appendChild(ncanvas);
ncanvas.id = "topCanvas" + npageIndex;
ncanvas.style.top = `${pageHeight.value * (npageIndex - 1)}px`;
renderPartialA(pdfDoc, npageIndex, ncanvas);
}
//监听滚动
function handleScroll(e) {
// console.log(e,'eee');
const canvasContainer0 = e.target;
const scrollTop = canvasContainer0.scrollTop;
emit("scroll", scrollTop);
clearTimeout(t);
// 防抖
t = setTimeout(() => {
if (totalPages.value <= canvasMaxNum) {
return;
}
let page = scrollTop / pageHeight.value;
let npage = Math.ceil(Math.max(1, page)); // 当前视图
if (npage != currentPage) {
// 获取视图应该展示的pageIndex
let currentIndexs = getCurrentIndexs(npage);
currentIndexs.forEach((item) => {
if (!allCanvasIndex.includes(item)) {
if (item > 0) {
addCanvas(item);
}
}
});
//移除操作
allCanvasIndex.forEach((item) => {
if (!currentIndexs.includes(item)) {
let canvas = canvasContainer.value.querySelector("#topCanvas" + item);
if (canvas) {
canvasContainer.value.removeChild(canvas);
}
}
});
allCanvasIndex = currentIndexs;
}
currentPage = npage;
},50);
}
async function loadPdf(url, renderIndex1) {
// if (!url) return;
try {
loading.value = true;
pdfDoc = await pdfjsLib.getDocument({ url: url }).promise;
if (renderIndex1 !== renderIndex) {
return;
}
// this.pdfDoc=pdfDoc
totalPages.value = pdfDoc.numPages;
// 先渲染全部或者canvasMaxNum
allCanvas = new Array(Math.min(totalPages.value, canvasMaxNum))
.fill(null)
.map((item, index) => {
// 维护allCanvasIndex
allCanvasIndex.push(index + 1);
let ncanvas = document.createElement("canvas");
ncanvas.id = "topCanvas" + (index + 1);
ncanvas.classList = ["canvasshow"];
return ncanvas;
});
renderPartialIndex++;
await renderPartial(pdfDoc, true, renderPartialIndex);
} catch (error) {
console.log(error);
console.error("PDF 加载失败:", error);
emit("error", error);
} finally {
loading.value = false;
}
}
let scale0=1
// 绘制单个pdf页
async function renderPartialA(pdfDoc, pageIndex, canvas) {
if (!pdfDoc) return;
try {
const page = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: scale0 });
const fullWidth = viewport.width;
const fullHeight = viewport.height;
canvas.style.width = fullWidth
canvas.style.height = fullHeight
canvas.width = fullWidth
canvas.height = fullHeight
renderPartialArea(page, clipParamsTop, canvas);
} catch (error) {
// Cannot read private member #pagePromises from an object whose class did not declare it
console.error("渲染失败:", error);
}
}
const getPageDimensions = (ratio) => {
let width
let height
if (props.height && !props.width) {
height = props.height
width = height / ratio
} else {
width = props.width ?? canvasContainer.value.clientWidth
height = width * ratio
}
return [width, height]
}
async function renderPartial(pdfDoc, init, renderPartialIndex1) {
if (!pdfDoc) return;
try {
const ps = allCanvas.map((item, index) => {
return pdfDoc.getPage(1 + index);
});
Promise.all(ps).then((pages) => {
if (renderPartialIndex1 !== renderPartialIndex) {
return;
}
let page = pages[0];
let w = canvasContainer.value.clientWidth;
const viewWidth = page.view[2] - page.view[0]
const viewHeight = page.view[3] - page.view[1]
let isTransposed=false
const pageWidth = isTransposed ? viewHeight : viewWidth
const [actualWidth, actualHeight] = getPageDimensions(
isTransposed ? viewWidth / viewHeight : viewHeight / viewWidth
)
let scale=actualWidth * window.devicePixelRatio/ pageWidth
console.log(scale,'scale');
scale0=scale
const viewport = page.getViewport({ scale: scale});
const fullWidth = viewport.width;
const fullHeight = viewport.height;
pageHeight.value = (fullHeight * (w - 10)) / fullWidth;
totalHeight.value = totalPages.value * pageHeight.value;
if (init) {
allCanvas.forEach((item, index) => {
if (index == 0) {
item.style.top = `${0}px`;
} else {
item.style.top = `${pageHeight.value * index}px`;
}
canvasContainer.value.appendChild(item);
});
}
// 根据预设计算裁剪参数
pages.forEach((page, index) => {
allCanvas[index].style.width = fullWidth
allCanvas[index].style.height = fullHeight
allCanvas[index].width = fullWidth
allCanvas[index].height = fullHeight
renderPartialArea(page, clipParamsTop, allCanvas[index]);
});
});
} catch (error) {
// Cannot read private member #pagePromises from an object whose class did not declare it
console.error("渲染失败:", error);
}
}
const scale = ref(1);
async function renderPartialArea(page, clipParams, canvas) {
const ctx = canvas.getContext("2d");
const { clipX, clipY, clipWidth, clipHeight } = clipParams;
const partialViewport = page.getViewport({ scale: scale0 }).clone({
scale: scale0,
});
// 应用裁剪和渲染
ctx.save();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const renderContext = {
canvasContext: ctx,
viewport: partialViewport,
};
await page.render(renderContext).promise;
ctx.restore();
}
</script>
<style>
.canvasshow {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
z-index: 10;
}
</style>
<style scoped>
.partial-pdf-viewer {
border: 1px solid #ddd;
/* border-radius: 8px; */
/* padding: 16px; */
}
.control-panel {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.clip-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.clip-controls > div {
display: flex;
align-items: center;
gap: 8px;
}
input[type="number"] {
width: 60px;
padding: 4px;
}
.canvas-container {
width: 100%;
height: 100%;
position: relative;
display: inline-block;
border: 1px solid #eee;
background: white;
}
.canvas-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.canvas-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.mark {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: -10;
pointer-events: none;
}
canvas {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 4px;
}
.info-panel {
margin-top: 12px;
padding: 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
color: #666;
}
</style>