「React 深入」手写一个 rc-form 吧!

Ant Design 的 Form 表单组件是我们最常用的组件之一,它可以帮助我们数据录入、校验等功能。

大多数开发者认为 Form 表单使用起来非常方便,那是因为组件的内部承担了许多功能,比如状态管理状态分配表单验证等诸多环节。接下来我们一起看看具体如何实现一个表单功能。

在正式开始前,请大家带着以下 2 个小问题阅读:

  1. Form 组件是如何管理整体的数据流,为什么能从 Form 中获取表单控件的值?
  2. Form.Item 的 name 属性如何替代表单控件(如:Input、Select)的 value、onChange 属性,使其受控?

先附上一张知识图谱,正式进入 Form 组件的学习:

自定义hooks.png

一、表单的整体设计

在设计之前,我们以 Ant Design 中的 Form 为例,来看看一个基本的表单长什么样,又具备什么样的功能(文件位置:example/AntDForm):

  <Form
    initialValues={{ book: "玩转 React Hooks" }}
    onFinish={(data: any) => {
      console.log("表单数据:", data);
    }}
    onReset={() => {
      console.log("重制表单成功");
    }}
  >
    <Form.Item label="小册名称" name="book">
      <Input placeholder="请输入小册名称" />
    </Form.Item>


    <Form.Item label="作者" name="name">
      <Input placeholder="请输入作者" />
    </Form.Item>


    <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
      <Button type="primary" htmlType="submit">
        提交
      </Button>
      <Button style={{ marginLeft: 4 }} htmlType="reset">
        重制
      </Button>
    </Form.Item>
  </Form>

效果:

img.gif

在这个基础表单案例中,可以大体将表单分为 Form => Form.Item => 表单控件 三层结构,分别承担不同的作用,如:

  1. Form 组件:满足原生 form 表单功能,具备提交、重置、初始化、管理表单整体的数据结构等。
  2. Form.Item 组件:具备 label 功能(表单左侧的展示)、name 功能(对应整体数据的传递)、校验等功能属性。
  3. 表单控件:可以是各种数据录入组件(如:Input、Select),在不影响原本功能的前提下,需要将数据内容通过 Form.Item 绑定,由 Form.Item 控制 value、onChange 等属性,而不是自身绑定触发事件

将示例转化成关系图,如下所示:

未命名文件.png

接下来,我们就一步一步实现出自己的 Form 组件。

二、整体布局

经过上面的示例,我们需要创建 Form 和 Form.Item 组件作为容器,表单控件需要通过包裹的形式(children 属性)进行展示。

大体结构为:

// Form


<form> // 满足原生的 form 表单
  {children} // 包裹 Form.Item
</form>

// Form.Item
<Layout>   // 布局组件
  {children}  // 包裹表单控件
</Layout>

其中,Layout 组件属于布局组件,可控制表单的样式。为了让后续的效果更加好看,我们在这里简单处理下,可通过 Col 和 Row 进行宽度的设置,如:

// Layout

import { Col, Row } from "antd";





const Index = ({ children, label }: any) => {

  return (

    <>

      <Row gutter={8}>

        <Col
          span={4}

          style={{ textAlign: "right", lineHeight: "32px", fontSize: 14 }}

        >

          {label ? label + ":" : ""}
        </Col>
        <Col span={9}> {children}</Col>
      </Row>
      <div style={{ height: 12 }}></div>
    </>
  );
};

export default Index;

效果:

image.png

提示语

提示语也是表单常见的功能之一,也相对简单,只需要通过 tooltip 字段控制配合即可,如:

// Layout

import { Col, Row } from "antd";





const Index = ({ children, label }: any) => {

  return (

    <>

      <Row gutter={8}>

         <Col
          span={4}

          style={{ textAlign: "right", lineHeight: "32px", fontSize: 14 }}

        >

          {label || ""}
          {tooltip && (
            <Tooltip title={tooltip}>
              <QuestionCircleOutlined style={{ margin: "0 3px" }} />
            </Tooltip>
          )}
          {label && ":"}
        </Col>
        <Col span={9}> {children}</Col>
      </Row>
      <div style={{ height: 12 }}></div>
    </>
  );
};


