Appearance
BizEmbedPage 外链嵌入容器
外链 / 站内页面 iframe 嵌入容器:加载遮罩(loadingText + 旋转图标)、超时兜底(timeout 内未 load → 失败占位 + 重试按钮;CSP / X-Frame-Options 拒嵌的站点无法主动探测,统一触发超时兜底)、右上角悬浮工具条(新窗口打开 / 刷新)、postMessage 双向通信封装(messageOrigin 来源白名单)、keep-alive 地址缓存(LRU,src 切回不重开 loading)、frame / dialog / drawer 三种展示模式。系统内嵌第三方页面、报表大屏、协议文档等场景。
说明:目标站点设置
X-Frame-Options: DENY/SAMEORIGIN或 CSPframe-ancestors拒绝被嵌入时,浏览器不会给出可监听的错误事件,iframe 的 load 可能始终不触发——组件统一以「超时占位 + 重试」兜底,请在业务侧提示用户改用工具条「新窗口打开」。demo 中 iframe 的 src 使用data:URL 内联页面(UTF-8 编码,离线可用)或站点内可嵌页面演示基本形态。
基础用法
报表中心 · 月度概览
load 事件次数:0(点击容器右上角「刷新」工具可再次触发)
frame 模式:容器内 iframe + 加载遮罩 + 右上角悬浮工具条(新窗口打开 / 刷新),src 为
data: URL 内联页(离线可用)。<script setup lang="ts">
/** 场景:基础——frame 模式容器 + data URL 内嵌页(离线可用) */
import { ref } from 'vue'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
const loadCount = ref(0)
/* data:text/html 内联页(UTF-8 编码 + encodeURIComponent),不依赖外部网络即可演示嵌入形态 */
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
'<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133}h3{margin:0 0 12px}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>内嵌业务页(data URL)</h3><p>这是通过 data:text/html 内联的页面占位,用于离线演示 biz-embed-page 的嵌入形态。</p><p>容器提供:加载遮罩、超时兜底、右上角工具条(新窗口打开 / 刷新)。</p></body></html>',
)}`
</script>
<template>
<div>
<BizEmbedPage :src="src" title="报表中心 · 月度概览" :height="260" @load="loadCount += 1" />
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
load 事件次数:{{ loadCount }}(点击容器右上角「刷新」工具可再次触发)
</p>
</div>
</template>
显示代码
弹窗模式
visible:false——v-model:visible 双向同步;dialog-width 控制弹层宽度
mode="dialog" 配合 v-model:visible 控制 ElDialog 弹窗内嵌同一容器,dialog-width 控制弹层宽度(首次打开才挂载 iframe)。<script setup lang="ts">
/** 场景:弹窗模式——mode=dialog + v-model:visible 控制开合 */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
const visible = ref(false)
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
'<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133}h3{margin:0 0 12px}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>外部系统 · 审批单详情</h3><p>弹窗首次打开时 iframe 才随 ElDialog 挂载并进入加载流程。</p><p>关闭再打开不重建页面;需要每次重载可在业务侧重设 src 或调用 expose 的 reload()。</p></body></html>',
)}`
</script>
<template>
<div>
<ElButton type="primary" @click="visible = true">打开弹窗嵌入</ElButton>
<BizEmbedPage
v-model:visible="visible"
mode="dialog"
title="外部系统 · 审批单详情"
:src="src"
:height="320"
dialog-width="560px"
>
<template #footer>
<ElButton @click="visible = false">关 闭</ElButton>
</template>
</BizEmbedPage>
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
visible:{{ visible }}——v-model:visible 双向同步;dialog-width 控制弹层宽度
</p>
</div>
</template>
显示代码
抽屉模式
visible:false——dialog-width="480px" 作为抽屉 size
mode="drawer" 配合 v-model:visible 控制抽屉内嵌容器,dialog-width 映射为 drawer 的 size。<script setup lang="ts">
/** 场景:抽屉模式——mode=drawer + v-model:visible,dialog-width 映射为 drawer 的 size */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
const visible = ref(false)
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
'<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133}h3{margin:0 0 12px}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>帮助中心 · 操作指引</h3><p>抽屉内嵌同一容器:加载遮罩、超时兜底、工具条能力与 frame 模式一致。</p><p>关闭抽屉会暂停超时计时,重新打开继续检测。</p></body></html>',
)}`
</script>
<template>
<div>
<ElButton type="primary" @click="visible = true">打开抽屉嵌入</ElButton>
<BizEmbedPage
v-model:visible="visible"
mode="drawer"
title="帮助中心 · 操作指引"
:src="src"
:height="360"
dialog-width="480px"
/>
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
visible:{{ visible }}——dialog-width="480px" 作为抽屉 size
</p>
</div>
</template>
显示代码
超时兜底与重试
第三方系统(演示超时兜底)
timeout=500ms:CSP / X-Frame-Options 拒嵌或网络不可达时 load 不触发,显示失败占位; 点击「重试」重置状态并强制重载。若当前环境可访问且允许嵌入,则正常加载不兜底。
timeout 内 iframe 未触发 load(目标站点拒嵌 / 网络不可达)→ 显示失败占位与「重试」按钮;重试会重置状态并重建 iframe 强制重载。演示地址为 https://example.com:若当前环境可访问且允许嵌入则会正常加载,否则走超时占位。<script setup lang="ts">
/** 场景:超时兜底与重试——timeout 内未 load 显示失败占位,重试强制重建 iframe */
import { ref } from 'vue'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
import type { BizEmbedPageError } from 'vue3-ep-biz-lib'
const errors = ref<string[]>([])
const onError = (payload: BizEmbedPageError): void => {
errors.value.unshift(`type=${payload.type}(${new Date().toLocaleTimeString()})`)
}
</script>
<template>
<div>
<BizEmbedPage
src="https://example.com"
title="第三方系统(演示超时兜底)"
:timeout="500"
:height="240"
@error="onError"
/>
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
timeout=500ms:CSP / X-Frame-Options 拒嵌或网络不可达时 load 不触发,显示失败占位;
点击「重试」重置状态并强制重载。若当前环境可访问且允许嵌入,则正常加载不兜底。
</p>
<p
v-for="(item, index) in errors"
:key="index"
style="margin: 4px 0 0; font-size: 12px; color: var(--el-color-danger)"
>
error 事件:{{ item }}
</p>
</div>
</template>
显示代码
postMessage 通信
消息通道演示
下行默认 targetOrigin='/'(同源放行);data URL 页面 origin 为 null,默认白名单 ['*'] 放行
内嵌页通过
parent.postMessage(...) 上行,组件经 messageOrigin 白名单校验 event.origin 后抛出 message 事件(data: URL 页面 origin 为 null,默认白名单 ['*'] 放行);下行通过 expose 的 postMessage(data, targetOrigin?) 发送,targetOrigin 默认 '/'(同源放行),跨源场景请传目标站点的具体 origin。生产环境请将 messageOrigin 收紧为已知来源,避免任意页面消息注入。<script setup lang="ts">
/** 场景:postMessage 双向通信——内嵌页上行 + expose postMessage 下行 */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
import type { BizEmbedPageInstance } from 'vue3-ep-biz-lib'
const pageRef = ref<BizEmbedPageInstance | null>(null)
const upLogs = ref<string[]>([])
/* 内嵌页(data URL,origin 为 null):parent.postMessage 上行;window message 监听接收容器下行 */
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
`<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:20px;background:#f5f7fa;color:#303133;font-size:13px}button{padding:4px 12px;cursor:pointer}#recv{margin-top:10px;color:#67c23a;word-break:break-all}</style></head><body><h4 style="margin:0 0 10px">postMessage 内嵌页</h4><button onclick="sendUp()">向容器发送消息</button><div id="recv">(等待容器下行消息)</div><script>function sendUp(){parent.postMessage({from:'embed-page',text:'来自内嵌页的上行消息',t:Date.now()},'*')}window.addEventListener('message',function(e){document.getElementById('recv').textContent='收到容器下行:'+JSON.stringify(e.data)})<\/script></body></html>`,
)}`
const onMessage = (data: unknown): void => {
upLogs.value.unshift(JSON.stringify(data))
}
const sendDown = (): void => {
pageRef.value?.postMessage({ from: 'container', text: '来自容器的下行消息', t: Date.now() })
}
</script>
<template>
<div>
<BizEmbedPage
ref="pageRef"
:src="src"
title="消息通道演示"
:height="240"
@message="onMessage"
/>
<div style="margin-top: 10px; display: flex; align-items: center; gap: 8px">
<ElButton size="small" @click="sendDown">向内嵌页 postMessage</ElButton>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
下行默认 targetOrigin='/'(同源放行);data URL 页面 origin 为 null,默认白名单 ['*'] 放行
</span>
</div>
<p
v-for="(item, index) in upLogs"
:key="index"
style="margin: 4px 0 0; font-size: 12px; color: var(--el-text-color-secondary)"
>
message 事件:{{ item }}
</p>
</div>
</template>
显示代码
keep-alive 缓存
按顺序 A → B → C → A:右侧 max-cache=2,C 载入后 A 已被 LRU 淘汰,切回 A 重新出现遮罩
未开 keep-alive(每次切换都重开遮罩)
load 次数:0
keep-alive + max-cache=2(缓存命中直接展示)
load 次数:0(load 仍会计数,差别在于缓存命中不再显示遮罩、不重启超时检测)
keep-alive 记录已加载完成的 src 集合:地址切回已缓存值时不再显示 loading 遮罩、不重启超时检测(iframe 单实例,浏览器 bfcache 负责页面还原);max-cache 控制缓存上限(LRU 淘汰最久未使用地址)。对比左右两侧切回同一地址时的遮罩与 load 次数。<script setup lang="ts">
/** 场景:keep-alive 缓存——已加载地址切回不重开遮罩 / 不重启超时,LRU 上限淘汰 */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
/** 内联页以同步忙等延迟约 1.2 秒触发 load,便于观察加载遮罩差异 */
const makePage = (name: string, bg: string): string =>
`data:text/html;charset=utf-8,${encodeURIComponent(
`<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:${bg};color:#303133}h3{margin:0 0 12px}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>页面 ${name}</h3><p>本页脚本同步忙等约 1.2 秒后才触发 iframe load。</p><script>var t=Date.now();while(Date.now()-t<1200);<\/script></body></html>`,
)}`
const pages = [
{ key: 'A', src: makePage('A', '#f5f7fa') },
{ key: 'B', src: makePage('B', '#f0f9eb') },
{ key: 'C', src: makePage('C', '#fdf6ec') },
]
const current = ref(pages[0]?.src ?? '')
const plainLoads = ref(0)
const cachedLoads = ref(0)
const switchTo = (src: string): void => {
current.value = src
}
</script>
<template>
<div>
<div style="margin-bottom: 10px; display: flex; gap: 8px; align-items: center">
<ElButton
v-for="page in pages"
:key="page.key"
size="small"
:type="current === page.src ? 'primary' : 'default'"
@click="switchTo(page.src)"
>
切到页面 {{ page.key }}
</ElButton>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
按顺序 A → B → C → A:右侧 max-cache=2,C 载入后 A 已被 LRU 淘汰,切回 A 重新出现遮罩
</span>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap">
<div style="flex: 1; min-width: 260px">
<p style="margin: 0 0 6px; font-size: 13px; color: var(--el-text-color-primary)">
未开 keep-alive(每次切换都重开遮罩)
</p>
<BizEmbedPage :src="current" :height="200" @load="plainLoads += 1" />
<p style="margin: 6px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
load 次数:{{ plainLoads }}
</p>
</div>
<div style="flex: 1; min-width: 260px">
<p style="margin: 0 0 6px; font-size: 13px; color: var(--el-text-color-primary)">
keep-alive + max-cache=2(缓存命中直接展示)
</p>
<BizEmbedPage :src="current" :height="200" keep-alive :max-cache="2" @load="cachedLoads += 1" />
<p style="margin: 6px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
load 次数:{{ cachedLoads }}(load 仍会计数,差别在于缓存命中不再显示遮罩、不重启超时检测)
</p>
</div>
</div>
</div>
</template>
显示代码
sandbox 安全限制
第三方挂件(sandbox 受限)
sandbox 数组 join(' ') 后写入 iframe sandbox 属性,缺省或空数组则不添加;另:未声明的原生属性(name / id / referrerpolicy / allow 等)会随 attrs 一并透传到 iframe 元素,见「原生属性透传」示例。
sandbox 传入权限 token 列表(join 为 iframe sandbox 属性);缺省或空数组不添加该属性。示例仅授予 allow-scripts:脚本可执行,但 window.open(缺 allow-popups)、表单提交(缺 allow-forms)等能力被拦截。<script setup lang="ts">
/** 场景:sandbox 安全限制——仅授予 allow-scripts,弹窗 / 表单等能力被拦截 */
import { BizEmbedPage } from 'vue3-ep-biz-lib'
/* sandbox=['allow-scripts']:脚本可执行;window.open(缺 allow-popups)、表单提交(缺 allow-forms)被拦截 */
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
`<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133;font-size:13px}button{padding:4px 12px;margin-right:8px;cursor:pointer}#r{margin-top:12px;color:#f56c6c;word-break:break-all}#s{color:#67c23a}</style></head><body><h4 style="margin:0 0 10px">sandbox 受限页面</h4><button onclick="tryOpen()">尝试 window.open</button><form action="https://example.com" target="_blank" style="display:inline"><button type="submit">提交表单</button></form><p id="s"></p><p id="r">(尚未操作)</p><script>document.getElementById('s').textContent='脚本已执行(allow-scripts 生效)';function tryOpen(){var w=window.open('about:blank');document.getElementById('r').textContent=w?'window.open 成功(未被拦截)':'被 sandbox 拦截:未授予 allow-popups'}<\/script></body></html>`,
)}`
</script>
<template>
<div>
<BizEmbedPage
:src="src"
title="第三方挂件(sandbox 受限)"
:height="240"
:sandbox="['allow-scripts']"
/>
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
sandbox 数组 join(' ') 后写入 iframe sandbox 属性,缺省或空数组则不添加;另:未声明的原生属性(name
/ id / referrerpolicy / allow 等)会随 attrs 一并透传到 iframe 元素,见「原生属性透传」示例。
</p>
</div>
</template>
显示代码
原生属性透传
(加载后回读 iframe 原生属性)
attrs 同时绑定根元素(class 合并到根与 iframe);事件(如 @load 前的 onClick 等)仅在根元素绑定, 避免与 iframe 冒泡双触发。
未声明的 attrs(
name / id / referrerpolicy / allow 等)在绑定根元素的同时,也会过滤事件后透传到 iframe 元素(事件仅在根元素绑定,避免冒泡双触发);可通过 expose 的 getFrame() 读取 iframe 原生元素验证。<script setup lang="ts">
/** 场景:原生属性透传——name / id / referrerpolicy / allow / class 等落到 iframe 元素 */
import { ref } from 'vue'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
import type { BizEmbedPageInstance } from 'vue3-ep-biz-lib'
const pageRef = ref<BizEmbedPageInstance | null>(null)
const info = ref('(加载后回读 iframe 原生属性)')
const src = `data:text/html;charset=utf-8,${encodeURIComponent(
'<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>透传验证页</h3><p>下方回读的 name / referrerpolicy / allow / class 均来自透传到 iframe 的原生属性。</p></body></html>',
)}`
const onLoad = (): void => {
const frame = pageRef.value?.getFrame()
if (frame === null || frame === undefined) return
info.value = `name=${frame.name} / referrerpolicy=${frame.referrerPolicy} / allow=${frame.getAttribute('allow')} / class=${frame.className}`
}
</script>
<template>
<div>
<BizEmbedPage
ref="pageRef"
:src="src"
:height="200"
name="biz-attrs-frame"
referrerpolicy="no-referrer"
allow="fullscreen"
class="attrs-demo-frame"
@load="onLoad"
/>
<p style="margin: 10px 0 0; font-size: 12px; color: var(--el-text-color-secondary); word-break: break-all">
{{ info }}
</p>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--el-text-color-secondary)">
attrs 同时绑定根元素(class 合并到根与 iframe);事件(如 @load 前的 onClick 等)仅在根元素绑定,
避免与 iframe 冒泡双触发。
</p>
</div>
</template>
显示代码
自定义加载与失败插槽
自定义加载 / 失败态
#loading 自定义加载态、#error 自定义失败占位(作用域参数 retry 为重试函数)。示例中内联页脚本以同步忙等延迟约 1.5 秒触发 load,便于观察自定义加载态;点击按钮切到拒嵌地址观察自定义失败态。<script setup lang="ts">
/** 场景:自定义加载与失败插槽——#loading 自定义加载态,#error 作用域参数 retry 重试 */
import { ref } from 'vue'
import { ElButton } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { BizEmbedPage } from 'vue3-ep-biz-lib'
/* 忙等约 0.7 秒后触发 load:1500ms 超时内可完整观察自定义加载态 */
const slowSrc = `data:text/html;charset=utf-8,${encodeURIComponent(
`<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:system-ui,sans-serif;padding:24px;background:#f5f7fa;color:#303133}p{margin:6px 0;color:#606266;font-size:13px}</style></head><body><h3>慢加载内嵌页</h3><p>脚本忙等约 0.7 秒后才触发 load,用于展示自定义 loading 插槽。</p><script>var t=Date.now();while(Date.now()-t<700);<\/script></body></html>`,
)}`
const current = ref(slowSrc)
const switchToSlow = (): void => {
current.value = slowSrc
}
const switchToBlocked = (): void => {
current.value = 'https://example.com'
}
</script>
<template>
<div>
<BizEmbedPage :src="current" title="自定义加载 / 失败态" :height="240" :timeout="1500">
<template #loading>
<div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--el-color-primary)">
<Loading style="font-size: 18px; animation: biz-embed-page-spin 1s linear infinite" />
自定义加载:页面准备中,请稍候……
</div>
</template>
<template #error="{ retry }">
<div style="font-size: 13px; color: var(--el-color-danger)">自定义失败占位:嵌入页迟迟没有就绪</div>
<ElButton size="small" type="danger" plain @click="retry()">自定义重试</ElButton>
</template>
</BizEmbedPage>
<div style="margin-top: 10px; display: flex; gap: 8px">
<ElButton size="small" @click="switchToSlow">切到慢加载页(看 #loading)</ElButton>
<ElButton size="small" @click="switchToBlocked">切到可能拒嵌地址(看 #error)</ElButton>
</div>
</div>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| src | string | — | 嵌入页面地址 |
| title | string | '' | 标题:frame 模式标题条 / 弹层模式标题 |
| mode | 'frame' | 'dialog' | 'drawer' | 'frame' | 展示模式 |
| v-model:visible | boolean | false | dialog / drawer 模式弹层开合(frame 模式忽略) |
| height | number | string | 480 | iframe 容器高度(number 按 px) |
| loading-text | string | '页面加载中…' | 加载遮罩文案 |
| timeout | number | 10000 | 加载超时毫秒数,超时显示失败占位;0 关闭检测 |
| sandbox | string[] | — | iframe sandbox 权限列表;空数组 / 缺省不加属性 |
| message-origin | string[] | ['*'] | postMessage 来源 origin 白名单('*' 不过滤,生产应收紧) |
| dialog-width | number | string | '60%' | 弹层尺寸:dialog 宽度 / drawer size |
| keep-alive | boolean | false | 缓存已加载地址,src 切回不重开 loading 与超时检测 |
| max-cache | number | 5 | keep-alive 缓存上限(LRU 淘汰) |
Events
| 事件名 | 参数 | 说明 |
|---|---|---|
| load | — | iframe 加载完成(遮罩消失) |
| error | (payload: { type: 'timeout' }) | 加载超时(目标站点可能拒嵌) |
| message | (data: unknown) | 收到白名单放行的 postMessage 数据 |
| update:visible | (value: boolean) | dialog / drawer 模式弹层开合变化 |
Slots
| 插槽名 | 参数 | 说明 |
|---|---|---|
| header | 弹层模式下透传 ElDialog / ElDrawer 头部作用域(含 close) | 标题区:frame 模式标题条 / 弹层模式头部,缺省显示 title |
| loading | — | 自定义加载遮罩内容 |
| error | (retry: () => void) | 自定义失败占位,retry 为重试函数 |
| footer | — | 底部区域:frame 模式容器底部 / 弹层模式 footer |
Expose
| 名称 | 类型 | 说明 |
|---|---|---|
| postMessage | (data: unknown, targetOrigin?: string) => void | 向内嵌 iframe 发送 postMessage,targetOrigin 默认 '/'(同源放行,跨源传具体 origin) |
| reload | () => void | 强制重载(重置加载 / 超时状态并重建 iframe) |
| getFrame | () => HTMLIFrameElement | null | 获取 iframe 原生元素(未挂载时为 null) |