💡 解决iframe无法预览大pdf(>10M)问题,解决思路通用,我使用React举例
背景
1、对于 PDF 在线预览,网上有很多种方案,比如
react-pdf
、pdfh5
、PDF.js
等等。但它们都有不同程度的缺点,比如功能不全需要自定义覆盖、对于某些格式的 PDF 无法展示全(文字丢失、签章丢失等)。2、实际上浏览器自带的预览功能已经可以满足绝大部分的需求,于是可以通过
<iframe>
标签预览 PDF,效果非常好。但是实际使用中,我的同事发现对于较大的 PDF,<iframe>
无法显示并展示空白。
原因
当时这个问题其实没人说是什么原因,我感觉既然浏览器能完美预览 PDF,怎么会达到某个阈值就完全空白了,于是我一路追踪检索到
stackoverflow
,在一众讨论中获得了答案。㊙️ 之前在使用
<iframe>
标签预览 PDF 的时候传递了一个参数src
,我们在这里传入了 PDF 的base64
串,该base64
串会拼接到路由地址后面(<iframe>
相当于嵌入了一个网页,拼接到该网页的路由地址,主页面是看不到的)。㊙️ 然而浏览器对地址有一个最大长度限制(不同浏览器限制不一),当地址长度过长则会被截断,这也是为什么过大的 PDF 展示空白,因为其
base64
串被截断了,<iframe>
拿到的是一个无效的src
。
解决方案
✅ 既然
base64
过长,那我们就考虑不传递base64
串,如果是远程的 PDF 文件(oss
、obs
)或者本地的 PDF 文件可以直接拼接其访问地址;✅ 如果 PDF 文件由后端传递过来,就是一个
base64
串呢(我所在的项目就是如此),正确的做法是将其转化为一个blob
对象,然后创建出该对象的访问url
,这个url
是一个固定长随机串,这样我们就能正常访问了。
完整组件代码(React)
import React, { useEffect, useState } from 'react';
import { Button, Result } from 'antd';
import { DownloadOutlined } from 'dw-mx-icons';
import styles from './style/index.less';
import { request } from 'dw-mx-request';
interface PropTypes {
// 传入pdf的base64串
src: string,
// 是否开启工具栏
openToolBar?: boolean,
// 是否全屏展示
fullScreen?: boolean,
// 是否使用自定义toolbar覆盖
useCustomToolBar?: boolean,
// 文件名
fileName?: string,
// 下载地址
downloadUrl?: string,
// 文件id
fileId?: string,
}
/**
* @description pdf预览组件(使用iframe并解决大文件空白问题)
* @author LFH
* @date 2024-04-22
*/
function PDFViewer(props: PropTypes) {
const {
src,
openToolBar = true,
fullScreen = false,
useCustomToolBar = false,
fileName = '附件材料',
downloadUrl,
fileId
} = props;
const config = {
openToolBar: openToolBar && !useCustomToolBar ? '' : '#view=FitH,top&type=accesspdf#scrollbars=0&toolbar=0&statusbar=0'
};
const [zoomLevel, setZoomLevel] = useState('page-width'); // 默认缩放级别
// 根据base64创建Blob
const dataURLtoBlob = (dataUrl) => {
const arr = dataUrl.split(',');
if (arr.size < 2) {
arr[1] = arr[0];
arr[0] = 'data:application/pdf;base64';
}
const suffix = arr[0].match(/\/(.*?);/)[1];
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
// 工具栏按钮处理函数
const handleZoomIn = () => {
let newZoomLevel = parseInt(zoomLevel);
if (isNaN(newZoomLevel)) {
newZoomLevel = 100; // 如果不是数字,则默认从100%开始
}
newZoomLevel += 10;
setZoomLevel(`${newZoomLevel}`);
};
const handleZoomOut = () => {
let newZoomLevel = parseInt(zoomLevel);
if (isNaN(newZoomLevel)) {
newZoomLevel = 100; // 如果不是数字,则默认从100%开始
}
newZoomLevel -= 10;
setZoomLevel(`${Math.max(20, newZoomLevel)}`); // 最小缩放级别为20%
};
const handleDownload = () => {
if (downloadUrl) {
request.download(downloadUrl, {
bge066: fileId
});
} else {
const a = document.createElement('a');
a.href = objURL;
a.download = fileName; // 设置文件名
a.click();
}
};
const [objURL, setObjURL] = useState(null);
useEffect(() => {
if (src?.length) {
if (objURL) {
window.URL.revokeObjectURL(objURL);
}
setObjURL(window.URL.createObjectURL(dataURLtoBlob(src)));
return () => {
// 组件销毁时执行 删除浏览器中blob对象
window.URL.revokeObjectURL(objURL);
}
}
}, [src]);
return (
<div style={{height:fullScreen?'calc(100vh - 0px)':'calc(100vh - 142px)', width: '100%'}}>
{objURL ?
<>
{openToolBar && useCustomToolBar && (
<div className={`${styles['custom-toolbar']}`}>
<Button type={'text'} icon={<DownloadOutlined />} onClick={handleDownload}>下载</Button>
</div>
)}
<iframe height={'100%'} width={'100%'} src={objURL + config.openToolBar} />
</>
: <Result
status='404'
title='暂无数据'
subTitle='该pdf暂时无法查看或已损坏'
/>
}
</div>
);
}
export default PDFViewer;
说明
💫 其中我为了可以控制浏览器工具栏的显隐,加入了一些逻辑。
💫 这里有个小问题,在用户通过浏览器提供的按钮触发下载的时候,默认下载的当前文件的本地
blob
对象,并且文件名是那串定长随机串。考虑到有原文件为word
,只是预览转为word
文件,所以我们需要重写浏览器工具栏行为或者覆盖浏览器的工具栏。💫 因为浏览器的工具栏我尝试了很多方法都无法通过
js
控制或重写行为,所以我考虑覆盖它,上面的下载函数中可以用自己项目里的Ajax
或者fetch
实现。
注意事项(必读)
⚠️ 注意,要在组件的生命周期末尾销毁该对象,不然频繁创建可能会造成 OOM。在 React 中,我在
useEffect
中的return
回调中执行的销毁。