export default Index;

效果:

image.png

三、数据管理与通信

在整个的表单的设计中,最核心点莫过于数据的状态管理。数据源如同整个表单的大脑,因此掌握好数据源是我们首要解决的问题。

其中,Form 组件需要承担表单的数据流向,当表单控件的值发生变化时,Form 管理的数据流也应该发生对应的改变。

除此之外,Form 组件还需要承担状态下发的作用,不仅可以管理这些数据,也要让这些数据通过 Form.Item 的 name 属性控制对应的表单控件,使其成为受控,这样做的目的是:可以自由传递 value,也能得到最新的 value,向上传递。

因此,我们通过 useForm (自定义 Hooks)来集中管理表单的数据,通过对应的实例,暴露对应的方法,在 Form、FormItem 组件中传递数据,更好地帮助管理表单。 如:

import { useRef } from "react";
import { FormInstance, DataProps } from "./interface.d";
import FormStore from "./FormStore";

const useForm = () => {
  const formRef = useRef<FormInstance | null>();

  if (!formRef.current) {
    // 创建一个实例,帮我们获取对应的方法
    formRef.current = new FormStore().getDetail();
  }


  return [formRef.current];
};

export default useForm;

其中 FormStore 是 useForm 的核心,而 getDetail 用于暴露 FormStore 的方法,防止将多余的方法暴露出来。

此外,Form 和 Form.Item 组件可能存在深层的嵌套关系,所以我们可以通过 context( createContext + useContext )跨层级方式传递数据。

数据如何通信?

通过上面的分析,我们需要将整个表单的数据源通过 useForm 来保存,但数据是通过表单控件而来,换言之我们需要将表单控件受控,使 Form 组件进行状态下发,精确控制对应的表单控件。

那么,如何在不改变结构的情况下,还能使组件受控,就变成了一个有趣的点,我们先来看看通常情况下如何让组件受控:

  <Input value={value} onChange={(e) => setValue(e.target.value)} />

在通常情况下,Input 受控,需要 valueonChange 属性的帮助,但在表单的场景中,并不需要通过 value 和 onChange 进行控制,主要原因有以下两点:

  1. 操作麻烦,不能确定具体表单控件的个数,如果每个控件都需要配置,比较麻烦。
  2. 破坏结构,相当于增加的两个属性是必须存在的,这样做会破坏表单控件的原有结构。

所以,我们并不希望通过 value、onChange 直接控制,而是通过 Form.Item 中的 name 属性来代替 value 和 onChange。为达到这一目的,就需要 React.cloneElement 的帮助,将这两个属性强行剥离出来,使组件受控。

问:React.cloneElement 是什么?

答:cloneElement 可以克隆并返回一个新的 React 元素。其结构为:

React.createElement(element, [props], [...children])

  1. element: 一个有效的 React 元素,大部分情况下是 JSX 节点;

  2. props: 对象或者为 null,如果存在,则会赋值给 element,如果不存在,则保留原来的 props;

  3. children: 零个或多个子节点,可以是任何 React 节点。

举个小例子:

import React from "react";

const Index: React.FC = () => {
  const children = React.cloneElement(
    <div>大家好,我是小杜杜,一起玩转Hooks吧!</div>,
    {
      book: "玩转 React Hooks",
    }
  );

  console.log(children);
  return <>{children}</>;
};


export default Index;

打印下 children 的结果:

image.png

可以看出,React.cloneElement 将 book 这个属性赋值给了 div,而 children 实际上等价于:

  const children = (
    <div book="玩转 React Hooks">大家好,我是小杜杜,一起玩转Hooks吧!</div>
  );

所以,我们可以通过 React.cloneElement 给表单控件加入 value、onChange 事件,使其受控。

检查 children 元素

在 React.cloneElement 要注意一个点,就是它的第一个参数 element,这个参数代表为:有效的 React 元素,换言之,Form.Item 所包裹的表单控件必须要符合这个条件。

而对于 Form.Item 来说,表单控件就是 children 属性,但 children 属性可能具备多种情况,比如字符串、单节点、多节点等情况,不同的情况,children 的形式不同,如:

