跳到主要内容位置

vue.js: 3 篇

查看所有标签(分类)

vue3实现高性能pdf预览器功能可行性方案及实践(pdfjs-dist5.x插件使用及自定义修改)

1 前言#

​ 近日,后端拿着电脑来与我说:“那个代理商反馈这个pdf看图大文件放大还是有些卡,说是放大到200%以上卡顿更明显”;我一想,不可能啊,我上次不是优化过了吗,怎么还卡呢,于是我说:“不会吧,上次不是优化过了吗,怎么还卡呢?我来看看”。 然后经过我的反复测试,在我的电脑上发现,每当我放大到400%以上的时候我的电脑就会异常的卡顿。

​ 于是本着从事多年计算机行业的专业素养加上学习多年计算机专业知识的压舱石,我不假思索的在键盘上按下了ctrl+alt+delete按键,打开了任务管理器,再次测试pdf放大功能,就当我放大到400%以上,然后令人惊讶的事件发生了!我giao,内存怎么彪到100%了?我一想,这pdf放大肯定有问题啊!还好巧不巧,只有当我放大到400%以上才出现这个问题。

​ 经过我数天的排查分析、原理解刨,总结把这个pdf的脉络理解清楚了,之前内存100%的问题也排查找到了,至于是什么问题,暂且不提(可放在下文的彩蛋之中),因为这个不会影响我们“高性能pdf预览功能”的实现。

​ 废话不多说了,下面就让我们穿越到代码世界吧!滴滴 ~ jun(科技的声音)~

2 实现效果图#

image-20250603163036010

在线预览网址:http://116.198.200.217:6005/

git仓库地址:https://github.com/JACK-ZHANG-coming/frontEnd-all-knowledge/tree/master/examples/funnyDemo/a09vue3%E9%AB%98%E6%80%A7%E8%83%BDpdf%E9%A2%84%E8%A7%88%E5%99%A8

由上图可见,我们实现pdf的预览功能,同时具有页面跳转、放大缩小(20%-1000%)、缩略图、下载、打印、编辑等功能,可以说是常规的pdf预览及编辑操作都具备了。这就是pdfjs-dist5.2.133版本的pdf预览插件,同时我也对这个源码进行了自定义修改,兼容了大部分的浏览器,增加了下载、打印的权限校验,同时也修改了部分样式,完成了贴合业务场景的高级pdf预览器的实现!

那么这个pdf预览器是如何从0 -> 初步引入项目 -> 兼容大部分现代浏览器 -> 自定义修改源码样式 -> 自定义增加下载、打印权限校验,完成这一系列的操作,打造成为一个完整的贴合业务的pdf预览器的,请看我下面的“实现过程”章节,为屏幕前的有缘的你细细道来~

3 实现过程#

3.1 初步引入项目#

首先我们要有一个可以正常启动起来的项目,比如一个vue项目,或者我们跟着

