Vue3实践问题:iframe的src设置不合法,引发页面刷新

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

代码:

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;
});

关键问题:

  1. 当 pdfUrl.value 为空字符串时

  2. pdfSrc 会变成 "#toolbar=0"(或 "#toolbar=1"

  3. 这会使得 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>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值