从0到1搭建react组件库-Input(受控模式&非受控模式)

185 阅读8分钟

引言

之前的几篇文章中,逐步搭建了组件库整体工程化的结构, 也完成了ButtonIcon这种基础组件的开发。接下来的总体规划大致是先实现数据录入的组件,数据展示的组件,最后就是实现部分数据反馈、布局组件。今天要介绍的重点就是Input组件,以及Arco组件库和rc-component中是如何处理受控与非受控的状态。

React开发过程中,不可避免的要使用到表单捕捉用户的输入来完成与用户之间的交互。在这个过程中就衍生了受控组件和非受控组件两个概念。我们先来了解一下这两个概念的定义是什么?

  • 受控组件: 顾名思义,当表单元素中的状态由父组件来控制和更新时就可以称为受控组件。
  • 非受控组件: 当表单元素中的状态由组件内部自己维护时就可以称之为非受控组件。

  掌握到这两个概念之后, 就继续往下介绍Input组件了。API的设计我参照Arco的组件库来进行分析的。

组件设计

实现Input组件的过程中,对我来说最棘手的反而是找到一个合适的切入点。不管是自己着手实现开始,还是写下这篇文章, 每一个组件几乎都涉及到很多状态和逻辑。 最后决定以组件的API设计作为切入点,然后抓住一个点顺着脉络切入。

API解析:

这里将Arco中的API大体分为四个方面:

Input组件基础功能

作为Input组件的基础功能,那就是数据的录入了。还有一些在录入功能上的拓展,例如:格式化、特殊事件的响应。

  • placeholder: 输入框的提示文字。
  • defaultValue: 将输入框作为受控组件使用时, 使用defaultValue声明默认值。
  • value:通常和onChange一起组合使用。
  • onChange: 输入时的回调函数。
  • normalize:在指定得时机对用户输入的值进行格式化处理。前后值不一致时,会触发onChange事件。
  • normlizeTrigger: 指定normalize执行的时机。 ('onBlur' | 'onPressEnter' )[]
  • onPressEnter: 按下回车键时的回调。
  • showWordLimit: 配合maxLength, 显示字数统计。
  • maxLength: 输入框最大输入的长度; 设置errorOnly为 true后, 超过maxLength会展示error状态, 并不限制用户输入。

Input组件的构成

这部分的API主要会影响到Input组件中的前缀、后缀、图标等组成元素。

  • clearIcon以及与clearIcon相关的props,比如allowClear、onClear。
  • prefix:添加前缀文字或者图标。
  • suffix:添加后缀文字或者图标。
  • addBefore:输入框前添加元素。
  • beforeStyle: 输入框前元素的样式。
  • addAfter:输入框后添加元素。
  • afterStyle: 输入框后元素的样式。

Input组件的状态

  • diabled: 输入框是否禁用。
  • status: 'error' | 'warning'。
  • readOnly: 当前输入框是否处于只读状态。

Input组件的尺寸样式

  • className: 输入框节点的类名。
  • style: 输入框节点绑定的样式名称。
  • autoWith: 设置宽度自适应。
  • size: 输入框的尺寸。
  • hieght: 输入框的高度。

基础功能实现:

基础功能模块中,先实现input组件的基础功能, 使得Input组件能够支持受控、非受控两种模式。

非受控组件:

非受控组件在此处主要体现在可以通过defaultValue来设置组件的默认值, 然后不参与后续组件状态的维护,最终通过ref等方式获取到Input组件的内部的值。

  1. 创建type.ts文件用来存储要暴露给父组件的Ref类型以及Input组件的props类型。

    1. export interface InputProps  {
        /**
      * @zh input组件的默认值
      * */ 
      defaultValue?: string;
      
      }
      
      export interface InputRef {
        focus: () => void;
        blur: () => void;
        dom: HTMLInputElement | null;
      }
      
  2. 借助inputRef来访问input元素实例。

  3. 在组件内部声明value来管理输入值的状态, 并且将props中的defaultValue设置为默认值。

  4. 定义一个事件处理的方法,监听用户的输入事件。

  5. 自定义一些属性和方法,然后通过useImperativeHandle暴露给用户, 以便父组件能够读取输入值。

