vue3 在微信中实现 pdf 预览功能
最近公司有个项目,要在微信端实现预览 pdf 的功能。参考掘金的这篇文章 VUE3+VITE 移动端H5 借助canvas实现预览PDF以及双指缩放 后 ,发现效果很不错,但是不同机型和平板上预览时偶尔会出现 no Promise 的报错。
仔细研究后发现可能是由于以下原因,第一是自己没有锁定包的版本号,package.json 应该锁定 pdfjs-dist 的版本号,以达到最佳的兼容效果。
// 错误写法
"dependencies": {
"pdfjs-dist": "^2.5.207",
},
// 正确写法
"dependencies": {
"pdfjs-dist": "2.5.207",
},
第二是包的路径引入方式可能不正确,导致可能并没有找到对应的文件,修改后,代码如下:
<template>
<div class="pdf-viewer" ref="pdfContent">
<template v-for="item in state.totalPageNum" :key="item">
<canvas
class="pdf-item"
:id="`pdf-canvas-${item}`"
></canvas>
</template>
</div>
</template>
<script setup>
import {reactive, nextTick, ref,onMounted,onUnmounted,watch} from 'vue'
import * as pdfjs from 'pdfjs-dist'
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.js',
import.meta.url
);// 这里是修改后的路径
import {useRoute} from 'vue-router'
import { closeToast, showLoadingToast } from 'vant'
const route = useRoute()
const url = ref(route.query.pdfSrc)
const emit = defineEmits(['onRendered'])
const state = reactive({
currentPageNum: 0, // 当前页
totalPageNum: 0
})
let pdfCtx;
/**
* @type {Ref<UnwrapRef<Element>>}
*/
const pdfContent = ref(null)
const resolvePdf = async (url) => {
try {
const loadingTask = pdfjs.getDocument(url)
const pdf = await loadingTask.promise
pdfCtx = pdf
state.totalPageNum = pdf.numPages
state.currentPageNum = 1
const res = await pdf.getPage(1)
let boxWidth = pdfContent.value.clientWidth - 20
const [x1, , x2] = res._pageInfo.view
const pageWidth = x2 - x1
state.scale = (boxWidth * (state.maxZoom / 10)) / pageWidth
await nextTick(async () => {
await renderPdf()
})
} catch (e) {
await closeToast()
console.error('加载PDF出错', e)
}
}
const renderPdf = async (num = 1) => {
const page = await pdfCtx.getPage(num)
const canvas = document.getElementById(`pdf-canvas-${num}`)
const ctx = canvas.getContext('2d')
const viewport = page.getViewport({scale: state.scale})
canvas.width = viewport.width
canvas.height = viewport.height
// 画布的dom大小, 设置移动端,宽度设置铺满整个屏幕
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
page.render({canvasContext: ctx, viewport})
if (num < state.totalPageNum) {
await renderPdf(num + 1)
} else {
emit('onRendered')
}
}
const resizeCanvas = async () => {
for (let i = 1; i <= state.totalPageNum; i++) {
const page = await pdfCtx.getPage(i)
const canvas = document.getElementById(`pdf-canvas-${i}`)
const viewport = page.getViewport({ scale: state.scale })
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
}
}
onMounted(() => {
window.addEventListener('resize', () => {
resizeCanvas()
})
})
onUnmounted(() => {
window.removeEventListener('resize', () => {
resizeCanvas()
})
})
watch(() => url.value, async (val) => {
await showLoadingToast()
if (val) {
await resolvePdf(val)
}
await closeToast()
}, {immediate: true})
</script>
<style scoped>
.pdf-viewer {
padding-right: 20px;
width: 100vw;
height: 100vh;
overflow: scroll;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.pdf-viewer::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
</style>
这里我删除了双指缩放的功能,因为我们的项目中没有限制屏幕缩放。自此,pdf 就可以正常预览了。
2024/7/26更新
经过一个多月的用户反映,pdf预览的功能还是有一些不足的地方,首先就是一上来就将所有的页面画到canvas上,导致负载过大,虽然在浏览器上查看时没有大问题,但是在 app 内嵌该页面时,总是出现 app 白屏闪退的问题,所以我将上述代码进行了优化,写了个懒加载,使用 IntersectionObserver,观察所有的 canvas 标签,rootMargin 的含义为计算交叉时添加到根边界盒的矩形偏移量,可以用像素(px)或百分比(%)来表达,默认值为“0px 0px 0px 0px”。如果出现在配置的范围内,则将内容渲染在 canvas 上 ,第二个问题是,上面写的组件卸载时取消监听的逻辑不对,第三个小修改就是我将 watch修改为使用 watchEffect,该方法一开始就会调用一次。代码如下:
<template>
<div class='pdf-viewer' ref='pdfContent'>
<template v-for='item in state.totalPageNum' :key='item'>
<canvas class='pdf-item' :id='`pdf-canvas-${item}`'></canvas>
</template>
</div>
</template>
<script setup>
import { reactive, nextTick, ref,onMounted,onUnmounted,watchEffect } from 'vue'
import * as pdfjs from 'pdfjs-dist'
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.js',
import.meta.url
)
import { useRoute } from 'vue-router'
import { closeToast, showLoadingToast } from 'vant'
const route = useRoute()
const url = ref(route.query.pdfSrc)
const emit = defineEmits(['onRendered'])
const state = reactive({
currentPageNum: 0, // 当前页
totalPageNum: 0,
})
let pdfCtx
/**
* @type {Ref<UnwrapRef<Element>>}
*/
const pdfContent = ref(null)
const resolvePdf = async (url) => {
await showLoadingToast()
try {
const loadingTask = pdfjs.getDocument(url)
const pdf = await loadingTask.promise
pdfCtx = pdf
state.totalPageNum = pdf.numPages
state.currentPageNum = 1
const res = await pdf.getPage(1)
let boxWidth = pdfContent.value.clientWidth - 20
const [x1, , x2] = res._pageInfo.view
const pageWidth = x2 - x1
state.scale = (boxWidth * (state.maxZoom / 10)) / pageWidth
// await nextTick(async () => {
// await renderPdf()
// })
// 进入视口时,
const ob = new IntersectionObserver( (entries) => {
for (const entry of entries){
if (entry.isIntersecting) {
const num = entry.target.id.split('-')[2]
renderPdf(Number(num))
ob.unobserve(entry.target)
}
}
},{
rootMargin:'20%',// 计算交叉时添加到根边界盒的矩形偏移量,可以使用百分比或像素,默认值为“0px 0px 0px 0px”。
})
for(let num = 1; num <= state.totalPageNum; num++){
const canvas = document.getElementById(`pdf-canvas-${num}`)
ob.observe(canvas)
}
} catch (e) {
console.error('加载PDF出错', e)
}
await closeToast()
}
const renderPdf = async (num = 1) => {
const page = await pdfCtx.getPage(num)
const canvas = document.getElementById(`pdf-canvas-${num}`)
const ctx = canvas.getContext('2d')
const viewport = page.getViewport({ scale: state.scale })
canvas.width = viewport.width
canvas.height = viewport.height
// 画布的dom大小, 设置移动端,宽度设置铺满整个屏幕
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
page.render({ canvasContext: ctx, viewport })
if (num >= state.totalPageNum) {
emit('onRendered')
}
}
const resizeCanvas = async () => {
for (let i = 1; i <= state.totalPageNum; i++) {
const page = await pdfCtx.getPage(i)
const canvas = document.getElementById(`pdf-canvas-${i}`)
const viewport = page.getViewport({ scale: state.scale })
const clientWidth = pdfContent.value ?pdfContent.value.clientWidth:500
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
}
}
onMounted(() => {
window.addEventListener('resize', resizeCanvas)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvas)
})
watchEffect( ()=>{
if (url.value) resolvePdf(url.value)
})
</script>
<style scoped>
.pdf-viewer {
padding-right: 20px;
width: 100vw;
height: 100vh;
overflow: scroll;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.pdf-viewer::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
</style>
效果如下:
原本这个逻辑应该是没有问题的,奈何在 app中,当向下滑动过快时,还是会出现 pdf 加载白屏的状况,分析原因我个人认为还是由于绘制了太多的canvas,性能消耗巨大,所以白屏闪退。写到这已经没有别的办法了,只能写成分页了,每次只渲染一个 canvas,这样应该就可以了,代码如下:
<template>
<div class='pdf-viewer' ref='pdfContent'>
<canvas class='pdf-item' id='pdf-canvas'></canvas>
<div class='pdf-controller'>
<van-stepper style='flex-direction: column' disable-input @plus='plus' @minus='minus' v-model="state.currentPage" integer min="1" :max="state.totalPageNum" />
</div>
</div>
</template>
<script setup>
import { reactive, nextTick, ref } from 'vue'
import * as pdfjs from 'pdfjs-dist'
import _ from 'lodash'
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.js',
import.meta.url
)
import { useRoute } from 'vue-router'
import { closeToast, showLoadingToast } from 'vant'
const route = useRoute()
const url = ref(route.query.pdfSrc)
const emit = defineEmits(['onRendered'])
const state = reactive({
currentPageNum: 0, // 当前页
totalPageNum: 0,
currentPage:1, // 当前页码
})
let pdfCtx
/**
* @type {Ref<UnwrapRef<Element>>}
*/
const pdfContent = ref(null)
const resolvePdf = async (url) => {
await showLoadingToast()
try {
const loadingTask = pdfjs.getDocument(url)
const pdf = await loadingTask.promise
pdfCtx = pdf
state.totalPageNum = pdf.numPages
state.currentPageNum = 1
const res = await pdf.getPage(1)
let boxWidth = pdfContent.value.clientWidth - 20
const [x1, , x2] = res._pageInfo.view
const pageWidth = x2 - x1
state.scale = (boxWidth * (state.maxZoom / 10)) / pageWidth
await nextTick(async () => {
await renderPdf()
})
// // 进入视口时,
// const ob = new IntersectionObserver( (entries) => {
// for (const entry of entries){
// if (entry.isIntersecting) {
// const num = entry.target.id.split('-')[2]
// renderPdf(Number(num))
// ob.unobserve(entry.target)
// }
// }
// },{
// rootMargin:'20%',// 计算交叉时添加到根边界盒的矩形偏移量,可以使用百分比或像素,默认值为“0px 0px 0px 0px”。
// })
// for(let num = 1; num <= state.totalPageNum; num++){
// const canvas = document.getElementById(`pdf-canvas-${num}`)
// ob.observe(canvas)
// }
} catch (e) {
await closeToast()
console.error('加载PDF出错', e)
}
}
const plus=_.throttle(()=>{
if(state.currentPage<state.totalPageNum){
state.currentPage++
renderPdf()
}
},500)
const minus=_.throttle(()=>{
if(state.currentPage>1){
state.currentPage--
renderPdf()
}
},500)
const renderPdf = async () => {
const page = await pdfCtx.getPage(state.currentPage)
const canvas = document.getElementById('pdf-canvas')
const ctx = canvas.getContext('2d')
const viewport = page.getViewport({ scale: state.scale })
canvas.width = viewport.width
canvas.height = viewport.height
// 画布的dom大小, 设置移动端,宽度设置铺满整个屏幕
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
page.render({ canvasContext: ctx, viewport })
if (state.currentPage >= state.totalPageNum) {
emit('onRendered')
}
}
const resizeCanvas = async () => {
// for (let i = 1; i <= state.totalPageNum; i++) {
const page = await pdfCtx.getPage(state.currentPage)
const canvas = document.getElementById('pdf-canvas')
const viewport = page.getViewport({ scale: state.scale })
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
// }
}
onMounted(() => {
window.addEventListener('resize', resizeCanvas)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvas)
})
watchEffect( ()=>{
if (url.value) resolvePdf(url.value)
})
</script>
<style scoped>
.pdf-viewer {
width: 100vw;
height: 100vh;
overflow: scroll;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
position:relative;
.pdf-controller{
transform:rotate(90deg);
position: fixed;
right: -20px;
bottom: 50%;
:deep(input[type="tel"]){
transform:rotate(-90deg);
}
}
}
.pdf-viewer::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
</style>
使用vant 的组件 stepper 进行切换,效果现在感觉不错,不过在 app中点击全屏时还是会出现白屏,应该是默认的页面大小 scale 越大渲染越耗时,资源消耗越大,这里只能含泪将 scale 改成固定值了。虽说原本的代码在电脑上都能正常工作,但是为了兼容,只能舍弃了。
最终代码如下:
<template>
<div class='pdf-viewer' ref='pdfContent'>
<canvas class='pdf-item' id='pdf-canvas'></canvas>
<div class='pdf-controller'>
<van-stepper style='flex-direction: column' disable-input @plus='plus' @minus='minus' v-model="state.currentPage" integer min="1" :max="state.totalPageNum" />
</div>
</div>
</template>
<script setup>
import { reactive, nextTick, ref } from 'vue'
import * as pdfjs from 'pdfjs-dist'
import _ from 'lodash'
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.js',
import.meta.url
)
import { useRoute } from 'vue-router'
import { closeToast, showLoadingToast } from 'vant'
const route = useRoute()
const url = ref(route.query.pdfSrc)
const emit = defineEmits(['onRendered'])
const state = reactive({
currentPageNum: 0, // 当前页
totalPageNum: 0,
currentPage:1, // 当前页码
scale:3, // 缩放倍数
})
let pdfCtx
/**
* @type {Ref<UnwrapRef<Element>>}
*/
const pdfContent = ref(null)
const resolvePdf = async (url) => {
await showLoadingToast()
try {
const loadingTask = pdfjs.getDocument(url)
const pdf = await loadingTask.promise
pdfCtx = pdf
state.totalPageNum = pdf.numPages
state.currentPageNum = 1
const res = await pdf.getPage(1)
await nextTick(async () => {
await renderPdf()
})
} catch (e) {
await closeToast()
console.error('加载PDF出错', e)
}
}
const plus=_.throttle(()=>{
if(state.currentPage<state.totalPageNum){
state.currentPage++
renderPdf()
}
},500)
const minus=_.throttle(()=>{
if(state.currentPage>1){
state.currentPage--
renderPdf()
}
},500)
const renderPdf = async () => {
const page = await pdfCtx.getPage(state.currentPage)
const canvas = document.getElementById('pdf-canvas')
const ctx = canvas.getContext('2d')
const viewport = page.getViewport({ scale: state.scale })
canvas.width = viewport.width
canvas.height = viewport.height
// 画布的dom大小, 设置移动端,宽度设置铺满整个屏幕
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
page.render({ canvasContext: ctx, viewport })
if (state.currentPage >= state.totalPageNum) {
emit('onRendered')
}
}
const resizeCanvas = async () => {
// for (let i = 1; i <= state.totalPageNum; i++) {
const page = await pdfCtx.getPage(state.currentPage)
const canvas = document.getElementById('pdf-canvas')
const viewport = page.getViewport({ scale: state.scale })
const clientWidth = pdfContent.value.clientWidth
canvas.style.width = clientWidth + 'px'
// 根据pdf每页的宽高比例设置canvas的高度
canvas.style.height = clientWidth * (viewport.height / viewport.width) + 'px'
// }
}
onMounted(() => {
window.addEventListener('resize', resizeCanvas)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvas)
})
watchEffect( ()=>{
if (url.value) resolvePdf(url.value)
})
</script>
<style scoped>
.pdf-viewer {
width: 100vw;
height: 100vh;
overflow: scroll;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
position:relative;
.pdf-controller{
transform:rotate(90deg);
position: fixed;
right: -20px;
bottom: 50%;
:deep(input[type="tel"]){
transform:rotate(-90deg);
}
}
}
.pdf-viewer::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
</style>