Formily 原理浅析

5,603 阅读7分钟

遇见

和 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/reactivemobx 在设计模式和目标上是十分相像的,他们都致力于帮助使用方搭建模型,引用 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/reactformily/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',开发时都会配合 ArrayTableArrayItems这样的高级 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递归渲染 voidproperties 对应的内容

  • 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-decoratex-componet对应的组件
  • 使用 reactive-react 将该组件变成了响应式,这也就是为什么 formily 能做到组件级别的精准刷新的原因,因为他的响应式监听是最小粒度的

antd

这里的 formily/antd 泛指被 formily 封装过的 UI 组件库框架,他们主要做了以下几件事情:

  • antd 的每个表单组件变成 formily 的子组件,并通过 mapReadPretty增加组件的阅读态、通过 mapProps 来映射 props
  • 提供高级业务组件, 类似 FormDialog 这样的,聚合了 DialogForm 的高级组件,让开发者使用起来更方便
  • 配合 coreRecursioField做递归渲染,上面已经讲到

其实 formily/antd 本身做的事情不多,大家感兴趣的话可以自行阅读源码

最后

Formily 本身还有很多东西可以讲的,如大家所熟知的 path 模块、validator 模块等,这篇文章所涉及的内容也只是冰山一角。

另外关于架构设计上,刚开始觉得 json-schema 设计的有些不纯粹,因为它过多的依赖了 corereactive,并不是单纯的做 schema 解析。但后来想到,我们不应该过多的关注设计模式本身,而是应该站在整个需求背景下来看,从这种角度来看 json-schema 并不是一个单独的模块,而是整个 formily 承上启下的一个环节。

最后我想发表下关于 formily/ui 组件库的一些疑虑。我们发现在这些 UI 组件库里还夹杂着一些解析和渲染逻辑,这些逻辑本身应该下沉到 formily/reactformily/vue 里来处理。就比如上文所介绍的 ArrayTable 组件,其内部还负责了对 schema.items 的解析以及调用 formily/reactRecursioField 做递归渲染。这段逻辑是否可以放到 formily/react 来做呢?(formily/react 本身就负责了一些解析和渲染工作)