跳到主要内容位置
Hello! 我是潜心专研的小张同学

在这里,有技术博客、以及UP主的想法和生活点滴。授人以鱼不如授人以渔,分享个人所学所见,在你学习前端开发的路上助你一臂之力!

QQ 1 群:706947563

去B站关注

最新博客 

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

前端 SSE 流式接口请求处理实践教程

一、前言#

在现代 Web 应用中,实时数据更新越来越普遍。比如在聊天、股票行情、系统监控甚至 GPT 流式回复中,用户体验要求数据能边生成边展示。传统 HTTP 请求一次性返回所有数据,很难满足这种需求。而 SSE(Server-Sent Events,服务器发送事件)技术通过建立长连接,让服务器主动向客户端推送数据,完美实现了流式响应。

本文将详细讲解 SSE 的原理、前端如何使用原生 API(EventSource 或基于 fetch 的流处理)来接收流式数据,并给出完整的示例代码和注意事项。

二、SSE 概述#

SSE(Server-Sent Events)是一种基于 HTTP 协议的单向通信方式,允许服务器通过长连接不断推送文本数据到客户端。其特点包括:

  • 单向通信:只支持服务器向客户端推送消息,客户端如需向服务器发送数据,仍需使用普通 HTTP 请求。
  • 长连接机制:建立连接后,服务器保持通道不断开,直到明确结束或发生异常。
  • 自动重连:当连接中断时,浏览器会自动重连,保证数据实时性。
  • 简单易用:基于 HTTP 协议,无需引入额外的协议支持,兼容性较好(IE/Edge 除外)。

服务端需要在响应头中设置:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

数据格式一般遵循如下规则,每条消息由若干行组成,消息之间用两个换行符分隔。例如:

data: 这是第一条消息
data: 多行数据可以这样发送
data: 第二条消息

三、前端实现原理#

前端接收 SSE 的核心 API 是浏览器内置的 EventSource 对象。使用时只需提供 SSE 接口 URL,然后监听 onmessageonopenonerror 等事件即可。

另外,由于 SSE 只支持 GET 请求,如果需要携带额外请求头或使用 POST 传参,可考虑使用 EventSourcePolyfill 或结合 fetch 实现流式处理(例如通过 TextDecoderStream 解析响应流)。

四、实战示例#

4.1 使用 EventSource 实现 SSE 流式数据接收#

这是最简单的使用方式。假设后端提供了一个 SSE 接口地址 http://example.com/sse

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSE 流式接口示例</title>
</head>
<body>
<h1>实时数据</h1>
<div id="output"></div>
<script>
// 创建 EventSource 实例
const eventSource = new EventSource('http://example.com/sse')
// 连接建立时回调
eventSource.onopen = function (event) {
console.log('SSE 连接已建立')
}
// 接收消息时回调
eventSource.onmessage = function (event) {
console.log('收到消息:', event.data)
const output = document.getElementById('output')
output.innerHTML += event.data + '<br>'
}
// 连接错误时回调
eventSource.onerror = function (error) {
console.error('SSE 连接出错:', error)
// 出现错误后可以选择关闭连接
eventSource.close()
}
</script>
</body>
</html>

在这个示例中,前端建立了一个 SSE 长连接,服务器推送的数据会以事件的形式自动触发 onmessage 回调,数据实时显示在页面上。

4.2 使用 fetch 处理流式响应(扩展方案)#

部分场景下(例如需要携带 POST 参数),可以使用 fetch 发起请求,然后结合 Web Streams API 对响应进行流式处理。示例如下:

// 使用 fetch 请求 SSE 接口(注意:服务器需返回 text/event-stream 格式数据)
fetch('http://example.com/sse', {
method: 'GET',
headers: {
Accept: 'text/event-stream'
}
})
.then(response => {
if (!response.body) {
throw new Error('响应流为空')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
// 循环读取数据块
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
console.log('数据读取完毕')
return
}
const chunk = decoder.decode(value, { stream: true })
console.log('收到流数据:', chunk)
// 这里可以对 chunk 进行分段处理,例如 split('\n\n')
document.getElementById('output').innerHTML += chunk.replace(/\n/g, '<br>') + '<br>'
read() // 继续读取下一块数据
})
.catch(error => {
console.error('读取流错误:', error)
})
}
read()
})
.catch(error => {
console.error('fetch 请求出错:', error)
})