import {isFunction} from "../../utils"
import {InputProps, InputRef} from "./type"
import {ChangeEvent, forwardRef, useImperativeHandle, useRef, useState} from "react"

export const Input = forwardRef<InputRef, InputProps>((props, ref) => {
  // props
const {
    defaultValue,
    ...rest
  } = props
  // 元数据
const inputRef = useRef<HTMLInputElement>(null) // input元素的ref实例
const [value, setValue] = useState(defaultValue) // input组件值

 // event 事件处理
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
      setValue(event.target.value)
  }

  // 向上层暴露ref
 useImperativeHandle(
      ref,
      () => {
        return {
          focus: () => {
            isFunction(inputRef.current?.focus) && inputRef.current?.focus()
          },
          blur: () => {
            isFunction(inputRef.current?.blur) && inputRef.current?.blur()
          },
          dom: inputRef.current
        }
      },
      []
  )

  return (
      <input
          ref={inputRef}
          {...rest}
          value={value}
          onChange={handleChange}
      />
  )
})

受控组件

受控组件在Input组件中主要体现在可以通过valueonChange在父组件中控制组件内部的状态。

为了使Input组件能够同时兼容受控模式和非受控模式, 需要针对这一点做出特殊的处理。

  1. 设定组件内部value状态时,需要根据优先程度判断应该选择默认值。优先程度从高到低依次为: value > defaultValue > defaultStateValue(自定义默认值)。
  2. 传递给input元素的值, 应该始终以props中的value值为准,但是为了兼容非受控组件,应当对props中的value进行判断, 若为undefined则传递组件内部维护的状态。
  3. 拦截input元素中的handleChange事件,在事件中判断组件所处模式,决定是否更新组件内部状态。
import {isFunction, isUndefined} from "../../utils"
import {InputProps, InputRef} from "./type"
import {ChangeEvent, forwardRef, useImperativeHandle, useRef, useState} from "react"

