今天的主要内容:
- 表单组件库使用场景以及formily表单交互模型分析
- formily核心渲染流程源码分析
1.动态表单组件库的主要使用场景:
1. 动态表单主要使用场景简介:
- 偏中后台项目可能存在大量的表单渲染的场景,而且某些页面表单特别多,可能有几十个上百个表单要渲染。而且表单字段以及组件类型变更可能比较频繁。
- 页面中存在大量表单之间复杂联动的场景。
- 页面中大量表单字段需要进行复杂的权限控制,比如不同的角色具有不同的表单字段的不同的操作权限。表单中某些字段的变化会影响当前用户对于大量表单的操作权限变更。
- 在某些场景下,可能表单的渲染就是数据驱动的,需要根据注入的数据的不同动态渲染不同的表单进行展示。
- 跨端渲染。
- 基于表单组件库搭建低代码平台。
2. 举一个动态表单交互的例子:
针对以上的大部分场景,普通的解决方案,当然也是可以解决的。比如以下的一个场景我们在一个表单渲染的场景中,有a, b, c, d 三个表单,初始化渲染的时候 a,b, c 是分别一个select表单,d是一个输入框表单。其中, a、d 表单可以直接渲染。 b 表单需要等到 a 表单选中某一个值的时候才可以渲染,c 表单需要等到b表单选中某一个值的时候才能进行渲染,而d表单的组件类型会受到 c 表单选中值的变化,比如c的值 === "a" 的时候,d渲染为一个 select,c === "b" 的时候,d渲染成为一个 redio。这就是一个典型的动态表单的场景。 假设上述需求我们使用 vue 框架来进行开发,我们可以直接想到的会是类似于下面的解决方案:
- 首先我们大致会写出这样的一个 vue 模板伪代码:
const modelValue = reactive({
a: "",
b: "",
c: "",
d: ""
})
<div>
<p>
<label>a: </label>
<Select v-model="modelValue.a" />
</p>
<p>
<label>b: </label>
<Select v-model="modelValue.b" />
</p>
<p>
<label>c: </label>
<Select v-model="modelValue.c" />
</p>
<p>
<label>d: </label>
<Select v-model="modelValue.d" />
</p>
</div>
模板很好理解,定义 a,b,c,d四个字段,每一个字段都渲染了对应的表单组件。每一个字段的表单组件可能都会不一样。然后,因为每一个字段以及对应的组件它们是否渲染需要根据具体的操作和情况来进行动态控制,所以很显然,需要通过一个响应式对象来定义字段的开关:
const filedsSwitch = reactive({
a: true,
b: false,
c: false,
d: true
})
然后通过每一个字段对应的开关来控制字段的显示与隐藏,所以我们会使用 v-if 来去进行控制:
<div>
<p v-if="filedsSwitch.a">
<label>a: </label>
<Select />
</p>
<p v-if="filedsSwitch.b">
<label>b: </label>
<Select />
</p>
<p v-if="filedsSwitch.c">
<label>c: </label>
<Select />
</p>
<p v-if="filedsSwitch.d">
<label>d: </label>
<Select />
</p>
</div>
这样控制之后就可以让界面初次渲染的字段符合预期了。 然后我们继续思考,因为当 a 字段的值变化之后会影响 b 字段的显示隐藏,所以我们肯定需要监听 a 字段对应的表单的 change 事件,当 change 事件触发之后,根据 a 的值来控制 b 字段的显示:
const handleAFiledChange = (aValue) => {
if (aValue === 'x') {
// 当 a 字段的值等于 xxx 的时候显示b组件
filedsSwitch.b = true
} else {
// 当 a 字段的值等于其他值的时候不显示b组件
filedsSwitch.b = false
}
}
将 a 字段的 change 事件注册到字段组件上:
<div>
<p v-if="filedsSwitch.a">
<label>a: </label>
<Select @change="handleAFiledChange" />
</p>
<p v-if="filedsSwitch.b">
<label>b: </label>
<Select />
</p>
<p v-if="filedsSwitch.c">
<label>c: </label>
<Select />
</p>
<p v-if="filedsSwitch.d">
<label>d: </label>
<Input />
</p>
</div>
这样就可以完成 a 字段和 b 字段之间的联动了。 关于 b 组件和 c 组件之间的联动,我们就不再赘述了,也是类似于 a、b 字段之间的处理方式。
const handleBFiledChange = (aValue) => {
if (aValue === 'x') {
// 当 a 字段的值等于 xxx 的时候显示b组件
filedsSwitch.c = true
} else {
// 当 a 字段的值等于其他值的时候不显示b组件
filedsSwitch.c = true
}
}
分析这里我们目前感觉还算是比较良好的,但是我们继续来看最后一个需求,当 c 字段取不同的值的时候,d字段对应的表单的类型都要发生变化。那么这个实现起来就比较恶心了,我们也简单实现一下: 首先我们需要调整模板,d 字段对应的组件类型就不能定死了,需要继续使用 v-if 这种方式来进一步控制不同条件下,d 字段的组件的渲染类型:
<p v-if="filedsSwitch.d">
<label>d: </label>
<Select v-if="xxxx" />
<Redio v-else />
</p>
我们进一步思考,可能会想到一个相对更加优化一点的方式:component。vue里面内置的动态组件:
<p v-if="filedsSwitch.d">
<label>d: </label>
<component :is="dCompIs" />
</p>
我们在 js 中,只需要根据 c 字段的值,动态设置 dCompIs 的值就可以了:
const dCompIs = ref(null)
const handleCFiledChange = (cValue) => {
switch (cValue) {
case "xxx1":
dCompIs.value = Select
break
case 'xxx2':
dCompIs.value = Input
break
default:
dCompIs.value = null
}
}
一直实现到这里,表单的基本功能就已经实现出来了,这个在功能上,没有任何问题。但是我们进一步去站在整个表单交互的原理层面去进行思考,看一看能否提出一些优化方向。
3. 设计表单场景交互模型,引出动态表单解决方案:
首先我们来分析目前的实现方式: 实际上我们上面聊到的vue内置动态组件,在理念上就是数据驱动视图的很好提现,已经和我们今天要聊到的解决方案沾边了。目前功能的实现已经基本上已经可以达到需求了,在最终效果上没有任何的问题。目前最直接问题是我们期望进一步优化编码体验,假如我们将表单的每一个字段抽象为一个对象,对象上每一个属性都是用来描述当前这一字段的交互状态的。比如当我需要控制表单b的显示或者隐藏的时候,我只需要通过类似于下面的方式来进行控制:
表单b对象.hidden = true
表示界面上隐藏表单b
表单b对象.hidden = false
表示界面上显示表单b
我需要切换表单d的组件类型的时候:
表单d.component = "select"
那么界面上显示 select 组件。
表单d.component = "redio"
那么界面上显示 redio 组件
这种表单设计的本质就是将整个表单配置化,表单所有操作都被视为对于配置的修改。这样就可以做到完全命令式编程,更加符合人的思维和直觉。而且业务开发者也可以完全像操作一个自动化工具一样,只需要在特定的情况下,输入特定的指令,整个表单就会完全按照特定的指令来进行自动化渲染和操作,业务开发者完全不需要过多思考。 而在实现表单联动的时候,比如当表单a的值 === 某一个值的时候,需要相应的调整表单b的显示隐藏的状态。我们期望,可以直接监听表单a的change事件,在这个change事件中进行类似于下面的处理就可以了:
const aFiledChange = (value) => {
if (a === xxx) {
b.hidden = true
} else {
b.hidden = false
}
}
业务开发者的目光完全聚焦于数据层面的变更,当配置数据变更之后,表单组件会自动根据新的数据展示新的ui。这种设计也是数据驱动的进一步发展。
而对于每一个表单的 modelValue 的绑定,以及表单的编码。我们也可以提出一个优化方向。目前我们需要手动定义一个表单数据源对象,并且需要手动处理双向绑定,也需要手动维护表单的模板。每当增加一个新的字段以及删除一个字段,我们都需要手动去维护 modelValue 双向绑定,手动去维护组件模板。我们现在提出优化,不管是增加某一个表单字段、删除某一个表单字段以及对应的表单数据,我们都只需要维修改这个描述界面的对象就可以了,其他的一切都可以自动化完成。
最后我再补充5个角度:
-
一个非常良好的跨端渲染。一套业务层面的逻辑配置以及业务操作就可以支持多端渲染和交互。普通的开发模式,不太容易做到无缝切换。而基于表单配置自动渲染的方案就可以比较容易的做到,只需要多端的物料组件统一接口,然后不同的端根据同一套页面配置加载不同的物料组件就可以了,实际上和vue以及react虚拟dom是同一个思路。
-
某些后台场景下,页面渲染的表单就是需要完全基于不同的数据来动态渲染特定的组件。这个场景普通的解决方案就完全没办法了,只能依靠完全数据驱动ui。
-
还是后台表单页面,表单的字段和组件特别多,类型特别丰富,而且要求能够快速新增,下架指定的一个或者多个字段。并且不同角色需要有操作不同字段的权限。这个场景基于配置化表单的解决方案就可以只关注对配置的增删改查以及字段属性的维护就可以了。
-
站在编码规范化的角度,将复杂的表单控制代码完全转化为对于配置的修改,有助于统一整个大型前端团队的编码规范,减少为了实现复杂表单而出现一些稀奇古怪的解决方案和代码的次数,有助于代码阅读与review。
-
站在前端工程化角度,使用表单组件库,业务开发可以直接基于配置来调用一整套开箱即用的物料。这也是大型前端团队打造物料平台其中的一种手段。
而要完成上面的优化,我们可以实现如下的表单模型:
- 我们全局需要一个 Form 表单层,这一层就负责维护整个表单的数据源(也就是我们上面说的 modelValue 对象)。它在表现上是一个对象,上面会挂载上整个表单所有字段的字段值。同时这一层也会维护变更字段值的方法,类似于下面这样:
const setModelValues = (key, value) => {
// 调用改方法将会变更指定的字段值
modelValue[key] = value
}
任何需要变更字段值的需求,都只能通过调用该方法来实现。 相应的,这一层也会提供一个获取指定字段的字段值的方法:
const getModelValue = (key) => modelValue[key]
除了 modelValue 相关的属性之外,这一层还会提供管理整个表单总体状态的属性,比如标识当前表单是否处于编辑模式还是阅读模式的状态:readOnly,标识当前表单是否整个都被禁用的状态:disabled。同时还会提供一些操作整个表单的方法,比如 validate, 调用该方法将会触发整个表单所有字段的校验。submit,触发该方法,就会获取到整个表单数据源并且进行表单提交。我们用一张图来标识这一层:
- 表单中会有很多字段,我们在上面说到的描述表单每一项的对象实际上就可以被抽象为一个字段层Field。这一层主要会提供操作字段值的属性,比如 .value 属性就会从最外层的 Form 对象的数据源中获取到当前字段对应的值,当设置 .value 属性的时候,就会更新Form数据源中当前字段关联的值。除此之外,Field 对象上面还会存在诸多描述当前字段状态的属性:component: 标识当前字段需要渲染的对应的ui组件类型,hidden: 控制当前字段的显示隐藏,disabled:控制当前字段是否被禁用的属性等,因此,我们可以在Form层的内部抽象出很多个字段层:
- 每一个字段都需要渲染对应的表单组件,因此在 Field 层内部会包含一个表单组件层:
这一层会提供各种各样开箱即用有允许自定义的表单组件。开箱即用很重要,他表示,每一个组件基本上不需要太多配置就可以满足指定业务的交互。
- 每一层之间彼此都可以进行数据以及事件的通信。字段层会自动从表单数据源中取出字段对应的字段值,并且自动注入到关联的表单组件库中,那么这个表单组件库就可以自动将数据渲染出来了。而当用户操作了表单组件,表单组件需要变更关联的字段值的时候,就会自动抛出change事件请求变更字段值。而字段值接收到事件之后,就会进一步请求变更 Form 数据源中字段对应的值,Form 中对应字段值变更之后又会自动流向字段层并最终注入对应的表单组件,表单组件更新值,界面也会自动更新。整个表单数据流表现为以下的几张动图:1. 表单初始化渲染的数据流向图:
- 表单change时候的数据流向图:
以上模型中每一层的组件渲染以及数据流动完全自动进行,开发者完全不需要关注,开发者只需要根据特定的情况控制配置对象的属性就来完成自己的表单业务需求就可以了。而我们接下来要分析的 formily,实际上就是对这整个表单交互模型的一种实现。
2. 分析使用案例以及 Formily 跨平台基础架构:
以vue版本的使用为例,我们打开vue版本的组件库文档:element-plus.formilyjs.org/guide/input…
查看 input 组件的使用案例:
<template>
<FormProvider :form="form">
<SchemaField :schema="schema" />
<Submit @submit="onSubmit">提交</Submit>
</FormProvider>
</template>
<script lang="ts" setup>
import { createForm } from '@formily/core'
import { createSchemaField, FormProvider } from '@formily/vue'
import { FormItem, Input, Submit } from '@formily/element-plus'
const schema = {
type: 'object',
properties: {
input: {
type: 'string',
title: '输入框',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
textarea: {
type: 'string',
title: '文本框',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
},
}
const form = createForm()
const { SchemaField } = createSchemaField({
components: {
FormItem,
Input,
},
})
const onSubmit = (value) => {
console.log(value)
}
</script>
以上的代码将渲染出以下的表单:
里面最核心的内容就是:
1. 调用 createForm() 函数的得到一个 form 表单的配置核心配置:
调用这个函数将得到一个表单的配置对象。这个配置将决定整个表单的整体行为和交互,比如:整个表单是否可见(display属性),整个表单是都正处于初始化状态、整个表单是否正处于表单提交的状态。后续的所有的子表单将根据这些全局状态来进行相应的操作和渲染。另外,最核心的,这个对象里面就存储着整个表单的数据源对象以及设置和变更数据源的操作方法。实际上这个对象就可以看作是我们第一节分析的模型的 Form 层。这也就决定了这个表单状态一定是整个表单组件树中全局共享的。为了实现这个配置状态共享,所以我们可以看到 formily 将这整个配置对象传给了 FormProvider 组件。
2. 创建共享全局状态的 FormProvider 组件。
顾名思义,这个组件内部起码会做两件最核心的工作:
-
提供插槽来渲染用户传入的内容。
-
将传入的 form 核心配置共享给后代组件。
3.实现和框架解耦的渲染方案:
上面我们分析到的负责实现全局状态共享的任务的 FormProvider 组件,稍微分析一下实现方案,不难想到 Vue 里面是完美的提供了相关的 api 的:provide,inject。要提供插槽的话,我们只需要使用 插槽就可以实现了。但是 vue 里面这些 api 在 react 里面是不通用的,react 自己提供了另一套的 api。所以如何可以兼容两个框架呢?我们查看一下 formily react 版本的文档就知道实现方案了:
对比前面的 vue 使用案例,我们就可以想到大致的思路,实际上就是一个依赖倒置的原则。主框架约定好接口:主框架不关注 FormProvider 组件的具体实现,而只是定义 FormProvider 入参的标准接口以及统一渲染结果。具体的ui框架利用你们自己提供的api来实现 FormProvider 组件,按照统一的接口定义 FormProvider 的 props,利用你们框架自己提插槽api来实现插槽的能力,统一返回渲染结果就可以了。而formily框架在设计之初就是 monorepo 的结构,所以很容易就可以将这些跨框架的api分别针对这些框架创建对应的子包来进行开发。而针对不同框架渲染的场景,只需要按照统一的api结构从对应的包中导入api然后传入统一的组件参数来进行渲染就可以了。
4. 抽离核心的core包:
上述 createForm 其实就是典型的和框架无关的内容。它主要是返回整个表单的状态和配置构成的配置对象。这个能力本身是js原生就可以提供的,而且任何框架场景都需要用到。并且我们上面分析得到要提供统一 FormProvider 组件入参的接口,这个接口就是在 core 核心包中提供的。
5. 创建动态组件表单渲染器组件
就是渲染器组件,这个组件同样是和具体的框架渲染相关,所以,需要具体的框架在对应的包中具体实现。所以vue相关的组件就被实现在了 @formily/vue 这个包里面。react里面当然也会有对应的实现。
然后我们继续分析 SchemaField 这个组件的需求:
- 核心入参就是 schema:formily 框架中的 schema 是严格按照社区 Json schema 的规范来实现的。这样可以复用社区中很多现成的能力。比如我们团队之前就遇到了一个场景,需要根据组件的 props 类型定义来直接生成标准的动态表单渲染的 schema,社区中就有专门的库。但是这样的话也会容易导致 schema 编写起来过于复杂,阅读起来也可能不要好理解。在一些相对比较简单的动态表单渲染的场景中以及开发专门在项目中直接书写schema的来实现表单渲染的动态表单组件的场景,自定义比较简单的json配置会更加适合。
const { SchemaField } = createSchemaField({
components: {
FormItem,
Input,
},
})
SchemaField 这个组件是通过调用 createSchemaField 这个工厂函数创造出来的组件,该函数还需要接收一些配置,最核心的配置就是需要注册的组件配置 components,schema 内部定义的所有的表单内容都会根据其配置的:x-component 来一一 映射成为用户传入的组件配置中的具体组件来进行渲染。
- SchemaField :schema="schema" 是整个动态表单组件库最核心的组件,我们上面案例界面上看到的所有的表单实际都是这个组件的返回结果。所以 formily 核心要分析的就是这个组件的实现细节。
3. 拆解实现细节:
上面我们通过分析 formily 最基础的使用案例来梳理出了一些实现核心要点,下面我们就来一一拆解:
-
分析 createForm
-
createForm 主要流程:
上面已经提到 createForm 返回的 Form 对象是整个表单的 Form 全局状态层,整个表单的数据源以及核心状态以及操作数据源的接口都是这个方法返回的对象提供的。所以极其重要。
因为 createForm 的逻辑和具体的框架和组件库无关,所以它的核心逻辑被实现在了 core 包中:
createForm 无非就是一个对外暴露的工厂函数,它主要就是通过 Form 这个类构造出一个 form 配置对象出来。因此我们主要看一下 Form 这个类提供了哪些核心的功能能:
我们首先看一下它身上的一些核心的属性:
其中values就是我们前面提到的整个 form 表单的数据源。除了 values 这个数据源之外,Form 对象上主要就是记录整个表单的整体状态的属性,比如:initialized,表单初始化完成;submitting:表单正在提交;validating:表单正在校验中;disabled:整个表单都被禁用;readOnly:渲染只读模式的表单 等等。在 Form 对象实例化成功之后,就会开始初始化这些表单状态:
紧接着会将大部分 Form 表单状态变成响应式数据:
这里的 observable 实际上就类似于 vue3 里面的 reactive,observable.ref 就类似于 vue3 里面的 ref。在这里插播一个点,就是我们看到 values 被转化为了一个响应式对象,这也就表示当某些函数在使用到这个数据的时候,会触发 get 操作,get 操作中会触发依赖的收集操作。这也就表示当 values 数据变化之后也会派发更新,重新执行对应的函数。即使是 react 相关的渲染场景也是类似的。如果对 formily 里面的响应式原理感兴趣,可以参考文章后面专门的章节。
-
紧接着我们再来看一下 Form 对象上还挂载了哪些重要的方法:
-
提供创建各类字段的构造函数:
我们在第一节分析 formily 的表单模型的时候,就分析过,formily 这类框架会在组件渲染层的外面包裹一个字段渲染层,专门来处理各类字段的操作。所以在 Form 对象上,它就提供了创建各种类型的字段配置对象的工厂函数:
这里我们先留一个印象,至于每一个类字段具体的特点以及为什么要拆分出这么多的字段类型我们后面来进行分析。阅读源码有一个很重要的一点就是聚焦主线,不能过于发散。
-
提供了改变表单数据源(values)的接口:
我们前面分析 formily 的模型的时候就提到过,不管是 formily 还是任何表单模型,数据流动最好都必须是单向的。子表单需要改变整个表单的数据源,一定是只能通过向父组件抛出事件或者调用父组件提供的接口来进行改变:
setValues 是Form 提供的整体上设置表单数据源(values)的方法,整个方法非常强大,既支持深度合并指定的对象和原始表单对象,也支持浅度merge,也支持整个覆盖掉原始数据源。这样就给后面的使用提供了极大的便利度。这就是一个工业化的公共库它强大的地方,也是公共库提供的方法和业务库提供的方法的不同。
Form 对象还提供了对具体某一个数据字段进行增删改查的方法。结合我们一开始看到的那个模型,具体的 Field 组件需要变更本字段关联的数据的时候以及获取字段值的时候,就会调用 Form 对象暴露的这些接口来实现。有很多同学看到 FormPath.setIn 这些方法可能忍不住又要继续去追问这类方法是做什么的?我们在这里只需要明确一个点,这套方法是用来针对Form对象上具体某一个字段进行增删改查的操作的就可以了。FormPath.setIn 简单理解就是设置数据源指定属性为某一个值。
-
提供表单校验以及表单提交等副作用方法:
-
分析 FormProvider 组件:
FormProvider 本身其实很简单,我们上面再分析使用案例的时候实际上已经都把它的大致实现给分析出来了,这里我们直接读它的源码:
我们前面已经分析,FormProvider 是 formily 专门针对 Vue 框架表单渲染实现的共享全局状态的组件。所以它的源码肯定是在 @formily/vue 这个包里面的,我们可以先简单看一下这个包的主要内容:
-
分析 @formily/vue 包的核心内容:
从目录结构我们可以看出 @formily/vue 主要包含三部分内容:
- components:针对 Vue 场景表单渲染的一些组件:
比如这个 Field 就是渲染表单字段的核心组件之一。
- hooks:主要就是一些辅助组件工作的公共 hooks 函数,我们在这里给大家看一个很重要的 hooks:
这个 hooks 很简单,一看就知道,它是为了方便在任意后代组件中获取在根组件上 provide 出来的 Form 对象的一个 hook。
- 其实 shared 和 utils 这两个文件夹里面的内容是类似的,都是一些功能强大的工具函数:
比如这个 h 函数,实际上就是封装了 vue 里面内置的 h 函数,进行了功能增强,并且兼容处理 vue2 和 vue3。这个函数在 formily 组件渲染中会大量使用,我们后面分析formily组件实现源码就会看到。
-
分析 FormProvider 组件:
代码其实很简单。大家感兴趣可以看一下我写的注释,这里就不过多赘述了。
-
动态组件渲染核心实现原理:
这一块源码里面的概念和内容非常繁杂。我们不需要过于纠结一些业务细节,我们主要追踪核心链路:
-
createSchemaField函数核心内容:
以下是我简化之后的核心代码:
export function createSchemaField(options) {
// 定义基于 schema 规则渲染的组件
const SchemaField = {
}
// 定义基于其他规则进行渲染的组件
return {
SchemaField,
...
}
}
这其实就是整个动态表单渲染的总工厂函数,里面定义了渲染引擎支持的所有动态表单渲染规则。目前业界最常用的就是基于 schema 渲染,所以我们重点跟踪 SchemaField 这个组件的核心内容:
const SchemaField = {
name: 'SchemaField',
inheritAttrs: false,
// 核心 props
props: {
// 接收需要渲染的 schema 配置
schema: {},
// 用户仍然可以通过组件 props 直接传入自定义组件
components: {},
},
// 统一 Schema 的配置,将其统一为正确的格式
const schemaRef = computed(() =>
Schema.isSchemaInstance(props.schema)
? props.schema
: new Schema({
type: 'object',
...props.schema,
})
),
// 合并组件配置
const optionsRef = computed(() => ({
...options,
components: {
...options.components,
...props.components,
},
})),
// 合并 scoped 配置
const scopeRef = computed(() => lazyMerge(options.scope, props.scope))
// 将核心配置共享给后代组件
provide(SchemaMarkupSymbol, schemaRef)
provide(SchemaOptionsSymbol, optionsRef)
provide(SchemaExpressionScopeSymbol, scopeRef)
// 主线相关,返回真正渲染表单内容的组件
return h(Fragment, {
}, {
// 在其default插槽中注入动态表单渲染组件
default() {
return h(
RecursionField,
{
attrs: {
...props,
schema: schemaRef.value,
},
},
{}
)
}
})
}
和大部分通用库的设计方式类似,这个入口组件其实就是一个高阶组件,核心的内容就是对参数进行校验和处理,将不合法的参数转化为合法的参数,然后统一注入到后代组件中。他自己本身不渲染内容,渲染具体的动态表单组件的内容交给了 RecursionField,并且将处理过后的 schema都传给后代组件。
-
RecursionField 组件核心内容:
RecursionField 核心内容其实就是一个策略模式,根据用户schema中配置的不同的 type 渲染不同类型的动态表单组件:
export default {
props: {
schema: {
required: true,
},
onlyRenderProperties: {
type: Boolean,
default: undefined,
},
onlyRenderSelf: {
type: Boolean,
default: undefined,
},
},
// 格式化 schema 配置
const fieldSchemaRef = computed(() => createSchema(props.schema))
const getPropsFromSchema = (schema: Schema) =>
schema?.toFieldProps?.({
...optionsRef.value,
get scope() {
return lazyMerge(optionsRef.value.scope, scopeRef.value)
},
})
const fieldPropsRef = shallowRef(getPropsFromSchema(fieldSchemaRef.value))
watch([fieldSchemaRef, optionsRef], () => {
fieldPropsRef.value = getPropsFromSchema(fieldSchemaRef.value)
})
if (fieldSchemaRef..value.type === 'object') {
// 渲染 Object 类型的表单
} else if (fieldSchemaRef.value.type === 'array') {
// 渲染 Array 类型的表单
} else if (fieldSchemaRef.value.type === 'void') {
// 渲染 viod 类型的表单
}
// 渲染其他类型的表单
}
这段核心代码中有一个操作很重要就是在获取到 schema 配置之后,在这个组件中会做一个很重要的操作:
fieldPropsRef.value = getPropsFromSchema(fieldSchemaRef.value)
这个操作实际上就是将 schema 配置进行解析,产生出最终可以渲染的schema 配置。比如将schema 中的 x-conponent 配置映射成为最终需要渲染的的 component 组件实例对象。这是实现基于 schema 动态渲染组件的非常重要的一步。
另外,这里我们简单介绍一下这几个主要的 schema 类型是什么意思:
总的来说,type 主要就是描述当前对应的表单字段的数据类型。
object 以及 array 是描述的复杂表单的数据类型,分别标识表单的数据是一个 Object 或者 array。
同时 array类型的表单是支持增加、删除某一项以及进行排序的。
void 类型的表单表单当前的表单项纯粹用来描述ui,不会关联任何数据。比如
{
"type": "void",
"title": "卡片",
"description": "这是一个卡片",
"x-component": "Card",
"properties": {
"string": {
"type": "string",
"title": "字符串",
"description": "这是一个字符串",
"x-component": "Input",
"x-component-props": {
"placeholder": "请输入"
}
}
}
}
这个 schema 最外层就是 void 类型的表单,这个表单是一个 Card,本身不包含任何的数据,纯粹就是一个
布局容器
其他类型的表单主要就是包含了所有非引用数据类型的表单,比如上面schema中的 string,表单但概念表单字段关联的数据类型是 string 类型。
总体上了解了这个组件的核心内容之后,我们就来了解字段渲染的核心原理。
-
每一类字段对应的表单组件是怎么渲染的?
-
Array 类型表单总体介绍:
我们先来看一下 array 类型的表单的渲染链路:
{
"type": "array",
"x-component": "ArrayTable",
"items": {
"type": "object",
"properties": {
"source": {
"type": "string",
"x-component": "Input"
},
"target": {
"type": "string",
"x-component": "Input",
"x-reactions": {
"dependencies": [".source"],
"fulfill": {
"schema": {
"x-visible": "{{$deps[0] === '123'}}"
}
}
}
}
}
}
}
以上就是一个最典型的 array 类型的 schema,它最终会形成的表单数据类似于下面这样:
[
{
source: 'xxx',
target: 'xxxx'
},
{
source: 'xxx',
target: 'xxxx'
}
]
Array Schema 中最关键的就是 items 字段,该字段决定了新增的项以及删除的项它的数据类型以及界面上的表单交互。每当增加一项都会新增一个 { source: 'xxx', target: 'xxxx' } 对象的数据,删除一项就会删除一个数据。当然array类型也原生支持调整字段的数据的顺序。
了解了 array 的基本特点和使用,下面我们就开始梳理它的核心实现原理:
基于上面已有的认知,我们可以直接定位到 RecursionField 组件中的相关分支:
else if (fieldSchemaRef.value.type === 'array') {
return h(
ArrayField,
{
attrs: {
...fieldProps,
name: props.name,
basePath: basePath,
},
},
{}
)
}
首先会进入到这个分支,渲染一个 ArrayField 的组件,而 ArrayField 组件本身也是一个高阶组件,它本身不渲染具体的内容,最关键的就是创建一个 ArrayField 的配置对象并将其注入到 ReactiveField 这个组件中去:
而 ArrayField 配置对象中最核心的配置就是 fieldType: 'ArrayField' 描述当前字段的数据类型。
相应的,其他的字段类型对应的组件的差异就是会调整注入的 fieldType 的值。比如 object 类型的表单字段就会将 fieldType 设置为 object。
ReactiveField 其实就是实现动态表单渲染的核心组件了。
-
ReactiveField 动态渲染组件总体结构分析:
我们先看一下它的总体结构,关键的注释我已经打上了,大家可以对照着看一下:
export default observer({
props: {
fieldType: {
type: String,
default: 'Field',
},
fieldProps: {
type: Object,
default: () => ({}),
},
},
setup() {
// inject Form 表单
const formRef = useForm()
// 取出 schema 中组件以及一系列配置
const optionsRef = inject(SchemaOptionsSymbol, ref(null))
const createField = () =>
formRef?.value?.[`create${props.fieldType}`]?.({
// 将字段的所有配置注入,包括字段需要渲染的组件
...props.fieldProps,
basePath: props.fieldProps?.basePath ?? parentRef.value?.address,
})
// 创建出当前表单配置 type 对应的字段配置对象(核心操作)
const fieldRef = shallowRef(createField()) as Ref<GeneralField>
// 支持字段配置的响应式变化
watch(
() => props.fieldProps,
() => (fieldRef.value = createField())
)
// 关键操作,将字段配置共享给字段对应的表单组件
provide(FieldSymbol, fieldRef)
return () => {
const field = fieldRef.value
const options = optionsRef.value
// 聚合生成 slots
const mergedSlots = mergeSlots(field, slots, field.content)
// 渲染装饰器组件
const renderDecorator = (childNodes: any[]) => {
return h(finalComponent, componentData, {
default: () => childNodes,
})
}
// 动态渲染表单组件
const renderComponent = () => {
return h(component, componentData, mergedSlots)
}
return renderDecorator([renderComponent()])
}
}
})
这里面核心概念超级多,我们需要慢慢解释。我们先点出这个组件的核心几个步骤:
- 调用 Array 字段对应的字段配置的构造函数,并且传入一系列的配置,比如当前字段对应的组件实例等。从而拿到字段的配置对象。这个配置对象上就包含了控制当前字段的基础状态,比如是否禁用,是否处于loading等。并且更重要的,还包含了获取并且设置当前字段值的接口。说到这里我们就逐步和一开始我们得出的架构图对应上了,实际上ReactiveField就可以代表了字段组件这一层了,它的下面就包裹着该字段对应的表单组件。任何对于字段值的操作都是借助这一层提供的能力来实现的,所以我们就可以看到这样的一个 provide:
// 关键操作,将字段配置共享给字段对应的表单组件
provide(FieldSymbol, fieldRef)
这个 provide 就是将字段操作的相关接口暴露给了当前字段对应的表单组件。表单字段将利用这个字段对象上暴露的方法操作字段数据。
-
renderComponent 这个函数就是实现动态组件渲染的函数,它的核心内容其实很简单,就是利用 h 函数,将当前字段关联的动态组件渲染出来就可以了。
-
ReactiveField 外部包裹了 observer,这个就等价于 vue3 里面的 effect 函数,它会在组件渲染的时候进行依赖的收集。这个是将 ReactiveField 组件和响应式数据比如 Form 对象上的 values 属性上的字段值关联起来的关键。当组件内部读取表单数据源属性值的时候,会将 ReactiveField 组件的渲染函数作为依赖收集起来,当 values 字段值属性发生变化的时候会重新渲染 ReactiveField 组件,从而更新界面。在 react 渲染场景中也是类似的道理,都会利用 observer 函数将 Form.values 上的字段值和对应的对应的字段渲染组件关联起来。
-
探究 ArrayField 字段的继承链条:
在 ReactiveField 内部首先会取出 FormProvider 中共享下来的 Form 配置:
const formRef = useForm() // inject 组件组件派发下来的 form 配置对象。
紧接着
let createField = () =>
formRef?.value?.[`create${props.fieldType}`]?.({
...props.fieldProps,
basePath: props.fieldProps?.basePath ?? parentRef.value?.address,
})
const fieldRef = shallowRef(createField()) as Ref<GeneralField>
watch(
() => props.fieldProps,
() => (fieldRef.value = createField())
)
调用 Array 字段对应的工厂函数,拿到字段元数据信息。而 Array 字段配置的工厂函数我们在上文介绍 Form 对象的时候已经提到过了,它的核心代码如下:
createArrayField = () => {
// ... 这里的 this 就是 form 对象
return new ArrayField(this)
}
这个函数的实现太绕了,具体业务逻辑我们不用过于关注,我们只需要知道它返回了ArrayField这个类的实例,并且将 form 对象注入了 ArrayField 实例:
export class ArrayField<
Decorator extends JSXComponent = any,
Component extends JSXComponent = any
> extends Field<Decorator, Component, any, any[]> {
constructor(form) {
super(form)
}
}
我们可以看到,这个类继承自 Field,并且在 super 的时候将 form 对象设置到了父元素的 this 上了。那就说明这个 Field 上一定包含了很重要的公共能力。具体是什么能力,我们后面就可以看到。
-
通过源码初步验证字段数据变更模型:
我们先把目光放到 ArrayField 这个类的自身的核心能力上:
class ArrayField {
push = (...items: any[]) => {
return action(() => {
if (!isArr(this.value)) {
this.value = []
}
this.value.push(...items)
return this.onInput(this.value)
})
}
pop = () => {
// ...
}
move = (fromIndex: number, toIndex: number) => {
// 。。。
}
}
我们不需要过于仔细看每一个原型方法的实现,看名字就可以猜出来它们的功能,其实就是对数组类型的数据进行增删改查以及移动的操作。我们仔细看一下 push 方法,它的核心操作无非就是两句:
this.value.push(...items)
return this.onInput(this.value)
这里其实就已经可以解释我们上面提出的问题了,在这个方法中操作了 value 属性以及调用了 onInput 方法。但是属性和方法 ArrayField 类本身没有实现,那么就说明一定是从 Field 这个类上面继承过来的。而至此,我们就已经看到了 formily 里面改变表单数据的核心方式,就是通过调整 value 属性的值以及调用 onInput 函数。任何和数据绑定在一起的表单字段,只要需要改变数据,都是进行这两步操作。我们可以继续追进去看一下它从Field类上继承下来的 onInput 方法做了什么:
onInput = async (...args: any[]) => {
// 这个判断主要是确保
// 1. 当前函数不是因为dom操作触发的
// 2. 如果当前函数是因为dom操作,那么必须确保是当前dom元素自身主动触发的而不是因为冒泡等操作而
// 被动触发的
// 上述条件满足其1,允许执行后续操作
if (!isHTMLInputEventFromSelf(args)) return
const values = getValues(args)
const value = values[0]
// 核心操作,对当前字段关联的数据数据进行赋值,从而变更字段的数据
this.value = value
// 可能需要触发表单校验
await validateSelf(this, 'onInput')
}
而 Field 类上面的 value 属性是一个存取器属性,赋值之后会自动触发 set 操作:
set value(value: ValueType) {
this.setValue(value)
}
set操作触发之后又会自动调用 Field 类上的 setValue 属性:
setValue = (value?: ValueType) => {
// ...
this.form.setValuesIn(this.path, value)
}
核心代码就是会调用 Form 对象上的 setValuesIn 方法。而这里的 form 对象实际上就是我们最开始看到的从根元素上面 provide 下来的 form 对象。它里面存储这当前表单相关的所有的表单数据。而这个 setValuesIn 其实就是 form 对象上提供的变更指定的字段值的方法,它的第一个参数实际上就是需要变更的对象的 key 值。
通过 ArrayField 这个字段组件的分析,其实就已经呼应了我们一开始给出的 Formily 数据更新的模型了:
暂时无法在飞书文档外展示此内容
-
进一步探究 Field 字段类的继承链条:
我们首先看一下 ReactiveField 组件实现动态表单渲染的核心逻辑,也就是 renderComponent 方法:
const renderComponent = () => {
const component =
FormPath.getIn(options?.components, field.componentType as string) ??
field.componentType
}
首先它会通过创建的字段对象取出当前需要渲染的组件。那么我们就来看一下 ArrayField 继承的 Field 类,它的上面是否存定义了componentType字段:
我们搜索了一下整个类的属性,并没有看到这个字段的定义,而只是看到了 Field 类内部会将这个字段转化为响应式数据。那么这个字段在哪里定义的呢?我们仔细来看这个类的定义的话,我们就可以看到,这个类也并不是最根上的基类:
Field 类还继承自:BaseField 类,在这个类上就定义了 componentType 属性,它的类型是 Component:
这个类才是整个字段类型的基础类型。是不是有点儿头皮发麻,目前我们已经整理出来的继承链条是:
ArrayField ---> Field ---> BaseField
为什么还要搞出一个 BaseField 类呀?有一个 Field 类不就行了吗?实际上这个得联系我们前面介绍的在 Formily 的宇宙有一个特殊的类型:
voidField: 这个类型的特点是,这类字段是ui字段,只进行ui渲染,不会产生表单 value
正因为这个类型的存在,所以包含 value 属性的 Field 类型就不适合了,因此 Formily 必须更高一层抽象出一个更加基础的类型出来:
我们可以看到和 ArrayField 类型对应,针对 void 类型的 VoidField 类就直接继承自 BaseField,因此这类字段上是不会绑定 value 数据的。通过阅读 formily 的源码,我想我们对于面向对象,尤其是继承在特别工业级的大型项目中的最佳实践,一定有了更加深刻的认知。而面向对象虽然在前端业务开发场景中不常见,但是在通用库的开发中是非常常见的,向上述这种字段类型的设计,要用代码表述出,最好的方式就是面向对象中的继承。这也是阅读这类库的源码的额外收获。
-
验证表单组件数据更新的事件派发链路:
追踪到这里,实际上实现动态表单渲染的就很简单了,就是取出每一个 Field 对象上的 componentType, 然后利用 h 函数动态渲染出来就可以了:
const renderComponent = () => {
// 取出组件配置
const component =
FormPath.getIn(options?.components, field.componentType as string) ??
field.componentType
// 动态渲染字段对应的组件
return h(component, componentData, mergedSlots)
}
以上就是动态组件渲染的核心逻辑。然后我们再看一下关于组件属性以及事件处理的细节:
const events = {} as Record<string, any> // 存储动态表单组件事件的对象
// 获取字段上关于组件的所有的配置,比如 style、class、事件等
const originData = toJS(field.component[1]) || {}
// 取出特殊事件配置,比如 onChange 事件
const originChange = originData['@change'] || originData['onChange']
const originFocus = originData['@focus'] || originData['onFocus']
const originBlur = originData['@blur'] || originData['onBlur']
// 处理事件特殊配置,将其转化为正常的配置
each(originData, (value, eventKey) => {
const onEvent = eventKey.startsWith('on')
const atEvent = eventKey.startsWith('@')
if (!onEvent && !atEvent) return
if (onEvent) {
const eventName = `${eventKey[2].toLowerCase()}${eventKey.slice(3)}`
// '@xxx' has higher priority
events[eventName] = events[eventName] || value
} else if (atEvent) {
const eventName = eventKey.slice(1)
events[eventName] = value
delete originData[eventKey]
}
})
// 组件change之后调用字段的 onInput 事件触发字段数据的变更(核心逻辑)
events.change = (...args: any[]) => {
if (!isVoidField(field)) field.onInput(...args)
originChange?.(...args)
}
// 组件的 其他表单事件触发之后额外调用字段的相关处理方法
events.focus = (...args: any[]) => {
if (!isVoidField(field)) field.onFocus(...args)
originFocus?.(...args)
}
events.blur = (...args: any[]) => {
if (!isVoidField(field)) field.onBlur(...args)
originBlur?.(...args)
}
const componentData = {
...// 其他组件配置
on: events
}
h(component, componentData, mergedSlots)
我们在这里需要关注的核心代码就是在渲染每一个动态组件的时候,都会注册一个 onChange 事件,在这个 onChange 事件内部就会进行字段值的更新。这实际上也就定义了一个规范,任何表单组件,当数据需要触发变更的时候,都必须向上抛出一个 change 事件。这是变更表单字段数据的唯一途径。至此我们一开始定义的表单交互模型的整个链路就全部都得到了验证。
在这里给大家提一个醒,我在自己实践以及整理社区表单组件库实现方案的时候,关于表单数据更新,看到过一个非常不好的实践。就是有很多库在设计的时候,为了在编码的时候图方便,会直接在表单组件内部直接修改传入的数据。而不是通过事件的方式来修改。这实际上是钻了 Vue 或者 React 这些框架设计层面的空子。因为他们都允许直接增删改查 Object 类型的 props。这样在初期编码的时候可能确实是比较方便。但是这无疑会导致整个表单数据流的混乱,尤其是复杂的表单组件,比如 Array 类型的或者 Object 类型的,这些组件很多时候是递归渲染的,一旦哪一层级数据错误了,那么想定位起来简直就是灾难了。所以建议大家在实现组件的时候尽量要按照 formily 这种框架去设计数据流,任何的数据变更一定要有痕可循。
-
分析 formily 对 input 这类组件变更状态的特殊处理:
以element-plus 中提供的 Input 组件为例,这类组件比较特殊,因为当它触发 input 事件的时候也必须要触发表单更新,但是 formily 在字段组件中只是处理了 change 事件,所以当 input 事件触发的时候,也得触发表单对应字段的更新。那么这个问题怎么解决呢?我们可以看一下 formily Input 组件的实现源码:
其实核心处理就是:
const TransformElInput = transformComponent<InputProps>(ElInput, {
change: 'input',
})
这个处理。transformComponent 本质上其实是基于 ElInput 返回了一个高阶组件,在这个组件内部:
这样当原本的 ElInput 触发 input 事件的时候,就会被 onChange 函数处理掉,从而正常触发字段值的更新。
看到这里我们就会发现,虽然 formily 考虑了特别多的问题,定义出了特别多的概念,但是它的表单交互的核心原理就是是按照我们一开始定义的事件流在处理的。并不是特别复杂。
-
通过装饰器组件感受高阶组件的妙用:
首先什么是装饰器组件?这个好像很高级的叫法。其实很简答,它在 schema 配置中表现为一个称之为装饰器的配置:
实际上就是这个配置。它的本质其实就是渲染一个高阶组件,这个组件本身不会绑定任何表单数据,也不会影响整个表单的核心流程,而只是对字段的 ui 起到装饰作用。比如 'x-decorator': 'FormItem': 最终会渲染成为一个 FormItem 组件。这个组件专门负责一定程度上的字段表单布局,展示字段 title,展示字段表单校验结果的作用。那么这个装饰器组件是怎么渲染的呢?它的核心源码,其实我们上面就已经看到了。它其实就是包裹在表单组件外面的一层组件:
这里的 renderDecorator 其实就是在渲染装饰器组件,它接收一个普通表单组件作为入参,返回一个高阶组件:
const renderDecorator = (childNodes: any[]) => {
// decoratorType 标识装饰器类型,如果用户没有指定这个类型,那么直接返回原始的组件进行渲染
if (!field.decoratorType) {
return wrapFragment(childNodes)
}
const finalComponent =
FormPath.getIn(options?.components, field.decoratorType as string) ??
field.decoratorType
const componentAttrs = toJS(field.decorator[1]) || {}
const events: Record<string, any> = {}
// 聚合组件的配置和属性
each(componentAttrs, (value, eventKey) => {
const onEvent = eventKey.startsWith('on')
const atEvent = eventKey.startsWith('@')
if (!onEvent && !atEvent) return
if (onEvent) {
const eventName = `${eventKey[2].toLowerCase()}${eventKey.slice(3)}`
// '@xxx' has higher priority
events[eventName] = events[eventName] || value
} else if (atEvent) {
const eventName = eventKey.slice(1)
events[eventName] = value
delete componentAttrs[eventKey]
}
})
const componentData = {
attrs: componentAttrs,
style: componentAttrs?.style,
class: componentAttrs?.class,
on: events,
}
delete componentData.attrs.style
delete componentData.attrs.class
// 再业务组件外面再包一层来渲染额外的内容以及处理副作用
return h(finalComponent, componentData, {
default: () => childNodes,
})
}
再看核心源码就很简单了,在前端开发中,高阶组件或者高阶函数可以将和主流程无关的内容完美的分解出来,使其不污染核心逻辑的代码,非常优雅。
-
Formily 表单校验以及表单提交的核心逻辑分析:
关于 formily 表单校验以及表单提交的触发函数,我们在前面分析 Form 对象的时候实际上就已经提到了,本质上就是绑定在 form 对象上的两个函数:
Submit 的核心逻辑其实特别简单,因为它本身就是 Form 对象上的一个实例方法,而整个 Form 的数据源实际上就是 Form 对象上的一个 values 属性,所以在 submit 方法中很容易就可以获取到 values 值了。并且 submit 接收一个高阶函数作为入参,在内部会一部调用这个函数,从而执行用户自定义的副作用提交逻辑。
validate 方法本身也是类似的原理,但是 validate 内部的表单校验逻辑还是非常值得说道的,formily自研了一个专门用于表单校验的库:
借助这个库进行表单校验。但是这个库过于庞大了,在主流程的分享中就塞不下这么多的内容了。在后面的章节或者文章中我们可以专题来进行讨论。
-
Input 组件源码分析:
下面我们就以 input 组件为例子,来简单分析一下 formily 表单组件是如何开发出来的:
首先我们先补充一个内容:我们还是打开 ReactiveField 组件:
我们在渲染表单组件的时候,取出了字段的 value,然后将其以value属性注入到了表单组件中去了。这也就代表着,我们在表单组件中,可以定义一个 value 属性来接收注入的字段值。
除此之外呢,在 ReactiveField 中,是定义了 provide 来将整个字段对象共享给了后代表单组件的:
这也就代表着,我们在后代的所有的表单组件中,可以直接 inject 到当前表单组件所关联的字段对象,自然也可以通过调用字段对象的接口来获取字段的值了。
下面我们来更加详细的欣赏一下 formily 的表单组件封装设计,为什么用欣赏,因为写的太牛逼了:
import { composeExport, transformComponent } from '../__builtins__/shared'
import { connect, mapProps, mapReadPretty } from '@formily/vue'
import { PreviewText } from '../preview-text'
import type { Input as ElInputProps, Input } from 'element-ui'
import { Input as ElInput } from 'element-ui'
export type InputProps = ElInputProps
const TransformElInput = transformComponent<InputProps>(ElInput, {
change: 'input',
})
const InnerInput = connect(
TransformElInput,
mapProps({ readOnly: 'readonly' }),
mapReadPretty(PreviewText.Input)
)
const TextArea = connect(
InnerInput,
mapProps((props) => {
return {
...props,
type: 'textarea',
}
}),
mapReadPretty(PreviewText.Input)
)
export const Input = composeExport(InnerInput, {
TextArea,
})
export default Input
以上就是 formily vue 版本的 input 组件的封装。代码非常精简。但是功能极其强大,而且包含了特别多的设计思想。我们一一来拆解一下:
transformComponent 这个函数我们之前已经分析过了,它会返回一个基于 ElInput 的高阶组件,目的就是将原本 input 的事件处理函数改成 change 事件的处理函数,从而使其表单更新符合 formily 框架的定义。
拿到 transformComponent 返回的高阶组件之后,执行了这样一段逻辑:
const InnerInput = connect(
TransformElInput,
mapProps({ readOnly: 'readonly' }),
mapReadPretty(PreviewText.Input)
)
这个代码表单面很精简,但其实它底层的逻辑十分复杂。我们得慢慢来分析:
-
connect 函数入参分析以及核心功能分析:
我们看一下 connect 的源码:
export function connect<T extends VueComponent>(
target: T,
...args: IComponentMapper[]
): T {
const Component = args.reduce((target: VueComponent, mapper) => {
return mapper(target)
}, target)
/* istanbul ignore else */
if (isVue2) {
const functionalComponent = defineComponent({
functional: true,
name: target.name,
render(h, context) {
return h(Component, context.data, context.children)
},
})
return markRaw(functionalComponent) as T
} else {
const functionalComponent = defineComponent({
name: target.name,
setup(props, { attrs, slots }) {
return () => {
return h(Component, { props, attrs }, slots)
}
},
})
return markRaw(functionalComponent) as T
}
}
首先看 connect 的入参:
target: 这里代码中写的是一个泛型定义,但是通过在 input 组件中的使用,我们可以看到它在大部分情况下都是接收一个 Component 组件类型。
args其实是一个剩余参数,它会将后续传入的参数组成一个数组,这里的 IComponentMapper
其实是一个函数,它接收一个组件作为入参,返回另外一个高阶组件。
我希望大家好好看一下我写的注释,好好分析一下这个 reduce 方法的执行效果。我不知道你们看到这段代码时候是什么感受,我目前是特别的激动的,这个写的确实太牛逼了。可以看出源码作者对函数式编程的深厚功底。这是一个典型的函数组合的技巧,输入 TransformElInput,然后可以不断的调用用户自定义的 map 函数,每一次调用 map 函数都会得到一个增强新功能的组件,最后得到一个可以叠加所有功能的组件,这也是典型的插件化设计方式,真牛逼。这就是阅读牛逼的框架的源码的乐趣所在!
在获取到增强了功能的 Component 之后,核心逻辑其实就是为返回一个高阶组件:
这个组件将负责渲染最终的表单组件。
至此,我们就分析完了 connect 函数的核心源码。
-
connect 究竟为 input 组件增强了哪些能力呢?
要解答这个问题,我们主要就是搞懂
这两个函数究竟做了什么。
首先不用看源码我们也可以知道,mapProps以及 mapReadPretty 一定是两个高阶函数,他们最终一定是返回了一个可以接收一个组件作为入参的函数。
我们看 mapProps 这个函数的基本结构就印证了我们的猜想了。它返回了一个高阶函数,这个函数接收一个组件作为入参,最终返回了一个被 observer 包裹的新组件,而 observer 我们之前已经介绍过,它就是会让被包裹的组件的渲染函数可以被 Form 里面定义的响应式数据进行依赖收集的。
打开它返回的函数,我们可以看到第一行就调用了一个 useField() 函数,这个函数其实就是 inject 到了当前表单关联的字段对象
因为字段对象在provide 的时候共享的就是一个浅层响应式对象,所以 .value 实际上就是拿到这个字段对象的实例。
回到 mapProp 函数,通过执行
const newAttrs = fieldRef.value
拿到了当前 Input 组件对应的所有的字段配置。这里面就包含了 value 以及 readOnly 等配置。紧接着会执行这个语句:
const newAttrs = fieldRef.value
? transform({ ...attrs } as VueComponentProps<T>, fieldRef.value)
: { ...attrs }
通过调用 transform 方法来映射得到最终的组件配置。这个 transform 函数我们就不那么一行一行去读了哈,在这个地方它的主要作用就是将 fieldRef.value 上面的 readOnly 属性赋值 attrs 上的 readonly 属性。因为 Elinput 提供的接口是 readonly。所以它本质上就是进行参数映射的,将 fieldRef.value 指定的属性的值赋值给 attrs 上面指定的属性。
执行了这个操作之后,attrs 对象上就已经包含了所有的配置了,我们前面已经分析过:ReactiveField 里面渲染动态组件的时候,已经将字段值注入到了 attrs 中的 value 属性上面:
因此当我们直接将最新的 attrs 展开传入到最终的组件属性中去之后
就已经成功将字段值注入到 Input 组件中去了。
回到主线,当执行完毕
这个函数的时候,实际上就已经得到了一个新的组件,这个组件内部主要就是能够正常接收字段的 readOnly 配置了。紧接着会以这个新组建作为入参调用
这个函数返回的高阶函数。
这个函数是一个典型的偏函数,它固定了第一个 component 参数就是 PreviewText.Input,input 组件的只读组件。然后再返回的高阶函数中接收 mapProps 返回的 Input 组件:
它返回的高阶组件的核心逻辑,我已经写在注释中了。大家可以自己查看一下。
关于 textarea 类型的 input 组件封装逻辑,大家也可以自行阅读一下:
-
将普通 input 以及 textArea 合并成为一个组件对外暴露:
最后会调用 composeExport 函数将 InnerInput 以及 TextArea 合并成为一个组件导出
其实本质上就是在 InnerInput 上面添加了一个 TextArea 的属性,这个属性的值就是 TextArea 组件。如果我们需要在schema指定渲染 textarea 组件的话,只需要
这样指定就可以了。
至此我们就从总体上探讨完了 formily 动态表单组件库的核心渲染原理了。后面的内容我们就会继续来看一些 formily 中比较有意思的一些细节以及比较优质的代码实现。
-
Formily monorepo 拆包策略:
其实前面已经简单提到了 Formily 里面一些核心包了,这一节,我们详细的介绍一下:
首先最核心的肯定是 core 包,这个包我们前面已经频繁接触了,这个包里面就提供了和具体的框架以及组件库无关的核心逻辑,它里面最核心的就是定义了一系列 Model
一些核心的类和接口。比如构造 Form 对象的 Form 类,构造各个字段的 Field 类。
为了保障 Vue 框架的适配和渲染,所以 Formily 专门拆分了 @formily/vue 这个包,专门提供 Vue 表单渲染场景的一些核心组件以及方法。这个包我在前面又专门的分析,这里就过多赘述了。
基于 Vue 我们可以定制各类Vue 场景渲染的表单组件库,而在 formily 中大部分的组件库都是基于成熟的通用组件库二次开发的,比如 element-plus,所以就产生了 @formily/element 这个基于element-plus 二次表单封装的组件库,它会引入 @formily/vue 里面提供的很多功能来进行开发。比如:
至于更多复杂的表单组件开发详细内容,我们会在后面详细介绍。这种架构就是的formily可以以极低的成本接入各类其他的成熟的通用组件库或者业务组件库,只要利用 @formily/vue 提供的一些方法进行桥接就可以轻松的搞定了。
和 Vue 组件库相对的 React 场景的渲染也会基于 core 包封装出另外一系列包。其实这也是典型的分层设计的思想,每一层基于底层的库,又对上层提供基础的能力。这也是 formily 库具有极强的扩展能力的根源。
除了这些核心链路相关的库之外,其他的一些库就属于是一些针对各类问题的工具库了,比如:
@formily/validator:负责进行表单校验
@formily/path:负责表单字段的路径解析以及数据存储,这个库非常有意思,我们后面会专题分析
@formily/json-schema:这个就是负责定义Jason-schema 规范以及按照规范解析用户传入的Jason字符串。这也是核心库之一,实际上formily里面表单渲染最重要的,设计难度最大的就是这个库了,包括低代码配置端,也必须按照这个库定义的规范来产生 Jason 配置,这个我们后面也会专题分析,这个是实现低代码平台的重中之重。
@formily/reactive:这个我们前面也简单提到过它的功能。主要提供了数据响应式的能力。
还有一些其他的库我们在这里就不过多赘述了,我们在这里可以画一个分层的图来看一下整个 formily 全家福:
至此我们就分析完毕了整个 formily 渲染引擎核心的源码以及源码。实际上关于formily以及企业业务表单组件库以及低代码的内容还有非常多。这些内容我们后面将会出一个很大的专题来进行探讨,大家可以持续关注。并且formily虽然功能丰富而且强大 但是,强者有强者的难处,那就是它的概念过于繁杂,配置过于多。导致脱离它配套的低代码配置端,直觉在项目中手动维护schema成本过高,一旦出错,调试成本也过高。所以基于它的理念在企业内部自研一套更加精简的表单组件库也是很有必要的。当然虽然精简,但是在开发过程中要考虑的问题依然会比较多。自研动态表单组件库以及formily配置端的原理分析我们会在后续系列来逐步展开。