动手打造React组件库-Select

1,125 阅读3分钟

本文正在参加「金石计划」

一:引言

本篇文章仿写antd组件库的Select组件,文章重点是模拟Select组件的内部功能实现,样式比较丑希望大家包涵。

二:组件分析

观察下图,我们设计一个可配置的select组件,它主要有两种模式。

单选

image.png

多选

image.png
  • className 类名
  • mode 选择模式,单选或多选
  • defaultValue 默认选择项
  • disabled 禁止选择
  • onChange 选择时触发回调

三:问题拆解

在设计组件前,我们可以先考子问题,最后组装起来就实现了目标组件。

问题1:点击非选择框区域,如何关闭下拉列表(重点)?

  1. 在select组件根元素上添加ref获取dom对象。

  2. 全局添加点击事件监听

     document.addEventListener('click',handle);
    
  3. handle添加方法,判断当前点击区域是否属于select组件内。

     const handle = (e:MouseEvent) => {
       const isOutside = !domRef.current?.contains(e.target as Node);
       if(isOutside) setDownModal(false);
     }
    
  4. 组件销毁时,释放全局点击事件

问题2:多选模式下如何控制选择项与下拉列表数据状态同步?

初始化选中数组muiltpe,每次点击下拉列表时更新选中数组,如果存在该项则删除,否则添加。

问题3:下拉列表如何基于输入框定位?多选模式下输入框高度发生变化如何定位?

可以给输入框盒子内部添加下拉列表盒子,设置输入框是相对定位,设置下拉列表是绝对定位,考虑到输入框高度是变化的,因此需要动态的获取输入框盒子高度,同步设置下拉框的top=输入框盒子高度就可以保证下拉框始终保持定位一致了。

五:Select代码实现

    import React,{useState,useRef, useEffect} from "react";
    import classNames from "classnames";
    interface selectProps {
      defaultValue?:string|string[]|number|number[],
      className?:string
      options?:Array<any>,
      onChange?:(e:any)=>void,
      mode?:string,
      children?:React.ReactNode
    }
    const Select = (props:selectProps) => {
      const {
        defaultValue,
        className,
        options,
        onChange,
        mode
      } = props;
      const domRef = useRef<HTMLDivElement>(null);
      const [divHeight,setDivHeight] = useState(0);
      const [downModal,setDownModal] = useState(false);
      const [choiceItem,setChoiceItem] = useState<{label:any,value:any}>();
      const [muiltItems,setMuiltItems] = useState<any>([]);//多选模式
      const classes = classNames('select',className,{})


      //初始化选择列表(排除用户随意添加默认值)
      const initSelect = () => {
        if(mode==='tags') {
          options?.forEach(item=>{
            if(item.value===defaultValue) {
              setChoiceItem(item);
            }
          })
        }else{
          if(options) {
            if(Array.isArray(defaultValue)) {
              let tmp = [];
              for(let i=0;i<defaultValue.length;i++) {
                let val = options?.find(item=>item.value===defaultValue[i])
                if(val) tmp.push(val);
              }
              setMuiltItems(tmp);
            }else {
              let val = options?.find(item=>item.value===defaultValue)
              if(val) {
                setMuiltItems([val])
              }
            }
          }   
        }
      }
      useEffect(()=>{
        /** 监听是否点击非元素自身 */
        const handle = (e:MouseEvent) => {
          const isOutside = !domRef.current?.contains(e.target as Node);
          if(isOutside) setDownModal(false);
        }
        document.addEventListener('click',handle);
        /**初始化选择列表 */
        initSelect();

        //组件卸载时销毁全局监听事件
        return ()=>{
          document.removeEventListener('click',handle);
        } 
      },[])

      //动态计算盒子高度
      useEffect(()=>{
        setDivHeight(domRef.current?.offsetHeight!);
      },[muiltItems])


      //点击下拉列表
      const clickItems = (e:any,item:any) => {
        e.stopPropagation()//阻止冒泡,否则触发列表关闭
        if(mode === 'tags') {
          setDownModal(false);
          setChoiceItem(item);
          onChange && onChange(item.value)
        } else {
          //多选是数组,存在添加,否则删除
          let tmp:any = [...muiltItems]
          let isHave = false;
          for(let i=0;i<tmp.length;i++) {
            if(tmp[i]=== item) {
              tmp.splice(i,1);
              isHave = true;
              break;
            }
          }
          if(!isHave) tmp.push(item);
          setMuiltItems(tmp);
          onChange && onChange(tmp);
        }
      }
      //点击祖先元素
      const clickSelectBox = (e:any)=> {
        e.stopPropagation();
        setDownModal(!downModal);
      }
      //根据下拉状态动态绑定class
      const judgeSelectClassName = (item:any) => {
        if(mode==='tag' && item.value===choiceItem?.value) {
          if(item.disabled){
            return "select_list_item select_list_item_disabled";
          }else{
            return "select_list_item select_list_item_actived";
          }
        }

        if(mode==='multiple' && muiltItems.includes(item)){
          if(item.disabled){
            return "select_list_item select_list_item_disabled";
          }else{
            return "select_list_item select_list_item_actived";
          }
        }

        if(item.disabled) {
          return "select_list_item select_list_item_disabled";
        }
        else{
          return "select_list_item"
        }
      }
      return (
        <div 
          className={classes}
          onClick={clickSelectBox}
          ref={domRef}
        >
          <div className="select_box">
            {mode==='tags' && choiceItem && choiceItem?.value}
            {mode === 'multiple' && (
              <>
                {muiltItems && muiltItems?.length>0 && muiltItems.map((item:any)=>(
                  <div className="select_muilt" key={item.value}>
                    {item?.value}
                    <span 
                      onClick={(e)=>{
                        //关闭
                        e.stopPropagation()
                        let tmp:any = [...muiltItems]
                        for(let i=0;i<tmp.length;i++) {
                          if(tmp[i]=== item) {
                            tmp.splice(i,1);
                            break;
                          }
                        }
                        setMuiltItems(tmp);
                        onChange && onChange(tmp);
                      }}
                    >
                      x
                    </span>
                  </div>
                ))}
              </>
            )}
          </div>
          {
            <div className='select_list' style={{top:`${divHeight}px`}}>
              {downModal && options && options.length>0 && options.map(item=>(
              <div 
                className={judgeSelectClassName(item)}
                key={item.label}
                onClick={(e)=>{
                  if(!item.disabled)
                    clickItems(e,item)
                }}
              >
                {item.value}
              </div>
            ))}
            </div>
          }
        </div>
      )
    }
    Select.defaultProps = {
      mode:'tags',
      options:[]
    }
    export default Select;

