在实际项目开发中,文件预览是高频需求,尤其是对 PDF、Word、图片、OFD 等常见格式的支持。本文将分享一个通用的 Vue 文件预览组件实现,支持多种格式解析、流式加载优化和灵活的父子组件通信,可直接集成到 Vue 3 项目中使用。
组件设计思路
核心目标是打造一个低耦合、高复用的文件预览组件,主要设计思路如下:
按文件类型分发渲染逻辑,不同格式使用对应解析库(PDF.js 处理 PDF、vue-office 处理 Word、ofd.js 处理 OFD)
支持文件 URL 和二进制流两种数据源,适配不同接口返回格式
实现 PDF/OFD 分页渲染与滚动加载,优化大文件预览性能
提供清晰的状态回调(渲染成功 / 失败)和下载功能
通过 props 灵活配置样式和文件信息,适配不同业务场景
核心依赖说明
组件依赖以下第三方库实现格式解析,需提前安装:
bash
PDF解析核心库
npm install pdfjs-dist
Word文档预览组件
npm install @vue-office/docx
OFD文件解析库
npm install ofd.js
UI组件库(按需引入)
npm install element-plus
组件完整实现(FilePreview.vue)
模板结构设计
模板部分采用v-if/v-else-if按文件类型分发渲染节点,为 PDF/OFD 预留独立 canvas 容器,Word 和图片使用专用预览组件,不支持的格式显示友好提示:
javascript
FilePreview.vue 子组件内容
<template>
<!-- pdf预览需要文件流 word需要url -->
<div class="file-preview-container">
<!-- PDF 预览 -->
<div v-if="fileType === 'pdf' || fileType === 'ofd'" :class="previewSty ? previewSty : pdf-preview">
<!-- 使用uniqueId作为前缀确保ID唯一 -->
<canvas v-for="pageIndex in pdfPages" :id="`${uniqueId}-pdf-canvas-${pageIndex}`" :key="pageIndex"></canvas>
</div>
<!-- Word 预览 -->
<div v-else-if="fileType === 'docx'" class="word-preview">
<vue-office-docx :src="fileUrl" @rendered="handleRendered" @error="handleError" />
</div>
<div v-else-if="fileType === 'jpg' || fileType === 'png'" class="png-preview">
<el-image class="item-img" :src="fileUrl"></el-image>
</div>
<!-- <div v-else-if="fileType === 'ofd'" class="ofd-preview">
<div id="stampTK"></div>
</div> -->
<!-- 不支持的文件类型 -->
<div v-else class="unsupported-preview">
<!-- <el-alert title="非pdf,docx文件类型暂不支持预览" type="warning" show-icon /> -->
<el-icon>
<Warning />
</el-icon>非pdf,docx,png,jpg,ofd文件类型暂不支持预览
<!-- <el-button type="primary" @click="handleDownload">下载文件</el-button> -->
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import pdfWorker from 'pdfjs-dist/build/pdf.worker';
import VueOfficeDocx from '@vue-office/docx';
import '@vue-office/docx/lib/index.css';
import { parseOfdDocument, renderOfd } from 'ofd.js'
import { ElMessage, ElAlert, ElButton } from 'element-plus';
import { getOneAttachmentInputStreamRequest } from '@common/api/upLoad/upLoad.js'; //下载
const props = defineProps({
fileUrl: { type: String, required: true }, // 文件URL
fileName: { type: String, default: '' }, // 文件名(用于下载)
fileId: { type: String, default: '' },
fileType: { type: String, default: '' },
previewSty: { type: String, default: '' }
});
const emit = defineEmits(['rendered', 'error', 'download']);
const fileType = ref(''); // pdf / docx / other
const pdfPages = ref(0); // PDF页数
const pdfScale = ref(1.0); // PDF缩放比例
let pdfDoc = null; // PDF文档对象
// 生成唯一ID
const uniqueId = 'file-preview-' + Math.random().toString(36).substring(2, 10);
// 根据URL后缀判断文件类型
const detectFileType = (url) => {
// 校验 url 是否为字符串
if (typeof url !== 'string') {
console.error('Invalid URL provided to detectFileType');
return 'other';
}
if (url.endsWith('.pdf') || props.fileType == 'pdf') return 'pdf';
if (url.endsWith('.docx') || props.fileType == 'docx') return 'docx';
if (url.endsWith('.jpg') || props.fileType == 'jpg') return 'jpg';
if (url.endsWith('.png') || props.fileType == 'png') return 'png';
if (url.endsWith('.ofd') || props.fileType == 'ofd') return 'ofd';
return 'other';
};
// 加载更多页
let currentPage = 0;
function loadMorePages(screenWidth, res, container) {
// 防止重复加载
if (currentPage >= res.length - 1) return;
// 一次加载2-3页,而不是全部
let loaded = false;
for (let i = 0; i < 2; i++) {
currentPage++;
if (!renderPage(currentPage, screenWidth, res, container)) {
break;
}
loaded = true;
}
return loaded;
}
// 渲染单页
function renderPage(pageIndex, screenWidth, res, container) {
if (pageIndex >= res.length) return false;
const divs = renderOfd(screenWidth, res[pageIndex]);
for (const div of divs) {
container.appendChild(div);
}
return true;
}
// 初始化预览
const initPreview = () => {
fileType.value = detectFileType(props.fileUrl);
if (fileType.value === 'pdf' || fileType.value === 'ofd') {
loadPdf(props.fileId);
}
// if (fileType.value === 'ofd') {
// parseOfdDocument({
// // ofd写入文件地址
// ofd: props.fileUrl,
// // ofd写入文件地址
// //ofd: process.env.VUE_APP_BASE_API + '/LicenseInformation/minePdfDownloadOFD?id=' + id,
// success(res) {
// if (res) {
// console.log('解析结果', res);
// let screenWidth = 1600;
// let contentDiv = document.getElementById('stampTK');
// contentDiv.innerHTML = '';
// // 只渲染第一页
// renderPage(0, screenWidth, res, contentDiv);
// // 添加分页加载逻辑
// contentDiv.addEventListener('scroll', function () {
// // 当滚动到接近底部时加载下一页
// if (contentDiv.scrollTop + contentDiv.clientHeight >= contentDiv.scrollHeight - 300) {
// loadMorePages(screenWidth, res, contentDiv);
// }
// });
// }
// },
// fail(error) {
// console.error(error);
// }
// })
// }
};
// 将返回的流数据转换为url
const getObjectURL = (binaryData) => {
let url = null;
if (window.createObjectURL !== undefined) {
// basic
url = window.createObjectURL(
new Blob([binaryData], {
type: 'application/pdf',
})
);
} else if (window.webkitURL !== undefined) {
// webkit or chrome
try {
url = window.webkitURL.createObjectURL(
new Blob([binaryData], {
type: 'application/pdf',
})
);
} catch (error) { }
} else if (window.URL !== undefined) {
// mozilla(firefox)
try {
url = window.URL.createObjectURL(
new Blob([binaryData], {
type: 'application/pdf',
})
);
} catch (error) { }
}
return url;
};
// PDF渲染逻辑
const loadPdf = async (fileId) => {
const params = {
attachmentId: fileId,
//bizType: 'xxxxxxxx',
};
const blodRes = await getOneAttachmentInputStreamRequest(params);
const res = getObjectURL(blodRes.data);
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker;
const loadingTask = pdfjsLib.getDocument(res);
pdfDoc = await loadingTask.promise;
pdfPages.value = pdfDoc.numPages;
renderPdfPage(1); // 渲染第一页
} catch (error) {
emit('error', { type: 'pdf', error });
}
};
// 递归渲染PDF所有页面
const renderPdfPage = (num) => {
pdfDoc.getPage(num).then((page) => {
const canvasId = `${uniqueId}-pdf-canvas-${num}`;
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const bsr =
ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio ||
1;
const ratio = dpr / bsr;
const viewport = page.getViewport({ scale: pdfScale.value });
canvas.width = viewport.width * ratio;
canvas.height = viewport.height * ratio;
canvas.style.width = viewport.width + 'px';
canvas.style.height = viewport.height + 'px';
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
const renderContext = {
canvasContext: ctx,
viewport: viewport,
};
page.render(renderContext);
if (num < pdfPages.value) {
renderPdfPage(num + 1);
}
});
};
// Word文档事件
const handleRendered = () => emit('rendered', { type: 'docx' });
const handleError = (error) => emit('error', { type: 'docx', error });
// 下载文件(不支持预览时)
const handleDownload = () => emit('download');
// 监听URL变化
watch(() => props.fileUrl, initPreview, { immediate: true });
</script>
<style scoped lang="scss">
.file-preview-container {
width: 100%;
text-align: center;
}
.pdf-preview {
max-height: 800px;
overflow-y: auto;
max-width: 800px;
margin: 0 auto;
}
.vue-office-docx {
height: 800px !important;
}
.pdf-preview canvas {
margin-bottom: 10px;
border: 1px solid #eee;
}
.ofd-preview {
max-height: 800px;
margin: 0 auto;
overflow-y: auto;
}
.word-preview {
min-height: 300px;
}
.png-preview {
max-width: 500px;
max-height: 500px;
margin: 0 auto;
overflow-y: auto;
}
.unsupported-preview {
padding: 20px;
border: 1px solid #CB8A0A;
border-radius: 4px;
color: #CB8A0A;
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
}
.stampTK {
:deep(.mask_div) {
display: none !important;
}
}
</style>
javascript
<!-- 使用子组件 -->
<template>
<FilePreview :file-url="previewUrl" :file-name="fileName"
@rendered="handlePreviewRendered" :file-id="fileId" :file-type="fileType"
@error="handlePreviewError" @download="handleDownload" />
</template>
<script setup>
import FilePreview from '@/components/FilePreview/index.vue';
const previewUrl = ref('')
const fileName = ref('');
const fileId = ref('');
const fileType = ref();
const handlePreviewRendered = ({ type }) => {
console.log(`${type} 文件渲染完成`);
};
const handlePreviewError = ({ type, error }) => {
console.error(`${type} 文件渲染失败:`, error);
ElMessage.error(`预览失败: ${type === 'pdf' ? 'PDF' : 'Word'} 文档加载异常`);
};
// 下载文件
const handleDownload = ({ fileUrl, name }) => {
console.log(fileUrl);
const link = document.createElement('a');
link.href = fileUrl;
link.download = name;
link.click();
};
</script>
关键功能说明
- 多格式支持细节
PDF:使用 PDF.js 解析二进制流,支持高清渲染和分页加载,适配不同屏幕像素比
Word:依赖 vue-office-docx 组件,直接解析文件 URL,支持复杂格式(表格、图片、样式)
OFD:通过 ofd.js 解析,实现滚动加载优化,避免大文件一次性渲染卡顿
图片:使用 Element Plus 的 ElImage 组件,支持自适应缩放和容器适配 - 性能优化点
PDF/OFD 采用分页渲染,避免一次性加载所有页面占用过多内存
OFD 实现滚动加载,仅渲染可视区域附近的页面
二进制流转换后及时释放 URL 对象,避免内存泄漏
生成唯一 ID 确保组件多实例时 DOM 节点不冲突 - 扩展性设计
通过previewSty props 支持自定义预览容器样式,适配不同页面布局
暴露rendered和error事件,方便父组件处理状态提示
下载逻辑由父组件实现,组件仅负责触发事件,提高灵活性
常见问题解决
PDF 预览空白:检查 PDF.js 工作线程配置是否正确,确保pdfWorker路径无误
Word 预览样式错乱:确认vue-office-docx版本与 Vue 3 兼容,且已引入对应的 CSS 样式
OFD 解析失败:检查文件 URL 是否可访问,ofd.js 仅支持标准 OFD 格式
大文件卡顿:可调整 PDF 缩放比例(pdfScale)或 OFD 每次加载页数,减少渲染压力