这种方式灵活性更高,但需要自己解析数据流的格式(例如每条 SSE 消息通常以 data: 开头并以两个换行符结尾)。

4.3 结合 Ant Design X Vue 来使用#

Ant Design X Vue 是 Ant Design 团队专门为规范 AI 界面设计打造的一个组件库。

SSE 流式接口请求

// 建立请求连接
const response = await OllamaApiTest({
message: message,
});
const stream = XStream({
readableStream: response.body as ReadableStream<Uint8Array>,
});
const reader = stream.getReader();

AI 聊天界面接收信息流式输出

while (reader) {
const { value, done, timeoutFlag = false }: any = await Promise.race([reader.read(), timeoutPromise])
if (done) {
senderLoading.value = false
onSuccess(current)
break
}
if (timeoutFlag) {
// 超时处理
senderLoading.value = false
current.event_data.content = '请求超时,请重试'
onSuccess(current)
break
}
if (!value) continue
const data = JSON.parse(value.data)
if (data?.event_data?.content_type == 'Text') {
current.event_data.content = current.event_data.content + (data?.event_data?.content || '')
current.event_data.content_type = data?.event_data?.content_type || ''
current.message_id = data?.message_id || ''
current.message_status = data?.message_status || ''
}
// 聊天界面流式输出
onUpdate(current)
}

参考教程链接:

https://antd-design-x-vue.netlify.app/component/use-x-chat.html#%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA

https://antd-design-x-vue.netlify.app/component/x-stream.html#%E9%BB%98%E8%AE%A4%E5%8D%8F%E8%AE%AE-sse

五、常见问题与注意事项#

  1. 请求方法限制 SSE 原生只支持 GET 请求。如果需要发送 POST 请求或者携带自定义请求头,建议使用 EventSourcePolyfill 或者结合 fetch 实现流式响应处理。
  2. 自动重连 浏览器内置的 EventSource 会在连接中断后自动重连,但这也可能导致“重复连接”的问题。需要在 onerror 中根据实际情况判断是否手动关闭连接。
  3. 数据格式解析 服务器返回的数据格式必须符合 SSE 协议规范。若返回数据分块不完整,可使用字符串分割(例如通过 split('\n\n'))来确保每条消息完整解析。
  4. 跨域问题 如果前后端分离部署,注意配置 CORS,确保 SSE 请求允许跨域访问。

六、总结#

本文详细介绍了 SSE 的基本原理及其在前端的实现方式。

  • 通过 EventSource 建立长连接,前端能够实时监听服务器推送的事件。
  • 如果需要更灵活的处理(如 POST 请求、携带请求头),可以结合 fetch 与 Web Streams API 实现流式数据解析。

希望通过本教程,你能快速掌握 SSE 的前端实现技巧,打造更流畅的实时交互体验!

七、比较火的插件分享#

github 时下比较流行的库 https://github.com/trending?since=daily

使用AI自动写代码,DeepSeek+CLine+VSCode实战教程

0 前言#

最近 DeepSeek 火了起来,各个媒体都在宣传,那 DeekSeek 到底是个啥?从程序员角度来看:DeepSeek 一个人工智能助手,可以帮助我们解决代码知识点疑问,以及某个晦涩难懂的知识点问题,处理生活中的一些文本信息问题等。

其实这些能力早在数年前 ChatGPT 也已经具备了,那么为什么最近 DeepSeek 这么火呢?一个原因就是因为它是国产的,那必须得支持。还有一个原因就是它实惠呀,便宜!目测目前比 ChatGPT 要便宜了近 10 倍不止吧~ 咱能用得起了…… 在价格下降的同时,表现的能力也是越来越聪明。

