初学react-hooks和ts!封装一个AutoComplete组件

562 阅读2分钟

基于React-hooksTypescript封装的AutoComplete组件。

组件介绍

1. 基本功能

1.1 一个文本输入框,能够输入文字并根据所输入的文字给出对应提示
1.2 所提示的文字以列表形式呈现,并能选择然后填充至输入框
1.3 提示的文本列表中,需要将输入框中的文字高亮显示(或者醒目的 css 样式)
1.4 当输入匹配结果为空时,需要显示特定的内容
1.5 输入搜索时需要 debounce
1.6 能够传递style参数,方便修改input样式

2. 需要实现的apis

fielddescriptiontype
options自动完成的数据源Array
notFoundContent当匹配结果为空时的内容React.ReactNode
debounceDelayDebounce 延迟时间,以毫秒记number
placeholder输入框的 placeholderstring
dropdownClassname提示下拉栏的类名string
style给input框添加样式CSSProperties

实现过程

创建组件

// props 接口
interface IProps {
  placeholder?: string,
  options: Array<any>,
  notFoundContent?: React.ReactNode,
  style?: CSSProperties,
  debounceDelay?: number,
  dropdownClassname?: string
}
// 声明并暴露组件
export const AutoComplete: React.FC<IProps> = (props) => {
  return (
    <div>
      <input 
        type="text" className="auto-complete" value={value}
        placeholder={placeholder} onFocus={onFocus} 
        onBlur={onBlur} onChange={onChange} style={style}
      />
    </div>
  );
}

下拉列表

下拉列表根据isShow决定是否显示:let [isShow, setIsShow] = useState(false)

jsx部分

<div>
  <input 
     type="text" className="auto-complete" value={value}
     placeholder={placeholder} onFocus={onFocus} 
     onBlur={onBlur} onChange={onChange} style={style}
   />
  {
    isShow ? 
      (filterOptions.length>0 ? (<ul className={`dropdown ${dropdownClassname}`}>
        {
          filterOptions.map((item,index) => {
            return <li className='li' key={index}>
              {item}
            </li>
          })
        }
      </ul>) : notFoundContent) : null
  }
</div>

输入框focus时(highlight是高亮,我们后面再讲)

const onFocus = (): void => {
  // value是输入框的value值:const [value, setValue] = useState('')
  if(value){
    // 先展示出下拉列表,再高亮
    setIsShow(true)
    setTimeout(() => {
      highlight()
    });
  }
}

输入框blur时

const onBlur = (): void => {
  setTimeout(() => {
      setIsShow(false)
  }, 100);
}

这里设置定时器是为了当点击下拉列表的时候也会触发blur事件,如果立马隐藏下拉列表就无法处理点击下拉列表的事件,所以这里设置100ms是让程序去处理完点击下拉列表的事件,再隐藏下拉列表。我不确定这样做是不是最优解,但是出来效果达到预期,所以暂时先这样 ┑( ̄Д  ̄)┍(摊手)

输入框change时

const delayEvent = useDebounce(event, debounceDelay)
const onChange = (e: React.FormEvent<HTMLInputElement>): void => {
  setValue(e.currentTarget.value)
  delayEvent()
}

delayEvent是实现输入防抖效果的,这里算是一个重难点,跟上哦~
useDebounce文件:

import { useRef } from 'react'

export default function useDebounce(fn:()=>void,delay:number) {
  const { current } = useRef({timer:0})
  return () => {
    if(current.timer){
      clearTimeout(current.timer)
    }
    current.timer = window.setTimeout(() => {
      fn()
    }, delay)
  }
}

防抖咱们都会写,但是这里为什么要用到useRef呢?

useRef保留了timer的唯一性,也就是说组件render前后timer都是这一个。

那有人就要说了(其实是之前的我自己。。):多此一举!直接这样不香吗:

const debounce: IDebounce = (delay, event) => {
  setValue(e.currentTarget.value)
  let timer:number | null = null  
  return function () {
    if(timer) {
      window.clearTimeout(timer as number);
    }
    timer = window.setTimeout(() => {
      event()
    }, delay);
  }
}

我们之前防抖都是这样写的对吧,但是在react中不得行,因为每次render的时候都会重新声明一个timer(跟之前的timer没关系),所以最后效果就是没有防抖。

插一嘴,React搭配TS时定时器的相关写法,也许可以帮助到你

说完了防抖的注意事项,我们再来看防抖中的事件event:

const event = (): void => {
  console.log('event');
  setTimeout(() => {
    // 获取最新的value值
    let _value = valueRef.current
    let filterArr: Array<string> = []
    if(_value){
      setFilter([])
      options.forEach(str => {
        // 如果str以input里的值开头
        if(str.startsWith(_value)){
          filterArr.push(str)
        }
      })
      setFilter(filterArr)
      setIsShow(true)
    } else {
      setIsShow(false)
    }
  });
}

我们主要做的就是从传入的option中筛选出用于提示的filterOption。我的判断依据是:是否是以当前输入框中的值开头。如果是,那么压入filterOption,供下拉列表显示。

关键字高亮(重难点)

useEffect(()=>{
  setTimeout(() => {
    if(filterOptions.length){
      // 高亮关键词
      highlight()
    }
  });
}, [filterOptions])

highlight函数:

const highlight = (): void => {
  let transformString = value.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
  let pattern = new RegExp(transformString, 'i'); // 不区分大小写
  const liList = document.querySelectorAll('.li')
  for(let i=0; i<liList.length; i++) {
    highlightKeyword(liList[i], pattern)
  }
}

highlightKeyword函数(重头戏):

const highlightKeyword = (node:any, pattern:RegExp):  void => {
  // nodeType等于3表示是文本节点
  if (node.nodeType === 3) {
      // node.data为文本节点的文本内容
      var matchResult = node.data.match(pattern);
      // 有匹配上的话
      if (matchResult) {
          // 创建一个span节点,用来包裹住匹配到的关键词内容
          let highlightEl = document.createElement('span');
          // 不用类名来控制高亮,用自定义属性data-*来标识,
          // 比用类名更减少概率与原本内容重名,避免样式覆盖
          highlightEl.dataset.highlight = 'yes';
          // 从匹配到的初始位置开始截断到原本节点末尾,产生新的文本节点
          let matchNode = node.splitText(matchResult.index);
          // 从新的文本节点中再次截断,按照匹配到的关键词的长度开始截断,
          // 此时0-length之间的文本作为matchNode的文本内容
          matchNode.splitText(matchResult[0].length);
          // 对matchNode这个文本节点的内容(即匹配到的关键词内容)创建出一个新的文本节点出来
          let highlightTextNode = document.createTextNode(matchNode.data);
          // 插入到创建的span节点中
          highlightEl.appendChild(highlightTextNode);
          // 把原本matchNode这个节点替换成用于标记高亮的span节点
          matchNode.parentNode.replaceChild(highlightEl, matchNode);
      }
  } 
  // 如果是元素节点 且 不是script、style元素 且 不是已经标记过高亮的元素
  // 不是已经标记过高亮的元素作为条件之一的理由是,避免进入死循环,一直往里套span标签
  else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase())) && (node.dataset.highlight !== 'yes')) {
      // 遍历该节点的所有子孙节点,找出文本节点进行高亮标记
      var childNodes = node.childNodes;
      for (var i = 0; i < childNodes.length; i++) {
          highlightKeyword(childNodes[i], pattern);
      }
  }
}

通过文本节点和splitText方法实现,具体实现参考这里

github

其他的需求是如何完成的直接看我的代码就好了,应该是简单易懂的,github走起 (✿◡‿◡)