React 表单源码阅读笔记

前端迷

共 36980字,需浏览 74分钟

 ·

2021-02-28 21:22

1 概念

1.1 什么是表单

实际上广义上的表单并不是特别好界定,维基上讲表单是一系列带有空格的文档,用于输写或选择。更具体的,在网页中表单主要负责数据采集的功能,我们下文中所提到的表单都指后者。如下图展示的是 Google 个人资料中配置页面更改姓名的表单:

1.2 表单的职责

表单通过适当的 UI & 交互,将用户的输入转化为特定的数据结构,js 中通常是对象然后传输过程中通常是 json,例如上述示例中很可能实际的表单数据。

{
  lastName: '韩'
  firstName: '红'
}

更具体来说, 表单主要需要解决以下一些常见的问题:

  1. 具体的表单项对应的 UI 组件, 例如输入框, 下拉框等等;
  2. 围绕 UI 组件,其对应 label 的样式以及错误信息的整体布局组织;
  3. 表单校验, 实际包括简单的校验以及依赖型校验;
  4. 嵌套数据的表示, 例如列表&对象;
  5. 字段与字段之间的联动。

2 React 官方表单方案

React 官方给出的表单方案非常简单, 直接看官方文档就可以 https://reactjs.org/docs/forms.html。总的来说,官方给出了两种不同的表单方案,基于受控组件以及基于非受控组件的表单实现,当然前者会比较常见一些。所有的第三方表单都可以认为是这两种方案的延伸及封装。

2.1 受控组件

简单来说就是指由父组件完全控制该组件的状态及回调函数,子组件的状态变更需要通过回到函数通知到父组件,由父组件完成状态变更后再将新值传回子组件。

表现在代码上就是形如之类的表单组件同时接收 value 以及 onChange 这两个 props 来实现受控。下图给出一个官方的实例:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.state.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>

    );
  }
}

2.2 非受控组件

刚说到受控组件所有的状态都由外界接管,非受控组件则恰恰相反,它将状态存储在自身内部,我们可以借用 React 中的 ref 来访问它。同样还是官方的例子:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>

    );
  }
}

2.3 该用哪个

关于受控 vs 非受控的选择,我查阅了许多资料,大部分文档认为应该优先考虑受控模式,可惜还是没有找到一个能让人信服的理由。大量文档无非是列举受控之于非受控更灵活,更 React 单项数据流,感觉更像是为了政治正确而不是基于真实的开发体验来看这个东西。甚至有一张广为流传的对比图:


我个人认为这个图实际上是有问题的,下面所列举的一些非受控组件无法覆盖的场景,实际上 ref 配合组件 onChange 是可以做到的,例如字段级别实时校验,我们完全可以在字段上挂上监听函数,在其值发生改变的时候进行字段校验,然后通过 ref 控制组件的内部状态。所以我认为上述场景并不能作为不推崇非受控组件的理由。

我们之前也讨论过这个问题,有一个比较有意思的说法是:

实际上问题的关键不在于 react,而在于 react 实现的背后思想是 ViewModel。理所应当的,react core team 希望使用 react 开发出来的东西也应该受 viewModel 控制。


但是我个人的理解是, 受控和非受控是站在组件状态(值)的存储位置来看的,或者说是基于组件是否是所谓的 Dummy Compnent,本质上受控与非受控的表达能力是相同的,从某种层面上看可以互相实现。

3 React 社区表单方案

官方给出的方案虽然简洁直观,但是直接拿来写需求的话还是有些简陋的,场景稍微一复杂其实效率不是很高。所以很自然地,React 社区给出了相当多的三方表单方案,下面我会分别提几个比较典型的来讲。要注意的是,由于许多设计上各个表单都是互通的(互相借鉴)的,所以有些功能(例如对嵌套数据的支持)的规范/实现我只会挑一个表单来讲。

3.0 前置概念

在深入各个表单方案之前,我想补充一个前置的概念,即 Field。那 Field 是什么?

Field 的概念比较宽泛,它可以简单理解为表单组件(比如输入框)的抽象体。要更具体的讨论这个问题,我们可以先从头来看,我们要如何在 React 当中写一个表单?

1.  从受控的角度来讲,首先我们会定义一个 state 来管理表单的状态,然后我         们会给每个表单组件挂载上 value+onChange。

  1. 接下来我们可能会希望加上表单项的校验规则的定义,添加标题,声明 name,完成表单项与底层数据结构的映射,其实就是 Field 的作用。所以 Field 主要是帮我们解决了具体表单组件的状态绑定、校验绑定,以及其他的一些像 label 的设置甚至样式,错误信息展示等一系列通用的逻辑。

  2. 我们熟悉的 antdesign 表单中的 Form.Item 其实就可以理解为一个 Field。


后面你会看到,几乎所有的类库都包含这个概念。本质上,你可以这么认为,每一个 Field 封装了与它所包含的输入框相关的一切信息。

3.1 rc-form[1] (Antd Form 3.x[2] )

3.1.1 背景

这个表单实际上是大家很熟悉的组件库 Antd Design Form 3.x 的底层依赖,本质上用过 Antd Design Form 的同学都可以认为是用过这个表单。决定先讲这个表单也是基于这一点。

当然表单本身不算复杂,特点鲜明,不管是源码还是暴露的 API 都比较有年代感,一看就能看出是 class component 时代的产物。

3.1.2 例子

简单来看一下官方的例子, 感受下:

import { Form, Icon, Input, Button } from 'antd';

function hasErrors(fieldsError{
  return Object.keys(fieldsError).some(field => fieldsError[field]);
}

class HorizontalLoginForm extends React.Component {
  componentDidMount() {
    // To disable submit button at the beginning.
    this.props.form.validateFields();
  }

  handleSubmit = e => {
    e.preventDefault();
    this.props.form.validateFields((err, values) => {
      if (!err) {
        console.log('Received values of form: ', values);
      }
    });
  };

  render() {
    const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;

    // Only show error after a field is touched.
    const usernameError = isFieldTouched('username') && getFieldError('username');
    const passwordError = isFieldTouched('password') && getFieldError('password');
    return (
      <Form layout="inline" onSubmit={this.handleSubmit}>
        <Form.Item validateStatus={usernameError ? 'error: ''} help={usernameError || ''}>
          {getFieldDecorator('username', {
            rules: [{ required: true, message: 'Please input your username!' }],
          })(
            <Input
              prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />
}
              placeholder="Username"
            />,
          )}
        </Form.Item>
        <Form.Item validateStatus={passwordError ? 'error: ''} help={passwordError || ''}>
          {getFieldDecorator('password', {
            rules: [{ required: true, message: 'Please input your Password!' }],
          })(
            <Input
              prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />
}
              type="password"
              placeholder="Password"
            />,
          )}
        </Form.Item>
        <Form.Item>
          <Button type="primary" htmlType="submit" disabled={hasErrors(getFieldsError())}>
            Log in
          </Button>
        </Form.Item>
      </Form>

    );
  }
}

const WrappedHorizontalLoginForm = Form.create({ name'horizontal_login' })(HorizontalLoginForm);

ReactDOM.render(<WrappedHorizontalLoginForm />, mountNode);

对应的页面如下图:

3.1.3 试跑源码

简单克隆下源码,nvm 切换到 10.x 下,yarn 然后 yarn start 即可,本身项目自带 dev server,然后尝试修改源码即可立即生效。

源码阅读过程中,有一些不太常用的小知识,希望对大家阅读源码有帮助:

Mixin[3]

react 早期提供的基于 createReactClass 的逻辑复用手段,不过官方文档说问题很多, 在 ES6 class 中甚至直接不支持了,从目的来看是为了复用一些具体组件类无关的通用方法。

hoist-non-react-statics[4]

用来拷贝 React Class Component 中的静态方法。应用场景是 Hoc 包裹的时候,很难知道有哪些方法是 React 提供的,哪些是用户定义的,而这个方法可以方便地把被包裹组件的非 React 静态方法拷贝到向外暴露的 HOC 上面(详见链接)[5], 这样即使用 HOC 包裹过的组件类上的静态方法也不会丢失。

3.1.4 大体思路

这是我当时阅读源码时留下的一张图, 希望对大家有所帮助。

3.1.5 整体设计

实际上,感觉 rc-form 整体设计上还是比较简单的, 从官方的受控组件出发,getDecorator 实际上就是个 HOC,内部用 React#cloneElement 将 value & onChange 注入到表单控件上去。通过这种方法,所有的表单控件都被 rc-form 接管了,后续交互过程中不管是输入输出,校验后的错误状态信息都被托管在了组件的内部,用户确实被解放了出来。

美中不足的是,rc-form 使用了一个 forceUpdate 来完成内部状态与视图 UI 的同步,这个 forceUpdate 被放在了 Form 组件中,换句话说,任何一个微小的变动 (比如某个字段的输入) 都将导致整个表单层面的渲染 (我们习惯称之为全局渲染),这也就是后来 rc-form 被广为诟病存在性能问题的根本原因,其本质就在于它粗犷的渲染粒度。

3.2 rc-field-form[6] (Antd Form 4.x[7])

3.2.1 背景

基于上述提到的一些缺陷,Antd 4.x 对于表单模块进行了重新设计。更具体来说,设计思路上,渲染粒度的把控从表单级别具化到了组件级别,使得表单性能大幅度提升。源码中大量使用 React Hooks,同时简化了暴露的 API, 提升了易用性。

3.2.2 例子

可以和上述的代码实例对比下,最直观的变化是砍掉了不知所谓的 getFieldDecorator 整体上感觉确实清爽不少。

import { Form, Input, Button, Checkbox } from 'antd';

const layout = {
  labelCol: { span8 },
  wrapperCol: { span16 },
};
const tailLayout = {
  wrapperCol: { offset8, span: 16 },
};

const Demo = () => {
  const onFinish = values => {
    console.log('Success:', values);
  };

  const onFinishFailed = errorInfo => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Form
      {...layout}
      name="basic"
      initialValues={{ remember: true }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >

      <Form.Item
        label="Username"
        name="username"
        rules={[{ required: true, message: 'Please input your username!' }]}
      >

        <Input />
      </Form.Item>

      <Form.Item
        label="Password"
        name="password"
        rules={[{ required: true, message: 'Please input your password!' }]}
      >

        <Input.Password />
      </Form.Item>

      <Form.Item {...tailLayoutname="remember" valuePropName="checked">
        <Checkbox>Remember me</Checkbox>
      </Form.Item>

      <Form.Item {...tailLayout}>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>

  );
};

ReactDOM.render(<Demo />, mountNode);

3.2.3 试跑源码

简单克隆下源码,yarn 然后 yarn start 居然跑步起来,提示我漏装了 hast-util-is-element,安装完成后 yarn start 成功启动了 dev server,然后尝试修改源码发现可立即生效。这是我当时整理的一张大概的流转图:


  1. 整体的代码还是比较清晰可读的,其实这里的想法很简单,我们知道其实强制 rerender 本质上是将最新的 props / state 重新地沿着组件树往下传递,其本质也可以理解为一种通讯的方式。只不过这种通讯方法的代价就是导致所有的组件都重新渲染。

  2. 既然在 Form 上无脑地 rerender 将导致性能问题,那么解决的方向一定是尽可能地缩小 rerender 的范围,如何将最新的表单状态仅仅是同步到需要同步的表单项呢? 很自然地,这里用到了订阅模式模式。

  3. 基于上面的设计,展开其实就是 2 点:

    a. 每个表单项各自维护自身的状态变化,value-onChange 实际只在当前表                        单项上面流传;
    b. 流转之后通知其他表单项,这时候其他表单项如何知道自己关心当前变            化的表单项呢? 这里 field-form 引入了 dependencies,shouldUpdate 来                帮助使用者方便地声明自己所依赖的表单项。

3.2.4 支持嵌套数据结构

其实在 rc-form 中我漏了一块比较重要的内容没讲,那就是对嵌套数据结构的支持,比如我们知道实际上用户填写表单最终得到的实际上是一个大 json。对于比较简单的场景,可能表单的 json 只有一级。

譬如登录场景下,比较常见的结构:

{
   phoneNumber'110',    // 手机
   captcha'YxZd' ,       // 图片校验码
   verificationCode'2471',  // 短信验证码
}

但是实际上在复杂场景下,很有可能会出现形如:

{
   companyName'ALBB'
   location'London'
   business: {
      commerce: {
        income: {
          curYear110
          lastYear90
        }
      },
      data: {
         dau1
      }
   },
   staffList: [
     {
        name'zhang3'
        age'22'
     },
     {
        name'li3'
        age'24'
     },
   ]
}

这时候, 简单的 <FormItem name="field1"<Input /><FormItem> 可能就不太够了,比如这里我们就需要表单项能表单包括对象和列表的嵌套结构,我们就势必需要在表单层面上表示我们所谓的嵌套关系。通常来讲,表单对于嵌套数据的支持都是通过表单项的唯一标识 name 上,这里就以 field-form 为例来看:

import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";
import "./index.css";
import { Form, Input, InputNumber, Button } from "antd";

const Demo = () => {
  const onFinish = (values) => {
    console.log(values);
  };

  return (
    <Form name="nest-messages" onFinish={onFinish}>
      <Form.Item
        name={["user", "name"]}
        label="Name"
        rules={[{ required: true }]}
      >

        <Input />
      </Form.Item>
      <Form.Item
        name={["user", "email"]}
        label="Email"
        rules={[{ type: "email" }]}
      >

        <Input />
      </Form.Item>
      <Form.Item
        name={["user", "age"]}
        label="Age"
        rules={[{ type: "number", min: 0, max: 99 }]}
      >

        <InputNumber />
      </Form.Item>
      <Form.Item name={["list", 0]} label="address1">
        <Input />
      </Form.Item>
      <Form.Item name={["list", 1]} label="address2">
        <Input.TextArea />
      </Form.Item>
      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>

  );
};

ReactDOM.render(<Demo />, document.getElementById("container"));

可以看到本质上就是将数据字段的嵌套的信息拍平存储在了 name 中(此时 name 是数组),当然更常见的一种做法是形如 lodash get/set 类似的 path 规则:user.name user.age address[0],只是表现上不同, 本质上都是一样的。关于为什么 antd 4.x 要使用数组而不是更常见的做法,官方也给出了解释:

In rc-form, we support like user.name to be a name and convert value to { user: { name: 'Bamboo' } }. This makes '.' always be the route of variable, this makes developer have to do additional work if name is real contains a point like app.config.start to be app_config_start and parse back to point when submit.


Field Form will only trade ['user', 'name'] to be { user: { name: 'Bamboo' } }, and user.name to be { ['user.name']: 'Bamboo' }.

正如你所理解的一样,实际在表单的内部也确实是这么做的,所以的字段,无论层级,都是被拍平管理的,虽然本质上可以认为是一颗树,但是关于结构的信息只体现在了 name。例如我们把上面的例子打印出来本质上就是:

其实了解了这一点,再回过头来看 Antd 4.x 中 form 的一众 API 中的 NamePath 也不难理解了:

对于表单项的操作都是接受 NamePath 然后对对应的匹配项进行操作,换个维度来理解的话,本质上你传入的 NamePath 就是需要操作节点的路径。当然这里稍微有个要注意的点就是当操作的目标节点为非叶子节点时,更新需要同步到它的所有子孙上去。

这一块实现上并不复杂,想了解的同学可以翻翻源码,看看 valueUtil.ts,Field.ts#onStoreChange 即可,此处不再赘述。

3.2.5 动态增减数组

现在有了嵌套数据的支持,来看看另一个问题,很多时候我们并不事先知道表单中的某个数组总共有多少项,比如用户再输入他的爱好时,他可以有任意多个爱好,这时候我们就需要使用到动态增减数组。

虽然 antd 4.x 提供了 Form.List 组件来帮助我们很方便地构建动态增减数组的表单,但是实现上绕不开我们前面所说的嵌套数据结构。先看个官方的例子:

import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";
import "./index.css";
import { Form, Input, Button, Space } from "antd";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";

const Demo = () => {
  return (
    <Form>
      <Form.List name="sights">
        {(fields, { add, remove }) => (
          <>
            {fields.map((field) => (
              <Space key={field.key} align="baseline">
                <Form.Item
                  {...field}
                  label="Price"
                  name={[field.name, "price"]}
                  fieldKey={[field.fieldKey, "price"]}
                  rules={[{ required: true, message: "Missing price" }]}
                >

                  <Input />
                </Form.Item>

                <MinusCircleOutlined onClick={() => remove(field.name)} />
              </Space>
            ))}

            <Form.Item>
              <Button
                type="dashed"
                onClick={() =>
 add()}
                block
                icon={<PlusOutlined />}
              >
                Add sights
              </Button>
            </Form.Item>
          </>
        )}
      </Form.List>

      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>

    </Form>
  );
};

ReactDOM.render(<Demo /
>, document.getElementById("container"));

实际渲染的页面长这样:

关于这一块的源码很简单我就不多赘述, 感兴趣可以看看 rc-field-form#src#List.tsx,本质上它要做的事情只有 3 个:
  1. 只是替用户维护一个可增减的数组, 数组中每个对象对应表单数组中的一个节点;
  2. 当用户想新增 item 时, 在数组中创建一个新的 field,并给予一个唯一的 key 同时根据 item 的位置生成 name,同时调用 onChange 来通知表单对象更新内部状态;
  3. 删除和新增同理。

问题: 那么在 antd 中上述每个 field 对象上的三个属性: fieldKey,key,name 都是干嘛的?

其中 name 最好理解,对应的是每个输入框在最终提交的数据结构中对应的位置,key 表示的是该节点的唯一 id (可做 react loop 中的 key),一直让我很费解的是 fieldKey,因为事实上在 field-form 的源码中 field 并没有包含该值,antd 文档中也没有对该值进行特别的解释。不过最后发现 fieldKey 和 key 本质上是同一个东西,因为在 antd#components#Form#FormList 中我发现:

3.2.6 感想

其实单单对比 Antd 3.x 以及 Antd 4.x 背后的表单, 我们已经可以得出一个有趣的结论:表单方案的性能问题本质是在解决各个表单项与项之间及与表单整体之间的通信问题。这里 antd 4.x 利用订阅有选择地通知替代了无脑 rerender 流重绘,实质上是更细粒度的组件通讯实现了更小的通讯代价。接下来我们会看看国外几个类似表单的发展,恰恰也印证了这个观点。

3.3 ------- 国际分割线 --------

其实国内社区主要还是是以 antd form 为主, 但是实际上,国外 React 社区的表单方案也有着类似的发展思路。下面大概过一下,思路上类似的地方就不再展开细讲了。

3.4 Redux-Form

Redux-form 算是比较早的一款 react 表单方案了,由于 redux 的流行,使用 redux 进行表单的状态管理是一个很自然的思路。v6 以前的表单大概是这样的:


这里存在两个问题:

  1. 性能问题:用户的任何一个按键操作都会引发状态的更新而后全局渲染整个表单,可以看到这个问题和我们之前说的 rc-form 存在的问题是类似的。
  2. 依赖 redux:用 redux 来管理表单数据流后来被证明是没有必要的,总的来说就是增大了体积,而且导致很多原本不需要 redux 的项目强制安装 redux。关于这个可以看看 Dan 的说法。

如果你还在犹豫,可以看看:

Dan 发表的看法:良好实践标准[8]


redux 官网的看法:Should I put form state or other UI state in my store?[9]


redux-form 作者看法:redux-form[10]

事实上 redux-form 在 v6 之后更改了更新策略,将 Field 单独注册到 Redux,用 Redux 天然的订阅机制实现了直接对 Field 的更新。可以认为实现了类似 rc-field-form 对于 rc-form 的优化。

3.5 Formik

3.5.1 背景

鉴于 Redux-form 强依赖 Redux 所带来的的问题,Formik 抛开了 Redux,自己在内部维护了一个表单状态。大概看了下作者的设计动机,主要是减少模板代码&兼顾表单性能。虽然提供了 FastField 来做一定的性能优化,不过仍然是以表单整体为视角的粗粒度的状态更新,所以本质上并没有逃开全局渲染的,抛开 FastField 来看,它和 rc-field 的设计思路甚至有些类似。

3.5.2 例子

3.5.3 核心思路


所谓的 FastField 性能优化,不过是通过一层 HOC 包裹实际的 Field,然后在这个中间层中用 shouldComponentUpdate 决定当前更新的状态是否为该 HOC 包裹的 Field 状态。

可以看出来就是粗暴地对表单中几个关键的状态 valueerrortouched 以及传入的 prop 的长度和 isSubmit 几个关键字段进行浅比较,如果碰到类似字段 a 决定字段 b 是一个输入框还是下拉框这种场景,还得自己实现 shouldUpdate 的逻辑。所以整体来看可以认为是这样:


3.5.4 React DevTools Tips

在探究这个表单的时候,碰到这里有个很有意思的结论, 由于只是 FastField 这种外层 connect 接入 context,内层 shouldComponentUpdate 做优化的机制,这时候通过 devtools 中 highlight updates 并不能看出是否是全局渲染,那玩意在这种情况下会给你误导,这时候更好的方法应该是使用插件提供的 profiler,下面这个例子很好地诠释了这一点:

Demo 地址:https://codesandbox.io/s/7vhsw



3.5.5 感想

总的来看,Formik 完成了它最初的设计,从 redux 中解放出来,并一定程度缓解了表单全局渲染导致的性问题,打包体积也很小只有 12.7k,在 redux-form 横行的年代确实给力(怪不得会被官方推荐)。但是由于发布的年代早, 对于后续 react hooks 的支持不是十分全面,且底层设计上还是没有支持到更细的空间更新粒度,所以当表单膨胀或是联动场景较多时,单单靠 FastField 也就力不从心了。

3.6 React-final-form

这是 Redux-form 的作者在维护了多年 Redux-form 之后的又一力作,作者的本意是写一个“无第三方依赖、pure JS、插件式”的表单。在渲染上,final-form 也采取了和 rc-field-form 类似的思路,Form 作为中心订阅器,负责各组件事件的分发,而每个 Field 管理自己的数据,独立地订阅自己感兴趣的其他表单项,并独立地完成渲染。

Final-form 我没有细看,只是大概了解了下,感兴趣的同学可以看看 final-form 作者的演讲: Next Generation Forms with React Final Form[11]

3.7 上述五个表单方案的变迁

其实你会发现历史总是惊人地相似,国内由于饱受 rc-form 全局渲染的困扰而推出了基于订阅的 rc-field-form,国外的发展历程也是类似的: redux-form(v5) => redux-form(v6)formik => react-final-form。总结下,整个变迁大概可以这么理解:


3.8 React-hook-form

3.8.1 背景

上面已经说了几个表单,我们可以发现这些表单都是通过维护了一个 state 来处理表单的状态,无论是集中更新,还是以订阅的方式进行分布式更新,它们都是基于这个表单状态来完成的,这就是 react 的受控表单模式。

现在,我们不妨换个思路,从非受控的角度入手,非受控表单就不是使用 state 那一套了,它是通过 ref 来直接拿到表单组件,从而可以直接拿到表单的值,不需要对表单的值进行状态维护,这就使得非受控表单可以减少很多不必要的渲染。

但是非受控表单也存在着它的问题,在动态校验、动态修改(联动)方面不是很方便,于是在 react hooks 出现以后诞生了一个以非受控思想为基础的表单库 react-hook-form, 这个表单的设计思路很新奇,完全是拥抱原生, 拥抱非受控. 核心思路是各个组件自身维护各自的 ref, 当校验、提交等发生时,便通过这些 ref 来获取表单项的值。

3.8.2 简单例子

3.8.3 核心思路

源码 Commit:1441a0186b8eab5dccb8d85fddb129d6938b994e

Demo 地址: https://codesandbox.io/s/happy-mccarthy-1nxuq?file=/src/App.js


这里其实有一些问题,由于所有的 rerender 都是 field 级别而不是 form 级别的 (表单渲染),即:

  1. 性能的优化:由于各个字段状态由组件自己托管,并不需要数据回流,除了这一大块以外,代码中也有很多处理很好的细节:

    a. 对错误进行浅层比较,例如上一轮渲染已经展示了错误信息 a, 如果这一轮渲染错误信息不变的话, 则不重新渲染.

    b. 表单的内部状态(isDirty,touched,submitCount,isSubmitting 等等)统一用过 Proxy 包装, 在初次渲染的时候利用 Proxy 记录用户对于各个状态的订阅情况,不订阅的话变化将被忽略,不引发重新渲染。

    c. 虽然 watch 默认会触发全局渲染,不过 useWatch 可以做到不触发全局渲染的情况下通知某个字段的更新,本质上是订阅机制,将 useWatch 调用方的 state hook 维护在了表单的内部对象上,一旦有更新通过这种方式可以做到仅仅通知订阅组件。

  2. 可惜的是,任何表单下的错误信息变化,都会触发全局的渲染,这一点感觉不是特别好。至少可以 ErrorMessage 里面可以来个浅层比较。

3.8.4 动态校验以及联动

为了支持动态校验,react-hook-form 在进行表单注册的时候还会将 onChange、onBlur 等事件挂载到表单组件上,保证对与用户输入、修改行为的监听,从而可以对表单校验、表单值监听等进行触发。

非受控表单除了动态校验的问题,还存在联动实现的问题。由于 react-hook-form 不会将表单的值维护在 state 中,用户输入不会触发整表层的 JSX 更新,因此 react-hook-form 提供了 watch,以及性能更好的 useWatch,来对于需要进行联动的表单进行注册,当用户进行修改的时候会调用更新。(本质上这两个东西和 rc-field-form 中的 dependences / shouldUpdate 目的类似)。

关于 watch 和 useWatch

其实两者是存在性能差异的,useWatch 可以认为是把更新移到了更局部的位置,所以性能上会更有优势:


3.8.5 兼容三方 UI 库

由于大部分第三方 UI 库是遵照了 react 受控思想设计的,例如 Antd#Input 并没有出 ref,官方也提供了 Controller 组件,用它包装的三方组件只需要遵循默认的受控规范 onChange / value 即可,Controller 会在其构建内部状态,变相模拟出了 Uncontrolled Component。


3.8.6 感想

最终来看 react-hook-form 的其实与其他受控表单库可以说是殊途同归,都是希望状态分布管理,单个表单项的更新不影响其他的表单项,而联动都可以说是使用了订阅来做到的,从某种程度上看,基于非受控实现的 react-hook-form 来看这一切甚至更加自然。可以看到, 非受控也可以做到任何受控表单能做的事情,这也是为什么我个人在 2.3 小节中提到, 受控和非受控从某种层面上看可以互相实现

3.9 横向比较

这里再给个横向比较图:

4.  Formily (1.x)

4.1 背景

如果不了解之前没有了解过 Formily 可能下面的内容会有些突兀,建议可以先大概了解下。因为这一节我不打算讲 Formily 的基本用法,只是谈谈我对于 Formily 的一些理解。

之所以把 Formily 单独抽出来作为一讲来讲,是因为在我做表单调研工作的这个阶段 (2020.10),毫不夸张地说,Formily 是我当时认知范围内,设计理念最先进 (也可以说是激进),表单领域研究最透彻的一个表单解决方案. 在我写总结的时候,它的断代全新版本 2.x 也已经算是发了 preview 版,据说有挺多改进的, 不过还没来得及仔细看,所以以下内容只针对 1.x。

4.2 场景

在我的认知里,虽然 Formily 初衷是大而全,对于场景的预设考虑比较完备,但是我感觉它还是比较适合高复杂度,表单联动多,有比较极端的性能要求的表单场景, 如果只是简单场景确实没必要,正如我们所说的,antd 4.x 已经拜托了全量渲染,如果能合理地利用 dependencies / shouldUpdate 其实性能表现表现上已经足够好。不过如果你的场景特别复杂,特别是联动逻辑比较多,利用 formily 还是能够有效地帮助你收敛逻辑,降低心智负担的。

4.3 学习成本

虽然我上面说到 Formily 理念先进,但其实业界对于它可以说是褒贬不一的,被诟病最多的问题就是学习成本。我个人也认为这个是阻碍 Formily 火起来的最最核心的问题 (我写文章的时候 Formily 已经有 3000 多个 star 了)。坦率来讲, Formily 的学习成本相较于社区其他表单方案还是偏高的,其实原因主要是两方面的:

  1. 用户文档:

    a. 在 Formily 用户群里面经常能看到有用户反映 Formily 官方文档访问速度慢, 甚至打不开, 这一点我个人也时常碰到.

    b. 用户文档不够清晰, 很多地方感觉没有介绍清楚, 诸多细节并没有提到, 需要自己去摸索, 这一点 antd design 简直是业界典范. (这一点也不绝对, 毕竟 Formily 可以算是表单垂直领域的高阶类库,antd 以组件库为核心, 两者本身的认知门槛也是有差距的)

  2. 整个方案大而全,理念先进,这也导致新的概念比较多,像是 schema 描述结构,coolpath 路径系统,effects 甚至引入 rxjs 来做联动管理,虽然认真看看会发现其实所以概念的引入都有一定的道理,不过对大部分开发者而言,尽可能低的学习成本,尽可能高的开发效率才是他们所追求的,所以 Formily 大量精致的概念对于一些新的用户来说是非常不友好的。

4.4 理念

上面说了 Formily 学习成本如此的高,但是我还是非常希望聊一聊,甚至单独开了一节来聊 Formily。因为它的设计理念确确实实代表了算是业界表单的比较先进的水平。下面讲一些我自己印象深刻的点:

4.4.1 通讯

4.4.1.1 从数据回流到订阅再到 effects

业界的表单一个一个看下来,我自己有个很直观的感觉,就是各个表单方案归根结底,其实是在解决各个表单项之间以及表单项与表单整体的通信问题:

  1. 我们可以看到最开始的 redux form (< 6), rc-form,他们通过全量 rerender 之后新的 props 层层透传来把信息通知到每个 Field 上,这样信息的通信效率是很低的,表现上就是表单性能比较差。

  2. 后面 rc-field-form 以及其他一些表单都采取了自己独立更新,依赖项目走订阅更新的路子,这本质上就是带来了更好的通信效率及更优秀的表单性能。

  3. 此外,无一例外他们也都推崇 onChange + fomrRef 的组合来表达逻辑:

很自然地, Formily 也是订阅式的, 但是它表现上更为独特, 因为它把你的所有的表单相关的逻辑都收敛到了一个叫做 effects 的字段中, 写法非常新颖, 即:


这里的核心在$('event_type', 'path_rule'), 意思是搜索所有 path_rule 命中的表单项,订阅他们的 event_type 事件,最后整体的返回是一个 rxjs 流。这里我想说几点:

  1. 首先用 effects 收敛各种表单行为,联动逻辑这真是一大创举,对比其他方案挂在组件上的各种 onChange 回调散落在整个视图层,这个收敛对于代码的可读性的提高确实有很大帮助。

  2. path_rule 必须要遵循作者自己实现的一套 DSL, 即 cool-path[12],感觉出发点是好的,目的是既可以精准定位到某个具体的表单项,又有能力根据情况做到批量处理,批量订阅。这其实也可以算是一大创新,它让赋予了 Formily 极强的联动表达能力,一对一,一对多,多对多都能很好的表达。但坏就坏在文档不全,偏偏它的语法还有点四不像,既不是路径系统也不是 glob pattern,其实说实话我用了挺久了有时候还是用不明白。

  3. 这里引入 rxjs,确实很 geek,不过就我自己而言感觉所有的场景都只是当成简单的事件订阅在用。

4.4.1.2 关于 react-eva

其实这套写法并不是空穴来风,effects + actions + rxjs 这套组合也是作者自创的,为此专门还专门抽象了一个库,叫 react-eva[13],想法很明确,即围绕 rxjs 针对 react 组件做的一个内外通讯方案:

这套方案有 2 个核心优势:

  1. 联动性能好,遵循这个规范很自然而然地就不需要在视图层使用 hooks 了。
  2. 提高了代码可维护性,所有的逻辑收拢到了 effects 中。
4.4.1.3 从 “Effect 只执行一次” 谈核心理解

之前有同事问我这样一个问题:为什么明明我的某个 react hook 已经更新了但是在最新的 effect 的某个 observer 中还是旧的值?

其实大概多试验几次就会发现,effects 函数本身只会执行一次。这是表面原因,不过我理解更深层次的原因是, 作者不希望让 effects(可以认为是你申明的联动逻辑) 和 当前视图层的 hooks 互相依赖,这里有两方面原因:

  1. effects 应该是是比较纯粹的东西,类似 reducer, 甚至可抽离和复用。
  2. 另一方面,如果 effects 和 视图层 hooks 有耦合,意味着你每次需要 effects 重新执行的话就需要 setState,不知不觉又变成了整表重绘,这可以看做是一种性能倒退,显然是作者不希望看到的。

所以我视角里的 Formily 其实没那么 React, 它更像是一个自动机,你写的 JSX / Schema 不过是在声明这个表单的结构,一旦完成首次渲染,这个表单完全可以通过 用户交互 + 开发者预先定义的 effects 完成表单闭环. 它的生命周期更像是自己独立的而不是属于包裹它的 React 组件容器。这一点在 “Formily 的数据模型是表单 UI 的完全表达”中也得到了了印证。

4.4.2 底层数据模型

4.4.2.1 可订阅模型作为基类

刚在说到表单中所有的事件都可以通过 effects 声明并监听,事实上,这一点在表单内部仍然成立。可以这么说, 整个 Formily 不论内外,都是一个基于一个简单的思想,一切结构可订阅,譬如他的类继承模型如下:

4.4.2.2 表单 = 树状结构组织的一堆字段

另一个令我印象深刻的点在于,Formily 底层的数据模型是它整个表单的完整表达。在看其他表单方案的时候,虽然也能看到每个表单的内部用形如 store,state 的状态,里面存的无非是一些 valueinitialValue,rules,dirty,touched 等等之类的一些状态相关的信息。然后对于 Formily:

Formily 不但包括了这些,它甚至把某个字段的输入组件上被传入的 props,当前表单的挂载状态,显示状态都给描述出来了。这里反映出几点:

  1. 整个表单的状态能完整地表达表单的 UI,这意味我们在 effects 中操作表单时获得了极高的自由度,你甚至通过设置某个字段的 state 来控制这个字段的下拉列表, 单纯地修改数据更新视图,非常纯粹。换作其他表单,你大概率需要在 JSX 中写三元表达式或是有了个 useState 的 hooks 专门用来操作视图层。

  2. 这里状态的完整表达意味着,Formily 跳脱出了 React 视图框架的禁锢,它是完完整整的内核,你可以通过它完整地驱动任意一个视图框架,例如 Vue。其他有一些表单其实多多少有一些视图层的信息是遗留在 React 的 UI 组件上的。

事实上,不但是具体的 field,如果把视野拉高,你会发现整个表单都可以用一个树状的结构来进行完全表达:

仔细想想,这其实是很自然的,因为在有嵌套数据的场景下,表单的天然结构就是一棵树。可以说,这颗树就是表单的完全表达,结合之前的 effects,表单所有的交互都能在 effects 内部闭环,因为 Formily 的数据层是可以做到 UI 层的完全表达的。

回到我们再 4.4.1.3 小节中说的那个问题,你可以看到,其实 Formily 的状态流转应该是很 Redux 的:

它并不需要依赖任何 React 相关的 hooks/callback 就能实现自己的链路闭环,整体的链路我大概理了下:

4.4.2.3 Immer

不过,刚才说到 effects 的 state 可以非常自由地操作表单/表单项的任意属性。这带来了极高的自由度,确实提高了库的使用者的使用体验,不过工作量并不会凭空消失,对于 Formily 的开发者而言,用户只管设置,那他们就需要对每一种情况进行兜底,比如用户只是简单地在 effects 中通过 state 重新设置了 rules,那 Formily 底层就得考虑重新校验,表单错误状态的更新等等. 那作者如何知道我们在回调中修改了哪个属性呢?

答案是 Immer。第一次看到确实是大呼牛逼,因为在我浅薄的认知里面,Immer 存在的价值仅仅是 “写 redux reducer 的时候便捷地创建不可变对象”,但是作者居然想到了利用 immer 中的 patches 来记录用户的操作记录,用在这里非常自然,整个思路大概如下:

这里确实非常精彩,因为涉及到比较函数前后对比两个对象的变化,第一想法都是 “脏检查”,作者很巧妙地绕开了脏检查。不过我当时的疑问是:里使用了 immer 性能开销真的会更小吗?

为此我专门去研究了下 immer 的原理. 简单来说 Immer 的高性能基于这样的一个事实:

新建/拷贝对象的开销高,引用的开销低。

所以为了更好的性能应该尽可能少地新建对象,这个背景下,immer 实现了 Copy on write 的效果:

当然,对于小表单这点性能优化可有可无,不过确实可以看出来 Formily 对性能是有极度压榨的。

4.4.3 Schema 与 Low / No Code

4.4.3.1 概念

Formily 最顶层还有个叫 Schema 的概念,为什么要有 Schema 呢?为了更好地解释 Schema,先聊聊什么是 Low / No Code。

表单方向的 Low / No Code,通俗来讲就是表单可视化搭建,产品形态就是各种表单生成器。不过对于 Low / No Code,Wiki 上有更准确的定义:

A low-code development platform (LCDP) is software that provides a development environment used to create application software through graphical user interfaces and configuration instead of traditional hand-coded computer programming.

No-code development platform (NCDPs) allows programmers and non-programmers to create application software through graphical user interfaces and configuration instead of traditional computer programming.

它的优势也很明显,那就是降低开发门槛,软件开发的工作不再局限于专业的技术人员。业界所谓的前端赋能,形态之一就是构建 No / Low Code 平台,把需求交给非前端来做.

4.4.3.2 产品与实现

包括 Formily 在内,市面上其实也已经有了一些表单生成器,譬如:

基本上所有的表单 No / Low Code 方案,都离不开一个核心的概念,即 DSL。DSL 可以认为是 UI 视图与用户交互之间的桥梁。通常来说这类产品的流程大概是这样:

  1. 用户通过在某些非编码的方式(比如在平台上拖拽配置)生产 DSL;
  2. DSL 通过映射规则转化为视图。

所以 DSL 是媒介,实际上它是抽象模型的代码表达,譬如:

用户可以不知道啥是 select,input 但是他知道什么是下拉框输入框;

用户可以不知道啥是相对定位绝对定位, 但是他知道自己要让框框再往左边一点;

用户可以不知道啥是 required 但是他会告诉你我希望表单的这一项必填。

下面是 Formily 通过扩充 JSON Schema 定义的 Schema, 也可以认为是一种 DSL:

对于 Formily 而言,其同时存在 3 种等效的表达方式, 见:https://formilyjs.org/#/0yTeT0/8MsesjHa,最贴近配置的 JSON Schema,然后是 JSX Schema,最后是最贴近 React 的纯 JSX。我们之前也说过, Formily 的 JSX 层更像是在声明一堆表单结构而不是视图,其底层逻辑就在这里。它必须保证你使用任何一种表达方式最终呈现出的表单是一致的。

Formily 引入 Schema 是有一个伟大的远景 -- 即后续的表单可以由机器生产或者可视化平台配置完成。不过从 react-schema-editor[14] 来看的话,暂时完成度还是比较有限的. 我自己业务中其实 Schema 也接触的比较少,从某种程度来讲,对于不需要考虑配置/机器生成表单的场景,其实这一层反而有点成为了我们的心智负担。我个人感觉 Formily 接地气的部分还是它的 React 层 (core 层) + Antd 桥接层。

4.4.3.3 现状

类似这样希望提供可视化解决方案的类库,Formily 并不是第一个,不过大多数感觉一直接受度不高,我自己的理解有几方面:

  1. 大部分此类平台是用来赋能非前端同学的,其实配置过程(也就是开发过程)并不难,难得是如何平衡产品灵活性与易用性之间的关系,这里搞不好横容易陷入“研发不想用, 非技术用户不会用”的窘境。
  2. 另一方面,大部分解决方案只解决了业务需求的第一步,开发,这个往往也是最简单的。但是如何扩充整个链路,把产品的整个生命周期考虑在内,即这种新的开发形态下的后续的 debug 优化改进是否也可以做到研发同学 0 参与?我感觉当前还不是很成熟,这里面还需要更多探索。

4.4.4 其他

其实 Formily 我接触稍微多一些的就是上面一些个概念,其他的譬如样式布局我们业务用得确实会比较少一点,也了解不多就不误人子弟了。虽然是说 Formily 的用户文档不太好,不过 Formily 理念性的介绍文章还是写地非常好的,如果大家想对 Formily 有深入了解的话,这里推荐下官方团队的 Formily 知乎专栏[15]

对于 Formily,客观来讲,他们算是表单领域的探索者, 虽然不尽完美,有这样那样的问题,但是瑕不掩瑜,从中我看到了中国开发者的伟大智慧,也给了我许多的启发。对于想要深入了解表单的同学,我个人认为 Formily 确实是个不错的学习对象。

5.  结尾

关于表单技术的一些想法都已经散落在文章的各个角落里了,到了总结的时候反而感觉没啥可说的。行文仓促,此外文章整体也比较主观,有些说法可能拿捏可能也没那么到位,如果有说的不对的地方,欢迎随时交流指正。

6.  招聘硬广告

急招!我们是字节跳动游戏前端团队,团队当前业务包括数个 DAU 千万量级的游戏中心化平台,覆盖今日头条、抖音、西瓜视频等等多个字节跳动宿主端;还有月流水千万级的创作者服务平台,是抖音官方最重要的游戏短视频分发平台和达人变现渠道。团队负责了这些项目产品,以及与之相关的运营后台、广告主服务平台、效率工具的前端研发,业务技术包括小程序、H5、Node 等多个方向,且在业务快速发展的同时,还在持续丰富技术支持场景。

简历投递欢迎点击阅读原文,检索「前端开发(高级)工程师-游戏中台」或者直接邮箱:lupengyu@bytedance.com

7.  参考链接

[1] https://github.com/react-component/form

[2] https://3x.ant.design/components/form-cn/
[3] https://reactjs.org/docs/react-without-es6.html#mixins
[4] https://github.com/mridgway/hoist-non-react-statics
[5] https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
[6] https://github.com/react-component/field-form
[7] https://ant.design/components/form-cn/
[8] https://github.com/reduxjs/redux/issues/1287#issuecomment-175351978
[9] https://redux.js.org/faq/organizing-state#should-i-put-form-state-or-other-ui-state-in-my-store
[10] https://github.com/redux-form/redux-form#%EF%B8%8F-attention-%EF%B8%8F
[11] rm: https://www.youtube.com/watch?v=WoSzy-4mviQ&feature=emb_logo

[12] https://github.com/janryWang/cool-path
[13] https://github.com/janryWang/react-eva
[14] https://github.com/alibaba/formily/tree/master/packages/react-schema-editor
[15] https://www.zhihu.com/column/uform




浏览 47
点赞
评论
收藏
分享

手机扫一扫分享

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

手机扫一扫分享

分享
举报