下面主要是介绍 DeepSeek+CLine+VSCode 的实战教程,所表现出了的能力也是相当出色的,可以直接操控我们的编译器来写代码, 自动写代码!也就是说它除了回答我们一些专业问题之外,还可以直接对我们的文件目录及文件内容进行增删改查!

1 先上结果#

pEnSBqJ.png](https://imgse.com/i/pEnSBqJ)

2 注册使用 deepseek 模型的账号#

这里注册 deepseek 账号我们可以选择在其官网上注册使用,也可以选择在其他平台上注册使用。但是现在 deepseek 官网因为频繁受到某国不知名网络的攻击已经关闭了新的注册入口。

现在主要是来介绍在华为云上面的注册方法,使用的效果也是相当可以的,华为云也是相对稳定一些。

首先点击下面的链接可以注册个账号,现在注册可以免费获得 14 块钱的使用额度:

https://cloud.siliconflow.cn/i/10XZLZAC

3 创建秘钥#

我们注册好账号后,再来创建一个 API 秘钥,后面要用到

pEnStP0.png](https://imgse.com/i/pEnStP0)

4 vscode 安装 cline 及配置 deepseek 模型#

刚刚的账号及 API 秘钥弄好以后,我们来到 vscode

4.1 在 vscode 中安装 cline 插件#

安装一个 cline 插件,这个插件可以自动操作我们的 vscode 终端,配合 deepseek、chatgpt 这个 AI 助手来使用效果相当强劲

pEnSbJP.png

4.2 配置 deepseek 模型#

下载好 cline 插件后,我们点击 vscode 侧边栏的 cline 的界面,在里面配置 deepseek 模型,见图 4-2-1

pEnSqRf.png

(图 4-2-1)

图 4-2-1 里面 2.2 文字内容:

https://api.siliconflow.cn/v1

图 4-2-1 里面的 2.3 秘钥就是之前我们创建的那个秘钥

图 4-2-1 里面的 2.4 内容,我选的是 deepseek 最聪明的(最贵的)那个模型:

deepseek-ai/DeepSeek-R1

配置完以后我们带点击一下那个“Done”按钮,就会保存我们的配置。

5 开始使用 vscode+cline+deepseek 写代码#

刚刚我们都已经配置好了,现在开始使用

pEnSXQS.png

pEnSvLQ.png

ok,到这里已经配置完成啦~ 可以体验 AI 写代码的魅力了~

阿里云域名申请ssl证书,通过https方式访问域名

之前我们提到了如何创建子域名,并且已经创建了子域名,那么我们创建了域名以后如何更好升级域名的安全性呢?其实这些域名都是以 http 开头的,不是 https,这个时候我们就要来申请 ssl 证书,通过部署 ssl 证书将域名升级为以 https 的形式来访问。

1. 填写完对应申请的域名,然后点“提交审核”#

img

img

2. SSL 证书验证#

接下了我们要进行一下 ssl 证书验证,点击“验证按钮”

img

然后我们可以看到这个记录值,记录下来,这个要域名解析那边填写一下

img

3. 域名解析添加记录值#

我们来域名解析页面,点击“解析设置”按钮

img

然后点击“添加记录”按钮,填写我们刚刚记录的值,记住一定要完全一致哦,不要填多了(之前我就是主机记录那里字符填多了,导致域名好几填都没有验证通过。。)。

img

4. 再次验证 SSL 证书#

这个时候我们再次回到 ssl 证书管理页面,点击验证(一般 2、3 分钟就可以生效),这个时候就可以验证成功了。

img

5. 小结#

至此,SSL 证书申请的流程已经操作完毕了,只需要静静等待邮箱发送申请成功的邮件就可以了,一般大概 10 分钟左右吧。

阿里云创建子域名(二级域名),同时结合腾讯云

申请子域名基于我们已经有了主域名的情况,如果我们没有主域名先要去搞一个主域名。

那么我们为啥需要子域名了,那肯定是一个域名不够用,需要多个域名,不同的域名对应不同的网站,这样一个几十块的域名就能创建出几十个子域名(对应几十个网站),单从使用性价比方面还是不错的,hhhh

1. 进入域名解析界面,添加子域名#

进入域名解析界面,点击添加域名,我这里添加一个二级域名,然后我输入了二级域名,下面有提示要进行“TXT授权校验”,没事,那我们就去TXT授权校验。

img

2. 进行TXT授权校验#

点击这个链接

img

点击完出现这个信息,记一下,我们要去填到对应的地方

img

域名:zhangqiang.hk.cn
主机记录:alidnscheck
记录值:c350e166c50d4d6a8b2993efcd93430b

3. 添加记录值#

3.1. 腾讯云#

因为我的域名是从腾讯云转过来的,所以我需要也在腾讯云里面添加一下记录值(如果你都是用的阿里云的,那就不用管了),创建如下

img

添加完点击确认,这上面说是要24小时,但是只要我们配置正确的话5、6分钟就会生效了

img

3.2. 阿里云#

阿里云的域名解析里面也要添加记录值

img

4. 验证子域名,添加域名#

刚刚记录值我们都已经填完了,有延迟,需要等5分钟左右,再来验证,我这里是等了5分钟左右的,然后验证成功

img

img

5. 小结#

至此,阿里云创建子域名(二级域名)同时结合腾讯云完成,我们只要在服务器上面给这个域名部署相关的资源(比如网站资源、后台服务等)就可以访问到了。

解决 Vite 项目启动时端口重复问题的总结


背景#

在前端开发的江湖上,Vite 就像一个轻盈迅捷的剑客,以“快”和“爽”闻名。默认栖身于本地 3000 端口,简直是开发者的“专属包间”。但江湖人多,难免遇到这种情况:你刚起剑练招,发现隔壁的同门师兄已经占了你的包间。于是,你只能抱着代码,哼哼唧唧换个地方继续修炼——这种“被端口抢占”的故事,简直就是开发者的日常笑话!

例如:

  1. 多人协作:团队中的多个开发者同时运行了 Vite 项目,使用了相同的端口。
  2. 多项目运行:本地同时启动了多个 Vite 项目或其他服务(如 Webpack、Node.js 服务器),导致端口冲突。
  3. 默认配置不足:Vite 默认没有处理端口占用的逻辑,遇到冲突会直接报错,开发中断。

为了解决这个问题,我研究了几种方法,最终采用了 detect-port 插件,实现了动态检测和自动调整端口的功能,极大地提升了开发效率。


解决方案#

1. 常见解决方法#

1.1 *手动更改端口**#

vite.config.ts 文件中修改 server.port 的值。例如:

export default defineConfig({
server: {
port: 3001 // 手动指定一个新的端口
}
})

缺点:需要手动调整端口,效率较低,且不方便多人协作。

1.2 尝试端口范围#

一些开发者会通过运行脚本动态尝试多个端口,但编写脚本可能较繁琐且不直观。

1.3 使用第三方工具#

利用工具如 detect-portportfinder,自动检测端口是否被占用并返回可用端口。

2. Detect-port 的解决方案(本人的解决方法)#

我最终采用了 detect-port 插件,因为它简单易用,能够自动检测当前端口是否被占用,并返回一个可用的端口。

Step 1: 安装 detect-port#

在项目根目录下运行以下命令:

pnpm install detect-port --save-dev

或者使用 npm/yarn 安装:

npm install detect-port --save-dev
yarn add detect-port --dev

Step 2: 配置 Vite 项目#

修改 vite.config.ts 文件,引入 detect-port,实现动态端口检测:

import { defineConfig } from 'vite'
import detectPort from 'detect-port'
export default defineConfig(async () => {
const DEFAULT_PORT = 3000 // 默认端口
// 使用 detect-port 检测端口是否被占用
const port = await detectPort(DEFAULT_PORT)
console.log(`Selected port: ${port}`) // 输出实际选用的端口
return {
server: {
host: '0.0.0.0', // 允许局域网访问
port, // 动态端口
open: true, // 自动打开浏览器
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
}
})

Step 3: 效果展示#

  • 如果默认端口(如 3000)已被占用,detect-port 会自动检测下一个可用端口,例如 3001 或 3002。
  • 启动时会输出实际使用的端口号:
Selected port: 3001

这样,我们无需手动修改端口设置,提升了开发效率。lalala~

3. 优化点#

在实践中,我还添加了一些优化:

3.1 添加日志输出#

使用 console.log 记录端口信息,便于调试:

console.log(`Using port ${port}. To change, edit vite.config.ts`)

3.2 范围检测(可选)#

通过扩展 detect-port,可尝试在特定范围内寻找端口:

const port = await detectPort(3000)
if (port !== 3000) {
console.log(`Port 3000 is in use. Using port ${port} instead.`)
}

小结#

问题总结#

Vite 默认端口冲突的问题在多人协作和多项目运行的场景中非常常见。传统的手动解决方案需要频繁调整配置,显得麻烦且低效。

我的解决方案#

利用 detect-port 插件,可以轻松实现动态端口检测和分配:

  • 自动检测端口冲突。
  • 动态分配可用端口,提升开发效率。
  • 配置简单、易于扩展。

未来优化#

  • 扩展端口范围检测:可以尝试自定义检测端口范围(如 3000-3100)。
  • 集成其他工具:例如结合 vite-plugin-inspect,提供更多启动信息。
  • 多环境支持:为不同环境(开发、测试、生产)定制端口策略。

在团队开发中,这种自动化的方式能够显著减少端口冲突问题,提升协作效率,非常值得推广。

Vue3中watch中props监听加箭头函数与不加箭头函数的区别

1 前言#

今天写项目时,遇到一个问题,我需要打开点击一个按钮打开一个弹框页面,然后通过watch去监听传进来的值,但是呢,写了watch我点击按钮只有首次打开能够监听到,尽管加上了deep: true页面也不能监听到变化,点击效果如下图:

image-20240903172257604

2 代码实例#

直接上我的关键代码,这个是我弹框页面的代码:

<template>
<div class="content_container">1111</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, onActivated } from 'vue'
import { ElMessageBox } from 'element-plus'
const props = defineProps(['currentRow'])
console.log('props', props)
watch(
props.currentRow,
(newVal, oldVal) => {
console.log('newVal', newVal)
},
{
immediate: true,
deep: true
}
)
</script>

解决后的代码:

<template>
<div class="content_container">1111</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, onActivated } from 'vue'
import { ElMessageBox } from 'element-plus'
const props = defineProps(['currentRow'])
console.log('props', props)
watch(
() => props.currentRow,
(newVal, oldVal) => {
console.log('newVal', newVal)
},
{
immediate: true,
deep: true
}
)
</script>

