马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
当 XML 学会调 JS:krpano 全景引擎与 Vue 的双向通信实践
本文基于一个真实运行的安全监测平台(Vue 2.6 + Ant Design Vue),讲清一件事:krpano 全景引擎(XML 驱动)是如何与宿主 HTML/Vue 页面双向通信的。上一篇我们聊了 iframe + postMessage 的跨文档通信,这次是另一种更"古老"也更故意思的模式——引擎嵌在当前文档里,XML 配置反过来调用页面 JS。文中所有代码均来自生产项目,文末附踩坑复盘与最佳实践清单。
一、背景:全景图模块为什么需要"XML 调 JS"
平台里有一块全景业务:对隐患点拍摄球形全景图,在全景中标注场景热点(点击切换到另一个场景)和设备热点(点击检察监测设备数据),管理端还要支持在全景里拖拽热点、拾取视角坐标回填表单。
渲染引擎选了 krpano——全景行业的老牌引擎。它有一个鲜明的架构特点:统统行为皆 XML。场景、图片、热点是 XML 元素,连"函数"都是 XML 里 标签定义的命令序列。项目里与之相关的三个关键文件:
文件脚色public/index.html → <script src="js/krpano.js">引擎本体,向 window 袒露 embedpano / removepanopublic/template_action.xml自定义 action 库——本文主角,XML 与 JS 的桥梁就定义在这里public/vtourskin.xmlkrpano 官方皮肤(导航按钮、缩略图等)业务需求很快把通信问题摆上桌面:
- 用户在全景里点击热点 → 需要唤起 Vue 的编辑弹窗(引擎 → 页面)
- 用户在工具箱选中图标 → 需要在全景当前视角创建热点(页面 → 引擎)
- 用户拖动全景选取初始视角 → 视角值要回填 Vue 表单(双向闭环)
也就是说,XML 世界和 JS 世界必须相互调用。krpano 给出了两条通道。
二、通信全景图:两条方向相反的通道
先给全景架构,后文逐条睁开:- ┌──────────────── Vue 组件(宿主 HTML)────────────────┐
- │ │
- │ 下行通道:krpano.call() / krpano.set() │
- │ ──────────────────────────────────────▶ │
- │ │
- │ 上行通道:XML action 里 js() 调用全局函数 │
- │ ◀────────────────────────────────────── │
- │ │
- └────────────────────────┬───────────────────────────┘
- │
- document.getElementById('krpanoSWFObject')
- │
- ┌────────────────────────▼───────────────────────────┐
- │ krpano 引擎(XML 驱动) │
- │ ... 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 的核心片段:- js(handleClickHotSpotsEdit(get(hotspot_title),get(hotspot_type),get(print_ath),get(print_atv),get(url)));
- </action>
- js(handleClickHotSpotsScene(get(scene_id),get(hotspot_code)));
- hotspot_get_ath_atv();
- </action>
- js(handleClickHotSpotsDevice(get(collector_id),get(host_id),get(obj_type),get(hotspot_title),get(hotspot_code)));
- hotspot_get_ath_atv();
- </action>
- 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 侧行为handleClickHotSpotsEdithotspot_click_edit打开热点编辑弹窗handleClickHotSpotsScenehotspot_click_scene编辑模式开弹窗 / 浏览模式 loadscene 切场景handleClickHotSpotsDevicehotspot_click_device编辑模式开弹窗 / 浏览模式查设备详情handleClickHotSpotsGetAthAtvhotspot_get_ath_atv记录当前热点球面坐标 ath/atvhandleGetViewTohTovview_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 里解决:- 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>
- 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 里出现 " 或 |