这篇文章,我想讲清楚一件事:如何把低代码平台的属性面板,一步步设计成"类型感知 Setter + 高级配置面板。
从架构决策、实现链路、设计态/预览态行为分离,以及一个具有代表性的 Bug 排查这几方面展开。
一、问题:属性面板能做哪些事
属性面板的能力,总而言之,就是编辑 Schema 上的字段 。
这里面包括两类字段:
第一类:schema.props ------ 组件属性
每个组件都有自己的 props,比如 Text 有 text、fontSize、color,Button 有 text、type。这些字段决定了组件"长什么样"。面板需要为不同类型的 prop 提供合适的编辑控件(文本框、数字滑块、颜色选择器、下拉选择等)。
scss
schema.props 示例(Text 组件):
{ text: '标题', fontSize: 24, color: '#111827' }
↑ 文本 ↑ 数字 ↑ 颜色
StringSetter NumberSetter ColorSetter
第二类:Schema 顶层字段 ------ 运行时行为控制
Schema 上还有一些顶层字段,不属于 props,而是控制节点的运行时行为:
arduino
schema.condition → 条件渲染(是否显示)
schema.loop → 循环数据源(绑定数组,展开为多个实例)
schema.loopArgs → 循环变量名(如 item / index)
schema.events → 事件绑定(如 onClick → loadList)
所以我的目标很明确:把这两类字段都纳入属性面板------props 通过类型感知的 Setter 体系来编辑,而 condition、events、loop 等顶层字段则通过专门的高级配置面板来配置。
二、技术方案设计
在动手前,我研究了阿里低代码引擎的设置器架构。它的核心分三层:
markdown
configure(描述层)
→ SettingField(代理层)
→ Setter(视图层)
| 层 | 职责 | 阿里实现 |
|---|---|---|
| configure | 声明每个 prop 对应什么 Setter、有什么约束 | MaterialMeta.configure.props |
| SettingField | 代理读写,管理联动/校验/autorun | SettingTopEntry → SettingField |
| Setter | 纯 UI 控件,只关心渲染和回调 | StringSetter、NumberSetter... |
我的决策:对齐 configure 描述层,跳过 SettingField 代理层。
V1 版本的目标是降低复杂度,先把核心框架和核心能力跑通,同时为后续扩展预留空间------跳过中间层不影响后续演进。
当前架构是两层直连:
bash
configure(描述)→ Setter(视图)→ command(写入 Schema)
后续需要联动、校验、autorun 等高级能力时,再插入 SettingField 代理层:
bash
configure(描述)→ SettingField(代理)→ Setter(视图)
↓
command(写入 Schema)
configure 的接口不变,Setter 的接口也不变,中间层只是新增的一层代理。这种"先薄后厚"的演进策略,保证了当前能跑通、后续能扩展。
三、基础属性 ------ Setter 体系
核心思路:每个物料在元数据中声明 configure,描述自己有哪些属性、每个属性用什么 Setter 编辑。面板读取 configure,渲染对应的控件,用户操作后通过 command 写回 Schema。
以 Text 组件为例,走一遍完整链路:
1. 物料声明 configure
json
{
"title": "文本",
"componentName": "Text",
"configure": [
{
"name": "text",
"title": "文本内容",
"setter": "StringSetter"
},
{
"name": "fontSize",
"title": "字号",
"setter": "NumberSetter",
"props": {
"min": 12,
"max": 100
}
},
{
"name": "color",
"title": "颜色",
"setter": "ColorSetter"
}
]
}
2. 面板按 configure 渲染 Setter
用户选中一个 Text 节点后,PropertyPanel 读取 Text 的 configure,遍历每一项,通过 setter 字段的字符串名称(如 'StringSetter')从注册表中查找对应的 React 组件,然后渲染:
ini
┌─────────────────────────┐
│ 文本内容 [___标题____] │ ← StringSetter(文本框)
│ 字号 [ 24 ↕] │ ← NumberSetter(数字输入,min=12 max=100)
│ 颜色 [■] #111827 │ ← ColorSetter(取色器 + 色值预览)
└─────────────────────────┘
如图:

3. 用户修改 → Setter → command → Schema 更新
比如用户把字号从 30 改成 40:
php
用户滑动 NumberSetter 到 40
→ Setter 调用 onChange(40)
→ PropertyPanel 执行 engine.command.execute('updateProps', {
nodeId: 'page-title',
props: { fontSize: 40 }
})
→ DocumentModel 合并写入 schema.props.fontSize = 40
→ notify() → 画布重新渲染
整条链路复用了现有的 command 体系,Setter 本身只负责渲染和回调,不关心数据怎么写入schema------onChange 是它与外部的唯一连接点。
四、高级配置面板 ------ 编辑 Schema 顶层字段
4.1 接入 updateSchemaAdvanced 专门处理 Schema 顶层字段
基础属性 Tab 编辑的是 schema.props,用 updateProps command 就够了。但高级 Tab 要编辑的是 Schema 顶层字段:
arduino
schema.condition → 条件渲染
schema.loop → 循环数据源
schema.loopArgs → 循环变量名
schema.events → 事件绑定
updateProps 是 merge 语义 (合并写入 schema.props),但这些字段需要 直赋值语义 (直接写入 schemafield)。
所以新增了 updateSchemaAdvanced command,基本逻辑如下:
arduino
execute(nodeId, field, value):
prevValue = schema[field] // 保存旧值(用于撤销)
if value 为空 → delete schema[field] // 清空时删除字段,保持 Schema 干净
else → schema[field] = value // 直接赋值
notify() // 通知画布重新渲染
return prevValue // 返回旧值用于 undo,command 体系的撤销/重做
4.2 三个高级配置项
| 配置项 | 控件形态 | 候选数据来源 |
|---|---|---|
| 条件渲染 | 复选框:勾选 = 隐藏该节点 | --- |
| 循环数据源 | 文本框 + datalist 候选提示,绑定后显示循环变量名(item / index)编辑 | 自动扫描 Schema 中值为数组的 state / data 字段 |
| 事件绑定 | 事件名下拉选择(onClick、onChange...) → 方法名文本框,支持多条 | Schema.methods 中已声明的方法 |
图示:

