手把手搭建基于React的前端UI库 (五)-- 基础表单组件

765 阅读7分钟

前言

        由于疫情原因,被封闭在家中比较烦躁,拖了好久才开始续写UI篇的文章,抱歉。本篇是React组件库的第5篇文章,我们来实现一下Form表单的功能。本文的代码展示的是主要的核心代码,全部代码见仓库:Gitee仓库。另,我已部署了本组件库的文档地址,还请批评指正:dh1992.gitee.io/dux-ui-reac…

Input

        Input作为最基本的HTML表单组件,肯定是组件库中必不可少的一个组件,同时也是实现起来相对简单的,H5本身就自带了input标签,我们只需要在其基础上自定义自己的样式,并且实现一些事件监听即可。

        我们先确定Input要接受的props有哪些。首先输入框肯定要有值,还要能禁用和清除,同时变化还要有检测事件,主要的参数我罗列一下:

属性描述
value受控值
prefix前缀
suffix后缀
clearable是否可清空
size尺寸
disabled是否禁用
onClear清除事件
onFocus聚焦事件
onBlur失焦事件
onChange变化事件

我们在components文件夹下,新建Input文件夹,并在其下新建index.tsx文件:

...
const Input = ({size, focused, disabled, customStyle, clearable, value}) => {
      
      return <SWrap
        {...{ size, focused, disabled, customStyle }}
        empty={!value}
      >
        <span className={inputWrapCls}>
          {renderPrefix}
          <input
            {...rest}
            value={value}
            onChange={onChange}
            ref={inputRef}
            onFocus={handleFocus}
            onBlur={handleBlur}
            disabled={disabled}
          />
          {renderClear}
          {renderSuffix}
        </span>
      </SWrap>
    );
}

        我用一个SWrap组件包裹了原生的inpu标签,接受用户传入的props属性。看一下SWrap是怎么实现的,仍然是返回一个styleWrap函数,具体的css实现可以看我的源码,这里不多赘述:

import styled from '@emotion/styled';
import { css } from '@emotion/core';

...
export const SWrap = styleWrap({})(
  styled.span((props) => {
    const {
      theme: { designTokens: DT },
      disabled,
      size,
      focused,
      clearable,
      customStyle,
    } = props;

    return css`
        /** css实现 */
        ...
    `
  })
);

我们来看一下每一个属性都是怎么实现其价值的:

value/disabled/onChange

通过props属性传入以后直接赋值给input标签:value={value}

prefix/suffix

前后缀我们通过renderPrefix/renderSuffix组件实现,分别放在input的前后:

// 如果用户传入了prefix,就返回一个span,加上classname,并给一个onMouseDown的事件,如果不需要这里也可以不要事件。后缀也同前缀一样。
const renderPrefix = useMemo(() => {
  return (
    prefix && (
      <span className={inputPrefixCls} onMouseDown={onMouseDown}>
        {prefix}
      </span>
    )
  );
}, [onMouseDown, prefix]);

clearable

clearable是组件自定义的属性,原生input没有自带的清除操作。我们通过后缀组件前边的renderClear组件实现:

// 其实与前后缀实现方式一样,不同的是content是固定的一个Icon,并强制赋予handleClear事件
const renderClear = useMemo(() => {
  if (clearable) {
    return (
      <span className={clearCls} onClick={handleClear} onMouseDown={onMouseDown}>
        <Icon type="remove_sign" />
      </span>
    );
  }
}, [clearable, handleClear, onMouseDown]);

// 清除value事件
const handleClear = useCallback(
  (e) => {
    if (disabled) return;
    onClear();
    const input = inputRef.current;
    if (!input) return;
    e.target = input;
    e.currentTarget = input;
    const cacheV = input.value;
    input.value = '';
    onChange(e);
    input.value = cacheV;
  },
  [disabled, onChange, onClear],
);

        我们设定的清除事件执行顺序是先执行用户传入的onClear,之后缓存value,然后拿到Input标签的ref,设置其value是空字符串,并将当前点击事件的target给Input,调用onChange返给用户。注意最后一行,要重新把input的value赋值回缓存的原值,因为这里并没有任何setState的操作,不应该改变原来的值。用一个onChange事件间隔一下,value值在一个渲染周期内不会渲染两次,所以就造成了被清空的假象。至于清空数据的真实操作,应该放在onChange中。

        至此,Input组件的功能实现已经介绍完毕。以此类推,数字输入框NumberInput,只需要在左右两侧个加一个Button来控制增减,在onBlur事件中需要通过正则校验是否为合法的数字即可;文本域Textarea完全可以参照Input,将原生标签换成textarea即可。

Radio

        H5的input标签虽然有radio属性,但是一般组件库就不太会直接使用了,一方面是原生的方法往往不能满足组件库的需求,另一方面,不想原始的Input输入框,原生标签不能做到组件库需要的样式定制和主题定制。比如我们自定义的radio组件,可以有原生样式,也可以有card模式,而且radio往往都不是一个单独存在,至少得有两个才能满足要求。我们先来定义一下props

属性描述
checked是否选中
defaultChecked默认是否选中
disabled是否禁用
onChange点选时的回调
valueradio的值
styleType样式风格, 可选 'default', 'button', 'tag', 'card', 'text', 'list'
size尺寸,可选'sm', 'md', 'lg',styleType 为 card、list 时无效
title标题,styleType 为 card 时使用

        接下来在src/components下新建文件夹Radio,并在其中新建index.tsx