其实,只是修改了一句话,多加了一个箭头函数~ 就可以了!他就可以每次进来就都监听了,为啥呢?请看下面的代码精读环节

3 代码精读#

3.1 直接监听 props 属性#

javascriptwatch(
props.currentRow,
(newVal, oldVal) => {
console.log('newVal', newVal);
},
{
immediate: true,
deep: true
}
);

当你直接监听 props.currentRow 时,watch 函数会尝试将 props.currentRow 当作一个响应式引用进行监听。然而,props 本身并不是一个响应式引用,而是一个对象。因此,这种方式可能会导致以下问题:

  • 非响应式引用: 如果 props.currentRow 是一个简单的值(如字符串或数字),那么 watch 可能无法正确地监听到它的变化。
  • 对象或数组内部属性变化: 即使设置了 deep: truewatch 仍然可能无法正确地监听到对象或数组内部属性的变化。

3.2 使用箭头函数监听 props 属性#

javascriptwatch(
() => props.currentRow,
(newVal, oldVal) => {
console.log('newVal', newVal);
},
{
immediate: true,
deep: true
}
);

使用箭头函数 () => props.currentRow 可以确保每次 props.currentRow 发生变化时,都会重新计算并触发 watch 回调。这种方式更可靠,因为它明确地告诉 watch 如何获取最新的 props.currentRow 值。