五、设计态 vs 预览态的边界
之前在设计 Runtime 的 DataSource 和 Events 时就讨论过设计态与预览态的边界问题------设计态的核心目标是设计体验 。
属性面板引入 condition 和 loop 编辑后,这个问题再次浮出水面:
在设计态,如果 condition='false',节点会从画布上消失
在设计态,如果绑定了 loop,列表为空时节点同样会消失
设计态的核心需求是"能看到、能选中、能编辑",节点不能因为 condition 或 loop 的配置而消失或变形。
因此在 DesignerRuntime 的 resolveSchemaNode 中直接跳过 condition 和 loop 的语义解析。
而 PreviewRuntime 则完整执行 condition 判断和 loop 展开逻辑。
同一个 Renderer,注入不同的 Runtime 就能得到不同的行为------这正是 Runtime 层在设计之初就具备的解耦能力。
如下:
vbnet
DesignerRuntime
↓
同一个 Renderer ← resolveSchemaNode → 始终 single,跳过 condition/loop
↓
PreviewRuntime
↓
同一个 Renderer ← resolveSchemaNode → 可能 hidden / 可能展开为 list
这个设计保证了:
- 设计态永远能看到所有节点
- 预览态完整执行运行时语义
- Renderer 代码零侵入
六、典型的一个问题分享:循环渲染在预览态不生效
6.1 现象
在属性面板配置了 loop: 'data.list' 后,预览页面中循环容器没有展开。但Network显示接口已经请求成功,数据源已经写入了 data.list。
6.2 分析
Schema 结构如下:
scss
Page (data.list 在这里)
├── Container (外层容器)
├── Container (loop: 'data.list')
├── Text ({{index}}) ← loop 展开后可用的 index 变量
├── Text ({{item.name}}) ← loop 展开后可用的 item 变量
循环渲染的执行链路:resolveSchemaNode 解析到内层 Container 时,发现它声明了 loop: 'data.list',于是交给表达式引擎求值,期望拿到一个数组,再将该节点展开为多个实例。
问题出在表达式引擎对 data.list 的求值过程。引擎先解析标识符 data,直接返回当前节点作用域 的 data 对象。但内层 Container 是子节点,它自身的作用域里 data 是空的 {},真正的数据存在根节点 Page 的作用域上。于是 {}.list 得到 undefined,循环无从展开。
根因:表达式引擎在取命名空间(state/data/props 等)时,没有沿作用域链向上查找。
引擎的 RuntimeContext 本身设计了父链机制------get('data.list') 会在当前 scope 找不到时自动往父级查找。但表达式引擎绕过了这个方法,直接读取当前作用域的属性,导致父链断裂。
6.3 修复
修复思路很直接:让表达式引擎在遇到命名空间路径(如 data.list、state.count)时,统一走 RuntimeContext 的 get() 方法,而不是直接访问当前作用域的属性。这样求值过程就能自动沿父链查找,子节点也能正确访问到根节点上的数据。
这个问题的代表性在于:作用域链是引擎的基础设施,它的任何断裂都会在上层功能(loop、condition、表达式绑定)中以难以预料的方式暴露出来。
七、总结
属性面板其实串联了引擎的多个核心能力:物料描述、command 体系、Runtime 分层、表达式引擎与作用域链。回顾整个实现过程,有几条设计原则贯穿始终:
1. Schema 驱动一切
属性面板所做的事情,本质上就是"编辑 Schema"。无论是基础属性通过 Setter 写入 schema.props,还是高级配置写入 schema.condition/schema.loop/schema.events,最终都归结为对 Schema 的读写。
2. 描述与渲染分离
物料通过 configure 声明"我有什么属性、该用什么控件编辑",面板只负责按描述渲染。新增物料或新增 Setter,都只需要扩展配置和组件,不需要改动引擎核心代码。
3. Runtime 层承担行为差异
设计态和预览态对 condition、loop 的处理截然不同,但这个差异完全由 Runtime 层消化,Renderer 对此无感知。这保证了渲染层就是纯粹渲染。
4. 表达式引擎是连接 Schema 与运行时的桥梁
loop 绑定的 data.list、props 中的 {{item.name}},这些写在 Schema 里的字符串,都需要表达式引擎在运行时求值。它依赖作用域链正确传递上下文,任何断裂都会在上层功能中以难以预料的方式暴露出来------这也是本文 Bug 排查环节的核心教训。
属性面板是用户与 Schema 之间的交互界面,也是引擎各层能力的集中体现。 当前 V1 版本覆盖了核心链路,后续在此基础上引入属性之间联动、跨字段校验、动态枚举、表达式编辑器、以及更丰富的 Setter 类型等,这些都是增量扩展,不需要推翻现有设计。
附上在线体验地址:lowcodeapp.blinkblink.top/