一、背景
在现有的低代码 Runtime 中,我们已经具备了以下基础能力:
- RuntimeContext:数据作用域管理
- Expression Engine:表达式执行
- Renderer 接入 Runtime:渲染层与运行时解耦
- 基础响应式:ctx.set → UI 更新
看起来,我已经能做到"Schema → 渲染出 UI"。但实际上,此时的 Schema 只能描述静态结构,还无法表达动态逻辑。比如:
- 如何控制某个组件是否渲染?(condition)
- 如何根据数据渲染一个列表?(loop)
- 循环渲染时,每一轮应该用什么上下文?(item.name、index 从哪来?)
这些问题指向了一个缺口:如何将 Schema 静态描述 解释为 可执行行为
让 Schema 具备以下三种能力:
- condition:条件渲染
- loop:循环渲染
- scope:作用域控制
二、问题本质:Schema ≠ 逻辑
先看一个典型 Schema:
json
{
"component": "List",
"loop": "{{ list }}",
"children": {
"component": "Text",
"props": {
"text": "{{ item.name }}"
}
}
}
这个结构表达了一个清晰意图:
对 list 进行循环
每一项渲染一个 Text
文本内容是 item.name
但问题是,这些"语义"不会自动执行。
谁去解析 loop?
item 是谁?
每一轮渲染的上下文如何构建?
所以结论很直接:
Schema 只是 DSL(领域描述语言),不足以驱动行为。
我们需要为它配一个真正的"行为解释器"
三、核心思路:引入 Schema Runtime
为了解决上述问题,我在 Runtime 里增加了一层:Schema Runtime(逻辑执行层)。
它的职责:解释 Schema 的语义,并生成可执行结果,给Renderer消费
它们三层的关系:
- Schema = DSL 静态描述
- Schema Runtime = 逻辑解释器
- Renderer = 结果渲染器
四、实现方案
4.1 整体执行模型
text
Schema
↓
Schema Runtime(解释结构化语义)
↓
ResolvedSchemaNode(标准化结果)
↓
Renderer(渲染 UI)
其中,底层 Runtime(RuntimeContext、Expression Engine)作为基础设施,为 Schema Runtime 提供执行环境。
4.2 Schema Runtime 的核心职责
1. 纯逻辑解释层
输入:Schema
输出:标准化的可渲染结果(ResolvedSchemaNode)
它的核心职责是:
把 Schema 的结构语义(condition / loop / scope)
转换成 Renderer 可消费的结果
2. 实现 Renderer 解耦
通过引入 Schema Runtime,Renderer 被彻底解耦:
Renderer 不再关心:
- condition
- loop
- 表达式执行
- 作用域
Renderer 只需要关心一件事:根据标准化结果去渲染组件
3. 标准化输出:ResolvedSchemaNode
typeScript
type ResolvedSchemaNode =
| { kind: 'empty' }
| { kind: 'single'; node }
| { kind: 'list'; nodes: node[] }
如上是 Schema Runtime 的输出,它将所有复杂语义统一收敛为三种结构:
- empty:不渲染
- single:单节点
- list:节点集合
本质是把"复杂控制流"降维为"简单数据结构":
复杂的 condition / loop / scope
被统一转换为 Renderer 可理解的最小模型
4.3 执行机制
Schema Runtime 的执行是一个递归解释过程,对每个 Schema 节点依次执行如下步骤:
- 判断 condition → 决定是否继续
- 判断 loop → 决定是否展开
- 构建当前节点的作用域(ctx)
- 解析 props(表达式求值)
- 生成标准化节点(empty / single / list)
一句话总结:对每一个 Schema 节点,依次完成"语义解释 → 作用域构建 → 数据解析"
1. 对 loop 的特殊处理
loop 的本质是:为每一次迭代创建独立作用域,并在该作用域下执行子节点的逻辑:
这使得每个节点实例拥有独立的数据上下文,
从而支持嵌套列表或更复杂的组合场景。
2. 作用域链
Schema Runtime 通过 RuntimeContext 构建作用域链:
text
rootCtx (state / props / methods)
↓
loopCtx (引入 item、index 到 locals)
↓
nodeCtx (解析后的 props)
变量查找遵循"就近原则 + 向上冒泡",类似 JavaScript 作用域链。
4.4 协作模型:边解析边渲染
当前方案中,Schema Runtime 与 Renderer 采用 "按需解释" 的协作模式:
- 不预构建完整运行时树
- 在渲染过程中,遇到一个节点就调用 resolveSchemaNode 即时解析
这样做的好处是:
- 避免一次性解析整棵树(性能更优)
- 天然支持动态数据变化(数据驱动时按需重新解析局部节点)
- 减少中间数据结构(降低整体复杂度)
五、核心方法:resolveSchemaNode
Schema Runtime 的核心入口是 resolveSchemaNode
resolveSchemaNode(schema,parentCtx) 完整的执行流程如下:
text
schema + parentCtx
│
▼
① evaluateCondition(schema.condition, parentCtx)
│
├── false → return { kind: 'empty' }
│
▼ true
② 有 loop 字段?
│
├── 无 → 路径 C(single)
│ resolveProps → createContext
│ → return { kind: 'single', ... }
│
▼ 有
③ evaluateLoop(schema.loop, parentCtx)
│
├── 空数组 → return { kind: 'empty' }
│
▼ 非空
④ 遍历数组,对每个 item 执行:
createLoopContext → resolveProps → createContext
→ 收集结果
→ return { kind: 'list', items: [...] }
示例说明(loop 路径)
假设 Schema结构:
json
{
"componentName": "Text",
"loop": "state.list",
"loopArgs": ["item", "index"],
"props": {
"children": "{{item.name}} - No.{{index + 1}}"
}
}
以 state.list = [{name:'Alice'}, {name:'Bob'}],每次迭代做三件事:
Step 1:createLoopContext --- 创建循环子作用域
typescript
parentCtx.createChild({
id: 'text1_loop_0',
locals: { item: { name: 'Alice' }, index: 0 }
})
把当前迭代的 item 和 index 放入 locals,形成一个新的作用域。
Step 2:resolveProps --- 在新作用域下解析 props
arduino
"{{item.name}} - No.{{index + 1}}"
→ 在 loopCtx 下求值
→ "Alice - No.1"
Step 3:createContext --- 为节点实例创建自己的 ctx
最终每个循环迭代产出一个 { key, schema, resolvedProps, ctx },作用域链为:
text
rootCtx (state.list, state.count, ...)
│
├── loopCtx₀ (locals: { item: {name:'Alice'}, index: 0 })
│ └── nodeCtx₀ (props: { children: 'Alice - No.1' })
│
└── loopCtx₁ (locals: { item: {name:'Bob'}, index: 1 })
└── nodeCtx₁ (props: { children: 'Bob - No.2' })
六、几个关键设计决策
6.1 Runtime 和 Renderer 分离
通过 Schema Runtime 将"逻辑解释"和"UI 渲染"彻底分开,带来的好处:
- Renderer 可以保持"纯 View":它只做渲染,不理解业务语义
- Runtime 可以独立测试:不需要启动 React Renderer 就能验证 Schema 解析逻辑
- 支持多渲染框架:同一套 Runtime 可以驱动 React Renderer 或 Vue Renderer
- 设计态/运行态统一:同一个 Renderer 组件树,通过注入不同 Runtime 实现完全不同的行为
6.2 边解析边渲染 vs 预构建运行时树
当前,我选择了"边解析边渲染"的方案,
核心原因:React 本身就是递归渲染组件树,没有必要推翻现有结构再维护另一棵运行时树。
"边解析边渲染" 的策略,实现简单,可以快速验证。
两种方案对比:
| 维度 | 边解析边渲染 | buildRuntimeTree |
|---|---|---|
| 核心思路 | Renderer 递归时,每个节点调 resolveSchemaNode 即时解析 | 先递归整棵 Schema 构建完整 RuntimeNode 树,再交给 Renderer 渲染 |
| 数据流 | Schema → 逐节点解析 → Renderer 消费 | Schema → 完整树 → Renderer 消费 |
| 与 React 的契合度 | 天然契合,React 本身就是递归组件树 | 需要额外的树、diff/同步机制 |
| 局部更新 | 未来可精确到单节点粒度更新 | 需要自己实现树 diff 才能局部更新 |
| 复杂度 | 低,Renderer + Runtime 职责清晰 | 高,多一层"运行时树"抽象 |
补充: buildRuntimeTree的优势:
- 可以形成一棵完整的"运行时中间结构" ------ 可观测性、可调试性强
- 先构建完整树,再渲染 ------ Runtime 拥有全局信息,更适合做"全局优化和调度"
- 有一棵"完整的语义树" ------ 更适合做依赖追踪、局部更新、编译优化
从架构演进角度来看,未来在需要依赖追踪、局部更新或性能优化时, 更适合逐步向 buildRuntimeTree 模型演进。
七、总结与展望
通过引入 Schema Runtime 这一逻辑解释层,我将低代码渲染引擎的复杂度拆解为两个清晰的部分:
解释层:负责所有语义解析(condition、loop、scope)
渲染层:只负责最简单的组件映射和渲染
这种分层设计让系统更加可测试、可扩展、可维护。
未来,我会基于这个稳定的地基,继续接入更多 Schema 层面的逻辑能力,例如:
- slot(插槽)
- fragment(片段)
- portal(传送门)
让低代码平台真正拥有"声明式逻辑描述"的能力。