Formily 核心概念解析与构建复杂表单的最佳实践总结
接手过一个后台的「商品发布」页面,一屏四十多个字段,规则大概是这样的:选了「实物商品」才出现物流那一组,选了「虚拟商品」出现兑换码那一组;SKU 是个可增删的表格,每行的价格要参与总价计算;优惠券字段要拿另外两个字段的值去后端校验一次。这页用 antd 的 Form 写了八百多行,改一个需求要在 useState、useEffect、onValuesChange 三个地方同时动手,谁改谁头疼。
后来这类页面我改用 Formily 重写,代码量掉到三分之一,更重要的是联动逻辑终于集中在一个地方了。这篇把 Formily 的核心概念、校验体系、联动模式,以及我在实际项目里踩出来的一些取舍写清楚。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 传统表单开发到底卡在哪,
Formily是冲着哪几个问题去的 Formily的 MVVM 领域模型,createForm/FormProvider/Field各管什么- 内置规则、格式校验、自定义规则、异步校验、联动校验五种校验方式
- 主动模式和被动模式两种联动写法,分别适合什么场景
- Schema 抽离、复杂联动集中管理、性能优化这些落地经验
- 什么项目适合上
Formily,什么项目上了反而更麻烦
# 一、Formily 想解决的到底是什么
# 1.1 传统表单开发的四个坑
代码重复。每个表单页都要写一遍状态管理、校验逻辑、联动处理。这些代码在不同表单之间长得极像,但字段名不一样、规则不一样,抽不成公共组件,只能复制粘贴改改。
联动逻辑散得到处都是。「选 A 之后显示 B」「C 变了 D 自动重算」这类需求,在命令式写法里会散落到 onChange 回调、useEffect、渲染函数的三元表达式里。字段一多,你根本说不清某个字段是被谁改的。
校验规则分散。有的写在组件 state 里,有的挂在 Form 的 rules 上,有的塞在 blur 回调里。想统一改一下「所有必填项的提示文案」,得全局搜一遍。
动态表单几乎写不了。字段结构由后端配置下发,前端要根据一份 JSON 动态渲染出整个表单,命令式写法在这种场景下基本抓瞎。
# 1.2 Formily 的定位
Formily 是阿里开源的表单解决方案,一句话概括就是一个抽象了表单领域模型的 MVVM 表单方案。它把表单开发从命令式改成了声明式:你不再是「监听某个事件然后手动改另一个状态」,而是「声明字段之间的关系」,剩下的交给框架。
它的几个核心能力:
- 完善的领域模型。把表单拆成
Form、Field、Validator等概念,每一层的职责边界清楚,状态管理不再是一团useState。 - 联动能力。支持主动和被动两种模式,一对一、一对多、多对一的联动都能写得很短。
- 校验体系。内置
@formily/validator校验引擎,支持 JSON Schema 协议校验、自定义规则、异步校验。 - 多框架支持。
@formily/react和@formily/vue两个桥接包,核心逻辑在@formily/core里,跟视图层无关。
# 二、核心概念与架构
# 2.1 MVVM 是怎么落到表单上的
Formily 采用 MVVM 架构,对应关系是这样的:
- Model:
@formily/core里的Form实例,管整个表单的状态、数据、校验规则 - View:
Input、Select这些 UI 组件 - ViewModel:
Field实例,连接 Model 和 View 的桥梁
这套拆分带来的直接好处是表单状态和 UI 渲染彻底解耦。Form 实例是一个独立于 React 的普通对象,你可以在任何地方拿到它、读它、改它,不用非得在组件树里。写单元测试的时候这一点尤其舒服,直接构造 form 实例跑逻辑,不用挂载组件。
底层撑起这套响应式的是 @formily/reactive,思路和 MobX 是一路的,都是靠 observable 加自动依赖收集来做精准更新。如果你对这套响应式的原理感兴趣,可以顺带看看 MobX 在 React 项目里的最佳实践,两者的心智模型基本相通。
# 2.2 包结构
Formily 的分层很清楚,装包之前先搞明白你需要哪几层:
核心层 @formily/core,负责表单状态、校验、联动。跟框架无关,Vue 项目用的也是它。
桥接层 @formily/react 和 @formily/vue,把核心库接到具体框架的渲染体系里。
组件层 @formily/antd 和 @formily/next,分别基于 Ant Design 和 Alibaba Fusion 封装,提供开箱即用的表单组件。这里有个版本对应要注意:@formily/antd 对应的是 antd v4,用 antd v5 的项目要装 @formily/antd-v5,装错了组件会各种样式错乱。
# 2.3 三个必须搞懂的 API
createForm 用来创建表单核心领域模型,它就是 MVVM 里那个标准的 ViewModel。
import { createForm } from '@formily/core'
const form = createForm({
// 表单初始值
initialValues: {
username: '',
password: ''
},
// 表单变化回调
onChange: (values) => {
console.log('表单值变化:', values)
},
// 副作用处理
effects: ($, { onFieldValueChange }) => {
onFieldValueChange('username', (field) => {
console.log('username 变化:', field.value)
})
}
})
这段代码表达的意思是对的(初始值 + 值变化监听 + 副作用),但写法上有两个地方跟 Formily 2 的实际 API 对不上,得说明一下。一是监听整体值变化,标准做法是在 effects 里用 onFormValuesChange 生命周期钩子,createForm 本身没有 onChange 选项;二是 effects 的签名是 effects(form),只接收一个 form 参数,onFieldValueChange 这些钩子是从 @formily/core 里 import 进来的,不是从第二个参数解构出来的。写成上面那样在新版本里会直接报「不是函数」。正确的形态是这样:
import { createForm, onFieldValueChange, onFormValuesChange } from '@formily/core'
const form = createForm({
initialValues: { username: '', password: '' },
effects() {
onFormValuesChange((form) => {
console.log('表单值变化:', form.values)
})
onFieldValueChange('username', (field) => {
console.log('username 变化:', field.value)
})
}
})
FormProvider 是视图层桥接表单模型的入口。它接收 createForm 创建出来的 Form 实例,用 Context 把实例传给子组件,Field 才知道自己该往哪个 form 上挂。
Field 用来承接普通字段,是表单数据绑定的最小单位。
createSchemaField 用来创建基于 JSON Schema 的字段组件,走的是声明式配置那条路,动态表单靠它。
# 三、跑通第一个表单
# 3.1 装包
用 Formily 一定要装 @formily/core,它管状态、校验、联动这些核心逻辑:
# 安装内核库
npm install --save @formily/core
# 安装 React 桥接库
npm install --save @formily/react
# 安装 Ant Design 组件库
npm install --save antd moment @formily/antd
顺带说一句,moment 这个依赖是 antd v4 时代的产物,antd v5 已经换成了 dayjs,新项目照着装 moment 会白白多一个几百 K 的包,按你的 antd 版本来。
# 3.2 一个完整例子
下面这个例子把上面提到的几个 API 全串起来了:
import React from 'react'
import { createForm } from '@formily/core'
import { FormProvider, FormConsumer, Field } from '@formily/react'
import {
FormItem,
FormLayout,
Input,
FormButtonGroup,
Submit,
} from '@formily/antd'
// 创建表单实例
const form = createForm()
export default () => {
return (
<FormProvider form={form}>
<FormLayout layout="vertical">
<Field
name="username"
title="用户名"
required
initialValue="Hello world"
decorator={[FormItem]}
component={[Input]}
/>
<Field
name="email"
title="邮箱"
required
decorator={[FormItem]}
component={[Input]}
/>
</FormLayout>
<FormConsumer>
{() => (
<div
style={{
marginBottom: 20,
padding: 5,
border: '1px dashed #666',
}}
>
实时响应:{form.values.username} - {form.values.email}
</div>
)}
</FormConsumer>
<FormButtonGroup>
<Submit onSubmit={console.log}>提交</Submit>
</FormButtonGroup>
</FormProvider>
)
}
这段代码里有几个关键点值得逐个拆开看:
- createForm 创建表单核心领域模型,注意它写在了组件外面。写在组件里的话每次渲染都会重建一个新实例,状态全丢,正确做法是用
useMemo(() => createForm(), [])包一层。 - FormProvider 是视图层桥接表单模型的入口。
- FormLayout 批量控制表单布局,
layout、labelCol这些配一次,底下所有字段都生效。 - Field 承接字段,它的几个属性各有分工:
name标识字段在最终提交数据里的路径(支持a.b.c这种嵌套路径),title是标题,required标记必填,initialValue是默认值,decorator是 UI 装饰器(通常给FormItem,负责 label、错误提示、必填星号),component是真正的输入控件。 - FormConsumer 是响应式模型的响应器,用 render props 模式。上面例子里它包住的那块内容,会在
username或email变化时自动重渲。 - Submit 是提交动作触发器,自动处理
loading状态和校验失败时的拦截。