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

前言

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

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

若是本文对你有所帮助,请点个👍 吧!html

工程搭建

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

Dumi

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

father-build

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

脚手架建立项目

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

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  吧。

完整代码地址git

源码编写

rc-field-form

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

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

简单的示例

首先来实现以下图所示的表单,相似于咱们写过的登陆注册页面。
image.png

代码示例:npm

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 实例也是整个库的核心。json

基础框架搭建

经过对 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 便可使用。

发布组件库文档

一、配置 .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 命令。

二、设置 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 。

相关文章
相关标签/搜索