六:功能演示

演示1:单选默认

1.gif

const options=[
    {label:'小米',value:'小米'},
    {label:'华为',value:'华为'},
    {label:'网易',value:'网易'},
    {label:'京东',value:'京东'},
  ]

  <Select options={options}/>

演示2:单选添加默认值和禁止选择项

3.gif

    const options=[
        {label:'小米',value:'小米'},
        {label:'华为',value:'华为'},
        {label:'网易',value:'网易'},
        {label:'京东',value:'京东',disabled:true},
      ]

      <Select options={options} defaultValue={'小米'}/>

演示3:单选触发回调函数

4.gif

const options=[
        {label:'小米',value:'小米'},
        {label:'华为',value:'华为'},
        {label:'网易',value:'网易'},
        {label:'京东',value:'京东',disabled:true},
      ]

<Select options={options} defaultValue={'小米'} onChange={(e:any)=>console.log(e)}/>

演示4:多选默认

5.gif

const options=[
    {label:'小米',value:'小米'},
    {label:'华为',value:'华为'},
    {label:'网易',value:'网易'},
    {label:'京东',value:'京东'},
  ]

  <Select options={options} mode='multiple'/>

演示4:多选添加默认值和禁止

6.gif

    const options=[
        {label:'小米',value:'小米'},
        {label:'华为',value:'华为'},
        {label:'网易',value:'网易'},
        {label:'京东',value:'京东',disabled:true},
      ]

    <Select options={options} mode='multiple' defaultValue={['小米']}/>

演示4:多选添加回调函数

7.gif

const options=[
        {label:'小米',value:'小米'},
        {label:'华为',value:'华为'},
        {label:'网易',value:'网易'},
        {label:'京东',value:'京东',disabled:true},
      ]

    <Select 
        options={options} 
        mode='multiple' 
        defaultValue={['小米']} 
        onChange={(e)=>console.log(e)}
    />

总结

今天Select组件到此结束,希望大家多多支持,我们下一个组件见。