当 Vue 遇上 iframe:一个安全监测平台的三维地图集成实践
当 Vue 遇上 iframe:一个安全监测平台的三维地图集成实践
本文基于一个真实运行的智慧安全监测管理平台(Vue 2.6 + Ant Design Vue,JeecgBoot 脚手架),总结 iframe 在中后台/大屏项目中的技术选型思考、典型使用场景、postMessage 跨域通信的协议设计,以及我们在生产环境中踩过的坑和对应的优化方案。
一、背景:为什么一个 Vue 项目里到处是 iframe
我们的平台做的是地质灾害监测预警:隐患点管理、监测设备(采集器/传感器/摄像头)接入、临灾预警、BIM 模型管理。技术栈是 Vue 2.6 + Ant Design Vue 1.x,基于 JeecgBoot 脚手架二次开发。
这个项目有一个鲜明的特点:三维可视化能力全部由独立团队、独立仓库、独立部署的引擎承担——Cesium 三维地图引擎、BIM 引擎都是单独的静态站点。主平台与它们域名不同、技术栈不同、发版节奏也不同。
于是 iframe 成了最自然的选择:
- 隔离复杂度:三维引擎有自己的依赖体系(Cesium、krpano 等),塞进主项目会让构建产物膨胀到不可维护;
- 独立部署:地图引擎更新不需要重新构建主平台,反之亦然;
- 跨技术栈复用:同一套地图引擎同时服务大屏端和管理端,一处更新两端生效。
除此之外,中后台项目还有一些"被动"使用 iframe 的场景:菜单里配置的外链页面、xxl-job 控制台、积木报表、文件在线预览……盘点下来,全项目有 30+ 个文件涉及 iframe。
二、技术选型:为什么最终是 iframe
"用 iframe"这个结论不是拍脑袋得出的。把三维引擎集成进 Vue 主平台,摆在桌面上的是三条路线,我们逐一评估后做了排除法。这段决策过程比结论本身更值得分享——因为下次你面对的约束条件可能不同。
2.1 三条候选路线的对比
路线 A:npm 包集成——把 Cesium 直接打进主项目
最"正统"的前端思路:npm install cesium,组件里直接 new Cesium.Viewer()。
- 优点:同进程调用,没有通信成本,可以共享类型、共享状态、共享组件,地图实体和业务数据天然双向绑定。
- 缺点:Cesium 不是普通的 npm 包——它带着海量静态资源(瓦片、Workers、Assets),webpack 需要专门配置 CopyWebpackPlugin 和 define;构建产物体积暴涨数倍;更致命的是引擎升级等于主平台全量回归 + 发版,两个团队的节奏被强行绑死。Vue 2 老项目 + 重型 WebGL 引擎的组合,在依赖冲突上也是重灾区。
结论:适合引擎与业务由同一团队、同一技术栈维护的项目。我们的地图引擎是独立团队的非 Vue 成品,此路不通。
路线 B:微前端——qiankun / single-spa
项目立项时微前端正火,我们也认真评估过:
- 优点:JS 沙箱可控、样式隔离可选、通信机制丰富(props 注入、全局状态、事件总线)、可以共享公共依赖。
- 缺点:微前端的前提是子应用可改造——需要暴露
bootstrap/mount/unmount生命周期、配置 CORS、适配 publicPath。地图引擎是独立团队的成品,让引擎团队按 qiankun 规范重构,协调成本和回归成本都远超收益。此外 qiankun 的 JS 沙箱对 WebGL 重交互应用有已知坑:全局变量劫持、定时器/事件泄漏排查困难,而三维引擎恰恰是全局副作用最多的那类应用。(wujie、micro-app 这类更年轻的方案当时尚未发布或未成熟,这是时代局限。)
结论:微前端解决的是"同一技术体系内拆分巨石应用"的问题,不适合"集成不可改造的外部成品引擎"。
路线 C:iframe
- 优点:对被集成方零改造要求;浏览器级硬隔离(JS、CSS、DOM 完全隔离,不存在样式互染和全局变量冲突);天然支持跨域;独立部署、独立发版、天然灰度。
- 缺点:通信只能走 postMessage(序列化开销、无类型共享);无法共享组件和登录态之外的任何状态;每个 iframe 是独立的浏览上下文,内存开销大;移动端和 SEO 体验差。
我们的决策依据归结为四个事实:引擎是独立团队的成品(不可改造)、发版节奏不同步(引擎周更、平台月更)、同一引擎要服务多个宿主(大屏端 $iframeURL、管理端 $systemIframeSrc)、父子交互需求可枚举(最终收敛为 20+ 个消息 key)。四条全部指向 iframe。
2.2 通信方案:选了 iframe 就等于选了 postMessage
集成方式定了之后,通信方案其实没得选,但排除过程仍然值得记录:
| 方案 | 结论 | 原因 |
|---|---|---|
直接调用 iframe.contentWindow.xxx() |
排除 | 跨域下浏览器直接抛 SecurityError,同源才可用 |
document.domain 降域 |
排除 | 要求同父域,且 Chrome 115+ 已默认禁用(见 5.4) |
| BroadcastChannel | 排除 | 只能同源通信,我们是跨域 |
| SharedWorker 中转 | 排除 | 不解决跨域,且调试成本高 |
| WebSocket 服务端中转 | 排除 | 父子页面明明在同一个浏览器里,却要绕道服务器,凭空增加延迟和故障点 |
| postMessage | 唯一正解 | 浏览器原生、跨域、零基础设施 |
这个排除法揭示了一个常被低估的事实:选 iframe 不是选了一个"嵌入页面的标签",而是选了一整套约束——通信退化为异步消息、数据退化为可序列化值、调用退化为协议约定。所以真正的功课不在 iframe 标签本身,而在通信协议的设计(第四节展开)。
2.3 消息格式:JSON 字符串 vs 结构化克隆
postMessage 的 data 既可以直接传对象(浏览器结构化克隆),也可以传 JSON 字符串。我们项目里两种都出现了——onMessage.js 的接收端被迫写了双分支兼容:
if ('object' == typeof ev.data) {
data = JSON.parse(JSON.stringify(ev.data)) // 对象:结构化克隆来的
} else if ('string' == typeof ev.data && ev.data) {
data = JSON.parse(ev.data) // 字符串:JSON 序列化来的
}
这是典型的历史包袱:地图引擎早期版本按字符串发,后来部分消息改传对象,接收端只能全兼容。复盘下来有三条经验:
- 协议要一开始就统一。双格式意味着每个接收端都要写双分支解析,且对象分支里
JSON.parse(JSON.stringify())的深拷贝在高频消息下是额外的性能税。 - JSON 字符串并非坏选择:跨端一致性好(对端无论是 jQuery 时代的旧代码还是新框架都能处理)、调试时直接 console 可读。结构化克隆的优势(免序列化、支持 Date/Map 等类型)在我们的场景里用不上。
- 消息类型应该集中定义。我们 20+ 个消息 key 散落在各组件的字符串字面量里,靠口头约定维护。更好的做法是把消息协议(key 常量、payload 结构)抽成一个 npm 包或 git submodule,父工程和引擎工程共享同一份定义——这是 iframe 架构下唯一可行的"类型共享"手段。
2.4 选型的本质:隔离边界放在哪一层
跳出 iframe 本身,这个项目里其实还有一个平行的选型案例:海康监控视频的接入。我们没有用 iframe 嵌海康平台,而是引入 jsWebControl 本地插件——它把隔离边界放在了本地进程层(浏览器插件起一个 exe 窗口,WebSocket 通信),而不是 iframe 的浏览上下文层。
两个案例对照,可以提炼出选型的本质:集成一个不可改造的外部能力时,真正要决策的是"隔离边界放在哪一层"——
| 隔离边界 | 典型方案 | 通信方式 | 适用 |
|---|---|---|---|
| 无边界(同进程) | npm 包 | 直接调用 | 同团队同栈 |
| 浏览上下文 | iframe | postMessage | 跨域 Web 成品 |
| JS 沙箱 | qiankun/wujie | 沙箱注入 | 可改造的子应用 |
| 本地进程 | 浏览器插件/WebControl | WebSocket | 需要本地硬件能力 |
如果宿主和子应用都是自己团队可控的,今天的新项目值得重新评估 wujie/micro-app;但"外部成品引擎 + 跨团队 + 跨域"这个前提不变,iframe 依然是最务实的解。
三、四类典型场景
场景 1:菜单外链——配置驱动的 iframe 页面
JeecgBoot 的菜单支持直接配置一个 URL,路由系统会把这类菜单渲染成 iframe 页面(src/components/layouts/IframePageView.vue):
<template>
<iframe :id="id" :src="url" frameborder="0" width="100%" height="800px" scrolling="auto"></iframe>
</template>
<script>
methods: {
goUrl() {
let url = this.$route.meta.url
let id = this.$route.path
this.id = id
if (url !== null && url !== undefined) {
// url 支持通过 ${token} 方式传递当前登录 TOKEN
let tokenStr = '${token}'
if (url.indexOf(tokenStr) != -1) {
let token = Vue.ls.get(ACCESS_TOKEN)
this.url = url.replace(tokenStr, token)
} else {
this.url = url
}
// 新窗口打开时关闭当前 tab,改为 window.open
if (this.$route.meta.internalOrExternal == true) {
this.closeCurrent()
window.open(this.url)
}
}
},
}
</script>
两个值得注意的设计:
${token}占位符:外链系统如果和主平台共享认证体系,可以在 URL 里写http://xxx/system?token=${token},组件渲染时自动替换为当前登录态的 token。代价是 token 会出现在 URL 里——它会被记进浏览器历史、服务器访问日志。更稳妥的做法是 cookie 共享(同父域下)或网关层注入,这一点我们在文末的安全清单里再展开。internalOrExternal双模式:同一个菜单配置,既支持内嵌 iframe,也支持window.open新窗口打开,通过关闭当前 tab(closeCurrent)保证用户体验一致。
场景 2:大屏三维地图——本文的主角
大屏页面(src/views/screen/index.vue)的骨架非常简单:一个全屏 iframe 承载三维地图,主平台的 UI(顶部导航、左右侧栏、搜索框、图例、各类弹窗)全部以浮层的形式叠在 iframe 之上:
<!-- 地图容器:内嵌 iframe 展示地图(与地图项目通信使用 postMessage) -->
<iframe
id="sreenIframe"
frameborder="0"
:src="$iframeURL"
allow="geolocation; microphone; camera"
referrerpolicy="no-referrer-when-downgrade"
/>
iframe 的 src 来自全局配置,按环境区分(src/main.js,下例地址已脱敏):
let envNodeLocation =
process.env.NODE_ENV == 'development'
? 'http://dev-map.example.local:7788/index.html'
: 'https://map-engine.example.com/index.html'
Vue.prototype.$iframeURL = envNodeLocation
主页面与地图引擎之间是完全跨域的,所有交互只能走 postMessage。这是全项目最复杂、也最值得分享的一块,第四节单独展开。
场景 3:BIM 模型管理与坐标拾取
BIM 管理模块同样内嵌独立的 BIM 引擎,但用法和大屏不同——它需要双向数据绑定:左侧树形列表(模型/主机/传感器/隐患点标记)与三维场景联动,还要支持在地图上双击拾取经纬度回填表单(src/mixins/getMapPositionMixins.js):
onWindowPostMessage() {
window.addEventListener('message', ev => {
if (!ev.data) return;
let data = {};
if ('object' == typeof ev.data) {
data = JSON.parse(JSON.stringify(ev.data));
} else if ('string' == typeof ev.data && ev.data) {
data = JSON.parse(ev.data);
}
switch (data.key) {
case 'loadedatat': // 地图就绪,主页面推送初始化数据
this.postInitData();
break;
case 'doubleClick': // 用户在地图上双击,回传拾取的坐标
this.getPositionModal = false;
this.form.changePos = `${data.pos.lon},${data.pos.lat}`;
break;
}
});
}
"在弹窗里打开地图 → 用户点选坐标 → 坐标回填业务表单"这个交互,用 iframe + postMessage 实现起来意外地顺滑——业务表单完全不需要知道地图引擎的 API。
场景 4:第三方系统与在线预览
- xxl-job 控制台(
src/views/system/xxlJob.vue):iframe 嵌入,加载完成后把 cookie postMessage 过去做免登; - 积木报表(
IframeFReportView.vue):报表平台整体嵌入; - 文件在线预览:后端提供
pdfPreviewIframe接口,各类附件预览统一走 iframe。
这类场景的共同点是:对方系统开箱即用,但改造它的成本远大于嵌入它的成本。iframe 是"集成不改造"的最短路径。
四、核心:postMessage 双向通信的协议设计
4.1 消息协议:一个 key 一个约定
跨域 iframe 无法直接调用对方的方法,所有交互都要序列化成消息。我们约定了一个极简协议:
{ key: '消息类型', value: '业务数据', ...附加字段 }
实际代码中消息是 JSON.stringify 后的字符串,例如主页面让地图飞到某个监测点:
receiver.postMessage(
JSON.stringify({ key: 'toViewPoint', value: data, status: status_ }),
this.$postMessageHref
)
全项目的消息 key 梳理下来有 20+ 个,可以归为三类:
| 方向 | key 示例 | 用途 |
|---|---|---|
| 父 → 子 | toLocation、toViewPoint |
相机飞行定位 |
| 父 → 子 | writeMonitorPoint、filterMonitorTarget |
推送/筛选监测点数据 |
| 父 → 子 | mapUtils、legendModule |
控制图层显隐 |
| 子 → 父 | loadedatat |
地图就绪信号(握手) |
| 子 → 父 | cameraMoveEnd |
相机移动结束(视高判断) |
| 子 → 父 | showMointorWindow、deviceHostWindow_MOVE |
实体交互事件 + 屏幕坐标 |
| 子 → 父 | doubleClick |
坐标拾取 |
4.2 就绪握手:loadedatat
iframe 加载是异步的,而且三维引擎初始化(加载瓦片、创建场景)耗时可能长达数秒。父页面什么时候可以开始推送数据?
我们的方案是子页面就绪后主动广播 loadedatat,父页面收到后再拉取监测点数据并推送:
case 'loadedatat':
setTimeout(() => {
this.getQuery('') // 获取所有监测点
})
this.toTargetScreen() // 飞到初始视角
break
这本质上是一个简易版的握手协议。如果消息更复杂,建议演进为带 msgId + ack 的请求响应模型,但对于我们的场景,"就绪信号 + 单向推送"已经够用。
4.3 浮窗跟随:地图坐标 → 屏幕坐标
大屏上有个体验要求很高的功能:鼠标 hover 地图上的监测点/设备时,要在旁边弹出信息浮窗,且浮窗要跟随实体移动。
难点在于:浮窗是父页面的 DOM,实体在子页面的 WebGL 场景里。解法是地图引擎在每帧渲染时把实体的屏幕投影坐标 postMessage 出来:
case 'deviceHostWindow_MOVE':
if (!isAddeventWindow) return
let clientWidth = document.body.clientWidth
let clientHeight = document.body.clientHeight
let left = data.pos.x || 0
let top = data.pos.y || 0
// 边界 clamp:浮窗靠近右边缘时翻转到左侧,靠近顶部时翻转到下方
if (data.pos.x + 400 + 340 >= clientWidth) {
left = left - 340
}
if (data.pos.y - 310 <= 0) {
top = data.pos.y + 320 + 40
} else {
top = data.pos.y - 310
}
if (left <= 450) left = 450
if (top <= 90) top = 90
deviceHostWindow.style.cssText =
`left:${left}px;top:${top}px;opacity:1;z-index:3700;transform: translate(0%, -100%);`
break
父页面只做两件事:坐标换算(翻转 + clamp)和 DOM 定位。地图引擎只负责报坐标,不感知父页面的 UI 结构——这个职责划分让两端可以独立演进。
五、踩坑与优化:生产环境教我们的事
5.1 高频消息把页面搞卡了:消息队列 + requestAnimationFrame
cameraMoveEnd、*_MOVE 这类消息在相机移动时每帧触发,直接在 onmessage 回调里操作 DOM,主线程很快就被打满。我们的优化方案是"队列攒批 + rAF 消化"(src/mixins/onMessage.js):
onMessages() {
let messageQueue = []
let processing = false
const processMessage = (ev) => {
if (!ev.data) return
// ... JSON 解析与过滤(见下文)
messageQueue.push({ data, ev })
if (processing) return // 已有批次在排队,直接入队
processing = true
requestAnimationFrame(() => {
while (messageQueue.length > 0) {
const { data: msgData } = messageQueue.shift()
this.handleMessage(msgData)
}
processing = false
})
}
window.onmessage = processMessage
}
效果:无论消息到达频率多高,DOM 操作最多每帧一次,且同帧内的多条消息合并处理。这是"生产者(地图引擎)速率不可控"场景下的经典背压手段。
5.2 消息噪声:不是每个 message 事件都是发给你的
上线后我们发现 onmessage 收到大量"垃圾":
- webpack 热更新:开发环境下 HMR 会通过 message 通道广播;
- 浏览器插件:翻译、密码管理类插件会向页面注入消息;
- 非 JSON 数据:
JSON.parse直接抛异常。
处理原则是白名单式解析,静默忽略一切解析不了的消息:
// 过滤掉 webpack 热更新消息
if (typeof ev.data === 'string' && ev.data.trim().startsWith('webpack')) {
return
}
let data = {}
try {
if ('object' == typeof ev.data) {
data = JSON.parse(JSON.stringify(ev.data))
} else if ('string' == typeof ev.data && ev.data) {
const trimmed = ev.data.trim()
// 只处理有效的 JSON 字符串(以 { 或 [ 开头)
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
data = JSON.parse(ev.data)
} else {
return
}
}
} catch (error) {
// JSON 解析失败,可能是 webpack 热更新或其他非应用消息,静默忽略
return
}
另外,一定要校验 event.origin。当前代码没有做 origin 校验(详见 5.5 的安全清单),任何页面嵌入我们的页面或恶意页面向其 postMessage,都可能被消息分支处理。生产环境建议在 processMessage 入口加一行:
const ALLOWED_ORIGINS = ['https://map-engine.example.com'] // 地图引擎的真实 origin
if (ev.origin && !ALLOWED_ORIGINS.includes(ev.origin)) return
5.3 监听器的生命周期:两种写法,一个坑
项目里存在两种监听写法,各有问题:
写法 A(onMessage.js):window.onmessage = processMessage,销毁时置 null:
beforeDestroy() {
if (this._messageHandler) {
window.onmessage = null
this._messageHandler = null
}
}
问题:onmessage 是单播属性,多个组件同时监听会互相覆盖。
写法 B(MaterialEdit.vue):window.addEventListener('message', ...),但销毁时只是 this.onMessage = null——监听器并没有被移除,组件销毁后回调仍然持有组件引用,这是典型的内存泄漏,而且组件反复开关会重复注册监听器。
正确姿势是保存引用、成对移除:
mounted() {
this._onMessage = (ev) => { /* ... */ }
window.addEventListener('message', this._onMessage)
},
beforeDestroy() {
window.removeEventListener('message', this._onMessage)
}
如果项目里监听 message 的组件很多,更彻底的方案是封装一个全局消息总线:单一监听器 + 按消息 key 订阅,组件只管订阅/退订。
5.4 document.domain 已死:postMessage 当立
老项目里跨域 iframe 通信曾流行设置 document.domain 让两个页面"同域"后直接互调。但 Chrome 自 115 版本起,document.domain 赋值默认失效(origin-keyed agent clusters),控制台会刷出警告。
我们的地图引擎早期版本也设置过 document.domain,迁移到 postMessage 后,主平台专门加了一个工具(src/utils/suppressDomainWarning.js)过滤残留的警告:
// 重写 console.warn 过滤 document.domain 相关警告
if (message.includes('document.domain') &&
(message.includes('mutation is ignored') || message.includes('agent cluster'))) {
return // 静默处理
}
结论:新项目不要再碰 document.domain,postMessage 是唯一正解。顺带一提,这个文件还顺手过滤了 ECharts 的废弃 API 警告——生产环境保持控制台干净,排查问题时能少很多噪声。
5.5 安全清单:三个待改进项
以这个项目为镜,总结 iframe + postMessage 的三个安全要点:
targetOrigin不要用'*'。当前代码Vue.prototype.$postMessageHref = '*',意味着消息内容(含监测点业务数据)可能泄露给任何嵌入了我们页面的恶意站点。应改为明确的 origin 白名单。- 接收端校验
event.origin(见 5.2)。 - token 不要走 URL。
${token}占位符方案虽然方便,但 token 会进浏览器历史和日志。优先同父域 cookie 共享,或后端签发一次性短时 ticket 换 token。
5.6 体验细节:iframe 的高度与加载
IframePageView里height="800px"是写死的,小屏溢出、大屏留白。建议改为容器自适应(height: calc(100vh - headerHeight)或 flex 布局)。- iframe 没有加载态,地图引擎初始化的数秒里用户面对白屏。可以在 iframe 上叠一个 loading 遮罩,收到
loadedatat后再淡出——我们已有的握手协议正好可以复用。 - 多页签(keep-alive)下 iframe 页面切换时会重新加载(iframe 不受 Vue keep-alive 保护),如果外链系统没有做状态持久化,体验会打折。方案要么放弃 keep-alive 改为 v-show 缓存 iframe 容器,要么接受重载。
六、总结:iframe 的最佳实践清单
适合用 iframe 的信号:对方是独立部署的系统/引擎、技术栈异构、发版节奏不同、"集成不改造"是硬需求。
不适合的信号:需要深度控制对方 UI 细节、高频大数据量交互(序列化开销)、强 SEO 需求。
如果用,请带上这份清单:
技术选型
通信协议
安全
工程化
架构
iframe 常被戏称为"上古技术",但在"多团队异构系统快速集成"的现实约束下,它依然是最务实的选项之一。选型时想清楚"隔离边界放在哪一层",落地时把通信协议、性能、安全这三件事做扎实——希望这篇来自生产一线的总结能帮你少踩几个坑。
浙公网安备 33010602011771号