低代码接入外部数据能力:DataSource Runtime 的设计
一、为什么需要 DataSource
在低代码系统中,从"页面渲染"到"应用构建",我目前的系统已经具备:
- 动态数据绑定(
{{state.xxx}}) - 生命周期执行(onMount)
- 条件渲染 / 循环(condition / loop)
但有一个关键问题:页面的数据从哪里来?如何对接真实业务 API?
用 Lifecycle 当然能做:在 onMount 里手写 fetch、解析 JSON、调 ctx.set。但每个接口都要重复这套样板代码。
因此我引入了 DataSource :让 Schema 具备"声明式接入外部数据"的能力。Schema 只需要说"我要 /api/users 的数据",Runtime 负责剩下的一切。
二、DataSource 在低代码 Runtime 中的角色
在 Runtime 体系中,DataSource 扮演的是"外部数据输入层"。它与其他 Runtime 模块的关系如下:
css
Runtime 能力层次
├── Context → 数据容器(存储 state / data)
├── Expression → 数据读取({{state.xxx}} / {{data.xxx}})
├── Lifecycle → 副作用通道 A:执行自定义逻辑
└── DataSource → 副作用通道 B:请求外部数据
Lifecycle 和 DataSource 是并行的两条副作用通道,各自独立地将结果写入 Context,最终都通过调度系统驱动 UI 更新。
三、DataSource 模型设计
当前是最小实现,我先建立了基础模型,设计如下:
Schema 协议
json
{
"dataSource": {
"list": [
{
"id": "userList",
"type": "fetch",
"options": {
"uri": "/api/list",
"method": "GET"
},
"isInit": true
}
]
}
}
DataSourceItem 类型定义
typescript
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[]
}
模型拆解
bash
DataSourceItem
├── id → 标识 + 结果存储位置(ctx.data[id])
├── type → 请求类型(V1 仅 fetch)
├── options → 请求配置(uri / method)
└── isInit → 触发时机(true = 挂载时自动请求)
这是典型的:声明式(Schema) + 命令式(Runtime) 设计: Schema 只描述"数据是什么",Runtime 决定"如何获取数据"。
四、实现方案
4.1 整体架构
4.2 请求处理器:DataSourceHandler
DataSource 的核心问题是"谁来发请求",解决方案是抽出 handler 接口,将"请求策略"与"请求时机"分离:
typescript
// 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 构造函数注入:
typescript
// 生产环境:使用fetch
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 是纯执行逻辑:遍历 → 过滤 → 请求 → 存储。
typescript
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 数据或静态数据,不发请求:
typescript
runDataSource(): void {}
4.4 接入 Renderer 层
与 Renderer 层现有 useEffect 的集成。RendererNodeItem 的 useEffect 同时触发 lifecycle 和 dataSource:
typescript
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 更新的完整实现:
typescript
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}}' },
},
],
}
执行流程:
kotlin
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 信息和 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 的全链路支持。