一、为什么需要 Runtime Lifecycle
在当前我的低代码 Runtime 中,我已经具备了:
- 上下文作用域管理 ------ RuntimeContext
- 表达式执行引擎 ------ Expression Engine
- Schema 逻辑解释能力 ------ Runtime Schema
- 基础响应式系统 ------ (ctx.set → UI 更新)
也就是目前能描述了"页面长什么样"、"数据怎么绑定",但还缺少一个关键能力:Runtime 无法解决"逻辑在什么时机被触发"
换句话说:
- Schema 解决的是「做什么」(声明能力)
- Runtime Lifecycle 要解决的是「什么时候做」(时序调度)
而这正是低代码走向"动态应用"的关键一步。
一个最真实的场景,如下Schema,表达的是页面加载后从接口拉取数据,并展示出来。
json
{
"state": { "list": [] },
"methods": {
"fetchList": {
"type": "JSFunction",
"value": "function(ctx) { fetch('/api/list').then(ctx.set('state.list', ...) }"
}
},
"lifeCycles": {
"onMount": {
"type": "JSFunction",
"value": "function(ctx) { ctx.methods.fetchList(ctx) }"
}
}
}
那么 fetchList 谁来调用它?什么时候调用?
这就是 Runtime Lifecycle要解决的问题。
二、Lifecycle 在低代码 Runtime 中的角色
从架构角度看,Lifecycle 本质是 Runtime 提供的一种"时序扩展点",允许业务逻辑在特定阶段接入,而不侵入核心渲染流程。
它解决的是"如何在不破坏 Runtime 主流程的情况下,引入副作用逻辑"。
拆开来看,Lifecycle 这套机制主要解决下面几个子问题:
| 问题 | 具体含义 |
|---|---|
| 何时执行 | 组件挂载后?卸载前?数据变化时? |
| 执行什么 | Schema 里面的 string 函数体,变成可执行的函数,并在合适的时机被调用 |
| 执行的上下文 | 函数体里的 ctx.set('state.list', ...) ,ctx 是谁 |
用一句话概括(onMount 钩子为例):在组件挂载后,将 Schema 中的 string 函数体编译成的真实函数,再用当前节点的 RuntimeContext 去调用它。
三、生命周期模型设计
3.1 函数存在哪里?------ JSFunction 协议
低代码平台的第一原则:Schema 是应用的完整描述 。数据在 Schema 里、结构在 Schema 里、逻辑也在 Schema 里。
但 JSON 没有函数类型。怎么办?
我参考了常见的 Schema 驱动的平台的做法,用一个描述符来表示函数,即 JSFunction 协议(type + value 描述符),如下:
json
{
"type": "JSFunction",
"value": "function(ctx) { ctx.set('state.list', [...]) }"
}
type: "JSFunction"是类型标记,告诉 Runtime 这是一段需要编译的函数体value是函数体的源码字符串
3.2 函数怎么变成可执行代码?------ 编译与执行分离
我的设计是将"编译"和"执行"拆成两个独立阶段:
scss
Schema 层(存储) → methods/lifeCycles 以字符串存储
↓
Runtime 编译阶段 → new Function() 把字符串编译成真实函数,缓存起来
↓
Runtime 执行阶段 → 在合适时机从缓存取出函数,fn(ctx) 调用
"编译"和"执行"分离是因为编译只需要做一次,但触发可能发生多次。
比如:一个 loop 节点渲染 100 个列表项,只需编译 1 次,可执行 100 次。
3.3 执行时机怎么挂?------ 借力 React useEffect
我目前的低代码引擎的渲染层是基于 React。
React 已经提供了完善的生命周期机制 ------ useEffect,所以直接借力:
typescript
useEffect(() => {
runtime.runLifecycle('onMount', ctx, schema) // 挂载后执行
return () => {
runtime.runLifecycle('onUnmount', ctx, schema) // 卸载前执行
}
}, [])
即Runtime 只负责定义生命周期语义,而具体调度交给渲染层框架(React)
四、实现方案
4.1 整体架构(画架构图)

