1. 引言:低代码平台中的事件监听
在中大型 Web 应用中,用户交互事件(点击、拖拽、键盘等)本身就非常繁杂;而低代码平台又进一步将可视化画布隔离到 iframe 中,形成了“事件孤岛”:
- 事件隔离:iframe 内的原生事件不会冒泡到父页面,父侧无法直接监听。
- 拖拽连贯性:如果仅在 iframe 文档里或者父页面单独监听,鼠标按下后一旦移出或移入 iframe 区域,内部
mousemove
/mouseup
就中断,拖拽体验断裂。 - 坐标映射:父页面与 iframe 的滚动、缩放、位置都可能不同,导致拿到的
clientX/Y
无法准确映射到画布坐标。
提到iframe通信,大家往往想到的就是postMessage,但是在低代码平台中一般不会使用postMessage来做通信,而是利用同源 iframe 的特性:
- 父页面直接引用
iframe.contentWindow.document
- 批量注册监听:无论在主文档还是 iframe 文档,事件都在父侧统一挂载
- 坐标校正:在每次事件里做滚动与位置转换,映射到“画布”坐标系
本文尝试通过对阿里的两个低代码平台(designable、lowcode-engine)进行分析,讲解一下相关的逻辑。
2.Designable 的统一驱动流水线与 this.dispatch
Designable 的核心在于 事件驱动器(EventDriver
)与 事件引擎(Event
)的分层架构:
- 驱动器(Driver) 捕获一切原生事件
- 引擎绑定 将所有的 Driver事件注册到
document
、outline
、iframeDocument
元素上 - 封装分发:Driver 拿到原生
mousedown
/mousemove
/mouseup
,封装成自定义事件(如DragStartEvent
、DragMoveEvent
、DragStopEvent
) - 订阅执行:业务代码通过
engine.subscribeTo(DragStartEvent, handler)
来响应。
拖拽驱动核心代码
// 驱动器
class XXXDriver extends EventDriver {
// 1. attach 时,将 mousedown 统一注册到所有上下文(包括 iframeDocument)
attach() {
this.batchAddEventListener('mousedown', this.onMouseDown, true);
}
// 2. onMouseDown 检测到真正的左键,注册后续 move/up 监听
onMouseDown(e) {
GlobalState.startEvent = e;
this.batchAddEventListener('mousemove', this.onDistanceChange);
this.batchAddEventListener('mouseup', this.onMouseUp);
}
// 3. onDistanceChange 判断移动距离 >0,才触发真正的 dragstart
onDistanceChange(e) {
const dx = e.pageX - GlobalState.startEvent.pageX;
const dy = e.pageY - GlobalState.startEvent.pageY;
if (Math.hypot(dx, dy) > 0) {
this.batchRemoveEventListener('mousemove', this.onDistanceChange);
this.onStartDrag(e);
}
}
// 4. onStartDrag 注册 move 与 stop,发出 DragStartEvent
onStartDrag(e) {
this.batchAddEventListener('mousemove', this.onMouseMove);
this.dispatch(new DragStartEvent({ /* clientX/Y, target, … */ }));
GlobalState.dragging = true;
}
// 5. onMouseMove 中不断 dispatch DragMoveEvent
onMouseMove(e) {
this.dispatch(new DragMoveEvent({ clientX: e.clientX, clientY: e.clientY, … }));
}
}
// 订阅事件
const useXXXEffect = (engine: Engine) => {
engine.subscribeTo(DragStartEvent, (event) => {...});
engine.subscribeTo(DragMoveEvent, (event) => {...});
engine.subscribeTo(DragStopEvent, (event) => {...});
}
this.dispatch
如何工作
EventDriver.dispatch(event)
实际调用了 事件引擎 的engine.dispatch(event, context)
- 引擎遍历所有通过
subscribeTo
注册的回调,按事件类型精确触发 - 这样,
DragStartEvent
、DragMoveEvent
、DragStopEvent
等业务事件就能被上层逻辑(如useDragDropEffect
)一致地消费和响应。
拖拽如何实现跨 iframe
假设用户从「组件面板」拖拽一个物料到「iframe 画布」:
在 Designable 里,拖拽事件从按下到释放,始终在父侧的三套上下文(document
、outline
、iframeDocument
)上被统一捕获和分发,具体流程如下:
-
三套容器都注册驱动器
当编辑器启动时,为document注册事件,outline和iframe画布在各自组件加载的时候注册事件engine.attachEvents(document, window); engine.attachEvents(outline, window); engine.attachEvents(iframeDocument, iframeWindow); // (outline 上也是一个 HTMLElement 容器)
每个容器下都会实例化一套
DragDropDriver
,并执行它的attach()
。 -
统一挂载
mousedown
在DragDropDriver.attach()
中:attach() { // 在所有已 attach 的 container(包括 iframeDocument)上, // 以 capture 模式挂载 mousedown this.batchAddEventListener('mousedown', this.onMouseDown, true); }
batchAddEventListener
会遍历所有 Driver 实例,把监听器加到它们的container
上——也就是说,无论用户是在主文档还是在 iframe 内部按下,onMouseDown
都能被触发。 -
延迟进入真正拖拽
onMouseDown
里记录起始点,并在所有上下文注册后续监听:onMouseDown(e) { GlobalState.startEvent = e; GlobalState.dragging = false; // 等待足够距离后,才算真正“拖拽启动” this.batchAddEventListener('mousemove', this.onDistanceChange); this.batchAddEventListener('mouseup', this.onMouseUp); }
-
阈值判断并触发
DragStartEvent
onDistanceChange
监测全局移动距离,一旦超过零,就:onDistanceChange(e) { // …计算距离… this.batchRemoveEventListener('mousemove', this.onDistanceChange); this.onStartDrag(e); } onStartDrag(e) { // 给所有上下文都注册 move/up/contextmenu this.batchAddEventListener('mousemove', this.onMouseMove); this.batchAddEventListener('contextmenu', this.onContextMenuWhileDragging, true); // 真正对外发起拖拽业务事件 this.dispatch(new DragStartEvent({ clientX: e.clientX, clientY: e.clientY, … })); GlobalState.dragging = true; }
此时,无论光标在父页面还是已经移入 iframe,都能继续走到
onMouseMove
。 -
持续分发移动与释放
- 移动:在
onMouseMove
中不断this.dispatch(new DragMoveEvent({ clientX: e.clientX, clientY: e.clientY, … }));
- 释放:在
onMouseUp
中if (GlobalState.dragging) { this.dispatch(new DragStopEvent({ clientX: e.clientX, clientY: e.clientY, … })); } // 卸载所有 move/up/contextmenu
这样,DragMoveEvent、DragStopEvent 也能跨容器被捕获。
- 移动:在
核心在于:
Engine.attachEvents
将同源iframe.document
当作第二个「容器」注册;batchAddEventListener
在所有容器上批量挂载所需事件;- 整个拖拽生命周期里,无论光标如何跨越父文档 ↔︎ iframe,都能在父侧驱动器里完整捕获与分发。
- lowcode-engine中虽然逻辑不同,但是原理是一致的。
3. Lowcode‑Engine 的模块化绑定与 handleEvents
(makeEventsHandler
)
Lowcode‑Engine 分成三大角色:
-
Host(
BuiltinSimulatorHost
)- 在
mountContentFrame
拿到同源contentDocument
setupDragAndClick()
、setupDetecting()
等直接在doc
上注册事件
- 在
-
Dragon(拖拽引擎)
- 通过
boost(dragObject, downEvent)
发起一次拖拽 - 内部维护
_dragging
、_canDrop
,并在拖拽生命周期里发出dragstart
、drag
、dragend
- 通过
-
Hotkey(快捷键)
- 负责监听组合键(
keydown
/keyup
)并执行对应命令
- 负责监听组合键(
makeEventsHandler
:跨文档批量注册
export function makeEventsHandler(
boostEvent: MouseEvent | DragEvent,
sensors: ISimulatorHost[],
): (fn: (sdoc: Document) => void) => void {
const topDoc = window.document;
const sourceDoc = boostEvent.view?.document || topDoc;
const docs = new Set<Document>([topDoc, sourceDoc]);
sensors.forEach(sim => {
if (sim.contentDocument) docs.add(sim.contentDocument);
});
// 返回一个“批量注册器”:给定一个回调,就在所有文档上执行一次
return (handle) => {
docs.forEach(doc => handle(doc));
};
}
- 原理:
docs
包含了主页面文档、拖拽源文档(iframe)以及所有激活的模拟器文档。 - Host/Dragon 拿到这个批量注册器
handleEvents
后,只要写一次doc.addEventListener(...)
,就自动在所有文档里挂载,保证事件无论在何处都能被捕获。
Dragon 中的事件挂载示例
const handleEvents = makeEventsHandler(boostEvent, masterSensors);
// 拖拽中移动
const move = (e: MouseEvent) => {
if (this._dragging) {
this.emitter.emit('drag', locateEvent);
} else if (isShaken(boostEvent, e)) {
dragstart(); move(e);
}
};
// 拖拽结束
const over = (e) => { /* emit('dragend') & cleanup */ };
handleEvents(doc => {
doc.addEventListener('mousemove', move, true);
doc.addEventListener('mouseup', over, true);
doc.addEventListener('mousedown', over, true);
});
- 拖拽连贯性:即使光标越过 iframe 边界,只要同源,事件依然会在该文档中派发,被
move
/over
捕获。 - 屏蔽原生:在拖拽启动时调用
this.setNativeSelection(false)
禁用浏览器选中、原生拖拽,让自定义逻辑全程掌控。
4. 跨 iframe 拖拽的坐标映射与连贯性
- 拖拽连贯性
- 由上可见,Designable 的
batchAddEventListener
和 Lowcode‑Engine 的makeEventsHandler
,都能在主文档+iframe 文档双重注册mousemove
/mouseup
,保证拖拽事件不丢失。
- 由上可见,Designable 的
- 坐标映射
- 在每次
mousemove
回调中,都会调用对应引擎/传感器的校正方法:// Lowcode‑Engine 中 sensor.fixEvent(locateEvent); // Designable 中 new DragMoveEvent({ topClientX, topClientY, pageX, pageY, … })
- 校正逻辑会读取各文档的
scrollX/Y
、innerWidth/Height
、容器边界等,转换出一个“画布坐标系”下的canvasX/Y
或topClientX/Y
,供后续拖拽计算、碰撞检测使用。
- 在每次
5. 结论与最佳实践
- Designable:通过
EventDriver
+Engine
的统一封装,简化多文档事件管理,对一致性和维护性友好,但是会存在键盘事件多次订阅的问题。 - Lowcode‑Engine:Host + Dragon + Hotkey 的拆分更灵活,颗粒度更细。
- 同源 iframe:务必保证
iframe
同源,才能无障碍地直接操作其contentDocument
/contentWindow
; - 拖拽建议:
- 拖拽启动即阻止原生行为,避免意外选中;
- 在主文档+iframe 双文档注册移动/结束事件,保证连贯性;
- 每次事件都做滚动与边界校正,确保坐标映射准确;
- 统一将原生事件封装为业务事件(
DragStartEvent
、drag
、dragend
)再分发,方便上层消费和复用。