image.png

很明显,只有单节点的情况才符合 React.cloneElement 的条件,至于其他情况,我们均不处理,只需正常展示即可。

单节点的本质是 React 元素,所以我们可以借助 React.isValidElement 来帮助我们判别下是否属于有效的 React 元素,如果是,则对其受控,如果不是,则不处理。如:

const FormItem = (props: any) => {
  const { name, children } = props;
  const update = useUpdate();
  
  const contextValue = useContext(FormContext);
  const { getFieldValue, dispatch, registerField, unRegisterField } = contextValue;
  
  let childrenPro;


  // 利用 isValidElement 来判断传递的数据是否是 React.ReactElement. 注意他可以判断多节点的情况,和无值的情况
  if (isValidElement(children) && name) {
    
    // 利用 cloneElement 给传递的组件加入 value 和 onChange 属性,剥离出对应的方法
    childrenPro = cloneElement(children as React.ReactElement, {
      value: getFieldValue(name),
      onChange: (v: any) => {
        let payload: any = {};
        payload[name] = v.target.value;

        // 更新 store 中的值
        dispatch({
          type: "updateValue",
          name
          ,
          value: v.target?.value,
        });

        update(); // 触发更新
      },
    });
  } else {
    childrenPro = children;
  }

  return <Layout {...props}>{childrenPro}</Layout>;
};

在 cloneElement 中,共涉及三个部分,分别是:

  1. getFieldValue: 获取对应表单的 value;
  2. dispatch: 触发更新,用于更新 useForm 中的 store;
  3. update: 强制刷新表单控件(有缺陷,后续会讲到)。

值的获取和更新

当学习完 cloneElement 和 isValidElement 后,值的获取和更新就变得非常简单,只要简单处理下 useForm 的核心:FormStore 即可。如:

class FormStore {
  store: DataProps = {}; // 管理表单的整体数据




  // 用于暴露方法
  public getDetail = (): FormInstance => ({
    getFieldValue: this.getFieldValue,
    dispatch: this.dispatch,
  });


  // 获取对应的值
  getFieldValue = (name: NameProps) => {
    return this.store[name];
  };


  // 触发更新
  dispatch = (action: ReducerAction) => {
    switch (action.type) {
      case "updateValue": {
        const { name, value } = action;
        this.updateValue(name, value);
        break;
      }
      default:
    }
  };


  // 更新
  updateValue = (name: NameProps, value: any) => {
    this.store = {
      ...this.store,
      [name]: value
    };
  };
}

只需要一个 store 变量去整体维护表单的值即可。

强制更新表单

当我们使用 dispatch 后,可以通过 useUpdate 实现对应控件的更新,但这么做存在一个缺陷:更新表单的操作,并不在 useForm 中,如果之后的操作涉及到更新(如:重置),是不是还要单独处理一套新的逻辑?

很明显,这样做多此一举,所以我们将更新的逻辑单独存储在 FormStore 中(update_store),有需要的话直接调用即可。

所以,我们需要记录当前的表单控件,一个 name 对应一个表单控件,同时在 Form.Item 进行注册和卸载,将更新方法进行保存。

然后,当值发生改变后,判断对应的表单控件进行控制,执行更新方法,使视图发生改变。如:

// Form.Item
const FormItem = (props: any) => {
  const contextValue = useContext(FormContext);
  const { getFieldValue, dispatch, registerField, unRegisterField } =
    contextValue;

  // 优化
  const updateChange = useCreation(() => {
    return {
      updateValue: () => update(),
    };
  }, [contextValue]);


  useEffect(() => {
    // 注册
    name && registerField(name, updateChange);
    return () => {
      //卸载
      name && unRegisterField(name);
    };
  }, [updateChange]);
  
  ...
}

// FormStore
class FormStore {
  update_store: DataProps = {}; // 保存更新的对象
  
  // 用于暴露方法
  public getDetail = (): FormInstance => ({
    unRegisterField: this.unRegisterField,
    registerField: this.registerField,
    ...
  });
  