export const Input = forwardRef<InputRef, InputProps>((props, ref) => {
  // props
const {
    defaultValue,
    onChange,
    ...rest
  } = props
  // 元数据
const inputRef = useRef<HTMLInputElement>(null) // input元素的ref实例
const [value, setValue] = useState(      // input组件值
!isUndefined(props.value)
          ? props.value
          : !isUndefined(defaultValue) ? defaultValue : ''
  )
  const mergedValue = isUndefined(props.value) ? props.value : defaultValue

  // event 事件处理
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (!('value' in props)) {
      setValue(event.target.value)
    }
    isFunction(onChange) && onChange(event.target.value, event)
  }

  // 向上层暴露ref
 useImperativeHandle(
      ref,
      () => {
        return {
          focus: () => {
            isFunction(inputRef.current?.focus) && inputRef.current?.focus()
          },
          blur: () => {
            isFunction(inputRef.current?.blur) && inputRef.current?.blur()
          },
          dom: inputRef.current
        }
      },
      []
  )

  return (
      <input
          ref={inputRef}
          {...rest}
          value={mergedValue}
          onChange={handleChange}
      />
  )
})
  1. 上述的代码已经简单的兼容了受控模式与非受控模式。但是随之而来就是一个新的需要解决的问题:

    1. 场景:Input组件为受控组件时, value刚开始非undefined, 但是被修改成undefined的值,通过value是不是undefined来判断input元素最终使用的值就会出现问题。

    2. arco组件中的处理方案为通过副作用监听属性中value值的变更, 当value变更为undefined的时候, 手动更新组件内部的值,这样反馈的结果是保持一致的。但是往往处理了一个边界情况,就会出现更多的边界情况等待处理。

      1. 首先我们需要避开组件挂载时的副作用, 因为此时valueundefined已经在默认值的逻辑中处理了。

      2. 其次在react的严格模式中,会通过双重渲染导致组件被挂载两次, 此时就会导致一个issuedefaultValue的值不生效。因此还要判断前后两次value的值是否相等。

      3. 通过usePrevious在渲染这一次的新闭包中获取到上一次的值:

        • import { ComponentState, PropsWithoutRef, useEffect, useRef } from 'react';
          
          export default function usePrevious<T>(value: PropsWithoutRef<T> | ComponentState) {
            const ref = useRef();
            useEffect(() => {
              ref.current = value;
            });
            return ref.current;
          }
          
      4. 在hooks中完成边界情况的处理

        • import React, { useState, useEffect, useRef } from 'react';
          import { isUndefined } from '../is';
          
          
          
          export default function useMergeValue<T>(
            defaultStateValue: T,
            props?: {
              defaultValue?: T;
              value?: T;
            }
          ): [T, React.Dispatch<React.SetStateAction<T>>, T] {
            const { defaultValue, value } = props || {};
            const firstRenderRef = useRef(true);
            const prevPropsValue = usePrevious(value);
          
            const [stateValue, setStateValue] = useState<T>(
              !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue
            );
          
            useEffect(() => {
              // 第一次渲染时候,props.value 已经在useState里赋值给stateValue了,不需要再次赋值。
              if (firstRenderRef.current) {
                firstRenderRef.current = false;
                return;
              }
          
              // 外部value等于undefined,也就是一开始有值,后来变成了undefined(
              // 可能是移除了value属性,或者直接传入的undefined),那么就更新下内部的值。
              // 如果value有值,在下一步逻辑中直接返回了value,不需要同步到stateValue
              /**
               *  prevPropsValue !== value: https://github.com/arco-design/arco-design/issues/1686
               *  react18 严格模式下 useEffect 执行两次,可能出现 defaultValue 不生效的问题。
               */
              if (value === undefined && prevPropsValue !== value) {
                setStateValue(value);
              }
            }, [value]);
          
            const mergedValue = isUndefined(value) ? stateValue : value;
          
            return [mergedValue, setStateValue, stateValue];
          }
          
    3. Antd组件库的处理方案则更充分的利用react本身的特性。其它的处理手法都和arco中的一致, 主要的不同点在于rc-components中的useLayoutUpdateEffect钩子。 通过两次useEffect的执行次序,巧妙的规避掉react严格模式下的两次渲染。

      1. 在第一次的useEffect中响应props中的value的值变化, 并判定是否初次渲染,处理value变更为undefined的情况。
      2. 第二个useEffect在挂载时将初次渲染标识设置为false,然后在返回值函数中,通过将初次渲染标识设置为true, 巧妙的避免由于react严格模式导致的defaultValue不生效。
      3. const useInternalLayoutEffect =
          process.env.NODE_ENV !== 'test' && canUseDom()
            ? React.useLayoutEffect
            : React.useEffect;
        
        const useLayoutEffect = (
          callback: (mount: boolean) => void | VoidFunction,
          deps?: React.DependencyList,
        ) => {
          const firstMountRef = React.useRef(true);
        
          useInternalLayoutEffect(() => {
            return callback(firstMountRef.current);
          }, deps);
        
          // We tell react that first mount has passed
          useInternalLayoutEffect(() => {
            firstMountRef.current = false;
            return () => {
              firstMountRef.current = true;
            };
          }, []);
        };
        

抽离处理受控非受控hook

接下来,我选用了上面一步介绍的Arco的实现方式,实现同时兼容受控模式以及非受控模式。下面就是使用hooks重构后的组件代码。

import {isFunction} from "../../utils"
import {InputProps, InputRef} from "./type"
import {ChangeEvent, forwardRef, useImperativeHandle, useRef} from "react"
import {useMergeState} from "../../hooks/useMergeState";

export const Input = forwardRef<InputRef, InputProps>((props, ref) => {
  // props
  const {
    defaultValue,
    ...rest
  } = props
  // 元数据
  const inputRef = useRef<HTMLInputElement>(null) // input元素的ref实例
  const [value, setValue] = useMergeState<string>(
      '',
      {
        defaultValue: defaultValue,
        value: props.value
      }
  ) // input组件值

  // event 事件处理
  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (!('value' in props)) {
      setValue(event.target.value)
    }

    isFunction(props.onChange) && props.onChange(event.target.value, event)
  }

  // 向上层暴露ref
  useImperativeHandle(
      ref,
      () => {
        return {
          focus: () => {
            isFunction(inputRef.current?.focus) && inputRef.current?.focus()
          },
          blur: () => {
            isFunction(inputRef.current?.blur) && inputRef.current?.blur()
          },
          dom: inputRef.current
        }
      },
      []
  )

  return (
      <input
          ref={inputRef}
          {...rest}
          value={value}
          onChange={handleChange}
      />
  )
})

仓库地址