问题描述:关闭抽屉时,还额外做了很多事情





代码:
src\components\base\BaseFilePreviewDrawer.vue
<script setup lang="ts">
/**
* 文件预览抽屉
*/
defineOptions({
name: "BaseFilePreviewDrawer"
});
import { isImageFile, isPdfFile } from "@/utils";
import { renderAsync } from "docx-preview";
import { computed, nextTick, ref, watch } from "vue";
interface Props {
/** 预览标题 */
title?: string;
/** 预览内容 */
content: Blob | File | string | null;
/** 扩展值,如:文件唯一id、文件编号、文件名称 */
expandValue?: number | string;
// /** 左侧导航栏 */
// showNavpanes?: boolean;
// /** 右侧滚动条 */
// showScrollbar?: boolean;
// /** 底部状态栏 */
// showStatusbar?: boolean;
/** 顶部工具栏 */
showToolbar?: boolean;
/** 文件预览失败的回调函数,返回一个Promise,解析为文本内容 */
onError?: (content?: Blob | File | string | null, expandValue?: number | string) => Promise<string>;
}
const props = withDefaults(defineProps<Props>(), {
title: "预览的内容",
content: null,
// showNavpanes: true,
// showScrollbar: true,
// showStatusbar: true,
showToolbar: true
});
// 抽屉显示标识
const drawerVisible = ref(false);
// 经过安全处理后,真正预览的标题
const previewTitle = ref("");
// 经过安全处理后,真正预览的内容
const previewContent = ref<Blob | File | string | null>(null);
// 经过安全处理后,真正预览的模式
const previewMode = ref<"pdf" | "docx" | "text" | "image" | "">("");
// docx文件预览内容实例
const docxContentRef = ref<HTMLElement | null>(null);
// pdf文件预览url
const pdfUrl = ref("");
// 图片文件预览url
const imageUrl = ref("");
const pdfSrc = computed(() => {
const params = [];
if (props.showToolbar !== undefined) {
// 实际测试,只有这个参数生效
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
// if (props.showStatusbar !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showStatusbar ? "statusbar=0" : "statusbar=0");
// }
// if (props.showNavpanes !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showNavpanes ? "navpanes=0" : "navpanes=0");
// }
// if (props.showScrollbar !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showScrollbar ? "scrollbar=0" : "scrollbar=0");
// }
const paramString = params.length > 0 ? "#" + params.join("&") : "";
console.log("pdfSrc.value = ", pdfUrl.value + paramString);
return pdfUrl.value + paramString;
});
// 打开抽屉
const openDrawer = async () => {
drawerVisible.value = true;
// 等待抽屉打开完成渲染
await nextTick();
// 抽屉滚动条回到顶部
document.querySelector(".el-drawer__body")?.scrollTo(0, 0);
};
// 关闭抽屉
const closeDrawer = () => {
if (pdfUrl.value) {
// 清理资源, 清理 PDF URL 对象,防止内存泄漏
URL.revokeObjectURL(pdfUrl.value);
pdfUrl.value = "";
}
if (imageUrl.value) {
// 清理资源, 清理 图片 URL 对象,防止内存泄漏
URL.revokeObjectURL(imageUrl.value);
imageUrl.value = "";
}
drawerVisible.value = false;
};
// 监听内容变化
watch(
() => props.content,
async () => {
previewContent.value = props.content;
// 如果内容是null,则不渲染
if (previewContent.value === null) {
previewMode.value = "";
return;
}
previewTitle.value = props.title;
// 判断是否为文件
if (props.content instanceof Blob) {
// 判断是否为 PDF 文件
if (await isPdfFile(props.content)) {
// 处理 PDF 文件预览,后端返回的响应实体已经设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流,只能下载,不能预览
// 1、创建新的 Blob 对象并明确指定 MIME 类型为application/pdf才可以预览,并且避免触发下载
const pdfBlob = new Blob([props.content], { type: "application/pdf" });
// 2、Blob 转换为 URL
pdfUrl.value = URL.createObjectURL(pdfBlob);
previewMode.value = "pdf";
return;
}
// 判断是否为图片文件
if (typeof props.expandValue === "string" && (await isImageFile(props.content, props.expandValue))) {
// 处理图片文件预览
// Blob 转换为 URL
imageUrl.value = URL.createObjectURL(props.content);
previewContent.value = imageUrl.value;
previewMode.value = "image";
return;
}
// Blob 转换为 ArrayBuffer
const arrayBuffer = await props.content.arrayBuffer();
// 前端解析文件,使用 docx-preview 预览docx文件,文档里有分页符才显示分页,无法复刻WPS的分页效果,顶部、中间和底部的分隔行高度一致
if (docxContentRef.value) {
try {
// 调用 renderAsync 方法渲染文档到预览容器
await renderAsync(arrayBuffer, docxContentRef.value);
previewContent.value = null;
previewMode.value = "docx";
return;
} catch (error) {
try {
// 失败的回调(可选),由父组件处理相关的逻辑(比如从后端获取文本内容),并且等待父组件返回文本内容
if (props.onError) {
// 调用父组件提供的错误处理函数,并等待返回的文本内容
previewContent.value = await props.onError(props.content, props.expandValue);
previewMode.value = "text";
return;
} else {
// 兜底处理,父组件未提供onError函数
previewContent.value = "该文件类型不支持预览,请下载文件查看";
previewMode.value = "text";
return;
}
} catch (error) {
// 兜底处理,父组件执行onError函数出错
previewContent.value = "该文件类型不支持预览,请下载文件查看";
previewMode.value = "text";
return;
}
}
}
}
if (typeof props.content === "string") {
previewContent.value = props.content;
previewMode.value = "text";
return;
}
}
);
defineExpose({ openDrawer });
</script>
<template>
<div>
<el-drawer v-model="drawerVisible" :title="previewTitle" :with-header="true" size="900px" @close="closeDrawer">
<template #>
<div class="preview-container">
<!-- PDF文件预览 -->
<!-- iframe URL 参数说明:#toolbar=0 - 隐藏顶部工具栏,#navpanes=0 - 隐藏左侧导航面板,#scrollbar=0 - 隐藏滚动条,#statusbar=0 - 隐藏底部状态栏,#view=FitH - 页面宽度适应 -->
<!-- iframe URL 参数示例::src="pdfUrl + `#toolbar=0&navpanes=0&scrollbar=0&statusbar=0&view=FitH`" -->
<iframe v-if="previewMode === `pdf`" class="pdf-content" :src="pdfSrc"></iframe>
<!-- 图片文件预览 -->
<div v-else-if="previewMode === `image`" class="image-content">
<img :src="previewContent as string" alt="预览图片" />
</div>
<!-- 文本预览 -->
<div v-else-if="previewMode === `text`" class="text-content" v-text="previewContent"></div>
<!-- docx文件预览,基于docx-preview逻辑,需要一个稳定的DOM元素来渲染内容,所以只能使用v-show,不能使用v-if,否则docx文件预览会失败 -->
<!-- 因为docx-preview会自动处理样式,所以这里不用设置样式,设置样式也不会起效 -->
<div v-show="previewMode === `docx`" ref="docxContentRef"></div>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
// 抽屉标题居中
display: flex;
text-align: center;
justify-content: center;
margin-bottom: 0;
padding: 0;
height: 32px;
}
// 抽屉标题
:deep(.el-drawer__title) {
font-weight: bold;
}
// 抽屉内容
:deep(.el-drawer__body) {
padding: 0 10px;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
padding: 0;
}
.preview-container {
border: 1px solid #ccc;
// 设置明确的高度值
height: calc(100vh - 40px);
.text-content {
// white-space: pre-wrap的作用是保留空格,并且除了碰到源码中的换行和会换行外,还会自适应容器的边界进行换行。
white-space: pre-wrap;
margin: 10px;
}
.pdf-content {
width: 100%;
// 继承父容器的高度
height: 100%;
}
.image-content {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
</style>
出错关键:
<iframe v-if="previewMode === `pdf`" class="pdf-content" :src="pdfSrc"></iframe>
原因分析:
打开抽屉时:pdfSrc.value = blob:http://localhost:5173/bed4bf8e-7734-468b-b14a-99e343bd7edb#toolbar=0
关闭抽屉时:pdfSrc.value = #toolbar=0
vue3中,
<iframe v-if="previewMode === `pdf`" class="pdf-content" :src="pdfSrc"></iframe>
如果pdfSrc的值不合法,比如#toolbar=0,会出现什么情况?
在 Vue3 中,如果 pdfSrc 的值是 #toolbar=0(不完整的 URL),iframe 会有以下行为:
会出现的情况:
1. 相对 URL 解析
#toolbar=0 是一个片段标识符(fragment identifier),当用作 iframe 的 src 时:
-
浏览器会将其解析为当前页面的 URL 加上
#toolbar=0 -
例如:如果当前页面是
https://example.com/page -
那么
iframe会加载https://example.com/page#toolbar=0
2. 导致页面循环加载
这是最可能的问题:
-
iframe会加载父页面本身 -
如果父页面包含相同的 Vue 组件,可能会触发组件重新初始化
-
可能导致数据重新加载或页面刷新
3. 控制台错误
浏览器控制台可能会显示:
text
Blocked a frame with origin "..." from accessing a cross-origin frame.
或者:
text
Unsafe attempt to load URL "..."
4. 安全性问题
-
加载当前页面到
iframe中可能引发安全性问题 -
特别是当页面包含敏感数据或状态时
在您的代码中的具体问题:
查看您的 pdfSrc 计算属性:
typescript
const pdfSrc = computed(() => {
const params = [];
if (props.showToolbar !== undefined) {
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
const paramString = params.length > 0 ? "#" + params.join("&") : "";
return pdfUrl.value + paramString;
});
关键问题:
-
当
pdfUrl.value为空字符串时 -
pdfSrc会变成"#toolbar=0"(或"#toolbar=1") -
这会使得
iframe加载当前页面
解决方案:
1. 确保 pdfUrl 有效时才渲染 iframe
vue
<iframe v-if="previewMode === 'pdf' && pdfUrl" class="pdf-content" :src="pdfSrc" ></iframe>
2. 修改计算属性,确保返回完整 URL
typescript
const pdfSrc = computed(() => {
// 如果 pdfUrl 为空,返回空字符串
if (!pdfUrl.value) return '';
const params = [];
if (props.showToolbar !== undefined) {
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
const paramString = params.length > 0 ? "#" + params.join("&") : "";
return pdfUrl.value + paramString;
});
3. 添加 URL 验证
typescript
const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
const pdfSrc = computed(() => {
if (!pdfUrl.value || !isValidUrl(pdfUrl.value)) return '';
const params = [];
if (props.showToolbar !== undefined) {
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
const paramString = params.length > 0 ? "#" + params.join("&") : "";
return pdfUrl.value + paramString;
});
4. 在清理 URL 时添加安全检查
typescript
const closeDrawer = () => {
// 先隐藏 iframe
if (previewMode.value === 'pdf') {
// 立即将 pdfSrc 设置为空
const iframe = document.querySelector('.pdf-content') as HTMLIFrameElement;
if (iframe) {
iframe.src = 'about:blank';
}
}
// 延迟清理 URL
setTimeout(() => {
if (pdfUrl.value) {
URL.revokeObjectURL(pdfUrl.value);
pdfUrl.value = "";
}
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value);
imageUrl.value = "";
}
}, 100);
drawerVisible.value = false;
};
5. 完整的修复方案
结合以上几点:
vue
<!-- 模板部分 --> <iframe v-if="previewMode === 'pdf' && pdfUrl" class="pdf-content" :src="pdfSrc" sandbox="allow-scripts allow-same-origin" ></iframe>
typescript
// 脚本部分
const pdfSrc = computed(() => {
// 确保 pdfUrl 是有效的 Blob URL
if (!pdfUrl.value || !pdfUrl.value.startsWith('blob:')) {
return '';
}
const params = [];
if (props.showToolbar !== undefined) {
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
const paramString = params.length > 0 ? "#" + params.join("&") : "";
return pdfUrl.value + paramString;
});
const closeDrawer = () => {
// 立即停止 iframe 加载
const iframe = document.querySelector('.pdf-content');
if (iframe) {
(iframe as HTMLIFrameElement).src = 'about:blank';
}
// 清空预览模式
previewMode.value = '';
// 延迟清理资源
setTimeout(() => {
if (pdfUrl.value) {
URL.revokeObjectURL(pdfUrl.value);
pdfUrl.value = "";
}
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value);
imageUrl.value = "";
}
}, 300);
drawerVisible.value = false;
};
调试方法:
要确认是否是这个问题,可以添加调试:
typescript
watch(pdfSrc, (newSrc, oldSrc) => {
console.log('pdfSrc changed:', {
old: oldSrc,
new: newSrc,
isValid: new URL(newSrc, window.location.href)
});
});
watch(pdfUrl, (newUrl, oldUrl) => {
console.log('pdfUrl changed:', {
old: oldUrl,
new: newUrl,
isEmpty: !newUrl
});
});
最可能的情况是:在关闭抽屉时,pdfUrl 被清空,导致 pdfSrc 变成了类似 #toolbar=0 的值,从而使 iframe 加载当前页面,触发了页面刷新。请按照上述方案进行修复。
修复后的代码:
<script setup lang="ts">
/**
* 文件预览抽屉
*/
defineOptions({
name: "BaseFilePreviewDrawer"
});
import { isImageFile, isPdfFile } from "@/utils";
import { renderAsync } from "docx-preview";
import { computed, nextTick, ref, watch } from "vue";
interface Props {
/** 预览标题 */
title?: string;
/** 预览内容 */
content: Blob | File | string | null;
/** 扩展值,如:文件唯一id、文件编号、文件名称 */
expandValue?: number | string;
// /** 左侧导航栏 */
// showNavpanes?: boolean;
// /** 右侧滚动条 */
// showScrollbar?: boolean;
// /** 底部状态栏 */
// showStatusbar?: boolean;
/** 顶部工具栏 */
showToolbar?: boolean;
/** 文件预览失败的回调函数,返回一个Promise,解析为文本内容 */
onError?: (content?: Blob | File | string | null, expandValue?: number | string) => Promise<string>;
}
const props = withDefaults(defineProps<Props>(), {
title: "预览的内容",
content: null,
// showNavpanes: true,
// showScrollbar: true,
// showStatusbar: true,
showToolbar: true
});
// 抽屉显示标识
const drawerVisible = ref(false);
// 经过安全处理后,真正预览的标题
const previewTitle = ref("");
// 经过安全处理后,真正预览的内容
const previewContent = ref<Blob | File | string | null>(null);
// 经过安全处理后,真正预览的模式
const previewMode = ref<"pdf" | "docx" | "text" | "image" | "">("");
// docx文件预览内容实例
const docxContentRef = ref<HTMLElement | null>(null);
// pdf文件预览url
const pdfUrl = ref("");
// 图片文件预览url
const imageUrl = ref("");
const pdfSrc = computed(() => {
// 如果 pdfUrl 为空,返回空字符串,确保 pdfSrc 数据合法
if (!pdfUrl.value) return "";
const params = [];
if (props.showToolbar !== undefined) {
// 实际测试,只有这个参数生效
params.push(props.showToolbar ? "toolbar=1" : "toolbar=0");
}
// if (props.showStatusbar !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showStatusbar ? "statusbar=0" : "statusbar=0");
// }
// if (props.showNavpanes !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showNavpanes ? "navpanes=0" : "navpanes=0");
// }
// if (props.showScrollbar !== undefined) {
// // 实际测试,这个参数不生效
// params.push(props.showScrollbar ? "scrollbar=0" : "scrollbar=0");
// }
const paramString = params.length > 0 ? "#" + params.join("&") : "";
return pdfUrl.value + paramString;
});
// 打开抽屉
const openDrawer = async () => {
drawerVisible.value = true;
// 等待抽屉打开完成渲染
await nextTick();
// 抽屉滚动条回到顶部
document.querySelector(".el-drawer__body")?.scrollTo(0, 0);
};
// 关闭抽屉
const closeDrawer = () => {
if (pdfUrl.value) {
// 清理资源, 清理 PDF URL 对象,防止内存泄漏
URL.revokeObjectURL(pdfUrl.value);
pdfUrl.value = "";
}
if (imageUrl.value) {
// 清理资源, 清理 图片 URL 对象,防止内存泄漏
URL.revokeObjectURL(imageUrl.value);
imageUrl.value = "";
}
drawerVisible.value = false;
};
// 监听内容变化
watch(
() => props.content,
async () => {
previewContent.value = props.content;
// 如果内容是null,则不渲染
if (previewContent.value === null) {
previewMode.value = "";
return;
}
previewTitle.value = props.title;
// 判断是否为文件
if (props.content instanceof Blob) {
// 判断是否为 PDF 文件
if (await isPdfFile(props.content)) {
// 处理 PDF 文件预览,后端返回的响应实体已经设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流,只能下载,不能预览
// 1、创建新的 Blob 对象并明确指定 MIME 类型为application/pdf才可以预览,并且避免触发下载
const pdfBlob = new Blob([props.content], { type: "application/pdf" });
// 2、Blob 转换为 URL
pdfUrl.value = URL.createObjectURL(pdfBlob);
previewMode.value = "pdf";
return;
}
// 判断是否为图片文件
if (typeof props.expandValue === "string" && (await isImageFile(props.content, props.expandValue))) {
// 处理图片文件预览
// Blob 转换为 URL
imageUrl.value = URL.createObjectURL(props.content);
previewContent.value = imageUrl.value;
previewMode.value = "image";
return;
}
// Blob 转换为 ArrayBuffer
const arrayBuffer = await props.content.arrayBuffer();
// 前端解析文件,使用 docx-preview 预览docx文件,文档里有分页符才显示分页,无法复刻WPS的分页效果,顶部、中间和底部的分隔行高度一致
if (docxContentRef.value) {
try {
// 调用 renderAsync 方法渲染文档到预览容器
await renderAsync(arrayBuffer, docxContentRef.value);
previewContent.value = null;
previewMode.value = "docx";
return;
} catch (error) {
try {
// 失败的回调(可选),由父组件处理相关的逻辑(比如从后端获取文本内容),并且等待父组件返回文本内容
if (props.onError) {
// 调用父组件提供的错误处理函数,并等待返回的文本内容
previewContent.value = await props.onError(props.content, props.expandValue);
previewMode.value = "text";
return;
} else {
// 兜底处理,父组件未提供onError函数
previewContent.value = "该文件类型不支持预览,请下载文件查看";
previewMode.value = "text";
return;
}
} catch (error) {
// 兜底处理,父组件执行onError函数出错
previewContent.value = "该文件类型不支持预览,请下载文件查看";
previewMode.value = "text";
return;
}
}
}
}
if (typeof props.content === "string") {
previewContent.value = props.content;
previewMode.value = "text";
return;
}
}
);
defineExpose({ openDrawer });
</script>
<template>
<div>
<el-drawer v-model="drawerVisible" :title="previewTitle" :with-header="true" size="900px" @close="closeDrawer">
<template #>
<div class="preview-container">
<!-- PDF文件预览 -->
<!-- iframe URL 参数说明:#toolbar=0 - 隐藏顶部工具栏,#navpanes=0 - 隐藏左侧导航面板,#scrollbar=0 - 隐藏滚动条,#statusbar=0 - 隐藏底部状态栏,#view=FitH - 页面宽度适应 -->
<!-- iframe URL 参数示例::src="pdfUrl + `#toolbar=0&navpanes=0&scrollbar=0&statusbar=0&view=FitH`" -->
<iframe v-if="previewMode === `pdf` && pdfSrc" class="pdf-content" :src="pdfSrc"></iframe>
<!-- 图片文件预览 -->
<div v-else-if="previewMode === `image`" class="image-content">
<img :src="previewContent as string" alt="预览图片" />
</div>
<!-- 文本预览 -->
<div v-else-if="previewMode === `text`" class="text-content" v-text="previewContent"></div>
<!-- docx文件预览,基于docx-preview逻辑,需要一个稳定的DOM元素来渲染内容,所以只能使用v-show,不能使用v-if,否则docx文件预览会失败 -->
<!-- 因为docx-preview会自动处理样式,所以这里不用设置样式,设置样式也不会起效 -->
<div v-show="previewMode === `docx`" ref="docxContentRef"></div>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
// 抽屉头部
:deep(.el-drawer__header) {
// 抽屉标题居中
display: flex;
text-align: center;
justify-content: center;
margin-bottom: 0;
padding: 0;
height: 32px;
}
// 抽屉标题
:deep(.el-drawer__title) {
font-weight: bold;
}
// 抽屉内容
:deep(.el-drawer__body) {
padding: 0 10px;
}
// 抽屉关闭按钮
:deep(.el-drawer__close-btn) {
padding: 0;
}
.preview-container {
border: 1px solid #ccc;
// 设置明确的高度值
height: calc(100vh - 40px);
.text-content {
// white-space: pre-wrap的作用是保留空格,并且除了碰到源码中的换行和会换行外,还会自适应容器的边界进行换行。
white-space: pre-wrap;
margin: 10px;
}
.pdf-content {
width: 100%;
// 继承父容器的高度
height: 100%;
}
.image-content {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
</style>



5551

被折叠的 条评论
为什么被折叠?



