低代码 Runtime Lifecycle:逻辑执行时机

10 阅读6分钟

一、为什么需要 Runtime Lifecycle

在当前我的低代码 Runtime 中,我已经具备了:

  • 上下文作用域管理 —— RuntimeContext
  • 表达式执行引擎 —— Expression Engine
  • 逻辑解释能力 —— Runtime Schema
  • 基础响应式系统 —— (ctx.set → UI 更新)

也就是目前能描述"页面长什么样"、"数据怎么绑定",但还缺少一个关键能力:Runtime 无法解决“逻辑在什么时机被触发”
换句话说:

  • Schema 解决的是「做什么」(声明能力)
  • Runtime Lifecycle 要解决的是「什么时候做」(时序调度)

而这正是低代码走向“动态应用”的关键一步。

一个最真实的场景,如下Schema,表达的是页面加载后从接口拉取数据,并展示出来。

{
  "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) }"
    }
  }
}

那么 onMount 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 描述符),如下:

{
  "type": "JSFunction",
  "value": "function(ctx) { ctx.set('state.list', [...]) }"
}

  • type: "JSFunction" 是类型标记,告诉 Runtime 这是一段需要编译的函数体
  • value 是函数体的源码字符串

3.2 函数怎么变成可执行代码?—— 编译与执行分离

我的设计是将"编译"和"执行"拆成两个独立阶段:

Schema 层(存储)       → methods/lifeCycles 以字符串存储
    ↓
Runtime 编译阶段        → new Function() 把字符串编译成真实函数,缓存起来
    ↓
Runtime 执行阶段        → 在合适时机从缓存取出函数,fn(ctx) 调用

"编译"和"执行"分离是因为编译只需要做一次,但触发可能发生多次。
比如:一个 loop 节点渲染 100 个列表项,只需编译 1 次,可执行 100 次。


3.3 执行时机怎么挂?—— 借力 React useEffect

我目前的低代码引擎的渲染层是基于 React。
React 已经提供了完善的生命周期机制 —— useEffect,所以直接借力:

useEffect(() => {
    runtime.runLifecycle('onMount', ctx, schema)   // 挂载后执行
    return () => {
        runtime.runLifecycle('onUnmount', ctx, schema) // 卸载前执行
    }
}, [])

Runtime 只负责定义生命周期语义,而具体调度交给渲染层框架(React)


四、实现方案

4.1 整体架构(画架构图)

Runtime Lifecycle 整体架构.jpg


4.2 编译阶段

编译的核心模块是 new Function()

输入字符串: "function(ctx) { ctx.set('state.list', [...]) }"new Function('"use strict"; return (function(ctx) { ... })')()
                    ↓
输出: 一个真实的 Function 对象

编译的核心阶段是 createContext

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 这里编译,但存储位置不同:

产物存储位置原因
methodsctx.methods需要被其他代码通过 ctx.methods.fetchList(ctx) 调用
lifeCyclesRuntime 内部 Map只有 Runtime 自己在特定时机调用,不需要暴露

4.3 执行阶段

执行阶段主要是 Renderer 调度,通过 Runtime 提供的方法 runLifecycle 完成,纯粹的查找 + 调用,如下:

runLifecycle(name: 'onMount' | 'onUnmount', ctx, schema) {
    const fn = this.compiledLifeCycles.get(schema.id)?.[name]
    fn(ctx)
}

五、示例说明

一个完整的"页面加载 → 拉取列表 → 渲染"场景:
Schema 表示如下:

{
  "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}}" } }
  ]
}

执行时序:

sequenceDiagram
    participant App as 页面
    participant Root as RendererRoot
    participant Node as RendererNodeItem
    participant Runtime as Lifecycle调度器
    participant Methods as fetchList
    participant Reactive as 响应式系统( ctx )

    App->>Root: 页面加载,渲染根节点
    Root->>Root: createContext 编译 methods / lifeCycles
    Root->>Node: 渲染节点
    Node->>Node: 挂载 ( useEffect 触发 )
    Node->>Runtime: runLifecycle('onMount')
    Runtime->>Methods: 执行 onMount → 调用 fetchList()
    Methods->>Methods: 异步请求
    Methods->>Reactive: ctx.set('state.loading', false)
    Methods->>Reactive: ctx.set('state.list', data)
    Reactive-->>Node: 依赖变更,触发 rerender
    Node-->>Root: 重新渲染

整个过程,所有行为都由 Schema 声明,Runtime 自动编排完成的。


六、关键决策

6.1 为什么用 ctx 参数而不是 this?

// 我选择了这种方式
"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 上的数据
JSFunctionmethods / lifeCyclesnew Function() + "use strict"中 — 能执行任意 JS

两者职责不同、互不干涉:

  • Expression Engine 负责读数据(state.count、item.name)
  • JSFunction 负责执行逻辑(发请求、改状态、副作用)

6.3 为什么 lifeCycles 和 methods 要分别缓存在 Runtime 和 ctx 上?

createContext 编译 methods 和 lifeCycles,分别存在了不同的位置。
methods 存在 ctx 上,是因为其他代码需要通过 ctx.methods.xxx(ctx) 调用它。
而 lifeCycles 由 Runtime 调度,在 useEffect 触发时内部调用,所以存在 Runtime 内部的 Map 缓存里,职责更清晰,也避免了 ctx 上的属性膨胀。

七、总结

Runtime Lifecycle 补上了低代码引擎从"静态描述"到"动态应用"的关键一环:

Schema Runtime  → 数据绑定、条件渲染、循环渲染
     +
Lifecycle Runtime → 逻辑执行时机、JSFunction 编译
     =
一段 JSON 描述一个完整的动态页面

核心设计主要做了三件事:

  1. JSFunction 协议 — 让函数以 JSON 可序列化的形式存在 Schema 中
  2. 编译与执行分离 — 编译一次,执行多次,缓存避免重复开销
  3. 借力 Renderer 层的 useEffect — 不重新发明生命周期,复用 Renderer 层已有的机制

八、展望

当前 V1 已经覆盖了核心的 onMount / onUnmount,后续我打算围绕三个方向继续演进:

1. 生命周期能力增强

  • onUpdate(结合依赖追踪)
  • async lifecycle(异步模型)

2. 表达能力扩展

  • props 支持 JSFunction(事件系统)

3. 调度系统升级

  • 引入 Scheduler 统一生命周期执行,支持批处理、优先级

最终目标是演进为一套“完整的运行时调度系统”。