深入学习并手写 React Ant Design4 表单核心库 rc-field-form

13,201 阅读13分钟

前言

最近有一个非常复杂的表单需求,可能需要对表单做“任何事情”,现有的 UI 组件库选用的是 Ant Design 简称 antd 。它的 Form 表单已经帮我们把“表单项校验”、“表单项错误信息”等常见操作全部封装好了。使用起来非常便捷。翻看了 antd Form  源码发现其核心能力都是通过 rc-field-form 库,提供出来的。因此阅读它的源码将是作者项目开始前必须要做的。

本文将模拟 rc-field-form 库,手写一个“学习版” ,深入学习其思想。

如果本文对你有所帮助,请点个👍 吧!

工程搭建

rc-field-form 使用的是 Dumi  和 father-build 对组件库进行打包,为了保持一致,作者也将使用这两个工具来完成项目。

Dumi

dumi 中文发音嘟米,是一款为组件开发场景而生的文档工具,与 father-builder 一起为开发者提供一站式的组件开发体验, father-builder 负责构建,而 dumi 负责组件开发及组件文档生成。

father-build

father-build 属于 father (集文档与组件打包一体的库)的一部分,专注于组件打包。

脚手架创建项目

使用 @umijs/create-dumi-lib 来初始化项目。这个脚手架整合了上面提及的两个工具。

mkdir lion-form // 创建lion-form文件夹
cd lion-form // 进入文件夹
npm init -y // 初始化 package.json
npx @umijs/create-dumi-lib // 初始化整体项目结构

项目结构说明

├──README.md // 文档说明
├──node_modules // 依赖包文件夹
├──package.json // npm 包管理
├──.editorconfig // 编辑器风格统一配置文件
├──.fatherrc.ts // 打包配置
├──.umirc.ts // 文档配置
├──.prettierrc // 文本格式化配置
├──tsconfig.json // ts 配置
└──docs // 仓库公共文档
	└──index.md // 组件库文档首页
└──src
	└──index.js // 组件库入口文件

启动项目

npm start 或 yarn start 

image.png
集文档,打包为一体的组件库就这样快速的搭建完成了。下面就让我们来手写一个 rc-field-form  吧。

完整代码地址

源码编写

rc-field-form

对于经常使用 react 开发的同学来说, antd 应该都不会陌生。开发中经常遇到的表单大多会使用 antd 中的 Form 系列组件完成,而 rc-field-form 又是 antd Form 的重要组成部分,或者说 antd Form 是对 rc-field-form 的进一步的封装。

想要学习它的源码,首先还是得知道如何使用它,不然难以理解源码的一些深层次的含义。

简单的示例

首先来实现如下图所示的表单,类似于我们写过的登录注册页面。
image.png

代码示例:

import React, { Component, useEffect} from 'react'
import Form, { Field } from 'rc-field-form'
import Input from './Input'
// name 字段校验规则
const nameRules = {required: true, message: '请输入姓名!'}
// password 字段校验规则
const passwordRules = {required: true, message: '请输入密码!'}

export default function FieldForm(props) {
  // 获取 form 实例
  const [form] = Form.useForm()
  // 提交表单时触发
  const onFinish = (val) => {
    console.log('onFinish', val)
  }
  // 提交表单失败时触发
  const onFinishFailed = (val) => {
    console.log('onFinishFailed', val)
  }
  // 组件初始化时触发,它是React原生Hook
  useEffect(() => {
    form.setFieldsValue({username: 'lion'})
  }, [])
	
  return (
    <div>
      <h3>FieldForm</h3>
      <Form form={form} onFinish={onFinish} onFinishFailed={onFinishFailed}>
        <Field name='username' rules={[nameRules]}>
          <Input placeholder='请输入姓名' />
        </Field>
        <Field name='password' rules={[passwordRules]}>
          <Input placeholder='请输入密码' />
        </Field>
        <button>Submit</button>
      </Form>
    </div>
  )
}

// input简单封装
const Input = (props) => {
  const { value,...restProps } = props;
  return <input {...restProps} value={value} />;
};

这种写法还是非常便捷的,不再需要像 antd3 一样使用高阶函数包裹一层。而是直接通过 Form.useForm() 获取到 formInstance 实例, formInstance 实例身上承载了表单需要的所有数据及方法。

通过 form.setFieldsValue({username: 'lion'}) 这段代码就不难发现,可以通过 form 去手动设置 username 的初始值。也可以理解成所有的表单项都被 formInstance 实例接管了,可以使用 formInstance 实例做到任何操作表单项的事情。 formInstance 实例也是整个库的核心。

基础框架搭建

通过对 rc-field-form 源码的学习,我们先来搭建一个基础框架。

useForm

  • 通过 Form.useForm() 获取 formInstance  实例;
  • formInstance 实例对外提供了全局的方法如 setFieldsValue 、 getFieldsValue ;
  • 通过 context 让全局可以共享 formInstance 实例。


src/useForm.tsx 

import React , {useRef} from "react";

class FormStore {
  // stroe 用来存储表单数据,它的格式:{"username": "lion"}
  private store: any = {};
  // 用来存储每个 Field 的实例数据,因此在store中可以通过 fieldEntities 来访问到每个表单项
  private fieldEntities: any = [];

  // 表单项注册到 fieldEntities
  registerField = (entity:any)=>{
    this.fieldEntities.push(entity)
    return () => {
      this.fieldEntities = this.fieldEntities.filter((item:any) => item !== entity)
      delete this.store[entity.props.name]
    }
  }
  // 获取单个字段值
  getFieldValue = (name:string) => {
    return this.store[name]
  }
  // 获取所有字段值
  getFieldsValue = () => {
    return this.store
  }
  // 设置字段的值
  setFieldsValue = (newStore:any) => {
    // 更新store的值
    this.store = {
      ...this.store,
      ...newStore,
    }
  // 通过 fieldEntities 获取到所有表单项,然后遍历去调用表单项的 onStoreChange 方法更新表单项
    this.fieldEntities.forEach((entity:any) => {
      const { name } = entity.props
      Object.keys(newStore).forEach(key => {
        if (key === name) {
          entity.onStoreChange()
        }
      })
    })
  }
  // 提交数据,这里只简单的打印了store中的数据。
  submit = ()=>{
    console.log(this.getFieldsValue());
  }
  // 提供FormStore实例方法
  getForm = (): any => ({
    getFieldValue: this.getFieldValue,
    getFieldsValue: this.getFieldsValue,
    setFieldsValue: this.setFieldsValue,
    registerField: this.registerField,
    submit: this.submit,
  });
}
// 创建单例formStore
export default function useForm(form:any) {
  const formRef = useRef();
  if (!formRef.current) {
    if (form) {
      formRef.current = form;
    } else {
      const formStore = new FormStore();
      formRef.current = formStore.getForm() as any;
    }
  }
  return [formRef.current]
}

其中 FormStore 是用来存储全局数据和方法的。 useForm 是对外暴露 FormStore 实例的。从 useForm  的实现可以看出,借助 useRef 实现了 FormStore 实例的单例模式。

FieldContext

定义了全局 context 。

import * as React from 'react';

const warningFunc: any = () => {
  console.log("warning");
};

const Context = React.createContext<any>({
  getFieldValue: warningFunc,
  getFieldsValue: warningFunc,
  setFieldsValue: warningFunc,
  registerField: warningFunc,
  submit: warningFunc,
});

export default Context;

Form 组件

  • 传递 FieldContext
  • 拦截处理 submit 事件;
  • 渲染子节点。


src/Form.tsx 

import React from "react";
import useForm from "./useForm";
import FieldContext  from './FieldContext';

export default function Form(props:any) {
  const {form, children, ...restProps} = props;
  const [formInstance] = useForm(form) as any;
	
  return <form
    {...restProps}
    onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      event.stopPropagation();
			// 调用了formInstance 提供的submit方法
      formInstance.submit();
    }}
  >
  	{/* formInstance 当做全局的 context 传递下去 */}
    <FieldContext.Provider value={formInstance}>{children}</FieldContext.Provider>
  </form>
}

Field 组件

  • 把自己注册到 FormStore 中;
  • 拦截子元素为其添加 value 以及 onChange 属性。


src/Field.tsx 

import React,{Component} from "react";
import FieldContext from "./FieldContext";

export default class Field extends Component {
  // Filed 组件获取 FieldContext
  static contextType = FieldContext;

  private cancelRegisterFunc:any;
  // Field 挂载时,把自己注册到FieldContext中,也就是上面提及的 fieldEntities 数组中。
  componentDidMount() {
    const { registerField } = this.context;
    this.cancelRegisterFunc = registerField(this);
  }
  // Field 组件卸载时,调用取消注册,就是从 fieldEntities 中删除。
  componentWillUnmount() {
    if (this.cancelRegisterFunc) {
      this.cancelRegisterFunc()
    }
  }
  // 每个 Field 组件都应该包含 onStoreChange 方法,用来更新自己
  onStoreChange = () => {
    this.forceUpdate()
  }
  // Field 中传进来的子元素变为受控组件,也就是主动添加上 value 和 onChange 属性方法
  getControlled = () => {
    const { name } = this.props as any;
    const { getFieldValue, setFieldsValue } = this.context
    return {
      value: getFieldValue(name),
      onChange: (event:any) => {
        const newValue = event.target.value
        setFieldsValue({[name]: newValue})
      },
    }
  }
	
  render() {
    const {children} = this.props as any;
    return React.cloneElement(children, this.getControlled())
  }
}

Form 组件的基础框架就此搭建完成了,它已经可以实现一些简单的效果,下面我们在 docs 目录写个例子。

docs/examples/basic.tsx 

...省略了部分代码

export default function BasicForm(props) {
  const [form] = Form.useForm()

  useEffect(() => {
    form.setFieldsValue({username: 'lion'})
  }, [])

  return (
    <Form form={form}>
      <Field name='username'>
        <Input placeholder='请输入姓名' />
      </Field>
      <Field name='password'>
        <Input placeholder='请输入密码' />
      </Field>
      <button>提交</button>
    </Form>
  )
}

解析:

  1. 组件初始化时调用 form.setFieldsValue({username: 'lion'}) 方法;
  2. setFieldsValue 根据传入的参数,更新了 store 值,并通过 name 找到相应的 Field 实例;
  3. 调用 Field 实例的 onStoreChange 方法,更新组件;
  4. 组件更新,初始值就展示到界面上了。


image.png

点击查看本小节代码

Form

Form 组件获取 ref

antd 文档上有这么一句话:“我们推荐使用 Form.useForm 创建表单数据域进行控制。如果是在 class component 下,你也可以通过 ref 获取数据域”。

使用方式如下:

export default class extends React.Component {
  formRef = React.createRef()

  componentDidMount() {
    this.formRef.current.setFieldsValue({username: 'lion'})
  }

  render() {
    return (
      <Form ref={this.formRef}>
      	<Field name='username'>
        	<Input />
        </Field>
        <Field name='password'>
          <Input />
        </Field>
          <button>Submit</button>
       </Form>
    )
  }
}

通过传递 formRef 给 Form 组件。获取 Form 的 ref 实例,但是我们知道 Form 是通过函数组件创建的,函数组件没有实例,无法像类组件一样可以接收 ref 。因此需要借助 React.forwardRef 与 useImperativeHandle 。

src/Form.tsx 

export default React.forwardRef((props: any, ref) => {
  ... 省略
  const [formInstance] = useForm(form) as any;

  React.useImperativeHandle(ref, () => formInstance);
  
  ... 省略
})

  • React.forwardRef 解决了,函数组件没有实例,无法像类组件一样可以接收 ref 属性的问题;
  • useImperativeHandle 可以让你在使用 ref 时,决定暴露什么给父组件,这里我们将 formInstance 暴露出去,这样父组件就可以使用 formInstance 了。


关于 React Hooks 不熟悉的同学可以阅读作者的这篇文章:React Hook 从入门应用到编写自定义 Hook

点击查看本小节代码

初始值 initialValues

之前我们都是这样去初始化表单的值:

useEffect(() => {
  form.setFieldsValue({username: 'lion'})
}, [])

显然这样初始化是不够优雅的,官方提供了 initialValues 属性让我们去初始化表单项的,下面让我们来支持它吧。

src/useForm.ts 

class FormStore {
  // 定义初始值变量
  private initialValues = {}; 

  setInitialValues = (initialValues:any,init:boolean)=>{
    // 初始值赋给initialValues变量,这样 formInstance 就一直会保存一份初始值
    this.initialValues = initialValues;
    // 同步给store
    if(init){
      // setValues 是rc-field-form提供的工具类,作者这里全部copy过来了,不用具体关注工具类的实现
      // 这里知道 setValues 会递归遍历 initialValues 返回一个新的对象。
      this.store = setValues({}, initialValues, this.store);
    }
  }	
  
  getForm = (): any => ({
    ... 这里省略了外部使用方法
    
    // 创建一个方法,返回内部使用的一些方法
    getInternalHooks:()=>{
      return {
        setInitialValues: this.setInitialValues,
      }
    }
  });
}

src/Form.tsx 

export default React.forwardRef((props: any, ref) => {
  const [formInstance] = useForm(form) as any;
  const {
    setInitialValues,
  } = formInstance.getInternalHooks();
  
  // 第一次渲染时 setInitialValues 第二个参数是true,表示初始化。以后每次渲染第二个参数都为false
  const mountRef = useRef(null) as any;
  setInitialValues(initialValues, !mountRef.current);
  if (!mountRef.current) {
    mountRef.current = true;
  }
  
  ...
}

useRef 返回一个可变的 ref 对象,其 current 属性被初始化为传入的参数( initialValue )。返回的 ref 对象在组件的整个生命周期内保持不变。

点击查看本小节代码

submit

在此之前,提交 submit 只能打印 store 里面的值,这并不能满足我们的需求,我们需要它可以回调指定函数。

src/useForm.ts

class FormStore {
  private callbacks = {} as any; //用于存放回调方法

  // 设置callbases
  setCallbacks = (callbacks:any) => {
    this.callbacks = callbacks;
  }

  // 暴露setCallbacks方法到全局
  getForm = (): any => ({
  	...
    getInternalHooks: () => {
      return {
        setInitialValues: this.setInitialValues,
        setCallbacks: this.setCallbacks
      };
    },
  });
  
  // submit 时,去callbacks中取出需要回调方法执行
  submit = () => {
    const { onFinish } = this.callbacks;
    onFinish(this.getFieldsValue())
  };
}

src/Form.tsx

export default React.forwardRef((props: any, ref) => {
  const { ..., onFinish, ...restProps } = props;
  const [formInstance] = useForm(form) as any;
  const {
    setCallbacks,
  } = formInstance.getInternalHooks();
  // 获取外部传入的onFinish函数,注册到callbacks中,这样submit的时候就会执行它
  setCallbacks({
    onFinish
  })
  
  ...
}

点击查看本小节代码

Field

shouldUpdate

通过 shouldUpdate 属性控制 Field 的更新逻辑。当 shouldUpdate 为方法时,表单的每次数值更新都会调用该方法,提供原先的值与当前的值以供你比较是否需要更新。

src/Field.tsx 

export default class Field extends Component {
  // 只改造这一个函数,根据传入的 shouldUpdate 函数的返回值来判断是否需要更新。
  onStoreChange = (prevStore:any,curStore:any) => {
    const { shouldUpdate } = this.props as any;
    if (typeof shouldUpdate === 'function') {
      if(shouldUpdate(prevStore,curStore)){
        this.forceUpdate();
      }
    }else{
      this.forceUpdate();
    }
  }	
  
}

src/useForm.js 

class FormStore {
  // 之前写了一个registerField是用来设置Field实例的存储,再添加一个获取的方法
  getFieldEntities = ()=>{
    return this.fieldEntities;
  }
  // 新增一个方法,用来通知Field组件更新
  notifyObservers = (prevStore:any) => {
    this.getFieldEntities().forEach((entity: any) => {
      const { onStoreChange } = entity;
      onStoreChange(prevStore,this.getFieldsValue());
    });
  }
  // 现在设置字段值之后直接调用 notifyObservers 方法进行更新组件
  setFieldsValue = (curStore: any) => {
    const prevStore = this.store;
    if (curStore) {
      this.store = setValues(this.store, curStore);
    }
    this.notifyObservers(prevStore);
  };  
}

好了更新的逻辑也差不多写完了,虽然并非跟原库保持一致(原库考虑了更多的边界条件),但是足矣帮助我们理解其思想。

点击查看本小节代码

表单验证

根据用户设置的校验规则,在提交表单时或者任何其他时候对表单进行校验并反馈错误。
读源码的时候发现,底层做校验使用的是 async-validator 做的。

async-validator

它是一个可以对数据进行异步校验的库, ant.design 与 Element ui 的 Form 组件都使用了它做底层校验。

安装

npm i async-validator

基本用法

import AsyncValidator from 'async-validator'
// 校验规则
const descriptor = {
  username: [
    {
      required: true,
      message: '请填写用户名'
    },
    {
      pattern: /^\w{6}$/
      message: '用户名长度为6'
    }
  ]
}
// 根据校验规则构造一个 validator
const validator = new AsyncValidator(descriptor)
const data = {
  username: 'username'
}
validator.validate(data).then(() => {
  // 校验通过
}).catch(({ errors, fields }) => {
  // 校验失败
});

关于 async-validator 详细使用方式可以查阅它的 github 文档

Field 组件设置校验规则

    <Field
	label="Username"
	name="username"
	rules={[
           { required: true, message: 'Please input your username!' },
           { pattern: /^\w{6}$/ }
	]}
    >
	<Input />
    </Form.Item>

如果校验不通过,则执行 onFinishFailed  回调函数。

[注意] 原库还支持在 rules 中设置自定义校验函数,本组件中已省略。

组件改造

src/useForm.ts 

class FormStore {
  // 字段验证
  validateFields = ()=>{
    // 用来存放字段验证结果的promise
    const promiseList:any = [];
    // 遍历字段实例,调用Field组件的验证方法,获取返回的promise,同时push到promiseList中
    this.getFieldEntities().forEach((field:any)=>{
      const {name, rules} = field.props
      if (!rules || !rules.length) {
        return;
      }
      const promise = field.validateRules();
      promiseList.push(
        promise
          .then(() => ({ name: name, errors: [] }))
          .catch((errors:any) =>
            Promise.reject({
              name: name,
              errors,
            }),
          ),
      );
    })
    // allPromiseFinish 是一个工具方法,处理 promiseList 列表为一个 promise
    // 大致逻辑:promiseList 中只要有一个是 rejected 状态,那么输出的promise 就应该是 reject 状态
    const summaryPromise = allPromiseFinish(promiseList);
    const returnPromise = summaryPromise
      .then(
        () => {
          return Promise.resolve(this.getFieldsValue());
        },
      )
      .catch((results) => {
        // 合并后的promise如果是reject状态就返回错误结果
        const errorList = results.filter((result:any) => result && result.errors.length);
        return Promise.reject({
          values: this.getFieldsValue(),
          errorFields: errorList
        });
      });

    // 捕获错误
    returnPromise.catch(e => e);
	
    return returnPromise;
  }
  // 提交表单的时候进行调用字段验证方法,验证通过回调onFinish,验证失败回调onFinishFailed
  submit = () => {
    this.validateFields()
      .then(values => {
        const { onFinish } = this.callbacks;
        if (onFinish) {
          try {
            onFinish(values);
          } catch (err) {
            console.error(err);
          }
        }
      })
      .catch(e => {
        const { onFinishFailed } = this.callbacks;
        if (onFinishFailed) {
          onFinishFailed(e);
        }
      });
  };
}

现在的核心问题就是 Field 组件如何根据 value 和 rules 去获取校验结果。

src/Field.tsx 

export default class Field extends Component {
  private validatePromise: Promise<string[]> | null = null
  private errors: string[] = [];
  // Field组件根据rules校验的函数
  validateRules = ()=>{
    const { getFieldValue } = this.context;
    const { name } = this.props as any;
    const currentValue = getFieldValue(name); // 获取到当前的value值
    // async-validator 库的校验结果是 promise
    const rootPromise = Promise.resolve().then(() => {
      // 获取所有rules规则
      let filteredRules = this.getRules();
      // 获取执行校验的结果promise
      const promise = this.executeValidate(name,currentValue,filteredRules);
      promise
        .catch(e => e)
        .then((errors: string[] = []) => {
          if (this.validatePromise === rootPromise) {
            this.validatePromise = null;
            this.errors = errors; // 存储校验结果信息
            this.forceUpdate(); // 更新组件
          }
        });
      return promise;
    });
    this.validatePromise = rootPromise;
    return rootPromise;
  }
  // 获取 rules 校验结果
  public getRules = () => {
    const { rules = [] } = this.props as any;
    return rules.map(
      (rule:any) => {
        if (typeof rule === 'function') {
          return rule(this.context);
        }
        return rule;
      },
    );
  };  
  // 执行规则校验
  executeValidate = (namePath:any,value:any,rules:any)=>{
    let summaryPromise: Promise<string[]>;
    summaryPromise = new Promise(async (resolve, reject) => {
      // 多个规则遍历校验,只要有其中一条规则校验失败,就直接不需要往下进行了。返回错误结果即可。
      for (let i = 0; i < rules.length; i += 1) {
        const errors = await this.validateRule(namePath, value, rules[i]);
        if (errors.length) {
          reject(errors);
          return;
        }
      }
      resolve([]);
    });
    return summaryPromise;
  }  
  // 对单挑规则进行校验的方法
  validateRule = async (name:any,value:any,rule:any)=>{
    const cloneRule = { ...rule };
    // 根据name以及校验规则生成一个校验对象
    const validator = new RawAsyncValidator({
      [name]: [cloneRule],
    });
    let result = [];
    try {
      // 把value值传入校验对象,进行校验,返回校验结果
      await Promise.resolve(validator.validate({ [name]: value }));
    }catch (e) {
      if(e.errors){
        result = e.errors.map((c:any)=>c.message)
      }
    }
    return result;
  }	  
}

到此为止我们就完成了一个简单的 Form 表单逻辑模块的编写。本文每小节的代码都可以在 github 上查看,而且在 dosc 目录下有相应的使用案例可以查看。

点击查看本小节代码

线上发布

发布到 npm

前面介绍过了,这个项目采用的是 dumi + father-builder 工具,因此在发布到 npm 这块是特别方便的,在登录 npm 之后,只需要执行 npm run release 即可。

线上包地址:lion-form

本地项目通过执行命令 npm i lion-form 即可使用。

发布组件库文档

1、配置 .umirc.ts 

import { defineConfig } from 'dumi';

let BaseUrl = '/lion-form'; // 仓库的路径

export default defineConfig({
  // 网站描述配置
  mode: 'site',
  title: 'lion form',
  description: '前端组件开发。',
  // 打包路径配置
  base: BaseUrl,
  publicPath: BaseUrl + '/', // 打包文件时,引入地址生成 BaseUrl/xxx.js
  outputPath: 'docs-dist',
  exportStatic: {}, // 对每隔路由输出html
  dynamicImport: {}, // 动态导入
  hash: true, //加hash配置,清除缓存
  manifest: {
    // 内部发布系统规定必须配置
    fileName: 'manifest.json',
  },
  // 多国语顺序
  locales: [
    ['en-US', 'English'],
    ['zh-CN', '中文'],
  ],
  // 主题
  theme: {
    '@c-primary': '#16c35f',
  },
});

配置完成后,执行 npm run deploy 命令。

2、设置 github pages 
image.png
image.png

设置完成后,再次执行 npm run deploy ,即可访问线上组件库文档地址

总结

本文从工程搭建,源码编写以及线上发布这几个步骤去描述如何完整的编写一个 React 通用组件库。

通过 Form 组件库的编写也让我们学习到:

  • Form 组件, Field 组件是通过一个全局的 context 作为纽带关联起来的,它们共享 FormStore 中的数据方法,非常类似 redux 工作原理。
  • 通过把每个 Field 组件实例注册到全局的 FormStore 中,实现了在任意位置调用 Field 组件实例的属性和方法,这也是为什么 Field  使用 class 组件编写的原因(因为函数组件没有实例)。
  • 最后也借助了 async-validator 实现了表单验证的功能。


学习优秀开源库的源码过程是不开心的,但是收获会是非常大的, Dont Worry Be Happy 。