在广义的前端领域,模型驱动视图已经不是什么新鲜话题了,“低代码”和“搭建”也炙手可热,而这些概念都是以增强应用系统的可配置性为前提的。在这个大前提下,建立元数据驱动的前端架构就变得很重要了。

本次分享的目标是希望从零开始,初步建立一个小小的元数据驱动的原型系统(暂时只包括前端部分),并以此介绍这套系统与业务领域的可能结合方式。

模型驱动的视图

从最简单的结构来看,一个模型驱动的视图体系包含以下要素:

  • 模型
    • 定义状态结构
    • 定义动作
  • 视图
    • 订阅状态
    • 触发动作

这是很简单的一种渲染模式,可以适用于所有的场景(暂且忽略性能之类的情况)。

举例来说,我们尝试把状态与渲染分离:

  1. type BooleanProps = {
  2. value: boolean,
  3. onChange: (v: boolean) => void
  4. }
  5. // 状态的持有者
  6. const Boolean = (props: PropsWithChildren<BooleanProps>) => {
  7. const { value, onChange, children } = props
  8. const context: DataContextValue = {
  9. value,
  10. onChange
  11. }
  12. return <DataContext.Provider value={context}>{children}</DataContext.Provider>
  13. }
  14. // 仅渲染和触发变更
  15. const Checkbox = () => {
  16. const { value, onChange } = useContext(DataContext)
  17. return (
  18. <input
  19. type="checkbox"
  20. checked={value}
  21. onChange={(e) => onChange(e.currentTarget.checked)}
  22. />
  23. )
  24. }
  25. // 两者的组合
  26. const Demo = () => {
  27. const [value, onChange] = useState(false)
  28. return (
  29. <Boolean value={value} onChange={onChange}>
  30. <Checkbox />
  31. </Boolean>
  32. )
  33. }

在这个例子中,Boolean 组件持有状态,而下层的 Checkbox 只负责消费这个状态,或者触发上层传入的修改状态的动作。

进而,可以造出更加泛化的数据表达形态:

  1. type DataProps<T> = {
  2. value: T,
  3. onChange: (v: T) => void
  4. }
  5. // 状态的持有者
  6. const Data = <T>(props: PropsWithChildren<DataProps<T>>) => {
  7. const { value, onChange, children } = props
  8. const context: DataContextValue = {
  9. value,
  10. onChange
  11. }
  12. return <DataContext.Provider value={context}>{children}</DataContext.Provider>
  13. }
  14. const Demo2 = () => {
  15. const [value1, onChange1] = useState(false)
  16. const [value2, onChange2] = useState('hello')
  17. return (
  18. <>
  19. <Data value={value1} onChange={onChange1}>
  20. <Checkbox />
  21. </Data>
  22. <Data value={value2} onChange={onChange2}>
  23. <Input />
  24. </Data>
  25. </>
  26. )
  27. }

到这里,我们可以注意到,在同一个数据上下文之下,可以拥有若干个共享该数据的纯渲染组件,也有机会在不影响整体结构的情况下,把 Checkbox 换成与之等价的其他交互,比如 Switch,并不会影响业务的表达。甚至我们在 Data 下面添加任意的布局组件,也不会产生额外的改动。

之前的结构中,我们对于状态的操作方式还是非常简单的,只有读写两种操作,还可以使用 useReducer 进一步拓展,支持添加更多的自定义动作响应:

  1. const Demo = () => {
  2. // reducer 可以是外部注册的
  3. const [state, dispatch] = useReducer(reducer, initialCount, init)
  4. const context: DataContextValue = {
  5. state,
  6. dispatch
  7. }
  8. return <DataContext.Provider value={context}>{children}</DataContext.Provider>
  9. }

在这个时候,下层渲染组件的能力包括:

  • 消费状态
  • 触发外层提供的动作来改变状态

更极端一点,这里的各种动作都可以是在外部注册的,这样,可以把动作的实现外置,放在某些类似 serverless 的体系中去支撑。

并且,我们发现,渲染部分仍然是很轻量的,而且可以很容易有跨平台实现。

对元数据的初步认知

以上的例子仍然太过简单了,我们逐步去看一些更加复杂的,比如表格和表单的状态结构:

表格:

  1. const Table = () => {
  2. // 表头信息
  3. // 行记录信息
  4. }

表单:

  1. const Form = () => {
  2. // 字段信息
  3. // 字段值信息
  4. }

如果是按照之前的理念来实现,我们当然也可以把这些信息全部糅合到状态里,类似这样:

  1. const Foo = () => {
  2. const [state, setState] = useState({
  3. fields: [],
  4. records: []
  5. })
  6. return <Table fields={state.fields} state={state.records} />
  7. }

表单也是类似这样的:

  1. const Foo = () => {
  2. const [state, setState] = useState({
  3. fields: [],
  4. record: {}
  5. })
  6. // 假定我们有一个叫做 Form 的组件,内部展开这些字段和数据
  7. return <Form fields={state.fields} state={state.record} />
  8. }

