遇见
和 Formily 相识比较偶然,最初是由于 formily/reative 才知道的 Formily。随后便在项目中逐步使用起来,在内部推行过程中发现大家一致觉得 Formily 的上手和理解成本很高,为了帮助大家更好的理解 Formily, 才有了这篇文章
这篇文章适用于对 Formily 和表单有基础使用经验的小伙伴,文章里不会对 Formily 的基础概念做介绍,而是会更侧重于 Formily 的原理层面
Formily 架构
这个架构图是自己消化后画的,部分和 Fomily 官网的架构图有些出入
reactive
formily/reactive 是个”独立“的响应式状态管理工具,你可以单独在 vue 或者 react 项目中单独使用,他类比于 mobx 这样的状态管理工具,但是 reactive 本身更简洁和通用,另外
reactive 是 Formily 做到组件级别精准刷新的核心
formily/reactive 的实现思路和 Vue 3 很像,都是借助了 Proxy 这个新特性来实现的,每个状态都会被 Proxy 化,当一个组件使用到这个状态时,这个组件的 render funciton 会被放置到这个状态的『依赖筐』里,当状态改变时,我们会把『依赖筐』里的所有组件的 render funciton 都拿出来执行一遍,从而来实现响应式刷新。
关于响应式感兴趣的小伙伴可以看下这篇文章:从零开始撸一个「响应式」框架
从个人角度来看,formily/reactive 比其他状态管理工具更能让使用方关注业务本身的逻辑。为什么这么说呢?是因为我在用 formily/reactive 时,借助 model 方法,我不用关心 『响应式』声明的,我就是像写普通 JS 一样写我的业务模型,比如:
import { model } from '@formily/reactive';
class Biz {
bizData = {};
fetchData() {
http.call().then((res) => {
this.bizData = this.transform(res);
})
}
transform() {}
}
export defautl model(new Biz())
如果使用其他状态管理工具,他们会直接或间接的对我 Biz 业务模型的本身做侵入,而且他们的接入方式很复杂,就像是 redux 我总是搞不明白接入它需要几个步骤。
另外 formily/reactive 和 mobx 在设计模式和目标上是十分相像的,他们都致力于帮助使用方搭建模型,引用 mobx 关于领域 store 设计的一篇文章 定义数据存储,感兴趣的小伙伴可以阅读下
core
formily/core 负责以下工作
- 提供
Form以及各种类型的Field类,用来记录表单数据状态和操作函数 - 提供
Form、Field生命周期钩子函数
core 的定位是通用的 JS 表单模型,和任何框架无关,他的职责都是围绕着表单数据状态和生命周期而展开的。这里不会有特别复杂的操作逻辑,也和我们理解整个 Formily 的流程没有太大关系,这里就不展开说明了
json-schema
当我们使用 schema 来描述表单时,我们会把类似下面的 object 传给 SchemaField组件:
const schema = {
type: 'object',
properties: {
field1: {
type: 'string',
title: '字段1',
'x-component': 'Input'
}
}
}
SchemaField组件内部会使用 JSON-schema来对 schema 进行初始化解析,解析后形成的一个个 item 会被 UI 框架使用(formily/react 和 formily/vue),如何使用会在下节中讲解,这边你只需要知道 json-schema 是将 schema 对象翻译成组件可识别的内容。
另外,我们经常会在 schema 里做些联动操作,比如像是这样:
'x-reactions': {
dependencies: ['maxLength'],
fulfill: {
state: {
selfErrors:
'{{$deps[0] && $self.value && $self.value > $deps[0] ? "长度范围不合法" : ""}}',
},
},
},
formily 提供了一种可以在 schema 里写 js 语法的能力 {{ js 语法 }},同时我们能通过一些内置的“全局”变量来获取当前字段的值、依赖的值。formily 的这种能力主要通过 JS 沙箱来实现的,其核心代码就是这样的:
new Function('$root', `with($root) { return (${expression}); }`)(
scope
)
这里使用到了一个 js 里不常用的关键字 with,在 MDN 上的介绍
with
with 语句 扩展一个语句的作用域链。
语法
with (expression) { statement }
- expression: 将给定的表达式添加到在评估语句时使用的作用域链上。表达式周围的括号是必需的。
- statement: 任何语句。要执行多个语句,请使用一个块语句 ({ ... }) 对这些语句进行分组。
我们经常在 JS 沙箱中使用到 with,因为他能提供一个指定的作用域链以及隔离能力。
回到 fomily 上,我们在上面的 demo 里写的 selfErrors 对应的值,在最终执行时就是 with 里的 statemet,而 experssion 是被 formily 包装后的对象,包括了:
- $recrod: 当前记录数据
- $index: 当前记录索引
- $deps: 字段依赖的值
当然这部分的源码比较复杂,因为 formily 还需要收集各种 experssion,同时执行时还需要和 reactive 结合,感兴趣的小伙伴可以阅读下 json-schema compiler 相关的源码
react
在上面的 json-schema 章节里我们有讲到,SchemaField组件内部会使用 JSON-schema来对 schema 进行初始化解析,然后 SchemaField会根据解析后的 type 进行递归渲染,如下
这里我们是已
json schema 的角度来分析的,至于 markup schema 其实只是少了对 schema 的解析步骤,其他流程大体一致
我们对 schema 解析后根据 type 递归渲染的流程做下细分介绍:
-
object:即type: 'object'这时 formily 内部会遍历object的值,再循环调用RecursioField组件递归渲染每个object下每个key对应的value -
array: 即type: 'array',开发时都会配合ArrayTable和ArrayItems这样的高级Array组件使用(即x-component: ArrayTable),formily/react这时会直接调用ArrayTable来做渲染。然后会在ArrayTable组件里面对schema.items进行解析。我们通常会在items配置ArrayTable各个列的schema,所以items本身就是个包含一个或多个表单字段的可递归对象,然后ArrayTable便会遍历items来分别调用RecursioField进行递归渲染。这里讲的
ArrayTable就是 formily/antd 这样的,由 formily 二次封装过后的 UI 组件库 -
void: 即type: 'void'他是空无字段,即没有具体的业务含义的纯 UI 容器的字段组件,formily在遇到这种情况时会使用RecursioField递归渲染void内properties对应的内容 -
other: 即type: 'string、number、boolean'这些都是非引用类型,formily内部会直接调用Field组件。这里算是所有type的最终归宿,Field组件会稍后给大家介绍
整体看下来我们不难发现所有类型的 type 通过递归后最终都会到 other 这个 type 上,并使用 Field 渲染,Field实现很简单,其内部是直接调用 ReactiveField 组件,我们来看下 ReactiveField 这个核心组件的实现逻辑
// 去除了大部分实现逻辑,只看组件流程
const ReactiveInternal: React.FC<IReactiveFieldProps> = (props) => {
const components = useContext(SchemaComponentsContext)
if (!props.field) {
return <Fragment>{renderChildren(props.children)}</Fragment>
}
const field = props.field
// 这里获取组件的 children
const content = mergeChildren(
renderChildren(props.children, field, field.form),
field.content ?? field.componentProps.children
)
if (field.display !== 'visible') return null
const getComponent = (target: any) => {
return isValidComponent(target)
? target
: FormPath.getIn(components, target) ?? target
}
// 根据 schema 里的 x-decorate 来获取对应组件
const renderDecorator = (children: React.ReactNode) => {
if (!field.decoratorType) {
return <Fragment>{children}</Fragment>
}
return React.createElement(
getComponent(field.decoratorType),
toJS(field.decoratorProps),
children
)
}
// 根据 schema 里的 x-component 来获取对应组件
const renderComponent = () => {
if (!field.componentType) return content
// 获取组件的 props
return React.createElement(
getComponent(field.componentType),
{
// props
},
content
)
}
return renderDecorator(renderComponent())
}
ReactiveInternal.displayName = 'ReactiveField'
export const ReactiveField = observer(ReactiveInternal, {
forwardRef: true,
})
我在关键地方做了注释,这里就不详细介绍了。看完 ReactiveField 组件,我们发现他就做了两件事
- 使用
React.createElement渲染x-decorate和x-componet对应的组件 - 使用
reactive-react将该组件变成了响应式,这也就是为什么formily能做到组件级别的精准刷新的原因,因为他的响应式监听是最小粒度的
antd
这里的 formily/antd 泛指被 formily 封装过的 UI 组件库框架,他们主要做了以下几件事情:
- 将
antd的每个表单组件变成formily的子组件,并通过mapReadPretty增加组件的阅读态、通过mapProps来映射props - 提供高级业务组件, 类似
FormDialog这样的,聚合了Dialog和Form的高级组件,让开发者使用起来更方便 - 配合
core和RecursioField做递归渲染,上面已经讲到
其实 formily/antd 本身做的事情不多,大家感兴趣的话可以自行阅读源码
最后
Formily 本身还有很多东西可以讲的,如大家所熟知的 path 模块、validator 模块等,这篇文章所涉及的内容也只是冰山一角。
另外关于架构设计上,刚开始觉得 json-schema 设计的有些不纯粹,因为它过多的依赖了 core 和 reactive,并不是单纯的做 schema 解析。但后来想到,我们不应该过多的关注设计模式本身,而是应该站在整个需求背景下来看,从这种角度来看 json-schema 并不是一个单独的模块,而是整个 formily 承上启下的一个环节。
最后我想发表下关于 formily/ui 组件库的一些疑虑。我们发现在这些 UI 组件库里还夹杂着一些解析和渲染逻辑,这些逻辑本身应该下沉到 formily/react、formily/vue 里来处理。就比如上文所介绍的 ArrayTable 组件,其内部还负责了对 schema.items 的解析以及调用 formily/react 的 RecursioField 做递归渲染。这段逻辑是否可以放到 formily/react 来做呢?(formily/react 本身就负责了一些解析和渲染工作)