Vue3+PDF.js實(shí)現(xiàn)高清PDF預(yù)覽組件開(kāi)發(fā)(移動(dòng)端)
大家好 在移動(dòng)端 H5 業(yè)務(wù)中,PDF 預(yù)覽是高頻需求(培訓(xùn)文檔、合同、試卷、資料預(yù)覽等)。原生瀏覽器 PDF 預(yù)覽體驗(yàn)差、無(wú)法自定義、不支持手勢(shì)縮放,因此基于 Vue3 + PDF.js 封裝了一套生產(chǎn)可用、高性能、高清渲染的移動(dòng)端 PDF 預(yù)覽組件。
本文完整實(shí)現(xiàn):
- ? 高清渲染(適配移動(dòng)端高分屏)
- ? 雙指手勢(shì)縮放
- ? 按鈕縮放 + 重置
- ? 加載進(jìn)度條展示
- ? 錯(cuò)誤處理 + 重新加載
- ? 內(nèi)存安全釋放
- ? 樣式美觀,直接用于生產(chǎn)
一、核心技術(shù)棧
- Vue3 +
<script setup> - PDF.js(Mozilla 官方 PDF 解析庫(kù))
- Vant(加載、空狀態(tài)、按鈕)
- Axios(文件流下載 + 進(jìn)度監(jiān)聽(tīng))
二、核心設(shè)計(jì)思路(重點(diǎn))
為了保證清晰度 + 流暢縮放 + 低性能消耗,本組件采用分離設(shè)計(jì):
- 渲染縮放(固定高清):使用設(shè)備像素比 DPR 做高清渲染,保證文字不模糊,只渲染一次。
- 視覺(jué)縮放(CSS transform):縮放不重新渲染,僅用 CSS scale 變換,極致流暢。
- 雙指手勢(shì)計(jì)算:監(jiān)聽(tīng)兩指距離變化,動(dòng)態(tài)計(jì)算縮放比例。
- 狀態(tài)管理:加載中 / 加載失敗 / 渲染完成 三狀態(tài)控制。
三、完整代碼實(shí)現(xiàn)(可直接復(fù)制使用)
1. Template 視圖結(jié)構(gòu)
<template>
<!-- PDF預(yù)覽外層容器 -->
<div class="pdf-preview-box">
<!-- 加載中狀態(tài):顯示加載動(dòng)畫(huà)和進(jìn)度 -->
<div v-if="loading" class="loading-wrapper">
<van-loading type="spinner" size="32px">加載中... ({{ progress }}%)</van-loading>
</div>
<!-- 加載失敗狀態(tài):顯示錯(cuò)誤提示和重試按鈕 -->
<div v-else-if="error" class="error-wrapper">
<van-empty :description="errorMessage" />
<van-button type="primary" size="small" @click="retry">
重新加載
</van-button>
</div>
<!-- PDF內(nèi)容滾動(dòng)容器:綁定手勢(shì) -->
<div
v-show="!loading && !error"
class="pdf-scroll-wrapper"
:style="scrollWrapperStyle"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- PDF渲染容器 -->
<div
class="pdf-container"
ref="containerRef"
:style="containerStyle"
/>
</div>
<!-- 縮放控制面板 -->
<div v-if="!loading && !error" class="zoom-controls">
<div class="zoom-btn" @click="zoomOut">
<van-icon name="minus" />
</div>
<div class="zoom-info">{{ Math.round(visualScale * 100) }}%</div>
<div class="zoom-btn" @click="zoomIn">
<van-icon name="plus" />
</div>
<div class="zoom-btn reset-btn" @click="resetZoom">
重置
</div>
</div>
</div>
</template>2. Script Setup 核心邏輯
<script setup>
import { ref, shallowRef, defineProps, watch, onMounted, onUnmounted, computed } from 'vue';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { commonProps } from './commonProps';
import request from '@/utils/request';
// 配置 Worker
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
const props = defineProps(commonProps());
// 狀態(tài)
const loading = ref(true);
const error = ref(false);
const errorMessage = ref('加載失敗');
const progress = ref(0);
const containerRef = ref(null);
const pdfDoc = shallowRef(null);
// 高清渲染倍率
const renderScale = computed(() => {
const dpr = window.devicePixelRatio || 1;
return Math.min(Math.max(dpr, 2.5), 4.0);
});
const contentWidth = ref(0);
const contentHeight = ref(0);
// 手勢(shì)縮放狀態(tài)
const touchState = ref({
startDistance: 0,
isZooming: false,
baseScale: 1.0,
});
// 視覺(jué)縮放系數(shù)(CSS scale)
const visualScale = ref(1.0);
// 滾動(dòng)容器樣式(動(dòng)態(tài)計(jì)算縮放后寬高)
const scrollWrapperStyle = computed(() => ({
width: contentWidth.value ? `${contentWidth.value * visualScale.value}px` : '100%',
height: contentHeight.value ? `${contentHeight.value * visualScale.value}px` : 'auto',
minWidth: '100%',
minHeight: '100%',
position: 'relative',
overflow: 'hidden',
}));
// PDF 容器樣式(縮放變換)
const containerStyle = computed(() => ({
transform: `scale(${visualScale.value})`,
transformOrigin: '0 0',
transition: touchState.value.isZooming ? 'none' : 'transform 0.2s ease',
position: 'absolute',
top: 0,
left: 0,
width: contentWidth.value ? `${contentWidth.value}px` : '100%',
height: contentHeight.value ? `${contentHeight.value}px` : 'auto',
}));
// 計(jì)算雙指距離
const getDistance = (touches) => {
return Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
};
// 觸摸開(kāi)始
const handleTouchStart = (e) => {
if (e.touches.length === 2) {
touchState.value.isZooming = true;
touchState.value.startDistance = getDistance(e.touches);
touchState.value.baseScale = visualScale.value;
}
};
// 觸摸移動(dòng)(雙指縮放)
const handleTouchMove = (e) => {
if (e.touches.length === 2 && touchState.value.isZooming) {
e.preventDefault();
const currentDistance = getDistance(e.touches);
const ratio = currentDistance / touchState.value.startDistance;
let newScale = touchState.value.baseScale * ratio;
newScale = Math.min(Math.max(newScale, 0.5), 4.0);
visualScale.value = newScale;
}
};
// 觸摸結(jié)束
const handleTouchEnd = () => {
touchState.value.isZooming = false;
};
// 渲染單頁(yè) PDF
const renderPage = async (num) => {
if (!pdfDoc.value || !containerRef.value) return;
try {
const page = await pdfDoc.value.getPage(num);
const screenWidth = window.innerWidth;
const originalViewport = page.getViewport({ scale: 1 });
const baseScale = screenWidth / originalViewport.width;
const finalScale = baseScale * renderScale.value;
const viewport = page.getViewport({ scale: finalScale });
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${screenWidth}px`;
canvas.style.height = 'auto';
canvas.style.marginBottom = '10px';
canvas.style.display = 'block';
await page.render({ canvasContext: ctx, viewport }).promise;
containerRef.value.appendChild(canvas);
} catch (err) {
console.error(`第 ${num} 頁(yè)渲染失敗`, err);
}
};
// 放大
const zoomIn = () => {
if (visualScale.value >= 4) return;
visualScale.value = parseFloat((visualScale.value + 0.2).toFixed(1));
};
// 縮小
const zoomOut = () => {
if (visualScale.value <= 0.5) return;
visualScale.value = parseFloat((visualScale.value - 0.2).toFixed(1));
};
// 重置縮放
const resetZoom = () => {
visualScale.value = 1.0;
};
// 加載 PDF 主邏輯
const loadPdf = async () => {
if (!props.url) return;
loading.value = true;
error.value = false;
visualScale.value = 1.0;
containerRef.value?.innerHTML = '';
try {
let url = props.url;
if (url.startsWith(window.location.origin)) url = url.replace(window.location.origin, '');
const res = await request({
url,
method: 'get',
responseType: 'arraybuffer',
onDownloadProgress: (e) => {
if (e.total) progress.value = Math.floor((e.loaded / e.total) * 100);
},
loading: false,
});
const data = res.data;
if (!data || !data.byteLength) throw new Error('文件內(nèi)容為空');
const loadingTask = pdfjsLib.getDocument({
data,
cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.7.284/cmaps/',
cMapPacked: true,
});
pdfDoc.value = await loadingTask.promise;
for (let i = 1; i <= pdfDoc.value.numPages; i++) await renderPage(i);
setTimeout(() => {
contentWidth.value = containerRef.value.scrollWidth || window.innerWidth;
contentHeight.value = containerRef.value.scrollHeight;
}, 300);
loading.value = false;
props.onLoad?.();
} catch (err) {
loading.value = false;
error.value = true;
if (err.message === '文件內(nèi)容為空') {
errorMessage.value = '文件為空,無(wú)法預(yù)覽';
} else if (err.name === 'InvalidPDFException') {
errorMessage.value = 'PDF 格式損壞或無(wú)效';
} else {
errorMessage.value = '預(yù)覽失敗,請(qǐng)重試';
}
}
};
// 重試
const retry = () => loadPdf();
watch(() => props.url, loadPdf);
onMounted(loadPdf);
onUnmounted(() => pdfDoc.value?.destroy());
</script>
3. Style 樣式(移動(dòng)端友好)
<style scoped>
.pdf-preview-box {
width: 100%;
height: calc(100vh - 100px);
overflow: auto;
background: #f5f5f5;
-webkit-overflow-scrolling: touch;
position: relative;
}
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.error-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 20px;
}
.pdf-container {
display: flex;
flex-direction: column;
will-change: transform;
}
.zoom-controls {
position: fixed;
bottom: 80px;
right: 20px;
background: rgba(255,255,255,0.92);
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
padding: 10px 6px;
z-index: 100;
}
.zoom-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
}
.zoom-info {
font-size: 12px;
color: #666;
text-align: center;
margin: 4px 0;
}
.reset-btn {
font-size: 12px;
border-top: 1px solid #eee;
padding-top: 6px;
}
:deep(canvas) {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
max-width: none !important;
}
</style>
<script>
export default { name: 'PdfPreview' }
</script>四、核心亮點(diǎn)技術(shù)解析
1. 高清渲染機(jī)制
const renderScale = computed(() => {
const dpr = window.devicePixelRatio || 1;
return Math.min(Math.max(dpr, 2.5), 4.0);
});
- 自動(dòng)適配手機(jī)像素比
- 最低 2.5 倍清晰度,解決移動(dòng)端文字模糊
- 上限 4 倍,避免內(nèi)存占用過(guò)大
2. 高性能縮放(不重渲染)
transform: `scale(${visualScale.value})`
- 純 CSS 縮放,流暢不卡頓
- 手勢(shì) / 按鈕雙控制
- 縮放區(qū)間 0.5 ~ 4.0 倍
3. 雙指手勢(shì)實(shí)現(xiàn)
- 監(jiān)聽(tīng)
touchstart記錄兩指初始距離 touchmove計(jì)算比例動(dòng)態(tài)縮放e.preventDefault()防止瀏覽器默認(rèn)行為沖突
4. 完整異常處理
- 文件為空
- PDF 格式損壞
- 網(wǎng)絡(luò)請(qǐng)求失敗
- 支持重新加載
五、組件使用方式
<PdfPreview url="/api/file/xxx.pdf或二進(jìn)制流" @load="handleLoad" />
六、總結(jié)
這套 Vue3 移動(dòng)端 PDF 預(yù)覽組件已經(jīng)在生產(chǎn)環(huán)境穩(wěn)定運(yùn)行,具備:
- 高清渲染不模糊
- 手勢(shì) / 按鈕縮放流暢
- 加載進(jìn)度、錯(cuò)誤、重試齊全
- 內(nèi)存安全,無(wú)泄漏
- 樣式美觀,開(kāi)箱即用
非常適合:
- 培訓(xùn)文檔預(yù)覽
- 合同 / 協(xié)議預(yù)覽
- 學(xué)習(xí)資料預(yù)覽
- 考試試卷預(yù)覽
到此這篇關(guān)于Vue3+PDF.js實(shí)現(xiàn)高清PDF預(yù)覽組件開(kāi)發(fā)(移動(dòng)端)的文章就介紹到這了,更多相關(guān)Vue3 PDF預(yù)覽組件內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
解決ElementUI組件中el-upload上傳圖片不顯示問(wèn)題
這篇文章主要介紹了解決ElementUI組件中el-upload上傳圖片不顯示問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-10-10
uniapp微信小程序使用webview嵌套u(yù)niappH5并實(shí)現(xiàn)通信詳細(xì)步驟
在開(kāi)發(fā)微信小程序的時(shí)候,我們有時(shí)候會(huì)遇到將H5頁(yè)面嵌入到小程序頁(yè)面中的情況,這篇文章主要給大家介紹了關(guān)于uniapp微信小程序使用webview嵌套u(yù)niappH5并實(shí)現(xiàn)通信的詳細(xì)步驟,需要的朋友可以參考下2024-05-05
教你在vue項(xiàng)目中使用svg圖標(biāo)的方法
本文給大家介紹了在vue項(xiàng)目中使用svg圖標(biāo)的方法,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2022-04-04
巧妙運(yùn)用v-model實(shí)現(xiàn)父子組件傳值的方法示例
這篇文章主要介紹了巧妙運(yùn)用v-model實(shí)現(xiàn)父子組件傳值的方法示例,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-04-04
vue導(dǎo)出excel和echart圖形分別在不同工作表的實(shí)現(xiàn)方法
這篇文章主要給大家介紹了如何使用vue實(shí)現(xiàn)導(dǎo)出excel和echart圖形分別在不同工作表,文中有詳細(xì)的代碼示例供大家參考,對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2023-10-10