    // 注册表单方法
  registerField = (name: NameProps, updateChange: DataProps) => {
    this.update_store[name] = updateChange;
  };

  // 卸载表单方法
  unRegisterField = (name: NameProps) => {
    delete this.update_store[name];
  };

  
    // 更新
  updateValue = (name: NameProps, value: any) => {
    this.store = {
      ...this.store,
      [name]: value,
    };

    this.updateStoreField(name);
  };

  // 更新对应的表单
  updateStoreField = (name: NameProps) => {
    const update = this.update_store[name];
    if (update) update?.updateValue();
  };
}

四、表单的基本操作

表单的基本操作有:初始化、提交、重置三个功能,简单分析下对应的功能点,来帮助我们更好地掌握表单。

  • initialValues: 初始化,如果存在,则赋值给 FormStore 中的 store,并将值进行保留,用于重置;
  • onFinish: 提交,将 store 的数据传递给 onFinish;
  • onReset: 重置,进行表单重置,如果存在 initialValues,则设为初始化值。

初始化

在初始化的过程中,我们将 initialValues(初始值)传入给 useForm,并将其赋到 FormStore 中的 store 和 initialValues 中。

// Form


const [formRef] = useForm(initialValues);




// useForm
const useForm = (initialValues: DataProps) => {
  ...
  if (!formRef.current) {
    formRef.current = new FormStore(initialValues).getDetail();
  }
  ...
};


// FormStore
class FormStore {
  ...
  initialValues: DataProps = {}; // 保存初始值


  constructor(initialValues: DataProps) {
    this.store = initialValues;
    this.initialValues = initialValues;
  }
  ...
}

提交、重置

跟刷新的逻辑一样,我们希望 useForm 去统一管理表单的提交和重置,将 onFinish 和 onReset 通过 setConfigWays 保留到 FormStore 的 configWays 中,然后再提交和重置的时候进行调用即可。如:

// Form


const Index = (props: FormProps) => {
  ...
  formRef.setConfigWays({
    onFinish,
    onReset,
  });

  return (
    <form
      {...payload}
      onSubmit={(e) => {
        // 阻止默认事件
        e.preventDefault();
        e.stopPropagation();
        formRef.submit();
      }}
      onReset={(e) => {
        e.preventDefault();
        e.stopPropagation();
        formRef.resetFields(); /* 重置表单 */
      }}
    >
      <FormContext.Provider value={formRef}>{children}</FormContext.Provider>
    </form>
  );
};

// FormStore
class FormStore {
   ...
   configWays: ConfigWayProps = {}; // 收录对应的方法集合
   ...
    
  // 设置方法区间
  setConfigWays = (configWays: ConfigWayProps) => {
    this.configWays = configWays;
  };

  // 用于表单提交
  submit = () => {
    const { onFinish } = this.configWays;

    onFinish && onFinish(this.store);
  };


  // 重置表单
  resetFields = () => {
    const { onReset } = this.configWays;
    Object.keys(this.store).forEach((key) => {
      // 重置表单的时候,如果有初始值,就用初始值,没有就删除
      this.initialValues[key]
        ? (this.store[key] = this.initialValues[key])
        : delete this.store[key];
      this.updateStoreField(key);
    });
    onReset && onReset();
  };
}

这样,一个基本的表单组件就完成了,来看看整体效果:

img3.gif

五、玩转 React Hooks 小册

小册已经上架一个多月了,销量对我而言非常好,也很感谢各位兄弟的捧场。

小册的内容偏向于基础,对新手更加友好,同时也更加倾向于思想,无论是 Hooks 的内容,还是实战篇的内容,建议大家都亲自实现一番,将这些思想应用于工作之中,封装出更加通用、全面的组件,才是我们学习完这本小册的目的。

小册链接:《玩转 React Hooks》

小册整体设计如下思维导图所示:

玩转hooks.png

另外,感兴趣的兄弟可以私信我,领取 七折 兑换码。

小结

本小节对应 rc-form(Antd form) 的实现,主要介绍 Form 组件的数据管理与通信。另外,表单的校验也是非常有趣的点,感兴趣的的小伙伴可以在小册中观看。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYF2IDMG' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片