这里的 fields 就是一种没有经过抽象的元数据,我们可以考虑对这些代码进行一种初步抽象,把字段信息隔离出去:

  1. type FieldsProviderProps = {
  2. fields: Field[]
  3. }
  4. const FieldsProvider = (props: PropsWithChildren<FieldsProviderProps>) => {
  5. const { fields } = props
  6. const context: FieldContextValue = {
  7. fields
  8. }
  9. return <FieldContext.Provider context={context}>{children}</FieldContext.Provider>
  10. }
  11. const Demo = () => {
  12. const fields = [] // 字段定义
  13. const [state, setState] = useState([])
  14. return (
  15. <FieldsProvider fields={fields} state={state}>
  16. <Table />
  17. <FormList />
  18. </FieldsProvider>
  19. )
  20. }

经过这样的抽象过程,我们把一些独立于数据状态的描述信息抽取出去,单独处理了。最下层的组件仍然职责很单一,只是与之前相比,多了使用一些配置信息的权利。

类似这种字段配置,就是一种元数据。它实际上是另外一个层面的类型信息,可以携带对业务模型的定义。

使用 Schema 描述数据结构

刚才的示例促使我们进行思考:在很多时候,我们需要运行时获取模型结构定义的详细信息。如果我们始终拥有这种信息,会导致编程过程变得不一样吗?

比如说,当我们试图表达一个任务实体的时候:

  1. type Task = {
  2. title: string,
  3. completed: boolean
  4. }

它可以分解为最原子的数据类型的组合,而每种类型又可以使用一个描述数据来约束,据此,我们尝试描述各种常见数据类型的结构:

  1. type BooleanSchema = {
  2. type: 'boolean',
  3. default?: boolean
  4. }
  5. type StringSchema = {
  6. type: 'string',
  7. default?: string
  8. }
  9. type NumberSchema = {
  10. type: 'number',
  11. default: number
  12. }
  13. type ObjectSchema = {
  14. type: 'object',
  15. properties: Record<string, Schema>,
  16. default?: Object
  17. }
  18. type ArraySchema = {
  19. type: 'array',
  20. items: Schema,
  21. default?: []
  22. }
  23. type Schema = BooleanSchema | NumberSchema | StringSchema | ObjectSchema | ArraySchema

上面的这些类型定义很简陋,但是可以初步描述数据的基本形态。在此之上,可以更进一步,直接把业务的领域模型表达出来,比如,把前面示例中的 Task,可以换成这样的方式来描述:

  1. const taskSchema = {
  2. type: 'object',
  3. properties: {
  4. title: {
  5. type: 'string'
  6. },
  7. completed: {
  8. type: 'boolean'
  9. }
  10. }
  11. }

这样,我们可以重构刚才的代码结构,变成下面这种形状:

  1. const Demo = () => {
  2. return (
  3. <SchemaProvider schema={schema}>
  4. <Table />
  5. <FormList />
  6. </SchemaProvider>
  7. )
  8. }

在 SchemaProvider 中,我们可以从定义中取出当前类型的初始值,甚至可以自动生成一个校验函数,以验证给定数据是否符合自身描述的规则。

从 Schema 到 TypeScript 类型

至此,我们已经可以给一个承载状态的组件添加相应的 schema,但是,需要注意到,它对 TypeScript 的支持很不友好,schema 跟 value 没有建立比较好的关联。

设想有如下代码:

  1. <Data schema={taskSchema} value={{}} />

在这个地方,当我们填写了 schema,然后为 value 传入数据的时候,它们并未产生关联,简单来说,在 DataProps 定义的时候,如果不建立 schema 与 value 之间的关联,至少需要两个泛型参数:

  1. type DataProps<T1 extends Schema, T2> = {
  2. schema: T1,
  3. value: T2
  4. }

在 T1 和 T2 之间,很明显 T1 的结构更可靠,那么,我们就考虑把类型定义变成下面这样,让 value 变成 schema 的一种类型运算:

  1. type DataProps<T extends Schema> = {
  2. schema: T,
  3. value: ValueOf<T>
  4. }

这样,我们就得实现 ValueOf 这么一个类型操作了,不难得出类似以下的代码:

  1. type ValueOfBoolean<T extends BooleanSchema> = boolean
  2. type ValueOfNumber<T extends NumberSchema> = number
  3. type ValueOfString<T extends StringSchema> = string
  4. type ValueOfObject<T extends ObjectSchema> = {
  5. [K in keyof T['properties']]: ValueOf<T['properties'][K]>
  6. }
  7. type ValueOfArray<T extends ArraySchema> = Array<ValueOf<T['items']>>
  8. type ValueOf<T extends Schema> = T extends BooleanSchema
  9. ? ValueOfBoolean<T>
  10. : T extends NumberSchema
  11. ? ValueOfNumber<T>
  12. : T extends StringSchema
  13. ? ValueOfString<T>
  14. : T extends ObjectSchema
  15. ? ValueOfObject<T>
  16. : T extends ArraySchema
  17. ? ValueOfArray<T>
  18. : unknown

