Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
xufei opened this issue May 7, 2021 · 1 comment
Closed

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

xufei opened this issue May 7, 2021 · 1 comment

Comments

@xufei
Copy link
Owner

xufei commented May 7, 2021

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

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

模型驱动的视图

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

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

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

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

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 邀请。

@YagamiNewLight
Copy link

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

@xufei xufei closed this as completed Jun 24, 2021
Repository owner locked and limited conversation to collaborators Jun 24, 2021

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants