「React基础」⚛️封装一个简单的表单

1,341 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第11天,点击查看活动详情

本篇文章会使用React基础知识封装一个简单的Form表单组件,主要涉及到:

  • 表单组件Form -- 用于管理表单状态
  • 子组件FormItem -- 用于管理表单项
  • 表单输入组件Input -- 用于输入文本字符串

表单组件特性

其特性如下:

  • Form组件提供submitForm提交表单和resetForm重置表单实例方法
  • 通过ref获取到Form组件的实例,从而能够顺利调用组件实例上的方法
  • Form的插槽中只渲染FormItem,对于其他的组件或元素会忽略
  • FormItem的插槽中只渲染Input组件,并且能够自动收集Input组件的数据,交给Form组件管理,而对于其他组件或元素会忽略渲染

相关知识点

涉及到的知识点主要包括:

  • Reactprops,如何通过props进行组件之间的通信
  • 通过props.children获取到插槽中的元素,并为它们隐式注入props

预览使用方式

我们先不急着写组件,先看看怎么使用它们吧,首先看看App.jsx

function App() {
  const form = useRef(null)

  const submit = () => {
    form.current.submitForm(formData => {
      console.log(formData)
    })
  }

  const reset = () => {
    form.current.resetForm()
  }

  return (
    <div className="app">
      <Form ref={form}>
        <h1>我不会被渲染</h1>
        <FormItem name="username" label="用户名">
          <p>我也不会被渲染</p>
          <Input />
        </FormItem>
        <FormItem name="password" label="密码">
          <Input />
        </FormItem>
      </Form>
      <div className="action">
        <button onClick={() => reset()}>重置</button>
        <button onClick={() => submit()}>登录</button>
      </div>
    </div>
  )
}

主要看看Form组件的使用,可以看到,首先Form组件上有一个ref属性,其值为用useRef声明的form变量,这样做的目的是能够获取到Form组件实例,从而能够在底下两个按钮中的点击事件回调中调用组件实例上的相应方法

然后Form组件的插槽中有FormItem组件,并且FormItem组件的插槽中嵌套了Input组件,从而完成一个表单项的渲染,值得注意的是,Form插槽中的h1以及FormItem中的p都不会被渲染,这是如何做到的呢?待会实现Form组件的时候再揭晓答案

Form 组件

我们先来解决刚刚的疑惑,如何控制插槽中只允许渲染指定类型的组件或元素?

如何获取插槽元素?

那我们要先知道如何在Form组件中获取到插槽中的元素,在React中,插槽中的组件或元素,是通过props传递的,放在props.children属性中,所以我们可以取出props.children,然后想办法设计一个能标识组件类型的方案,遍历children,只渲染允许渲染的类型即可

如何标识组件类型?

那么这个组件类型的标识如何设计呢?首先要明确一点,不管是类组件还是函数组件,它们本质上都是函数(类组件看成是一个构造函数),而在js中,函数是一个对象,因此我们可以在组件中添加一个displayName的属性,用于标识组件的类型

这样就能够通过child.type.displayName获取到组件的标识displayName

知道了这点之后,就可以开始实现我们的Form组件了

/**
 * @description 表单组件 -- 用于管理表单状态
 *
 * 1. 可以被 ref 获取组件实例,然后调用实例方法`submitForm`获取并提交表单内容
 * 2. 实例方法`resetForm`重置表单内容
 * 3. 可以自动过滤除了`FormItem`之外的 React 元素
 */
import React from 'react'

export default class Form extends React.Component {
  state = {
    formData: {},
  }

  // 提交表单
  submitForm(cb) {
    cb({ ...this.state.formData })
  }

  // 重置表单
  resetForm() {
    const { formData } = this.state
    Object.keys(formData).forEach(item => {
      formData[item] = ''
    })
    this.setState({
      formData,
    })
  }

  // 修改表单数据
  setValue = (name, value) => {
    this.setState({
      formData: {
        ...this.state.formData,
        [name]: value,
      },
    })
  }

  render() {
    // 从 props 中取出 children 就是插槽
    const { children } = this.props

    // 遍历插槽 检测是否是 FormItem 组件
    // 只渲染 FormItem 组件,其他组件忽略
    const slots = []
    React.Children.forEach(children, child => {
      if (child.type.displayName === 'formItem') {
        const { name } = child.props
        // 克隆 FormItem 结点,混入改变表单项的方法
        const Child = React.cloneElement(
          child,
          {
            key: name,
            handleChange: this.setValue,
            value: this.state.formData[name] || '',
          },
          child.props.children,
        )

        slots.push(Child)
      }
    })

    return slots
  }
}

Form.displayName = 'form'

实现条件性渲染组件的核心就在于遍历插槽元素,看看它们的displayName是否是需要渲染的目标元素,是的话才进行渲染,并且这里通过React.cloneElement隐式注入了新的props到子元素中,或许你会好奇,为什么要隐式注入,不能直接显式注入props呢?

因为我们要渲染的目标元素是不确定的,并不是声明式地将其写出来,而是命令式地编写逻辑代码去生成待渲染元素,如果是声明式地直接返回<FormItem></FormItem>元素的话,就可以直接在标签中传入props了,而现在显然不是这种场景

这里注入的props主要有:

  • key -- 用于标识元素,方便React底层进行diff,避免不必要的性能损失
  • handleChange -- 用于作为子组件Input输入内容时的回调,修改state中的formData的对应值
  • value -- 作为输入框的值

这里的valuehandleChange组合起来其实就相当于vue中的双向绑定 -- v-model,只是React中没有类似的语法糖(也可能有,只是我刚接触React,不知道是否有类似语法糖,如果有知道的读者欢迎评论区留言告知我一下),不过也不影响我们使用,只要懂得原理都不是大问题

FormItem 组件

FormItem组件中也是类似的,要条件性地渲染组件,只渲染Input组件(感兴趣的读者可以自行拓展别的表单组件),原理上面已经讲过了,都是通过props.children获取插槽元素,然后通过displayName是否是目标渲染元素来进行选择性渲染

/**
 * @description 表单项
 *
 * 1. 只支持渲染 Input 组件,忽略其他组件
 */
import React from 'react'

export default function FormItem(props) {
  const { children, name, handleChange, value, label } = props
  const onChange = value => {
    handleChange(name, value)
  }

  return (
    <div className="form-item">
      <span className="label">{label}:</span>
      {
        // 只渲染 Input 组件
        React.isValidElement(children) && children.type.displayName === 'input'
          ? React.cloneElement(children, { onChange, value })
          : null
      }
    </div>
  )
}

FormItem.displayName = 'formItem'

Input 组件

Input组件就更简单了,就是将props绑定到原生的input标签上即可

export default function Input({ onChange, value }) {
  return (
    <input
      className="input"
      type="text"
      onChange={e => onChange && onChange(e.target.value)}
      value={value}
    />
  )
}

Input.displayName = 'input'

别忘记要给组件添加displayName标识,这样才能正确实现选择性渲染的特性