当 XML 学会调 JS:krpano 全景引擎与 Vue 的双向通信实践
当 XML 学会调 JS:krpano 全景引擎与 Vue 的双向通信实践
本文基于一个真实运行的安全监测平台(Vue 2.6 + Ant Design Vue),讲清一件事:krpano 全景引擎(XML 驱动)是如何与宿主 HTML/Vue 页面双向通信的。上一篇我们聊了 iframe + postMessage 的跨文档通信,这次是另一种更"古老"也更有意思的模式——引擎嵌在当前文档里,XML 配置反过来调用页面 JS。文中所有代码均来自生产项目,文末附踩坑复盘与最佳实践清单。
一、背景:全景图模块为什么需要"XML 调 JS"
平台里有一块全景业务:对隐患点拍摄球形全景图,在全景中标注场景热点(点击切换到另一个场景)和设备热点(点击查看监测设备数据),管理端还要支持在全景里拖拽热点、拾取视角坐标回填表单。
渲染引擎选了 krpano——全景行业的老牌引擎。它有一个鲜明的架构特点:一切行为皆 XML。场景、图片、热点是 XML 元素,连"函数"都是 XML 里 <action> 标签定义的命令序列。项目里与之相关的三个关键文件:
| 文件 | 角色 |
|---|---|
public/index.html → <script src="js/krpano.js"> |
引擎本体,向 window 暴露 embedpano / removepano |
public/template_action.xml |
自定义 action 库——本文主角,XML 与 JS 的桥梁就定义在这里 |
public/vtourskin.xml |
krpano 官方皮肤(导航按钮、缩略图等) |
业务需求很快把通信问题摆上桌面:
- 用户在全景里点击热点 → 需要唤起 Vue 的编辑弹窗(引擎 → 页面)
- 用户在工具箱选中图标 → 需要在全景当前视角创建热点(页面 → 引擎)
- 用户拖动全景选取初始视角 → 视角值要回填 Vue 表单(双向闭环)
也就是说,XML 世界和 JS 世界必须互相调用。krpano 给出了两条通道。
二、通信全景图:两条方向相反的通道
先给全景架构,后文逐条展开:
┌──────────────── Vue 组件(宿主 HTML)────────────────┐
│ │
│ 下行通道:krpano.call() / krpano.set() │
│ ──────────────────────────────────────▶ │
│ │
│ 上行通道:XML action 里 js() 调用全局函数 │
│ ◀────────────────────────────────────── │
│ │
└────────────────────────┬───────────────────────────┘
│
document.getElementById('krpanoSWFObject')
│
┌────────────────────────▼───────────────────────────┐
│ krpano 引擎(XML 驱动) │
│ <action name="..."> ... js(全局函数(参数)) ... │
└────────────────────────────────────────────────────┘
- 下行(HTML → XML):
krpano.call('action名(参数)')执行 XML 里定义的 action,krpano.set('hotspot[x].属性', 值)直接写引擎状态; - 上行(XML → HTML):action 内部用
js(函数名(参数))调用宿主页面的 window 全局函数。
没有 postMessage、没有事件总线,就是最朴素的两条原生通道。妙就妙在:XML 这个"配置文件"获得了调用 JS 的能力,JS 也获得了执行 XML"函数"的能力。
三、初始化:embedpano 与 loadxml
通信的前提是拿到引擎实例。krpano 的初始化分两步(src/views/bim/component/XmlPreview.vue):
mounted() {
this.$nextTick(() => {
window.embedpano({
xml: 0, // 关键:0 表示不加载任何默认 XML
target: 'pano', // 挂载目标 div 的 id
html5: 'only', // 强制 HTML5 渲染(不用 Flash)
mobilescale: 1.0,
passQueryParameters: 'startscene,startlookat',
initvars: { KRPANOPATH: process.env.NODE_ENV === 'development' ? '/' : '' },
})
this.initKrpanoReady(this.info.panoSceneXml)
})
}
两个细节值得展开:
1. xml: 0 是整个设计的支点。 正常用 krpano 会传一个 XML 文件路径。这里传 0,等于先启动一个"空引擎",XML 稍后再注入——因为全景的 XML 是后端数据库里存的字符串(每个场景一份,随热点增删动态变化),而不是静态文件。
2. 引擎实例靠 id 约定获取。 embedpano 会在目标容器里创建一个接口对象,默认 id 为 krpanoSWFObject(历史遗留命名,Flash 时代的产物):
initKrpanoReady(tempXml) {
this.krpano = document.getElementById('krpanoSWFObject')
// ...
this.krpano.call(`loadxml(${tempXml})`)
}
拿到实例后,loadxml() 把后端返回的 XML 字符串动态注入引擎——这就是下行通道的第一次使用。
3. 路径重写:XML 里的相对路径要按环境改写。 后端存的 XML 里引用 template_action.xml、vtourskin.xml、images/ 都是相对路径,而前端开发环境与生产环境的静态资源根路径不同,于是有了三连 replaceAll:
tempXml = tempXml.replaceAll(`url="template_action.xml"`, `url="${window._CONFIG.krpanoPath}template_action.xml"`)
tempXml = tempXml.replaceAll(`url="vtourskin.xml"`, `url="${window._CONFIG.krpanoPath}vtourskin.xml"`)
tempXml = tempXml.replaceAll(`url="images/`, `url="${window._CONFIG.krpanoPath}images/`)
土办法,但有效。本质是在做"XML 模板渲染"——后端存模板,前端按环境补全上下文。
四、上行通道:XML 里的 js() 全局函数桥
4.1 XML 侧:action 里的 js() 调用
看 public/template_action.xml 的核心片段:
<!-- 热点点击弹出编辑框 -->
<action name="hotspot_click_edit">
js(handleClickHotSpotsEdit(get(hotspot_title),get(hotspot_type),get(print_ath),get(print_atv),get(url)));
</action>
<!-- 热点点击切换场景 -->
<action name="hotspot_click_scene" scope="local" args="scene_id, hotspot_code">
js(handleClickHotSpotsScene(get(scene_id),get(hotspot_code)));
hotspot_get_ath_atv();
</action>
<!-- 热点点击设备列表 -->
<action name="hotspot_click_device" scope="local" args="collector_id, host_id, obj_type, hotspot_title, hotspot_code">
js(handleClickHotSpotsDevice(get(collector_id),get(host_id),get(obj_type),get(hotspot_title),get(hotspot_code)));
hotspot_get_ath_atv();
</action>
<!-- 获取当前视角的值 -->
<action name="view_get_toh_tov">
copy(print_fov, view.fov);
copy(print_hlookat, view.hlookat);
copy(print_vlookat, view.vlookat);
roundval(print_fov, 1);
roundval(print_hlookat, 3);
roundval(print_vlookat, 3);
js(handleGetViewTohTov(get(print_hlookat),get(print_vlookat),get(print_fov)))
</action>
js() 是 krpano 提供的宿主桥:把括号里的表达式求值后,直接调用宿主页面 window 上的同名函数。get(xxx) 取当前作用域/热点的变量值作为参数。注意 view_get_toh_tov 里先 copy + roundval 做了数值规整——数据在过桥之前先在 XML 侧清洗好,JS 侧拿到的就是可直接入库的值,这是一个值得坚持的好习惯。
4.2 Vue 侧:把 methods 挂到 window 上
XML 只认 window 全局函数,而 Vue 的 methods 天然不在 window 上。组件的解法是在 beforeMount 里逐个挂载(XmlPreview.vue):
beforeMount() {
window['handleClickHotSpotsDevice'] = this.handleClickHotSpotsDevice
window['handleClickHotSpotsScene'] = this.handleClickHotSpotsScene
window['handleClickHotSpotsEdit'] = this.handleClickHotSpotsEdit
window['handleGetViewTohTov'] = this.handleGetViewTohTov
window['handleClickHotSpotsGetAthAtv'] = this.handleClickHotSpotsGetAthAtv
}
于是 XML 的 js(handleClickHotSpotsEdit(...)) 就能直达组件方法。五个桥函数的分工:
| 全局函数 | 触发源(XML action) | Vue 侧行为 |
|---|---|---|
handleClickHotSpotsEdit |
hotspot_click_edit |
打开热点编辑弹窗 |
handleClickHotSpotsScene |
hotspot_click_scene |
编辑模式开弹窗 / 浏览模式 loadscene 切场景 |
handleClickHotSpotsDevice |
hotspot_click_device |
编辑模式开弹窗 / 浏览模式查设备详情 |
handleClickHotSpotsGetAthAtv |
hotspot_get_ath_atv |
记录当前热点球面坐标 ath/atv |
handleGetViewTohTov |
view_get_toh_tov |
接收当前视角 hlookat/vlookat/fov |
注意 handleClickHotSpotsScene 的双模式设计——同一个上行回调,按 info.toolkit(是否编辑模式)分流:编辑模式下打开弹窗,浏览模式下反过来走下行通道 krpano.call('loadscene(...)') 切换场景。上行和下行在同一个方法里交汇。
五、下行通道:call() 与 set()
5.1 call():执行 XML action 或内置命令
// 执行自定义 action:获取当前视角(结果经 js() 回调返回)
this.krpano.call(`view_get_toh_tov()`)
// 内置命令:切换场景,带 2 秒缩放混合过渡
this.krpano.call(`loadscene(${sceneId}, null, MERGE, ZOOMBLEND(2.0, 2.0, easeInOutSine))`)
// 内置命令:镜头飞向某个热点
this.krpano.call(`looktohotspot(${hotspotCode},120)`)
call() 的参数就是一段 krpano action 代码字符串,自定义 action 和引擎内置命令一视同仁。
5.2 set():逐属性创建热点
工具箱创建热点是下行通道最密集的用法——addhotspot 后连打十几个 set():
handleCreateHotspot(val) {
this.krpano.call(`view_get_toh_tov()`) // ① 先取当前视角(上行回调写入 this.hlookat)
this.$nextTick(() => {
this.krpano.call(`addhotspot(hotspotname${val.toolId})`) // ② 创建热点
this.krpano.set(`hotspot[hotspotname${val.toolId}].url`, `${window._CONFIG.krpanoPath}${val.hotspotUrl}`)
this.krpano.set(`hotspot[hotspotname${val.toolId}].ath`, this.hlookat) // ③ 用刚取的视角当坐标
this.krpano.set(`hotspot[hotspotname${val.toolId}].atv`, this.vlookat)
this.krpano.set(`hotspot[hotspotname${val.toolId}].hotspot_title`, val.hotspotName)
this.krpano.set(`hotspot[hotspotname${val.toolId}].scale`, '0.6')
this.krpano.set(`hotspot[hotspotname${val.toolId}].zoom`, 'true')
// ④ 行为绑定:动画、标题、拖动、点击——全部指向 XML action
this.krpano.set(`hotspot[hotspotname${val.toolId}].onloaded`, 'hotspot_do_animation();hotspot_show_title();')
this.krpano.set(`hotspot[hotspotname${val.toolId}].ondown`, 'hotspot_drag();')
this.krpano.set(`hotspot[hotspotname${val.toolId}].onclick`, 'hotspot_click_edit();')
})
}
第 ④ 步是点睛之笔:JS 侧只负责"造数据",热点的行为全部委托回 XML action。动画(hotspot_do_animation 精灵图逐帧播放)、标题(hotspot_show_title)、拖动(hotspot_drag)、点击(hotspot_click_edit)都是 template_action.xml 里的能力。职责划分非常清晰:Vue 管业务数据与弹窗,XML 管三维交互。
六、三个完整闭环
闭环 A:点击热点 → 编辑弹窗(纯上行)
用户点击热点
→ 引擎触发 hotspot.onclick
→ 执行 XML action: hotspot_click_edit()
→ js(handleClickHotSpotsEdit(标题, 类型, ath, atv, url))
→ Vue 方法打开 HotspotEdit 弹窗
→ 表单提交 → 接口保存 → 重新 loadxml 刷新全景
闭环 B:选取初始视角(最典型的双向闭环)
场景编辑表单里有三个字段:水平视角 hlookat、垂直视角 vlookat、缩放 fov。让用户手填球面坐标是不可能的,于是做了"所见即所得"的拾取(SceneEdit.vue):
// 1. 用户 focus 视角输入框 → 打开全景弹窗,动态拼 XML 注入引擎
handleFocusAthAtv() {
this.lookatInfo.visible = true
this.$nextTick(() => {
window.embedpano({ xml: 0, target: 'pano', html5: 'only', /* ... */ })
const krpano = document.getElementById('krpanoSWFObject')
// 模板字符串拼出完整场景 XML,把表单当前值设为初始视角
var tempXml = `<krpano version="1.20.7" onstart="loadscene(scene1);">
<include url="template_action.xml" />
<scene name="scene1" thumburl="${sceneUrl}" title="场景1">
<view hlookat="${hlookat}" vlookat="${vlookat}" fovmin="${fovmin}" fovmax="${fovmax}" fov="${fov}" ... />
<preview url="${previewUrl}"/>
<image><cube url="${cubeUrl}" /></image>
</scene>
</krpano>`
krpano.call(`loadxml(${tempXml})`)
})
}
// 2. 用户拖到满意视角后点保存 → 下行:call XML action
handleSaveXMLLookat() {
const krpano = document.getElementById('krpanoSWFObject')
krpano.call(`view_get_toh_tov()`) // 触发 XML 侧取值并 js() 回调
this.lookatInfo.visible = false
window.removepano('pano')
}
// 3. 上行:XML action 把视角值送回来,写回表单
handleGetViewTohTov(hlookat, vlookat, fov) {
this.formData.hlookat = hlookat
this.formData.vlookat = vlookat
this.formData.fov = fov
}
一个来回:JS call XML → XML 取值 → XML call JS → JS 写表单。两条通道首尾相接,这就是"XML 与 HTML 通信交互"的完整形态。
闭环 C:拖动热点 → 坐标回传(XML 内部计算 + 上行回传)
拖动热点最难的部分——球面坐标与屏幕坐标的实时换算——完全在 XML action 里解决:
<action name="hotspot_drag">
spheretoscreen(ath, atv, hotspotcenterx, hotspotcentery, calc(mouse.stagex LT stagewidth/2 ? 'l' : 'r'));
sub(drag_adjustx, mouse.stagex, hotspotcenterx);
sub(drag_adjusty, mouse.stagey, hotspotcentery);
asyncloop(pressed,
sub(dx, mouse.stagex, drag_adjustx);
sub(dy, mouse.stagey, drag_adjusty);
screentosphere(dx, dy, ath, atv); <!-- 屏幕坐标 → 球面坐标,写回热点 -->
copy(print_ath, ath);
copy(print_atv, atv);
roundval(print_ath, 3);
roundval(print_atv, 3);
);
</action>
<action name="hotspot_get_ath_atv">
js(handleClickHotSpotsGetAthAtv(get(print_ath),get(print_atv)));
</action>
spheretoscreen / screentosphere 是引擎内置的坐标变换,asyncloop(pressed, ...) 在按住期间每帧执行。拖完后由 hotspot_get_ath_atv 把规整到 3 位小数的坐标 js() 回 Vue,存库时就是这个值。坐标换算这种引擎强相关的逻辑留在 XML,Vue 只接收最终结果——再次印证两边的职责边界。
七、踩坑复盘:生产环境教我们的事
7.1 全局函数只挂不摘:悬挂引用与单播覆盖
组件 beforeMount 挂了 5 个 window 函数,但 destroyed 里只清理了全景(window.removepano('pano')),没有摘除全局函数。后果:
- 组件销毁后
window.handleClickHotSpotsEdit仍持有已销毁组件的引用(内存泄漏 + 若被调用会操作僵尸组件); - 两个组件(
XmlPreview与XmlPreviewModel)挂载同名函数,后挂的覆盖先挂的——和 iframe 场景下window.onmessage单播覆盖是同一类问题。
正确姿势:
beforeMount() {
const bridge = {
handleClickHotSpotsEdit: this.handleClickHotSpotsEdit,
// ...
}
this._bridge = bridge
Object.entries(bridge).forEach(([k, v]) => (window[k] = v))
},
destroyed() {
Object.keys(this._bridge).forEach((k) => {
if (window[k] === this._bridge[k]) delete window[k] // 只删自己的,防止误删后挂者
})
}
7.2 call() 是同步的,$nextTick 只是侥幸
handleCreateHotspot 里先 call('view_get_toh_tov()'),再在 $nextTick 里使用 this.hlookat。这能工作,纯属 krpano 的一个特性帮了忙:call() 同步执行 action,action 里的 js() 也是同步调用——所以 call() 返回时 this.hlookat 已经被回调写好了,$nextTick 等不等都一样。
但这个隐含契约非常脆弱:一旦 action 里有异步步骤(比如 setinterval、asyncloop、delayedcall),"call 返回即结果就绪"的假设立刻崩塌。更稳的写法是回调驱动——view_get_toh_tov() 触发的 js() 回调里直接完成后续创建逻辑,而不是依赖时序巧合。
7.3 桥函数签名漂移
view_get_toh_tov 这个 action 传 3 个参数(hlookat、vlookat、fov),但 XmlPreview.vue 里的 handleGetViewTohTov 只声明了 2 个形参(靠 this.hlookat/this.vlookat 存值,fov 被丢弃);SceneEdit.vue 里则是 3 个。XML 与多个组件之间的"协议"没有单一事实来源,全靠口头约定。改进方案和上一篇 iframe 博客的结论一致:把桥函数的签名(参数名、顺序、含义)收敛到一个共享的常量/文档里,最好由一个统一的 bridge 模块集中挂载。
7.4 XML 字符串拼接的注入风险
SceneEdit.vue 用模板字符串拼 XML,thumburl="${sceneUrl}" 这些值来自后端。一旦 URL 里出现 " 或 <,整个 XML 解析失败,全景黑屏。krpano 的 loadxml 对字符串还有自己的转义规则(逗号、括号都有讲究)。生产上至少要做两件事:属性值过一层 XML 转义;对后端返回的 panoXml 做 loadxml 失败兜底(krpano 有 onxmlerror 回调可用)。
7.5 换场景必须"毁尸重建"
handleChangeXml 切换全景时,不是简单换个 XML,而是 removepano → 重新创建 div#pano → 重新 embedpano。因为 krpano 会接管目标 DOM 的内部结构,直接复用旧容器二次 embedpano 行为不可预期。代价是每次切换都是完整的引擎重启(白屏一瞬),如果场景切换频繁,值得评估 loadxml 原地替换 + loadscene 过渡的方案。
7.6 krpanoSWFObject 这个 id 是隐式契约
document.getElementById('krpanoSWFObject') 依赖引擎的默认 id 命名。embedpano 其实支持 id 参数自定义,多实例场景(页面上同时有两个全景)必须显式指定不同 id,否则第二个实例会找不到——或者更糟,操作到第一个。
八、横向对比:js() 桥 vs postMessage
把本文的 krpano 集成与上一篇的 iframe 集成放在一起,正好是"宿主集成外部引擎"的两种通信范式:
| 维度 | krpano embed(本文) | iframe(上一篇) |
|---|---|---|
| 引擎位置 | 同一 document,DOM 内嵌 | 独立 browsing context |
| 下行通道 | krpano.call() / set(),同步 |
iframe.contentWindow.postMessage(),异步 |
| 上行通道 | XML js() 调 window 全局函数,同步 |
window.onmessage 事件,异步 |
| 数据形态 | 函数实参(自动类型转换) | 消息序列化(JSON/结构化克隆) |
| 隔离性 | 无隔离,共享全局作用域 | 浏览器级硬隔离 |
| 安全边界 | 全局函数谁都能调,需自证来源 | 可校验 event.origin |
| 典型坑 | 全局函数覆盖、签名漂移 | 消息噪声、origin 校验缺失 |
本质规律:隔离边界越硬,通信越异步、越像协议;隔离边界越软,通信越直接、越像函数调用。iframe 把你挡在门外,只能隔墙传纸条(postMessage);krpano 与你同住一屋,喊一嗓子就行(全局函数)——但同住就要遵守同一屋檐下的规矩(全局命名空间管理)。
九、总结:XML-HTML 通信的最佳实践清单
初始化
上行(XML → JS)
下行(JS → XML)
架构
XML 调 JS,听起来像配置文件"越权",实则是 krpano 这类嵌入式引擎在无框架时代就设计好的宿主协议。理解它之后你会发现,从 Flash 时代的 ExternalInterface,到今天的 js() 桥、postMessage、甚至微前端的通信协议,宿主与被集成者的对话方式万变不离其宗:找到那条双方都够得着的通道,然后把协议、生命周期和安全边界约定清楚。
浙公网安备 33010602011771号