创建一个空的vue项目 npm create vue@latest ,又或者直接拿我的项目来用(一步到位:https://github.com/JACK-ZHANG-coming/frontEnd-all-knowledge/tree/master/examples/funnyDemo/a09vue3%E9%AB%98%E6%80%A7%E8%83%BDpdf%E9%A2%84%E8%A7%88%E5%99%A8)。

3.1.1 下载pdfjs-dist网页离线包#

好的,现在我们都有了一个可以启动起来的项目了,那么下面我们来到pdfjs-dist的官网页面:https://mozilla.github.io/pdf.js/getting_started/#download

进来以后我们选择 Prebuilt (older browsers) 这个类型版本下载,这种可以适配大部分的新旧浏览器,功能基本也是很齐全的。

image-20250612165224487

我之前下载的是5.2.133,现在看了一下,才过了一两个月,又更新了一个新的版本(当屏幕的你进入这个链接时估计又更新了几个版本吧)。如果想和我下一个版本的可以到官方的github仓库里面下载:

https://github.com/mozilla/pdf.js/releases

image-20250612170206650

不过这都没啥问题,小的版本更新不会有多大变化,不影响我们的技术落地。干就完了!

下载完以后,我们把下载好的压缩包解压一下,里面有两个文件夹,放到我们的项目里面,我是放在这里(记住放的位置,后面要根据对应路径引用):

image-20250612170645593

3.1.2 引入项目运行#

ok,刚刚我们已经把pdfjs-dist这个网页插件下载下来了并且放入了项目中了,我们需要有一个空白页面用来做pdf展示,我是用的iframe 标签来展示的,同时我们还需要有一个可以直接访问pdf的url链接(大家可以把pdf上传到图床或者服务器,或者让后端直接提供一个链接,访问能够直接下载pdf,如果没有可以暂且用我的: http://116.198.200.217:7501/api/v1/user/getPdfFile?file=%E6%B5%8B%E8%AF%95pdf%E6%96%87%E4%BB%B61.pdf ),下面是我pdf展示的关键代码:

template 标签里面的关键代码:

<iframe
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdfjs-5.2.133-legacy-dist/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>

script标签里面的关键代码:

currentFileUrl.value = ''
currentFileUrl.value = encodeURIComponent(`http://116.198.200.217:7501/api/v1/user/getPdfFile?file=%E6%B5%8B%E8%AF%95pdf%E6%96%87%E4%BB%B61.pdf`)

好了,经过这两段关键代码,我们就能在界面上面看到pdf的效果了,效果如下:

image-20250613171233110

如果不知道咋运行的小伙伴也不要着急,文末有完整代码,如果你能访问github直接访问文章开头的github仓库也能直接下载完整项目代码。

3.2 兼容老版本设备#

3.2.1 下载老版本的预览器网页#

之前我们已经下载了较新版本的pdfjs-dist插件了,但是呢,根据国内硬件的迭代速度,总有一些用户还在使用老版本的设备(比如安卓7啦、低版本的浏览器啦等,当然这里我们是不考虑IE浏览器的>_<)。

好滴,现在我们还要再下载一个旧一点的pdfjs-dist插件作为备用,我这里又下载了个旧的版本 2.0.943 的这个,下载网址:https://github.com/mozilla/pdf.js/releases?page=5 (这里面有很多个版本,很上面的下载官网是一个地址)

image-20250613164654463

下载完以后,我们可以把压缩好的文件夹放在此处:

image-20250613171733936

由上图可见,我直接创建了个pdf文件夹,然后放在了里面,至于我为啥把旧版本pdf预览器文件夹的名字叫做"pdf"呢,这是因为我之前也是从别的项目里面copy的,图省事(lan)。

3.2.2 进行新老设备判断#

直接上关键代码,其原理依旧是通过iframe来渲染,然后通过函数判断新旧设备进行展示不同的iframe

template 标签里面的关键代码:

<iframe
v-if="pdfViewVersionType === 'newest'"
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdfjs-5.2.133-legacy-dist/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>
<!-- 低版本的 pdfjs 版本 -->
<iframe
v-else-if="pdfViewVersionType === 'old'"
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdf/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>

script标签里面的关键代码:

currentFileUrl.value = ''
currentFileUrl.value = encodeURIComponent(
`http://116.198.200.217:7501/api/v1/user/getPdfFile?file=%E6%B5%8B%E8%AF%95pdf%E6%96%87%E4%BB%B61.pdf`,
)
const pdfViewVersionType = ref<'' | 'newest' | 'old'>('')
// 检测设备
const checkDevice = () => {
if (isMobileDevice(false, 'LowerDevice')) {
pdfViewVersionType.value = 'old'
} else {
pdfViewVersionType.value = 'newest'
}
}
const isMobileDevice = (
excludeIpad: boolean = false,
customFlag: 'IOS' | 'ANDROID' | 'PC' | 'LowerDevice' | '' = '',
) => {
// @ts-expect-error 忽略报错
const userAgent = navigator.userAgent || navigator.vendor || window.opera
let flag = false
// 检查常见的移动设备的用户代理
if (/android/i.test(userAgent)) {
flag = true
}
// @ts-expect-error 忽略报错
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
flag = true
}
if (/windows phone/i.test(userAgent)) {
flag = true
}
if (/mobile/i.test(userAgent)) {
flag = true
}
// 排除列表
if (excludeIpad && /iPad|iPod/.test(userAgent)) {
flag = false
}
// 自定义标识 单独校验某种设备,其他都不看
if (customFlag === 'IOS') {
flag = /iPad|iPhone|iPod/.test(userAgent) ? true : false
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 && (flag = true) // iPad伪装成Mac的情况
} else if (customFlag === 'LowerDevice') {
// 目前将iPad|iPhone|iPod 都视为较低的设备
flag = /iPad|iPhone|iPod/.test(userAgent) ? true : false
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 && (flag = true) // iPad伪装成Mac的情况
}
// 如果都不匹配,则认为是PC端
return flag
}
onMounted(() => {
checkDevice()
})

通过如上的判断,我们就能根据IOS、安卓、PC来写显示不同的类型,目前我是将 iPad|iPhone|iPod 看作低版本,直接显示低版本的样式(因为用最新的pdf预览器在一些苹果设备上会显示不出来)。

对于低版本设备显示的样式如下:

image-20250616153739360

3.3 自定义修改源码样式#

当使用官方的pdf预览器时总会有一些样式我(产)们(品)会觉得需要优化一下,这里以pdfjs-5.2.133-legacy-dist 这个版本来举例,其实每个版本都大差不差,学会一种其他的也就能自得其然的举一反三了。

比如说我们想要把下图所示弹框中的"pdf生成器"那行文字去掉:

image-20250616155820018

首先我们使用审查元素找到该元素对应的id,然后找到public\lib\pdfjs-5.2.133-legacy-dist\web\viewer.htmlC:\GitHub\frontEnd-all-knowledge\examples\funnyDemo\a09vue3高性能pdf预览器\public\lib\pdfjs-5.2.133-legacy-dist\web\viewer.mjs 这两个文件(这两个文件是这个pdf预览器核心代码,一个用于写页面样式,一个是js交互事件),然后将和这个producerField dom元素id相关的内容注释或者删掉即可。操作代码截图如下:

viewer.html文件:

image-20250616161229664

viewer.mjs文件:

image-20250616161412818

如此修改完,界面运行效果如下:

image-20250616161556285

由此可见界面那行文字样式已经被我们去掉了。以此举一反三,我们可以在viewer.html文件和viewer.mjs文件中添加我们自定义的样式以及事件。

3.4 自定义增加下载、打印权限校验#

官方提供的pdf预览器网页中为我们提供了下载以及打印的操作,然后在我们实际的项目使用中,下载和打印功能往往是需要加权限控制的。那么下面就将为大家展示一下如何增加权限控制,思路:给pdfjs-dist预览器的源码的下载、打印按钮的元素上增加自定义属性,然后再通过iframe 标签去监听带有这个属性的按钮的点击事件,当用户点击相应按钮时捕捉到用户的点击事件,进行检验判断,如果有权限则触发该按钮的点击事件,反之提示无权限,点击事件的触发终止。

关键代码如下:

C:\GitHub\frontEnd-all-knowledge\examples\funnyDemo\a09vue3高性能pdf预览器\public\lib\pdfjs-5.2.133-legacy-dist\web\viewer.html 文件中添加data-require-perm="true" 属性

image-20250616163609648

应用层的代码文件 PdfView.vue 中的关键代码如下:

<script setup lang="ts">
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
const currentFileUrl = ref('')
const loading = ref(false)
const route = useRoute()
const fullName = ref('未命名文件')
const fileType = ref<'pdf' | ''>('')
const verifyPrintRight = async () => {
// 打印权限校验
let flag = true // 默认有权限
loading.value = true
/**
* ... 权限接口请求
*
*/
flag = false // 这里根据接口返回的权限判断结果返回 true 或 false,当前临时赋值为静态数值没有权限
loading.value = false
return flag
}
const verifyDownloadRight = async () => {
// 这里进行权限判断接口请求
let flag = true // 默认有权限
loading.value = true
/**
* ... 权限接口请求
*
*/
flag = true // 这里根据接口返回的权限判断结果返回 true 或 false,当前临时赋值为静态数值有权限
loading.value = false
return flag
}
const initPreview = async () => {
currentFileUrl.value = ''
loading.value = true
fullName.value = (route.query.file as string) || ''
fileType.value = getFileType(fullName.value) as 'pdf' | ''
window.document.title = `${fullName.value}`
currentFileUrl.value = encodeURIComponent(
`http://116.198.200.217:7501/api/v1/user/getPdfFile?file=%E6%B5%8B%E8%AF%95pdf%E6%96%87%E4%BB%B61.pdf`,
)
switch (fileType.value) {
case 'pdf':
nextTick(() => {
iframeRef.value?.addEventListener('load', onFrameLoad)
})
break
default:
break
}
loading.value = false
}
//#region pdf预览相关
const iframeRef = ref<HTMLIFrameElement | null>(null)
// 用来临时存放 iframe 的 document 以便卸载时移除监听
let iframeDoc: Document | null = null
// “放行”标记属性名
const ALLOWED_FLAG = 'data-parent-allowed'
// 事件处理器,注册在 iframeDoc 上
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const interceptor = async (e: any) => {
// console.log('点击了按钮')
const target = e.target
// console.log('target:', target)
if (!target.matches('button[data-require-perm]')) return
// 如果是我们之前人为触发放行的,就清除标记并放行
if (target.hasAttribute(ALLOWED_FLAG)) {
target.removeAttribute(ALLOWED_FLAG)
return
}
e.preventDefault()
e.stopImmediatePropagation()
// 拿到按钮标识:优先 id,否则取 data-action
const buttonId = target.id || target.getAttribute('data-action') || ''
// 在父页面统一做权限判断
const allowed = await checkUserPermission(buttonId)
if (allowed) {
// 如果有权限 打标记再触发一次 click,让它执行原始逻辑
target.setAttribute(ALLOWED_FLAG, 'true')
target.click()
} else {
alert('没有权限')
}
}
// iframe 加载完成后的回调:拿到 iframeDoc 并注册拦截器
const onFrameLoad = () => {
const iframeEl = iframeRef.value
if (!iframeEl) return
iframeDoc = iframeEl.contentDocument
if (!iframeDoc) return
// 捕获阶段拦截 click 和 touchend(兼容移动端)
iframeDoc.addEventListener('click', interceptor, true)
iframeDoc.addEventListener('touchend', interceptor, true)
}
const checkUserPermission = async (buttonId: unknown) => {
try {
let flag = true
switch (buttonId) {
case 'downloadButton':
case 'secondaryDownload':
case 'download':
flag = await verifyDownloadRight()
break
case 'printButton':
case 'secondaryPrint':
flag = await verifyPrintRight()
break
default:
break
}
return flag
} catch (err) {
console.error('权限校验出错', err)
return false
}
}
// 在 onUnmounted 中移除事件监听
onUnmounted(() => {
// 卸载时,先移除 iframe 的 load 监听
iframeRef.value?.removeEventListener('load', onFrameLoad)
// 再移除在 iframeDoc 上注册的事件
if (iframeDoc) {
iframeDoc.removeEventListener('click', interceptor, true)
iframeDoc.removeEventListener('touchend', interceptor, true)
}
})
//#endregion
/**
* 获取文件的后缀名
* e.g: abc.pdf===>pdf
* @param fileName 文件名称
* @param bigOrSmall 是否大小写 '0' 原样输出 '1'小写(默认) '2'大写
* @returns
*/
const getFileType = (fileName: string, bigOrSmall: '0' | '1' | '2' = '1') => {
let fileType = ''
let findIndex = -1
for (let i = fileName.length - 1; i >= 0 && fileName; i--) {
if (fileName.charAt(i) === '.') {
findIndex = i
break
}
}
if (findIndex >= 0) {
fileType = fileName.slice(findIndex + 1)
}
if (bigOrSmall == '1') {
return fileType.toLocaleLowerCase()
} else if (bigOrSmall == '2') {
return fileType.toLocaleUpperCase()
} else {
return fileType
}
}
onMounted(() => {
initPreview()
})
</script>
<template>
<div v-loading="loading" class="common-layout view-view">
<el-container class="el_container">
<div className="iframe">
<iframe
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdfjs-5.2.133-legacy-dist/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>
</div>
</el-container>
</div>
</template>

下图所示为打印按钮没有权限的效果:

image-20250616163459823

如此,下载及打印的权限校验功能已经实现。

4 项目线上部署bug解决#

当我们把pdf预览器开发好以后,肯定是要部署到生产环境(线上/服务器)给别人使用的,但是呢,如果你是首次部署到服务器上面,大概率会出现如下这种情况:

image-20250616165715109

我们看到控制台分别报了Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.

这两个错误,这个问题是我们的服务器对 一些MIME 类型文件不支持的原因,此刻我们修改一些服务器的配置就可以解决。你的服务器可能是IIS或者Nginx或者是其他,我这里就以宝塔为例(我的是宝塔),修改一下宝塔该网站的nginx相关的配置,在nginx加入如下配置:

location ~* \.(?:js|mjs)$
{
default_type application/javascript;
expires 12h;
add_header Cache-Control "public";
}

image-20250616170423707

加完这个nginx配置然后保存,重启服务器,pdf就可以正常预览了,效果如下:

image-20250616170541927

5 完整代码#

上面我们将pdf预览器的完整实现过程做了一个讲解,并且增加了相应自定义操作,为了防止有小伙伴觉得代码比较零散,下面粘贴出完整代码,或者也可以直接访问我的github仓库(https://github.com/JACK-ZHANG-coming/frontEnd-all-knowledge/tree/master/examples/funnyDemo/a09vue3%E9%AB%98%E6%80%A7%E8%83%BDpdf%E9%A2%84%E8%A7%88%E5%99%A8)获取。

完整代码如下:

<!-- pdf预览器 -->
<script setup lang="ts">
import { onMounted, ref, watch, reactive, nextTick, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
const currentFileUrl = ref('')
const loading = ref(false)
const route = useRoute()
const fullName = ref('未命名文件')
const fileType = ref<'pdf' | ''>('')
const currentBase64FileData = reactive({
base64: '',
name: '',
})
const verifyPrintRight = async () => {
// 打印权限校验
let flag = true // 默认有权限
loading.value = true
/**
* ... 权限接口请求
*
*/
flag = false // 这里根据接口返回的权限判断结果返回 true 或 false,当前临时赋值为静态数值没有权限
loading.value = false
return flag
}
const verifyDownloadRight = async () => {
// 这里进行权限判断接口请求
let flag = true // 默认有权限
loading.value = true
/**
* ... 权限接口请求
*
*/
flag = true // 这里根据接口返回的权限判断结果返回 true 或 false,当前临时赋值为静态数值有权限
loading.value = false
return flag
}
// 检测设备
const checkDevice = () => {
if (isMobileDevice(false, 'LowerDevice')) {
pdfViewVersionType.value = 'old'
} else {
pdfViewVersionType.value = 'newest'
}
}
const initPreview = async () => {
currentBase64FileData.base64 = ''
currentBase64FileData.name = ''
currentFileUrl.value = ''
loading.value = true
fullName.value = (route.query.file as string) || ''
fileType.value = getFileType(fullName.value) as 'pdf' | ''
window.document.title = `${fullName.value}`
currentFileUrl.value = encodeURIComponent(
`http://116.198.200.217:7501/api/v1/user/getPdfFile?file=%E6%B5%8B%E8%AF%95pdf%E6%96%87%E4%BB%B61.pdf`,
)
switch (fileType.value) {
case 'pdf':
nextTick(() => {
iframeRef.value?.addEventListener('load', onFrameLoad)
})
break
default:
break
}
loading.value = false
}
//#region pdf预览相关
const pdfViewVersionType = ref<'' | 'newest' | 'old'>('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
// 用来临时存放 iframe 的 document 以便卸载时移除监听
let iframeDoc: Document | null = null
// “放行”标记属性名
const ALLOWED_FLAG = 'data-parent-allowed'
// 事件处理器,注册在 iframeDoc 上
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const interceptor = async (e: any) => {
// console.log('点击了按钮')
const target = e.target
// console.log('target:', target)
if (!target.matches('button[data-require-perm]')) return
// 如果是我们之前人为触发放行的,就清除标记并放行
if (target.hasAttribute(ALLOWED_FLAG)) {
target.removeAttribute(ALLOWED_FLAG)
return
}
e.preventDefault()
e.stopImmediatePropagation()
// 拿到按钮标识:优先 id,否则取 data-action
const buttonId = target.id || target.getAttribute('data-action') || ''
// 在父页面统一做权限判断
const allowed = await checkUserPermission(buttonId)
if (allowed) {
// 如果有权限 打标记再触发一次 click,让它执行原始逻辑
target.setAttribute(ALLOWED_FLAG, 'true')
target.click()
} else {
alert('没有权限')
}
}
// iframe 加载完成后的回调:拿到 iframeDoc 并注册拦截器
const onFrameLoad = () => {
const iframeEl = iframeRef.value
if (!iframeEl) return
iframeDoc = iframeEl.contentDocument
if (!iframeDoc) return
// 捕获阶段拦截 click 和 touchend(兼容移动端)
iframeDoc.addEventListener('click', interceptor, true)
iframeDoc.addEventListener('touchend', interceptor, true)
}
const checkUserPermission = async (buttonId: unknown) => {
try {
let flag = true
switch (buttonId) {
case 'downloadButton':
case 'secondaryDownload':
case 'download':
flag = await verifyDownloadRight()
break
case 'printButton':
case 'secondaryPrint':
flag = await verifyPrintRight()
break
default:
break
}
return flag
} catch (err) {
console.error('权限校验出错', err)
return false
}
}
// 在 onUnmounted 中移除事件监听
onUnmounted(() => {
// 卸载时,先移除 iframe 的 load 监听
iframeRef.value?.removeEventListener('load', onFrameLoad)
// 再移除在 iframeDoc 上注册的事件
if (iframeDoc) {
iframeDoc.removeEventListener('click', interceptor, true)
iframeDoc.removeEventListener('touchend', interceptor, true)
}
})
//#endregion
const isMobileDevice = (
excludeIpad: boolean = false,
customFlag: 'IOS' | 'ANDROID' | 'PC' | 'LowerDevice' | '' = '',
) => {
// @ts-expect-error 忽略报错
const userAgent = navigator.userAgent || navigator.vendor || window.opera
let flag = false
// 检查常见的移动设备的用户代理
if (/android/i.test(userAgent)) {
flag = true
}
// @ts-expect-error 忽略报错
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
flag = true
}
if (/windows phone/i.test(userAgent)) {
flag = true
}
if (/mobile/i.test(userAgent)) {
flag = true
}
// 排除列表
if (excludeIpad && /iPad|iPod/.test(userAgent)) {
flag = false
}
// 自定义标识 单独校验某种设备,其他都不看
if (customFlag === 'IOS') {
flag = /iPad|iPhone|iPod/.test(userAgent) ? true : false
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 && (flag = true) // iPad伪装成Mac的情况
} else if (customFlag === 'LowerDevice') {
// 目前将iPad|iPhone|iPod 都视为较低的设备
flag = /iPad|iPhone|iPod/.test(userAgent) ? true : false
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 && (flag = true) // iPad伪装成Mac的情况
}
// 如果都不匹配,则认为是PC端
return flag
}
/**
* 获取文件的后缀名
* e.g: abc.pdf===>pdf
* @param fileName 文件名称
* @param bigOrSmall 是否大小写 '0' 原样输出 '1'小写(默认) '2'大写
* @returns
*/
const getFileType = (fileName: string, bigOrSmall: '0' | '1' | '2' = '1') => {
let fileType = ''
let findIndex = -1
for (let i = fileName.length - 1; i >= 0 && fileName; i--) {
if (fileName.charAt(i) === '.') {
findIndex = i
break
}
}
if (findIndex >= 0) {
fileType = fileName.slice(findIndex + 1)
}
if (bigOrSmall == '1') {
return fileType.toLocaleLowerCase()
} else if (bigOrSmall == '2') {
return fileType.toLocaleUpperCase()
} else {
return fileType
}
}
watch(route, (nowValue) => {
if (nowValue.name !== 'view') {
return
}
initPreview()
})
onMounted(() => {
checkDevice()
initPreview()
})
</script>
<template>
<div v-loading="loading" class="common-layout view-view">
<el-container class="el_container">
<div className="iframe">
<iframe
v-if="pdfViewVersionType === 'newest'"
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdfjs-5.2.133-legacy-dist/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>
<!-- 低版本的 pdfjs 版本 -->
<iframe
v-else-if="pdfViewVersionType === 'old'"
ref="iframeRef"
style="height: 100%; width: 100%"
:src="`./lib/pdf/web/viewer.html?file=${currentFileUrl}`"
>
</iframe>
</div>
</el-container>
</div>
</template>
<style scoped lang="scss">
.common-layout {
width: 100vw;
height: 100vh;
.el_container {
width: 100%;
height: 100%;
.el_header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
font-size: 16px;
.el-button {
--el-button-disabled-border-color: #c4c4c4 !important;
--el-button-disabled-bg-color: #c4c4c4 !important;
}
.tool-div {
display: flex;
flex-wrap: wrap;
}
}
.el_main {
position: relative;
width: 100%;
height: 100%;
--el-main-padding: 0px;
overflow: auto;
}
}
.iframe {
height: 100%;
width: 100%;
overflow: hidden;
}
}
</style>
<style lang="scss">
// 禁用当前页面浏览器自带打印
@media print {
body {
display: none;
}
}
</style>

6 总结#

​ 这个高性能pdf预览器是基于vue3做的,但是本质上还是使用的pdfjs-dist这个插件,也就是说原生的html、js与react理论上也是都可以实现的,只要我们安装pdfjs-dist这个插件就可以了。

​ 当然,实现高性能的pdf预览器也还有很多种方案,可以选用别的比较流行的插件,或者使用pdfjs-dist内置的方法getDocument ,读取相应的pdf文件流然后手动渲染到canvas标签,最一开始笔者就是采用的这种方案(然后嘞,就出现了大文件渲染卡顿的问题,为什么嘞,因为我在放大pdf的时候把canvas也给放大,当文件过大canvas的尺寸都能达到上万像素以上,严重消耗电脑的性能,卡顿感可想而知。>< 嘿嘿,恭喜你获取开头前言所说的彩蛋内容~ 后续,但是笔者当时排查到这个问题时候依然还是想 采用原来的读取pdf文件流手动渲染canvas 的方案去解决嘛,然后也尝试了限制设备像素比、分块渲染等方案,但是依然还是存在bug——分块渲染显示不全、放大缩小有错位显示不全现象、滚动有问题等,还学艺不精、学艺不精呀。 故直接采用了pdfjs-dist官方封装的预览器,拿来就能用,真香! 然后再改改源码,增加一些业务上自定义的功能就可以了`><` 真是选对方案简单翻倍啊~)。

​ 由于水平有限,文章难免有所纰漏之处,欢迎大家批评指正,也欢迎大家分享自己的对搭建高性能pdf预览的见解及实践经验 ~ 互相进步 ~

vue3引入pdfjs-dist5.x预览器viewer报错问题解决及pdfjs-dist5.x使用教程

1 前言#

最近在做 pdf 在预览的功能,pdfjs-dist 插件使用了一下感觉还可以,具体就不用介绍了,能看到这篇文档的小伙伴想必都是研究过 pdfjs-dist(官方文档:https://github.com/mozilla/pdf.js)的。 我是在引入 pdfjs-dist 5.2.133 遇到的报错,下面开始 show time:

2 报错复现#

报错内容:

app.js:2498 Uncaught (in promise) Error: file origin does not match viewer's at validateFileURL (app.js:2498:10) at Object.run (app.js:960:28)

app.js:1507 加载 PDF 时发生错误。

PDF.js v5.2.133 (build: 4f7761353) Message: file origin does not match viewer's

image-20250519170550373

3 报错解决#

首先确认咱穿过来的这个 url 地址是放在浏览器里面是可以直接下载 pdf(接口没啥问题)的,这里 url 被转码了,不过没关系,用我们转码之前的 pdf 的 url 测试就可以了

我转码之前的 pdf url 是:http://127.0.0.1:7501/api/v1/user/getPdfFile?file=8c424181bb74b34199b0f02bc3b2b012.pdf

image-20250519171219097

重点:

更改 pdfjs-dist 的源码,因为里面有跨域相关的校验,咱前端不要这个

将下面这段咔咔注释掉,就 OK 了!

image-20250519171506892

正常展示效果如下:

image-20250519171823133

有的博客说,要在这里加一句file = decodeURIComponent(file) ,不过我试了加不加都不影响,估计是版本问题吧

image-20250519172045311

4 pdfjs-dist5.x 完整引用代码#

关键代码如下:

image-20250519172550077

pdf 预览器离线包下载:https://mozilla.github.io/pdf.js/getting_started/#download

关键代码块:

let currentFileUrl = ref('') currentFileUrl.value = encodeURIComponent( `http://127.0.0.1:7501/api/v1/user/getPdfFile?file=8c424181bb74b34199b0f02bc3b2b012.pdf` )
<iframe style="height: 100%; width: 100%" :src="`./lib/pdfjs-5.2.133-legacy-dist/web/viewer.html?file=${currentFileUrl}`">
</iframe>

5 参考#

Vue3中的slot插槽知识总结,看这一篇就够了!

Vue 中的 slot 是一种用于组件内容分发的技术。它允许父组件向子组件传递任意内容,根据插槽的位置显示在任意位置,从而使组件更加灵活和可组合。

作用: 增加了组件的复用性,同时也可以让代码结构更清晰,利于团队协作和组件库的开发。

1 默认插槽#

最基本的插槽类型。当使用子组件时,没有指定具体名字的插槽的内容就会被渲染到默认插槽位置。

例子:

<!-- BaseCard.vue -->
<template>
<div class="card">
<header class="card-header">
<h3>卡片标题</h3>
</header>
<section class="card-body">
<!-- 默认插槽 -->
<slot></slot>
</section>
<footer class="card-footer">卡片底部信息</footer>
</div>
</template>
<script>
export default {
name: 'BaseCard'
}
</script>
<!-- 使用 BaseCard 的父组件 -->
<template>
<BaseCard>
<p>这是一段使用默认插槽填充的内容。</p>
</BaseCard>
</template>
<script>
import BaseCard from './BaseCard.vue'
export default {
components: { BaseCard }
}
</script>

2 具名插槽#

为不同的插槽区域使用 name 属性命名,父组件在传递内容时可以使用<template #插槽的名字>... </template><template v-slot:插槽的名字>...</template>来指定父组件传递的内容显示在子组件的位置。

例子:

<!-- ProfileCard.vue -->
<template>
<div class="profile-card">
<div class="profile-avatar">
<slot name="avatar">
<!-- 可选:默认头像 -->
<img src="default-avatar.png" alt="默认头像" />
</slot>
</div>
<div class="profile-info">
<slot name="info">
<!-- 可选:默认信息 -->
<p>暂无详细信息</p>
</slot>
</div>
</div>
</template>
<script>
export default {
name: 'ProfileCard'
}
</script>
<!-- 使用 ProfileCard 的父组件 -->
<template>
<ProfileCard>
<template v-slot:avatar>
<img src="user-avatar.jpg" alt="用户头像" />
</template>
<template v-slot:info>
<h2>张三</h2>
<p>前端开发工程师,热爱分享技术。</p>
</template>
</ProfileCard>
</template>
<script>
import ProfileCard from './ProfileCard.vue'
export default {
components: { ProfileCard }
}
</script>

3 条件插槽#

根据判断条件渲染不同插槽,比如v-if...v-else

例子:

<!-- StatusCard.vue -->
<template>
<div class="status-card">
<!-- 默认插槽作为 fallback 方案 -->
<slot></slot>
</div>
</template>
<script>
export default {
name: 'StatusCard'
}
</script>
<!-- 使用 StatusCard 的父组件 -->
<template>
<StatusCard>
<template v-if="isLoggedIn">
<p>欢迎回来,{{ username }}!</p>
</template>
<template v-else>
<p>请登录以继续操作。</p>
</template>
</StatusCard>
</template>
<script>
import StatusCard from './StatusCard.vue'
export default {
components: { StatusCard },
data() {
return {
isLoggedIn: false,
username: '张三'
}
}
}
</script>

4 动态插槽#

可以理解为具名插槽的升级吧,只是这个插槽的名字是一个变量,会变。通过这个插槽名字的变化,他就能在子组件的固定位置动态显示父组件传过来的不同插槽内容。

例子:

假设你有一个 DynamicTab 组件,根据选中的标签动态展示内容

<!-- DynamicTab.vue -->
<template>
<div class="dynamic-tab">
<div class="tab-header">
<button v-for="(tab, index) in tabs" :key="index" @click="activeTab = tab.name">
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<!-- 动态插槽,根据 activeTab 来决定显示哪个 slot -->
<slot :name="activeTab"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'DynamicTab',
data() {
return {
activeTab: 'tab1',
tabs: [
{ name: 'tab1', label: '标签 1' },
{ name: 'tab2', label: '标签 2' },
{ name: 'tab3', label: '标签 3' }
]
}
}
}
</script>
<style scoped>
.tab-header button {
margin-right: 8px;
}
.tab-content {
border: 1px solid #ccc;
padding: 16px;
margin-top: 8px;
}
</style>

在父组件中使用时,通过 v-slot 将每个标签对应的内容关联到正确的名称上:

<!-- 使用 DynamicTab 的父组件 -->
<template>
<DynamicTab>
<template v-slot:tab1>
<p>这里是标签 1 的内容。</p>
</template>
<template v-slot:tab2>
<p>这里是标签 2 的内容。</p>
</template>
<template v-slot:tab3>
<p>这里是标签 3 的内容。</p>
</template>
</DynamicTab>
</template>
<script>
import DynamicTab from './DynamicTab.vue'
export default {
components: { DynamicTab }
}
</script>

5 作用域插槽#

作用域插槽(scoped slot)允许子组件向插槽内容传递数据或方法,从而让父组件在使用插槽时能获得子组件的局部状态或计算结果。 也就是说父组件里面使用子组件的插槽时,可以通过插槽获取到子组件传过来的值。

5.1. 常规用法#

例子:

假设我们有一个 ListDisplay 组件,用于展示一组列表数据,并将每个列表项的数据通过作用域插槽传递给父组件以便自定义渲染

<!-- ListDisplay.vue -->
<template>
<ul>
<li v-for="(item, index) in items" :key="index">
<!-- 将 item 作为 slotProps 传递出去 -->
<slot :item="item"></slot>
</li>
</ul>
</template>
<script>
export default {
name: 'ListDisplay',
props: {
items: {
type: Array,
default: () => []
}
}
}
</script>

在父组件中,我们通过作用域插槽接收子组件传来的 item 数据,并灵活地自定义列表项的展示形式:

<!-- 使用 ListDisplay 的父组件 -->
<template>
<ListDisplay :items="['苹果', '香蕉', '橘子']">
<template v-slot:default="slotProps">
<strong>{{ slotProps.item }}</strong> —— 好吃的水果!
</template>
</ListDisplay>
</template>
<script>
import ListDisplay from './ListDisplay.vue'
export default {
components: { ListDisplay }
}
</script>

5.2. 进阶用法:传递多个数据#

作用域插槽不仅可以传递单个数据,也可以传递多个数据或方法。例如,一个图表组件可能需要传递数值和格式化方法:

<!-- ChartDisplay.vue -->
<template>
<div class="chart">
<slot :data="chartData" :format="formatValue"></slot>
</div>
</template>
<script>
export default {
name: 'ChartDisplay',
data() {
return {
chartData: [10, 20, 30, 40]
}
},
methods: {
formatValue(value) {
return `${value} 单位`
}
}
}
</script>
<!-- 使用 ChartDisplay 的父组件 -->
<template>
<ChartDisplay>
<template v-slot:default="slotProps">
<ul>
<li v-for="(val, index) in slotProps.data" :key="index">
{{ slotProps.format(val) }}
</li>
</ul>
</template>
</ChartDisplay>
</template>
<script>
import ChartDisplay from './ChartDisplay.vue'
export default {
components: { ChartDisplay }
}
</script>

6. 举一反三:插槽混合使用#

下面这个是一个基于 Vue 3+ 组合式 API 语法编写的综合示例,包含默认插槽、具名插槽、条件插槽和作用域插槽。示例中定义了一个 UserCard 组件,以及一个父组件 ParentComponent,父组件在使用 UserCard 时灵活定制各个插槽的内容。

子组件 UserCard.vue:

这个组件是使用 vue3+组合式 API(通过 <script setup> 语法)来定义 props 和内部方法,把多种插槽结合在一起灵活使用,同时包含:

  • 具名插槽 **header**:可自定义头部信息,默认显示用户姓名;
  • 默认插槽:显示主体内容,默认展示用户年龄;
  • 条件插槽 **premium**:当 user.isPremiumtrue 时才展示高级会员区域(可被父组件覆盖);
  • 作用域插槽 **actions**:将用户数据传递到父组件,父组件可自定义操作按钮。、
<!-- UserCard.vue -->
<template>
<div class="user-card" style="border:1px solid #ccc; padding:16px; border-radius:4px;">
<!-- 具名插槽:header -->
<header style="margin-bottom:8px;">
<slot name="header">
<h2>{{ user.name }}</h2>
</slot>
</header>
<!-- 默认插槽:主体内容 -->
<div class="user-content" style="margin-bottom:8px;">
<slot>
<p>年龄:{{ user.age }}</p>
</slot>
</div>
<!-- 条件插槽:premium -->
<div v-if="user.isPremium" class="premium-section" style="margin-bottom:8px; color: darkorange;">
<slot name="premium">
<p>高级会员</p>
</slot>
</div>
<!-- 作用域插槽:actions,将 user 作为属性传递出去 -->
<footer>
<slot name="actions" :user="user">
<button @click="defaultAction">默认操作</button>
</slot>
</footer>
</div>
</template>
<script setup>
const props = defineProps({
user: {
type: Object,
required: true
}
})
function defaultAction() {
alert('默认操作被触发!')
}
</script>

父组件 ParentComponent.vue:

父组件使用组合式 API,通过 <script setup> 导入并使用 UserCard 组件,同时对各个插槽进行定制:

  • 为具名插槽 header 提供自定义头部显示;
  • 默认插槽中覆盖了组件内部提供的主体内容;
  • 条件插槽 premium 被父组件自定义显示高级会员信息;
  • 作用域插槽 actions 接收了传递的 user 数据,自定义一个操作按钮。
<!-- ParentComponent.vue -->
<template>
<UserCard :user="user">
<!-- 具名插槽:header 自定义头部显示 -->
<template v-slot:header>
<h2 style="color: teal;">自定义头部:{{ user.name }}</h2>
</template>
<!-- 默认插槽:自定义主体内容 -->
<p>这是 {{ user.name }} 的详细介绍,年龄:{{ user.age }},充满活力!</p>
<!-- 条件插槽:premium 自定义显示 -->
<template v-slot:premium>
<p style="font-weight: bold;">欢迎高级会员 {{ user.name }},尊享专属特权!</p>
</template>
<!-- 作用域插槽:actions,根据传入的 user 数据自定义操作按钮 -->
<template v-slot:actions="slotProps">
<button @click="handleAction(slotProps.user)" style="background-color: lightblue; padding: 4px 8px;">为 {{ slotProps.user.name }} 执行操作</button>
</template>
</UserCard>
</template>
<script setup>
import UserCard from './UserCard.vue'
import { reactive } from 'vue'
// 使用 reactive 定义用户数据
const user = reactive({
name: '张三',
age: 28,
isPremium: true // 修改为 false 可测试条件插槽不显示的情况
})
function handleAction(user) {
alert(`为 ${user.name} 执行了定制操作!`)
}
</script>

7 小结#

ok,vue 的所有插槽在上面已经总结完毕,可能在我们开发中默认插槽、具名插槽、条件插槽会用的多一些,复杂的组件封装时作用域插槽也会有所使用,动态插槽用的少些,可能是我使用的少。知识点就是上面这些,更多的是在我们日常开发中要灵活使用、融会贯通。