4 总结#

在正常的开发中,我们应该使用箭头函数组合watch来进行props值的变化,来确保每次 props 变化时都能重新计算。

pnpm依赖安装解决pnpm项目从一个文件夹复制到另一个文件夹运行失败问题解决-以vbenAdmin项目为例

1 前言#

最近在使用 vben admin 项目迁移时,从一个文件夹复制到另一个文件夹运行不起来,排查了一天,终于把问题排查出来了,特地记录一下,便于自己看也便于大家看。

使用项目安装包版本:

v2.11.5 https://github.com/vbenjs/vue-vben-admin/tags

2 解决方案#

2.1 软链接问题解决#

问题: pnpm 使用硬链接或符号链接来管理 node_modules 中的依赖项。如果链接路径在新的文件夹或文件系统中失效,可能会导致依赖解析失败。

解决方法: 尝试删除并重新安装所有依赖,确保链接正确创建。

rm -rf node_modules pnpm-lock.yaml

node_modules 文件夹一定要全部删除,pnpm-lock.yaml文件也要删除,上述是用命令删除的,也可以自己手动删(所有的 node_modules 都要删)

image-20240830135249168

2.2 文件路径过长问题#

问题: 在 Windows 系统上,文件路径过长(超过 260 个字符)可能导致文件系统问题,导致文件无法被正确访问。