...
renderRadioList(props: any) {
    /* eslint-disable no-unused-vars */
    const {
      children,
      checked,
      onChange,
      onClick,
      disabled,
      ...rest
    } = props;
    /* eslint-enable no-unused-vars */

    return (
      <RadioListWrap
        checked={checked}
        disabled={disabled}
        {...rest}
        onClick={(...args: any) => this.onClick(props, { ...args })}
      >
        <RadioIcon checked={checked} disabled={disabled} />
        {children != null && <span className={contentCls}>{children}</span>}
      </RadioListWrap>
    );
}

        RadioListWrap仍然是用styleWrap包裹的包含样式的div,内容物我们放一个图标RadioIcon接收最重要的属性checked,后边用一个span包裹传入的children,使用时可以这样写:

<Radio checked>checked</Radio>

组件RadioIcon内部放一个实心圆形的Icon,SIconWrap仍然是一个div:

const RadioIcon = (props: { checked?: boolean; disabled?: boolean }) => {
  return (
    <SIconWrap {...props}>
      <Icon className={iconCls} type="whitecircle" />
    </SIconWrap>
  );
};

SIconWrap样式定义里,通过判断是否checked来控制样式高亮:

${checked &&
  css`
    // checked时,SIconWrap加一个主题色的边框
    &.${iconWrapCls} {
      color: ${DT.T_COLOR_LINE_PRIMARY_DEFAULT};
      border-color: ${DT.T_COLOR_LINE_PRIMARY_DEFAULT};
    }
    // checked时,Icon字体加上主题色
    .${iconCls} {
      visibility: visible;
      opacity: 1;
      fill: ${DT.T_COLOR_TEXT_PRIMARY_DEFAULT};
    }
`}

基本结构讲解完了,我们来看加上样式美化后的效果:

image.png

        可以看到,除了模拟原生样式外,还可以模拟按钮、标签和列表样式,实现方式同上,只需要将RadioWrap内的Icon替换成其他的组件即可。

表单组件比较多,这里只是讲述了基本的几个实现,为写表单组件提供一个思路,更多组件可以参见组件库主页

Form

        讲完基础表单组件,我们家实现一下表单组件的布局,就叫Form。H5原生的组件是有form的,我们这里需要用到其submit方法,所以就使用原生的form,并对其进行样式封装。

        表单容器,必须有一个根组件Form,内部有至少一个formitem,每一个formitem内包含一个基础表单输入组件,定义不同的name。在src/components下新建Form文件夹,并在其下新建Form.tsx,Item.tsx。先看Form.tsx

const Form = ({ onSubmit, ...rest }: any) => {
  const { preventFormDefaultAction } = useContext(ConfigContext);
  const handleSubmit = React.useCallback(
    (e) => {
      if (e) {
        e.preventDefault();
        e.stopPropagation();
      }
      onSubmit && onSubmit(e);
    },
    [onSubmit],
  );
  return (
      <FormWrap onSubmit={preventFormDefaultAction ? handleSubmit : onSubmit} {...rest} />
  );
};

        表单容器,必须有一个根组件Form用于容纳所有的输入元素,触发原生的submit事件;内部children有至少一个formitem,每一个formitem内包含一个基础表单输入组件,定义不同的name,formitem应该遵循左右布局格式,使用float或者flex都可以实现。我们看传入的参数onSubmit,在组件FormWrap的回调onSubmit中调用。我们来看一下FormWrap,他返回的就是一个加了样式的原生form标签:

export const FormWrap = styleWrap<{ size: string }>({
  className: prefixCls,
})(
  styled('form')((props) => {

    return css`
      font-size: 12px;

      // 给下属的每一个item加样式
      .${itemCls} {
        margin-bottom: 16px;
        &:last-child {
          margin-bottom: 0;
        }
      }
    `;
  }),
);

        我们再来看内容物Item.tsx的实现:

...
return (
    <ItemWrap>
      <LabelWrap {...labelCol}>
        {label}
        {required && <RequiredLabel>*</RequiredLabel>}
        {help && <CommentWrap>{help}</CommentWrap>}
      </LabelWrap>
      <ControllerWrap {...controllerCol}>
        {children}
        <RenderTip tip={tip} status={status} />
      </ControllerWrap>
    </ItemWrap>
);

        ItemWrapLabelWrapControllerWrap,用于表单条目左右布局的实现:

// ItemWrap 使用12栅格布局
export const ItemWrap = styleWrap<{ size: string }>({
className: prefixCls,
})(
  styled('form')((props) => {

  return css`
    font-size: 12px;
    display: flex;

    // 给下属的每一个label加样式,样式类名从LabelWrap获取
   .${prefixCls} > .${itemLabelCls} {
      flex: ${props.labelCol.span || 6}
    }
    
    // 给下属的每一个controller受控区域加样式,样式类名从ControllerWrap获取
   .${prefixCls} > .${itemControllerCls} {
      flex: ${props.controllerCol.span || 6}
    }
  `;
}),
);

        我们来看看放组件后的效果(主页demo):

image.png

        总结一下,表单组件需要放在原生form标签内,使用栅格布局,左侧放置label,右侧放置具体的输入组件;本文章介绍了Input和Radio如何来封装,其他组件也都是大同小异,由于篇幅限制,读者可以去主页查看效果或者自己去npm安装一下实施效果(相关配置可查看源码README):

npm install --save dux-ui