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

脑洞前端

共 9201字,需浏览 19分钟

 ·

2021-05-16 02:26

作者:徐飞
https://zhuanlan.zhihu.com/p/370499228

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

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

模型驱动的视图

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

  • 1. 模型

    •     1. 定义状态结构

        2. 定义动作


  • 2. 视图

    •     1. 订阅状态

          2. 触发动作


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

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

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>
);
};

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

  • 1. 消费状态

  • 2. 触发外层提供的动作来改变状态

更极端一点,这里的各种动作都可以是在外部注册的,这样,可以把动作的实现外置,放在某些类似 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,一个可以在表单中下的象棋游戏,篇幅所限,暂不放出代码,在现场有过演示。

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

小结

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

  • 1. 业务:领域模型

  • 2. 基础设施:框架与服务

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

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

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

  • 1. 使用什么数据库存储数据

  • 2. 使用什么服务端开发框架

  • 3. 使用什么 Web 或者客户端开发框架

而是侧重于描述:

  • 1. 当前是什么业务?

  • 2. 有哪些领域模型?

  • 3. 关联关系如何?

  • 4. 支持什么操作?

  • 5. 有什么校验逻辑?

  • 6. 权限如何分配?

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

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

  • 1. 建立了简单的领域模型解释层

  • 2. 建立了可替换的等价交互体系

  • 3. 实现了常见数据结构的展开机制

  • 4. 把包含“逻辑”的部分尽可能隔离出去

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

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

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

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

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

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

爱心三连击

1.看到这里了就点个在看支持下吧,你的在看是我创作的动力。

2.关注公众号脑洞前端,获取更多前端硬核文章!加个星标,不错过每一条成长的机会。

3.如果你觉得本文的内容对你有帮助,就帮我转发一下吧。

浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报