一、为什么需要事件模型
在低代码系统中,我的 Runtime 已经具备了:
- 动态数据绑定(
{{state.xxx}}) - 生命周期执行(onMount / onUnmount)
- 条件渲染 / 循环(condition / loop)
- 声明式数据请求(DataSource)
但有一个关键缺失:页面只能"展示",不能"交互"。即用户点击按钮、提交表单——这些最基本的交互行为,还无法实现。
当然我可以在 onMount 里手动绑定 DOM 事件,但这违背了低代码"声明式描述页面"的核心理念。因此我引入了 事件模型(Events):让 Schema 具备"声明式绑定用户交互"的能力。
Schema 以声明式方式描述"onClick 时执行 loadList",Runtime 负责完成方法查找、包装回调、执行、状态更新的全部链路。
二、事件模型在 Runtime 中的角色
在 Runtime 体系中,Events 扮演的是"用户交互入口"。它与其他 Runtime 模块的关系如下:
Runtime 能力层次
├── Context → 数据容器(存储 state / data)
├── Expression → 数据读取({{state.xxx}} → 值)
├── Lifecycle → 副作用通道 A:组件生命周期触发自定义逻辑
├── DataSource → 副作用通道 B:声明式请求外部数据
└── Events → 交互入口:用户操作 → 触发方法 → 状态变更 → UI 更新
可以这么说 Lifecycle 和 DataSource 是"系统驱动"的副作用,Events 则是"用户驱动"的副作用 —— 在用户交互时才触发。
三者最终都通过 ctx.set → notifyUpward → rerender 这条链路驱动 UI 更新,共享同一套调度机制。
三、事件模型设计
Schema 协议
{
"componentName": "button",
"props": { "children": "刷新" },
"events": {
"onClick": "loadList"
}
}
类型定义
export interface Schema {
events?: Record<string, string> // key = 事件名(onClick), value = 方法名引用(loadList)
}
模型拆解
schema.events
├── key (onClick) → DOM 事件名,最终成为 Renderer 层的 props
└── value (loadList) → 方法名引用,运行时从 rootCtx.methods 获取实际方法
注意 events 的 value 是方法名字符串,而不是 {{methods.loadList()}} 表达式 —— 二者语义根本性不同,具体分析见 6.1 events 用独立字段(loadList)而非复用表达式语法({{methods.loadList}})。
这是典型的 声明式(Schema)+ 命令式(Runtime) 设计:Schema 只声明"事件和方法的绑定",Runtime 负责"如何查找方法、如何包装回调和执行"。
四、实现方案
4.1 整体架构
sequenceDiagram
participant User as 用户
participant DOM as DOM / React
participant RT as PreviewRuntime
participant RootCtx as rootCtx (methods)
participant State as rootCtx.state
participant Root as RendererRoot
Note over RT,Root: 解析阶段(resolveSchemaNode)
RT->>RT: resolveEvents(schema)
RT->>RT: events.onClick → 包装为回调函数
RT->>DOM: resolvedProps = { ...props, onClick: callback }
Note over User,Root: 交互阶段(用户点击)
User->>DOM: click
DOM->>RT: callback(...args)
RT->>RootCtx: rootCtx.methods.loadList(rootCtx, ...args)
RootCtx->>State: ctx.set('state.list', newData)
State->>Root: notifyUpward → rerender
Root->>Root: setTick → rerender
Root-->>DOM: UI 更新
完整数据流:
Schema.events: { onClick: "loadList" }
↓ resolveEvents
回调函数: onClick = (...args) => rootCtx.methods.loadList(rootCtx, ...args)
↓ 合并到 resolvedProps
<Component onClick={callback} />
↓ 用户点击
loadList(ctx) 执行
↓
ctx.set('state.list', newData)
↓ notifyUpward
RendererRoot rerender → UI 更新
4.2 核心实现:resolveEvents
PreviewRuntime 的 resolveEvents 方法负责将声明式的事件配置信息解析为可执行的回调函数:
resolveEvents(schema: Schema): Record<string, Function> {
const events = schema.events
if (!events) return {}
const methodCtx = this.rootCtx
const resolved: Record<string, Function> = {}
for (const [eventName, methodName] of Object.entries(events)) {
resolved[eventName] = (...args: any[]) => {
const method = methodCtx?.methods[methodName]
if (typeof method === 'function') {
method(methodCtx, ...args)
} else {
console.warn(`[Event] 方法未找到: ${methodName} (node: ${schema.id})`)
}
}
}
return resolved
}
这段代码有两处值得注意的设计点:
- 延迟查找:method 在事件触发时才查找,而非解析时绑定。原因分析见 6.2 方法延迟查找而非解析时绑定。
- rootCtx 作为执行上下文:所有事件回调的调用签名为
method(rootCtx, ...args)。这是 V1 版本"页面级状态空间"的核心取舍,详细拆解见 6.3 rootCtx 作为执行上下文(V1 的取舍)。
4.3 DesignerRuntime:空实现
设计态不触发事件——画布内点击由 overlay 接管用于选中组件,不触发业务逻辑:
resolveEvents(): Record<string, Function> {
return {}
}
这与 DataSource 的设计态处理方式一致:DesignerRuntime 不执行副作用。
4.4 集成到 resolveSchemaNode
这一步是将 events 的解析结果合并到 resolvedProps,让 Renderer 层接收。在 resolveSchemaNode 的逻辑中进行处理,如下 single 分支合并的代码:
// single 分支
const resolvedProps = this.resolveProps(schema, parentCtx)
const resolvedEvents = this.resolveEvents(schema)
return {
kind: 'single',
resolvedProps: { ...resolvedProps, ...resolvedEvents }, // events 合并到 props
}
Renderer 层无需任何改动。因为 <Component {...resolvedProps}> 天然能接收 onClick 等回调函数。
4.5 事件触发后响应式更新
事件触发后的状态更新复用了已有的调度系统:ctx.set → notifyUpward → rerender → UI 更新。
五、示例说明
我以一个"点击按钮刷新列表"的场景为例,看看事件模型是如何工作的。
Schema 声明:根节点定义了 loadList 方法,按钮通过 events 绑定该方法:
{
id: 'page',
componentName: 'div',
state: { list: [], loading: false },
methods: {
loadList: {
type: 'JSFunction',
value: `async function(ctx) {
ctx.set('state.loading', true)
const data = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5').then(r => r.json())
ctx.set('state.list', data)
ctx.set('state.loading', false)
}`,
},
},
children: [
{
id: 'refresh-btn',
componentName: 'button',
props: { children: '刷新' },
events: { onClick: 'loadList' }, // 关键:声明式绑定点击事件
},
// ...其他子节点
]
}
运行时链路,当用户点击"刷新"按钮时:
1. 用户点击按钮
2. React 触发 onClick 回调
3. resolveEvents 包装的回调执行:rootCtx.methods.loadList(rootCtx, event)
4. loadList 内部调用 ctx.set('state.list', data)
5. RendererRoot rerender → 列表更新
整个过程中,Schema 只声明:events: { onClick: 'loadList' }。方法查找、包装回调、状态更新、UI 刷新——全部由 Runtime 完成。
六、关键决策
6.1 events 用独立字段(loadList)而非复用表达式语法({{methods.loadList}})
{{}} 表达式是"立即求值"语义,事件绑定是"延迟执行"语义。用独立的 eventsName 字段从协议层就区分了这两种语义,避免混淆。
同时,events 的 value 是纯字符串(方法名引用),而非 JSFunction 代码块。这使得 Schema 更简洁,编辑器层可直接通过下拉框选择进行绑定。
6.2 方法延迟查找而非解析时绑定
resolveEvents 返回的回调函数,在触发时才从 rootCtx.methods 查找目标方法。这样做的好处是:
- 如果 methods 在运行过程中被更新(热更新),事件回调指向的是最新版本
- 方法不存在或异常时,不会在解析阶段报错,而是在触发时 warn,这样更适合低代码“高容忍度”的原则。
6.3 rootCtx 作为执行上下文(V1 的取舍)
我当前的 V1 版本采用"一个页面 = 一个全局状态空间"的模型:methods 定义在根节点,所有事件回调的执行上下文都是 rootCtx,不管事件发生在哪个子节点,方法都在当前页面级上下文中执行。
当然这个决策带来一些已知局限——loop 内事件无法访问当前 item 的上下文、子节点无法访问自己的上下文。
我的思路是 V1 选择接受这个局限,优先完成最小版事件模型的闭环。
后续可通过 ctx.set 向上查找或双 ctx 参数等方案解决,因为这些改动会影响 RuntimeContext 的核心语义,所以更适合在独立迭代中处理。
6.4 DesignerRuntime 空实现的必要性
设计态的核心目标是设计体验——组件选中、拖拽排列、属性编辑,而非业务交互。画布内的点击被 overlay 拦截用于这些设计操作,如果同时绑定了业务事件(如 onClick 触发数据请求),一次点击会同时触发"选中组件"和"执行业务逻辑",行为冲突且干扰设计流程。
因此 DesignerRuntime 返回空对象,从根源上保证设计态专注于设计本身。事件的实际效果可以通过切换到预览态验证,职责更清晰。
七、总结
引入事件模型之后,Schema 的作者只需声明 events: { onClick: 'loadList' },Runtime 就能自动完成方法查找、包装回调、执行、状态更新的完整链路。
这一设计的完成,使得我的整个 Runtime 系统的能力演进为如下版图:
Renderer → 能显示
Expression → 能计算
Lifecycle → 能调度
DataSource → 能接入数据
Events → 能交互
最终形成了 Schema = UI 结构 + 数据绑定 + 生命周期调度 + 外部数据 + 用户交互 —— 一段 JSON 能描述一个完整的、可交互的页面。
八、展望
V1 完成了事件模型的最小闭环——用户交互 → 执行 method → 状态更新 → UI 刷新。后续我会在这两个方向上继续演进:
交互能力增强
- 解决 loop 内事件访问当前 item 上下文的问题
- 支持事件参数传递:
events: { onClick: { method: 'handleClick', params: { type: 1 } } } - 支持事件链:一个交互触发多个方法(串行 / 并行)
与 Runtime 其他模块联动
- 事件内直接触发数据源重新请求:
ctx.reloadDataSource('userList') - 事件参数支持表达式求值:
params: ['{{state.count}}']
这样,事件模型就更加完善,就能够满足更多的业务场景了。