4.2 编译阶段
编译的核心模块是new Function():
javascript
输入字符串: "function(ctx) { ctx.set('state.list', [...]) }"
↓
new Function('"use strict"; return (function(ctx) { ... })')()
↓
输出: 一个真实的 Function 对象
编译的核心阶段:createContext:
typescript
createContext(schema, resolvedProps, parentCtx?) {
if (!parentCtx) {
// 根节点:编译 methods,存入 ctx
const methods = compileMethods(schema.methods)
this.rootCtx = createRuntimeContext({ ..., methods })
}
// 所有节点:编译 lifeCycles,缓存在 Runtime 内部
this.compileAndCacheLifeCycles(schema)
}
methods 和 lifeCycles 都在 createContext 这里编译,但存储位置不同:
| 产物 | 存储位置 | 原因 |
|---|---|---|
| methods | ctx.methods | 需要被其他代码通过 ctx.methods.fetchList(ctx) 调用 |
| lifeCycles | Runtime 内部 Map | 只有 Runtime 自己在特定时机调用,不需要暴露 |
4.3 执行阶段
执行阶段主要是 Renderer 调度,通过 Runtime 提供的接口 runLifecycle 完成,纯粹的查找 + 调用,如下:
typescript
runLifecycle(name: 'onMount' | 'onUnmount', ctx, schema) {
const fn = this.compiledLifeCycles.get(schema.id)?.[name]
fn(ctx)
}
五、示例说明
一个完整的"页面加载 → 拉取列表 → 渲染"场景:
Schema 表示如下:
json
{
"id": "page",
"componentName": "div",
"state": { "list": [], "loading": true },
"methods": {
"fetchList": {
"type": "JSFunction",
"value": "function(ctx) { setTimeout(function() { ctx.set('state.loading', false); ctx.set('state.list', [{name:'Alice'}, {name:'Bob'}]) }, 1000) }"
}
},
"lifeCycles": {
"onMount": {
"type": "JSFunction",
"value": "function(ctx) { ctx.methods.fetchList(ctx) }"
}
},
"children": [
{ "id": "tip", "componentName": "p", "condition": "state.loading", "props": { "children": "加载中..." } },
{ "id": "item", "componentName": "p", "loop": "state.list", "loopArgs": ["item"], "props": { "children": "{{item.name}}" } }
]
}
执行时序:
整个过程,所有行为都由 Schema 声明,Runtime 自动编排完成的。
六、关键决策
6.1 为什么用 ctx 参数而不是 this?
javascript
// 我选择了这种方式
"function(ctx) { ctx.set('state.list', [...]) }"
// 而不是
"function() { this.set('state.list', [...]) }"
三个原因:
- 显式优于隐式:ctx 从哪来一目了然,不存在 this 指向歧义
- 无需绑定 :不需要
.bind()/.call()等 this 绑定机制 - 一致性:和 Expression Engine 的求值上下文是同一个 ctx 对象
6.2 两套解析机制共存
引擎中存在两套"把字符串变成可执行逻辑"的机制:
| 机制 | 场景 | 实现 | 安全性 |
|---|---|---|---|
| Expression Engine | 数据绑定 {{state.count}} |
手写 Parser,白名单 AST | 高 --- 只能访问 ctx 上的数据 |
| JSFunction | methods / lifeCycles | new Function() + "use strict" |
中 --- 能执行任意 JS |
两者职责不同、互不干涉:
- Expression Engine 负责读数据(state.count、item.name)
- JSFunction 负责执行逻辑(发请求、改状态、副作用)
6.3 为什么 methods 要通过 createChild 继承?
如果子节点的 ctx 上没有 methods,ctx.methods.fetchList 就是 undefined。解决办法是在 createChild 时显式继承上级的方法:
typescript
createChild(extra?) {
return new RuntimeContext({
...extra,
methods: extra?.methods ?? this.methods, // 显式继承
}, this)
}
6.4 为什么 lifeCycles 和 methods 要分别缓存在 Runtime 和 ctx 上?
createContext 编译 methods 和 lifeCycles,分别存在了不同的位置。
methods 存在 ctx 上,是因为其他代码需要通过 ctx.methods.xxx(ctx) 调用它。
而lifeCycles 由 Runtime 调度,在 useEffect 触发时内部调用,所以存在 Runtime 内部的 Map 缓存里,职责更清晰,也避免了 ctx 上的属性膨胀。
七、总结
Runtime Lifecycle 补上了低代码引擎从"静态描述"到"动态应用"的关键一环:
markdown
Schema Runtime → 数据绑定、条件渲染、循环渲染
+
Lifecycle Runtime(本篇)→ 逻辑执行时机、JSFunction 编译
=
一段 JSON 描述一个完整的动态页面
核心设计有三件事:
- JSFunction 协议 --- 让函数以 JSON 可序列化的形式存在 Schema 中
- 编译与执行分离 --- 编译一次,执行多次,缓存避免重复开销
- 借力 useEffect --- 不重新发明生命周期,复用 Renderer 层已有的机制
八、展望
当前 V1 已经覆盖了核心的 onMount / onUnmount,后续我打算围绕三个方向继续演进:
1. 生命周期能力增强
- onUpdate(结合依赖追踪)
- async lifecycle(异步模型)
2. 表达能力扩展
- props 支持 JSFunction(事件系统)
3. 调度系统升级
- 引入 Scheduler 统一生命周期执行,支持批处理、优先级
最终目标是演进为一套"完整的运行时调度系统"。