在广义的前端领域,模型驱动视图已经不是什么新鲜话题了,“低代码”和“搭建”也炙手可热,而这些概念都是以增强应用系统的可配置性为前提的。在这个大前提下,建立元数据驱动的前端架构就变得很重要了。
本次分享的目标是希望从零开始,初步建立一个小小的元数据驱动的原型系统(暂时只包括前端部分),并以此介绍这套系统与业务领域的可能结合方式。
模型驱动的视图
从最简单的结构来看,一个模型驱动的视图体系包含以下要素:
- 模型
- 定义状态结构
- 定义动作
- 视图
- 订阅状态
- 触发动作
这是很简单的一种渲染模式,可以适用于所有的场景(暂且忽略性能之类的情况)。
举例来说,我们尝试把状态与渲染分离:
type BooleanProps = {
value: boolean,
onChange: (v: boolean) => void
}
// 状态的持有者
const Boolean = (props: PropsWithChildren<BooleanProps>) => {
const { value, onChange, children } = props
const context: DataContextValue = {
value,
onChange
}
return <DataContext.Provider value={context}>{children}</DataContext.Provider>
}
// 仅渲染和触发变更
const Checkbox = () => {
const { value, onChange } = useContext(DataContext)
return (
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)
}
// 两者的组合
const Demo = () => {
const [value, onChange] = useState(false)
return (
<Boolean value={value} onChange={onChange}>
<Checkbox />
</Boolean>
)
}
在这个例子中,Boolean 组件持有状态,而下层的 Checkbox 只负责消费这个状态,或者触发上层传入的修改状态的动作。
进而,可以造出更加泛化的数据表达形态:
type DataProps<T> = {
value: T,
onChange: (v: T) => void
}
// 状态的持有者
const Data = <T>(props: PropsWithChildren<DataProps<T>>) => {
const { value, onChange, children } = props
const context: DataContextValue = {
value,
onChange
}
return <DataContext.Provider value={context}>{children}</DataContext.Provider>
}
const Demo2 = () => {
const [value1, onChange1] = useState(false)
const [value2, onChange2] = useState('hello')
return (
<>
<Data value={value1} onChange={onChange1}>
<Checkbox />
</Data>
<Data value={value2} onChange={onChange2}>
<Input />
</Data>
</>
)
}
到这里,我们可以注意到,在同一个数据上下文之下,可以拥有若干个共享该数据的纯渲染组件,也有机会在不影响整体结构的情况下,把 Checkbox 换成与之等价的其他交互,比如 Switch,并不会影响业务的表达。甚至我们在 Data 下面添加任意的布局组件,也不会产生额外的改动。
之前的结构中,我们对于状态的操作方式还是非常简单的,只有读写两种操作,还可以使用 useReducer 进一步拓展,支持添加更多的自定义动作响应:
const Demo = () => {
// reducer 可以是外部注册的
const [state, dispatch] = useReducer(reducer, initialCount, init)
const context: DataContextValue = {
state,
dispatch
}
return <DataContext.Provider value={context}>{children}</DataContext.Provider>
}
在这个时候,下层渲染组件的能力包括:
- 消费状态
- 触发外层提供的动作来改变状态
更极端一点,这里的各种动作都可以是在外部注册的,这样,可以把动作的实现外置,放在某些类似 serverless 的体系中去支撑。
并且,我们发现,渲染部分仍然是很轻量的,而且可以很容易有跨平台实现。
对元数据的初步认知
以上的例子仍然太过简单了,我们逐步去看一些更加复杂的,比如表格和表单的状态结构:
表格:
const Table = () => {
// 表头信息
// 行记录信息
}
表单:
const Form = () => {
// 字段信息
// 字段值信息
}
如果是按照之前的理念来实现,我们当然也可以把这些信息全部糅合到状态里,类似这样:
const Foo = () => {
const [state, setState] = useState({
fields: [],
records: []
})
return <Table fields={state.fields} state={state.records} />
}
表单也是类似这样的:
const Foo = () => {
const [state, setState] = useState({
fields: [],
record: {}
})
// 假定我们有一个叫做 Form 的组件,内部展开这些字段和数据
return <Form fields={state.fields} state={state.record} />
}
这里的 fields 就是一种没有经过抽象的元数据,我们可以考虑对这些代码进行一种初步抽象,把字段信息隔离出去:
type FieldsProviderProps = {
fields: Field[]
}
const FieldsProvider = (props: PropsWithChildren<FieldsProviderProps>) => {
const { fields } = props
const context: FieldContextValue = {
fields
}
return <FieldContext.Provider context={context}>{children}</FieldContext.Provider>
}
const Demo = () => {
const fields = [] // 字段定义
const [state, setState] = useState([])
return (
<FieldsProvider fields={fields} state={state}>
<Table />
<FormList />
</FieldsProvider>
)
}
经过这样的抽象过程,我们把一些独立于数据状态的描述信息抽取出去,单独处理了。最下层的组件仍然职责很单一,只是与之前相比,多了使用一些配置信息的权利。
类似这种字段配置,就是一种元数据。它实际上是另外一个层面的类型信息,可以携带对业务模型的定义。
使用 Schema 描述数据结构
刚才的示例促使我们进行思考:在很多时候,我们需要运行时获取模型结构定义的详细信息。如果我们始终拥有这种信息,会导致编程过程变得不一样吗?
比如说,当我们试图表达一个任务实体的时候:
type Task = {
title: string,
completed: boolean
}
它可以分解为最原子的数据类型的组合,而每种类型又可以使用一个描述数据来约束,据此,我们尝试描述各种常见数据类型的结构:
type BooleanSchema = {
type: 'boolean',
default?: boolean
}
type StringSchema = {
type: 'string',
default?: string
}
type NumberSchema = {
type: 'number',
default: number
}
type ObjectSchema = {
type: 'object',
properties: Record<string, Schema>,
default?: Object
}
type ArraySchema = {
type: 'array',
items: Schema,
default?: []
}
type Schema = BooleanSchema | NumberSchema | StringSchema | ObjectSchema | ArraySchema
上面的这些类型定义很简陋,但是可以初步描述数据的基本形态。在此之上,可以更进一步,直接把业务的领域模型表达出来,比如,把前面示例中的 Task,可以换成这样的方式来描述:
const taskSchema = {
type: 'object',
properties: {
title: {
type: 'string'
},
completed: {
type: 'boolean'
}
}
}
这样,我们可以重构刚才的代码结构,变成下面这种形状:
const Demo = () => {
return (
<SchemaProvider schema={schema}>
<Table />
<FormList />
</SchemaProvider>
)
}
在 SchemaProvider 中,我们可以从定义中取出当前类型的初始值,甚至可以自动生成一个校验函数,以验证给定数据是否符合自身描述的规则。
从 Schema 到 TypeScript 类型
至此,我们已经可以给一个承载状态的组件添加相应的 schema,但是,需要注意到,它对 TypeScript 的支持很不友好,schema 跟 value 没有建立比较好的关联。
设想有如下代码:
<Data schema={taskSchema} value={{}} />
在这个地方,当我们填写了 schema,然后为 value 传入数据的时候,它们并未产生关联,简单来说,在 DataProps 定义的时候,如果不建立 schema 与 value 之间的关联,至少需要两个泛型参数:
type DataProps<T1 extends Schema, T2> = {
schema: T1,
value: T2
}
在 T1 和 T2 之间,很明显 T1 的结构更可靠,那么,我们就考虑把类型定义变成下面这样,让 value 变成 schema 的一种类型运算:
type DataProps<T extends Schema> = {
schema: T,
value: ValueOf<T>
}
这样,我们就得实现 ValueOf 这么一个类型操作了,不难得出类似以下的代码:
type ValueOfBoolean<T extends BooleanSchema> = boolean
type ValueOfNumber<T extends NumberSchema> = number
type ValueOfString<T extends StringSchema> = string
type ValueOfObject<T extends ObjectSchema> = {
[K in keyof T['properties']]: ValueOf<T['properties'][K]>
}
type ValueOfArray<T extends ArraySchema> = Array<ValueOf<T['items']>>
type ValueOf<T extends Schema> = T extends BooleanSchema
? ValueOfBoolean<T>
: T extends NumberSchema
? ValueOfNumber<T>
: T extends StringSchema
? ValueOfString<T>
: T extends ObjectSchema
? ValueOfObject<T>
: T extends ArraySchema
? ValueOfArray<T>
: unknown
这时候,再看看刚才的数据类型:
const Demo = () => {
return (
<Data
schema={{
type: 'object',
properties: {
title: {
type: 'string',
},
completed: {
type: 'boolean',
},
},
}}
value={{ title: '' }}
/>
)
}
就能够实时校验出 value 结构的错误了。
语义化的数据展开
建立了完整的 schema 结构之后,我们再回头去看表格和表单,就会发现比较简单了。
我们会发现,它们其实是两种迭代模式,一种是对象迭代为字段,一种是列表迭代为列表项。如果在迭代过程中拥有字段这类信息,那么,整个迭代过程都是可以抽象的。
比如这里是简单的字段迭代的过程:
type ObjectIteratorProps<T extends ObjectSchema> = {
schema: T,
value: ValueOf<T>,
onChange: (v: ValueOf<T>) => void
}
const ObjectIterator = <T extends ObjectSchema>(props: PropsWithChildren<ObjectIteratorProps<T>>) => {
const { schema, value, onChange, children } = props
return Object.keys(schema.properties).map((key) => {
const fieldSchema = schema.properties[key]
const fieldValue = value[key]
const fieldOnChange = (v) => {
onChange({
...value,
key: v,
})
}
return (
<Field key={key} value={fieldValue} onChange={fieldOnChange}>
{children}
</Field>
)
})
}
在使用的时候,可以:
const Demo = () => {
const [value, onChange] = useState<ValueOf<taskSchema>()
return <ObjectIterator schema={taskSchema} value={value} onChange={onChange}></ObjectIterator>
}
类似,ListIterator 也可以很容易表达出来。这样,我们之前碰到的表格表单,或者类似的形态,就有了比较统一的抽象方式了。
更夸张一些,我们还可以对常见的数据结构都实现一遍这样的组件,而且内部可以做很多优化,比如虚拟滚动之类的,这样,就减轻了渲染组件的负担。
基于类型的等价交互
在业务中,我们常常看到若干种交互形态,其内在的数据结构完全一致。在之前的示例中,已经简单看到一些了。
在软件架构中,一个很重要的过程是在抽象的基础上合并同类项。回到刚才的场景,我们会发现,对字段的描述,实际上是很通用的,这部分信息很大程度上并非来自前端,而是业务建模的一个体现。
这就是说,只要存在能够表达这种业务模型的最低交互,它在业务上就是可用的,只是不一定友好。然后,在不修改其他代码的情况下,替换为表达能力等价,但是交互更友好的渲染器,就可以提升这部分的体验。
举例来说,假设我们有一个下象棋的游戏,已知规则,但是暂时还没时间写棋盘和棋子,能不能在表单和表格里面下棋呢?
下面展示一个 demo,一个可以在表单中下的象棋游戏,篇幅所限,暂不放出代码,在现场有过演示。
从这里我们就可以认识到,棋盘和表单,尽管形态差异非常大,实际上是等价的。推而广之,我们甚至可以用表单表达一切业务。
小结
理想状态下,应用架构可以划分以下两个部分
- 业务:领域模型
- 基础设施:框架与服务
在这种状态下,我们期望:
业务专家尽可能不需要去关注具体实现,而通过某种方式描述和表达业务细节,这就是业务建模。
比如说,当我们做业务建模的时候,并不需要去额外关心:
- 使用什么数据库存储数据
- 使用什么服务端开发框架
- 使用什么 Web 或者客户端开发框架
而是侧重于描述:
- 当前是什么业务?
- 有哪些领域模型?
- 关联关系如何?
- 支持什么操作?
- 有什么校验逻辑?
- 权限如何分配?
然后,尽可能把技术设施变成一个底层实现多样化的业务解释引擎,再去具体组合业务。
在以上的探讨中,我们已经努力去做了以下事项:
- 建立了简单的领域模型解释层
- 建立了可替换的等价交互体系
- 实现了常见数据结构的展开机制
- 把包含“逻辑”的部分尽可能隔离出去
在此基础上,前端部分成为了对领域模型的解释引擎,视图的组合与布局都不再影响业务正确性。沿着这个角度思考,我们可以看到更多的可能性,比如:
<DataSource schema={model}>
<Query />
<Table />
</DataSource>
更语义化地表达:数据源、查询、请求、异常 等概念,并且定义它们的组合方式。
而更大的体系,则是前后端一体化,整个都是业务领域的解释引擎,元数据从存储、到传输、再到呈现,一直伴随整个应用的生命周期。
这个时候,我们发现,一个完整的“配置化”的业务软件系统,就拥有了完整的表达链路了。
注:本文主要是为了说明基于元数据思考的方式,本身的实现很简陋,也并不代表需要这样完全从底层建立应用架构,在一些环节,社区早已存在很多相关库可以使用了。
本文是在厦门稿定的现场分享稿,感谢 @doodlewind(doodlewind) 邀请。