解决方法: 尝试将项目目录移动到一个路径较短的位置,比如 C:\Projects\my-project,然后重新安装依赖。

node_modules 文件夹里面的内容路径会很长,所以整个项目文件夹所在路径不宜过长,否则路径可能会超过 260 个字符。

image-20240830135718984

2.3 pnpm 缓存问题#

问题: pnpm 的缓存可能导致一些包没有被正确更新或安装。

解决方法: 清除 pnpm 的缓存:

依次执行下面命令

  • 清理未使用的缓存包
pnpm store prune
  • 清空整个 pnpm 的缓存存储库
pnpm store clear

2.4 pnpm 缓存深度清理#

执行下面这句话:

pnpm store path

然后我们看到这个路径

image-20240830140127029

进入这个目录,将这 3 个文件夹都手动删除

image-20240830140240181

2.5 pnpm install#

删完之后,执行pnpm install

pnpm install

image-20240830141017683

2.6 pnpm dev#

等待依赖安装完毕后,我们执行pnpm dev

pnpm dev

程序运行成功

image-20240830142025994

3 pnpm 安装依然失败解决方案#

当我们试了各种方法还是不行,诶?这个时候我们返璞归真,是不是我们的 pnpm 的源下载不了的原因呢?国内的 pnpm 下载出现网络相关的问题还是挺多的,那么让我们来替换成国内专属的 pnpm 源试试

3.1 国内可用源#

3.1.1 淘宝 pnpm 源

https://registry.npmmirror.com

3.1.2 腾讯云 pnpm 源

https://mirrors.cloud.tencent.com

3.1.3 cnpm 源

https://r.cnpmjs.org/

3.2 设置国内可用源命令#

pnpm config set registry https://registry.npmmirror.com

3.3 查看设置的源#

pnpm config get registry

返回信息内容是你刚刚设置地址,即设置成功。

image-20240929154343078

3.4 再次 pnpm install#

ok,pnpm 相关依赖在徐徐下载,我的终于又又装成功了~

4 遗留的瑕疵#

项目虽然可以成功运行了,也可以正常写代码的了,但是终端还有几个 warning ,如果有大佬知道如何解决也可以指点一下,感谢~

image-20240830142224622