Vue文件预览终极方案:PNG/EXCEL/PDF/DOCX/OFD等10+格式一键渲染,开源即用!

在实际项目开发中,文件预览是高频需求,尤其是对 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>

关键功能说明

  1. 多格式支持细节
    PDF:使用 PDF.js 解析二进制流,支持高清渲染和分页加载,适配不同屏幕像素比
    Word:依赖 vue-office-docx 组件,直接解析文件 URL,支持复杂格式(表格、图片、样式)
    OFD:通过 ofd.js 解析,实现滚动加载优化,避免大文件一次性渲染卡顿
    图片:使用 Element Plus 的 ElImage 组件,支持自适应缩放和容器适配
  2. 性能优化点
    PDF/OFD 采用分页渲染,避免一次性加载所有页面占用过多内存
    OFD 实现滚动加载,仅渲染可视区域附近的页面
    二进制流转换后及时释放 URL 对象,避免内存泄漏
    生成唯一 ID 确保组件多实例时 DOM 节点不冲突
  3. 扩展性设计
    通过previewSty props 支持自定义预览容器样式,适配不同页面布局
    暴露rendered和error事件,方便父组件处理状态提示
    下载逻辑由父组件实现,组件仅负责触发事件,提高灵活性
    常见问题解决
    PDF 预览空白:检查 PDF.js 工作线程配置是否正确,确保pdfWorker路径无误
    Word 预览样式错乱:确认vue-office-docx版本与 Vue 3 兼容,且已引入对应的 CSS 样式
    OFD 解析失败:检查文件 URL 是否可访问,ofd.js 仅支持标准 OFD 格式
    大文件卡顿:可调整 PDF 缩放比例(pdfScale)或 OFD 每次加载页数,减少渲染压力
相关推荐
Z***u6593 小时前
前端性能测试实践
前端
xhxxx3 小时前
prototype 是遗产,proto 是族谱:一文吃透 JS 原型链
前端·javascript
倾墨3 小时前
Bytebot源码学习
前端
用户93816912553604 小时前
VUE3项目--集成Sass
前端
S***H2834 小时前
Vue语音识别案例
前端·vue.js·语音识别
CodeCraft Studio4 小时前
ABViewer 16全新发布:3D可视化、PDF转DWG、G-code生成全面升级
pdf
涔溪4 小时前
通过Nginx反向代理配置连接多个后端服务器
vue.js·nginx
啦啦9118864 小时前
【版本更新】Edge 浏览器 v142.0.3595.94 绿色增强版+官方安装包
前端·edge
蚂蚁集团数据体验技术5 小时前
一个可以补充 Mermaid 的可视化组件库 Infographic
前端·javascript·llm
LQW_home5 小时前
前端展示 接受springboot Flux数据demo
前端·css·css3