xufei / blog

my personal blog
6.67k stars 762 forks source link

建立元数据驱动的前端架构 #56

Closed xufei closed 3 years ago

xufei commented 3 years ago

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

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

模型驱动的视图

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

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

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

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,一个可以在表单中下的象棋游戏,篇幅所限,暂不放出代码,在现场有过演示。

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

小结

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

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

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

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

而是侧重于描述:

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

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

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

<DataSource schema={model}>
  <Query />
  <Table />
</DataSource>

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

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

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

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

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

YagamiNewLight commented 3 years ago

民工叔叔能不能给一些概念如(模型驱动视图,为什么要这么做,是为了解决什么问题,和状态管理有什么关系)提供一些解释或者链接,小白对那些概念比较生疏,看得有点晕