当 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.xmlvtourskin.xmlimages/ 都是相对路径,而前端开发环境与生产环境的静态资源根路径不同,于是有了三连 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 仍持有已销毁组件的引用(内存泄漏 + 若被调用会操作僵尸组件);
  • 两个组件(XmlPreviewXmlPreviewModel)挂载同名函数,后挂的覆盖先挂的——和 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 里有异步步骤(比如 setintervalasyncloopdelayedcall),"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 转义;对后端返回的 panoXmlloadxml 失败兜底(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、甚至微前端的通信协议,宿主与被集成者的对话方式万变不离其宗:找到那条双方都够得着的通道,然后把协议、生命周期和安全边界约定清楚

posted on 2026-09-14 14:31  中文还在写码  阅读(9)  评论(0)    收藏  举报