这时候,再看看刚才的数据类型:

  1. const Demo = () => {
  2. return (
  3. <Data
  4. schema={{
  5. type: 'object',
  6. properties: {
  7. title: {
  8. type: 'string',
  9. },
  10. completed: {
  11. type: 'boolean',
  12. },
  13. },
  14. }}
  15. value={{ title: '' }}
  16. />
  17. )
  18. }

就能够实时校验出 value 结构的错误了。

语义化的数据展开

建立了完整的 schema 结构之后,我们再回头去看表格和表单,就会发现比较简单了。

我们会发现,它们其实是两种迭代模式,一种是对象迭代为字段,一种是列表迭代为列表项。如果在迭代过程中拥有字段这类信息,那么,整个迭代过程都是可以抽象的。

比如这里是简单的字段迭代的过程:

  1. type ObjectIteratorProps<T extends ObjectSchema> = {
  2. schema: T,
  3. value: ValueOf<T>,
  4. onChange: (v: ValueOf<T>) => void
  5. }
  6. const ObjectIterator = <T extends ObjectSchema>(props: PropsWithChildren<ObjectIteratorProps<T>>) => {
  7. const { schema, value, onChange, children } = props
  8. return Object.keys(schema.properties).map((key) => {
  9. const fieldSchema = schema.properties[key]
  10. const fieldValue = value[key]
  11. const fieldOnChange = (v) => {
  12. onChange({
  13. ...value,
  14. key: v,
  15. })
  16. }
  17. return (
  18. <Field key={key} value={fieldValue} onChange={fieldOnChange}>
  19. {children}
  20. </Field>
  21. )
  22. })
  23. }

在使用的时候,可以:

  1. const Demo = () => {
  2. const [value, onChange] = useState<ValueOf<taskSchema>()
  3. return <ObjectIterator schema={taskSchema} value={value} onChange={onChange}></ObjectIterator>
  4. }

类似,ListIterator 也可以很容易表达出来。这样,我们之前碰到的表格表单,或者类似的形态,就有了比较统一的抽象方式了。

更夸张一些,我们还可以对常见的数据结构都实现一遍这样的组件,而且内部可以做很多优化,比如虚拟滚动之类的,这样,就减轻了渲染组件的负担。

基于类型的等价交互

在业务中,我们常常看到若干种交互形态,其内在的数据结构完全一致。在之前的示例中,已经简单看到一些了。

在软件架构中,一个很重要的过程是在抽象的基础上合并同类项。回到刚才的场景,我们会发现,对字段的描述,实际上是很通用的,这部分信息很大程度上并非来自前端,而是业务建模的一个体现。

这就是说,只要存在能够表达这种业务模型的最低交互,它在业务上就是可用的,只是不一定友好。然后,在不修改其他代码的情况下,替换为表达能力等价,但是交互更友好的渲染器,就可以提升这部分的体验。

举例来说,假设我们有一个下象棋的游戏,已知规则,但是暂时还没时间写棋盘和棋子,能不能在表单和表格里面下棋呢?

下面展示一个 demo,一个可以在表单中下的象棋游戏,篇幅所限,暂不放出代码,在现场有过演示。

从这里我们就可以认识到,棋盘和表单,尽管形态差异非常大,实际上是等价的。推而广之,我们甚至可以用表单表达一切业务。

小结

理想状态下,应用架构可以划分以下两个部分

  • 业务:领域模型
  • 基础设施:框架与服务

在这种状态下,我们期望:

业务专家尽可能不需要去关注具体实现,而通过某种方式描述和表达业务细节,这就是业务建模。

比如说,当我们做业务建模的时候,并不需要去额外关心:

  • 使用什么数据库存储数据
  • 使用什么服务端开发框架
  • 使用什么 Web 或者客户端开发框架

而是侧重于描述:

  • 当前是什么业务?
  • 有哪些领域模型?
  • 关联关系如何?
  • 支持什么操作?
  • 有什么校验逻辑?
  • 权限如何分配?

然后,尽可能把技术设施变成一个底层实现多样化的业务解释引擎,再去具体组合业务。

在以上的探讨中,我们已经努力去做了以下事项:

  • 建立了简单的领域模型解释层
  • 建立了可替换的等价交互体系
  • 实现了常见数据结构的展开机制
  • 把包含“逻辑”的部分尽可能隔离出去

在此基础上,前端部分成为了对领域模型的解释引擎,视图的组合与布局都不再影响业务正确性。沿着这个角度思考,我们可以看到更多的可能性,比如:

  1. <DataSource schema={model}>
  2. <Query />
  3. <Table />
  4. </DataSource>

更语义化地表达:数据源、查询、请求、异常 等概念,并且定义它们的组合方式。

而更大的体系,则是前后端一体化,整个都是业务领域的解释引擎,元数据从存储、到传输、再到呈现,一直伴随整个应用的生命周期。

这个时候,我们发现,一个完整的“配置化”的业务软件系统,就拥有了完整的表达链路了。

注:本文主要是为了说明基于元数据思考的方式,本身的实现很简陋,也并不代表需要这样完全从底层建立应用架构,在一些环节,社区早已存在很多相关库可以使用了。

本文是在厦门稿定的现场分享稿,感谢 @doodlewind(doodlewind) 邀请。