低代码接入外部数据能力:DataSource Runtime 的设计

27 阅读6分钟

一、为什么需要 DataSource

在低代码系统中,从“页面渲染”到“应用构建”,我目前的系统已经具备:

  • 动态数据绑定({{state.xxx}}
  • 生命周期执行(onMount)
  • 条件渲染 / 循环(condition / loop)

但有一个关键问题:页面的数据从哪里来?如何对接真实业务 API?

用 Lifecycle 当然能做:在 onMount 里手写 fetch、解析 JSON、调 ctx.set。但每个接口都要重复这套样板代码。

因此我引入了 DataSource:让 Schema 具备"声明式接入外部数据"的能力。Schema 只需要说"我要 /api/users 的数据",Runtime 负责剩下的一切。


二、DataSource 在低代码 Runtime 中的角色

在 Runtime 体系中,DataSource 扮演的是"外部数据输入层"。它与其他 Runtime 模块的关系如下:

Runtime 能力层次
├── Context       → 数据容器(存储 state / data)
├── Expression    → 数据读取({{state.xxx}} / {{data.xxx}})
├── Lifecycle     → 副作用通道 A:执行自定义逻辑
└── DataSource    → 副作用通道 B:请求外部数据

Lifecycle 和 DataSource 是并行的两条副作用通道,各自独立地将结果写入 Context,最终都通过调度系统驱动 UI 更新。


三、DataSource 模型设计

当前是最小实现,我先建立了基础模型,设计如下:

Schema 协议

{
  "dataSource": {
    "list": [
      {
        "id": "userList",
        "type": "fetch",
        "options": {
          "uri": "/api/list",
          "method": "GET"
        },
        "isInit": true
      }
    ]
  }
}

DataSourceItem 类型定义

interface DataSourceItem {
  id: string              // 数据源标识,结果存入 ctx.data[id]
  type: 'fetch'           // V1 只支持 fetch
  options: {
    uri: string           // 请求地址
    method?: 'GET' | 'POST'  // 默认 GET
  }
  isInit?: boolean        // 是否组件挂载时自动请求,默认 false
}

interface DataSourceConfig {
  list: DataSourceItem[]
}

模型拆解

DataSourceItem
├── id        → 标识 + 结果存储位置(ctx.data[id])
├── type      → 请求类型(V1 仅 fetch)
├── options   → 请求配置(uri / method)
└── isInit    → 触发时机(true = 挂载时自动请求)

这是典型的:声明式(Schema) + 命令式(Runtime) 设计: Schema 只描述"数据是什么",Runtime 决定"如何获取数据"。


四、实现方案

4.1 整体架构

sequenceDiagram
    participant Schema as Schema (声明)
    participant RendererNode as RendererNodeItem
    participant PreviewRT as PreviewRuntime
    participant Handler as DataSourceHandler
    participant Ctx as RuntimeContext
    participant Root as RendererRoot

    Note over Schema,Root: 组件挂载阶段
    RendererNode->>RendererNode: mount
    RendererNode->>PreviewRT: useEffect → runDataSource(ctx, schema)
    PreviewRT->>PreviewRT: 遍历 isInit=true 的 DataSourceItem
    loop 每个需要初始化的数据源
        PreviewRT->>Handler: dataSourceHandler(uri, method)
        Handler-->>PreviewRT: Promise<data>
        PreviewRT->>Ctx: ctx.set('data.' + id, data)
    end
    Ctx-->>Root: notifyUpward (向上冒泡)
    Root->>Root: rerender
    Root-->>RendererNode: UI 更新

4.2 请求处理器:DataSourceHandler

DataSource 的核心问题是"谁来发请求",解决方案是抽出 handler 接口,将"请求策略"与"请求时机"分离:

// handler 接口:只关心"给我 uri 和 method,返回数据"
type DataSourceHandler = (
  options: { uri: string; method: string },
) => Promise<any>

// 默认实现:fetch
const defaultDataSourceHandler: DataSourceHandler = async (options) => {
  const res = await fetch(options.uri, { method: options.method })
  return res.json()
}

handler 通过 PreviewRuntime 构造函数注入:

// 生产环境:使用fetch handler
const runtime = new PreviewRuntime({dataSourceHandler:defaultDataSourceHandler})

// 单元测试:注入 mock handler
const runtime = new PreviewRuntime({
  dataSourceHandler: async () => [{ name: 'Alice' }, { name: 'Bob' }]
})

这样 Runtime 只负责"何时调用 handler、结果存到哪里",具体的请求实现由调用方在创建 Runtime 时注入。

4.3 执行阶段:runDataSource

PreviewRuntime 的 runDataSource 是纯执行逻辑:遍历 → 过滤 → 请求 → 存储。

runDataSource(ctx: RuntimeContext, schema: Schema): void {
  const items = schema.dataSource?.list
  if (!items) return

  for (const item of items) {
    if (!item.isInit) continue
    this.dataSourceHandler({ uri: item.options.uri, method: item.options.method ?? 'GET' })
      .then(data => ctx.set(`data.${item.id}`, data))     // 结果存入 ctx.data[id]
      .catch(e => console.warn(`[DataSource] ${item.id} 请求失败:`, e))
  }
}

将结果按约定存入 ctx.data[id]。与 Lifecycle 存入 state 不同,DataSource 有自己专属的数据槽位 data,表达式引擎通过 {{data.userList}} 读取。

DesignerRuntime(设计态)空实现,通常通过 mock 数据或静态数据,不发请求:

runDataSource(): void {}

4.4 接入 Renderer 层

与 Renderer 层现有 useEffect 的集成。RendererNodeItem 的 useEffect 同时触发 lifecycle 和 dataSource:

useEffect(() => {
    runtime.runLifecycle('onMount', item.ctx, item.schema)
    runtime.runDataSource(item.ctx, item.schema)       // 触发 DataSource 执行
    return () => {
        runtime.runLifecycle('onUnmount', item.ctx, item.schema)
    }
}, [])

至此数据进入系统,形成完整闭环:fetch → ctx.set → UI 更新。
执行顺序:onMount(有异步任务)和 runDataSource 是并发执行的,不保证完成先后顺序,两者相互独立。


五、示例说明

如下 Schema,展示了一个从请求列表数据到 UI 更新的完整实现:

const schema: Schema = {
  id: 'ds-demo',
  componentName: 'div',
  state: {},
  dataSource: {
    list: [
      {
        id: 'userList',
        type: 'fetch',
        options: { uri: 'https://jsonplaceholder.typicode.com/posts', method: 'GET' },
        isInit: true,
      },
    ],
  },
  children: [
    {
      id: 'user-item',
      componentName: 'p',
      loop: 'data.userList',
      loopArgs: ['user'],
      props: { children: '标题是:{{user.title}}' },
    },
  ],
}

执行流程:

1. 页面加载 → RendererNodeItem 挂载
2. useEffect 触发 → runtime.runDataSource(ctx, schema)
3. 遍历 dataSource → userList 的 isInit=true → 发起请求
4. handler({ uri: '.../posts', method: 'GET' }) → fetch → 返回 data
5. ctx.set('data.userList', data) → notifyUpward → RendererRoot rerender
6. loop 'data.userList' 展开 → 渲染 UI

六、关键决策

6.1 DataSourceHandler 通过构造函数注入

Runtime 内部不关心数据怎么拿(fetch / axios / mock),只关心"何时拿、存到哪"。handler 通过构造函数注入,遵循依赖注入原则:上层决定策略,Runtime 只负责执行。
所以 测试可以通过注入 mock handler,模拟任意接口的返回,降低调试复杂度。

6.2 结果存 data 槽位而不是 state

RuntimeContext 有两个数据槽位:state(用户可变状态)和 data(外部数据)。DataSource 的结果约定存入 data
好处是职责清晰:state 是页面自己管理的状态(如 loading、visible),data 是外部注入的数据(如 API 返回的列表)。
两者来源不同、生命周期不同,分开存储让 Schema 结构一眼就能区分。

6.3 为什么不直接用 Lifecycle 做数据请求?

目前 DataSource 能做的事(请求接口,获取数据,绑定到data),Lifecycle 手写也能做到。那为什么还要单独建一套 DataSource 系统?

方案A:不引入 DataSource

所有数据请求都用 Lifecycle 的 onMount + methods 手写。
优点:

  • 系统更简单,只有一套副作用机制。

缺点:

  • 每个接口都要重复写 fetch → parse → ctx.set 的样板代码,Schema 体积膨胀
  • Runtime 无法识别哪些是数据请求,无法做统一的缓存、状态管理、错误处理。

方案B:引入 DataSource

把"请求外部接口"这个最高频场景抽成声明式配置。
缺点:

  • 系统多了1套类型(DataSourceItem)、1个接口方法(runDataSource)、1系列 handler

优点:

  • 只需要通过声明式方式在 Schema 里配置 uri 和 method等信息,简单通用
  • Runtime 统一管控请求行为,后续可以在 Runtime 层加缓存、加 loading、加重试,而不需要单独处理。

所以我最终选择引入 DataSource,因为数据请求是低代码场景中最普遍的副作用,用一层抽象来标准化是很有必要的。
它们二者的使用场景:DataSource 处理标准的接口请求,Lifecycle 处理需要自定义逻辑的场景。


七、总结

引入 DataSource 之前,页面的数据要么写死在 Schema 的 state 里,要么在 Lifecycle 里手写 fetch 逻辑。引入之后,Schema 只需要声明"我要哪个接口的数据",Runtime 自动完成请求、存储、更新的完整链路。

回顾整个系统的能力演进,每一层所解决的问题:

Renderer    → 能显示       将 Schema 的 UI 结构渲染到页面
Expression  → 能计算       能绑定 {{state.count * price}} 动态数据到 UI
Lifecycle   → 能执行       在合适时机(onMount)触发自定义逻辑
DataSource  → 能接入数据    声明式方式对接外部 API

最终形成 Schema = UI 结构 + 数据绑定 + 行为逻辑 + 外部数据 —— 一段 JSON 能描述一个完整页面。


八、展望

当前 V1 版本的 DataSource 是基础版,可以简单配置 API 的 option 和 Data-id 等信息,Runtime 自动完成接口数据请求和数据绑定。
后续我会继续演进,如下:

能力增强方向

  • 支持options.params / headers / body
  • 请求状态的管理:ctx.data.loading / ctx.data.error
  • Plugin 扩展数据源类型:注册新的 DataSource type(如 WebSocket、GraphQL)

与 Runtime 深度结合

  • 页面交互 手动触发:ctx.reloadDataSource('userList')
  • URI 支持表达式:"/api/users/{{state.userId}}"
  • Lifecycle 可直接调用 DataSource

最终完成 DataSource 真正的"数据入口"职责,实现从数据到 UI 的全链路支持。