【UI组件库】input组件

组件开发思路

在这里插入图片描述
组件开发的流程:

  1. 分析组件的属性
    在这里插入图片描述
  2. 一个组件由4部分组成:主题代码、样式文件、stories文件、test文件
    新建src/components/Input/input.tsx文件,我们现在先在里面写伪代码,捋清楚组件开发的流程
// 1. 引入
import React, {ReactElement, InputHTMLAttributes} from 'react'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
// 2. 定义接口,限制组件的属性
type InputSize = 'lg' | 'sm'
export interface InputProps extends InputHTMLAttributes<HTMLElement>{
	disabled?: boolean;
	size?: InputSize;
	icon?: IconProp;
	prepand?: string | ReactElement;
	append?: string | ReactElement;
}

要想InputProps中包含input的所有属性,需要继承InputHTMLAttributes<HTMLElement>,继承之后报错了,因为InputHTMLAttributes<HTMLElement>中有size属性,InputProps中也定义了size,两者重了,导致上述代码报错,所以我们使用Omit移除/忽略接口中的某个值,Omit接收一个泛型,第一个参数是接口,第二个参数是要忽略的接口中的属性,上述代码改为:

// 1. 引入
import React, {ReactElement, InputHTMLAttributes} from 'react'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
// 2. 定义接口,限制组件的属性
type InputSize = 'lg' | 'sm'
export interface InputProps extends Omit<InputHTMLAttributes<HTMLElement>, 'size' > {
	disabled?: boolean;/**是否禁用 Input */
	size?: InputSize;/**设置 input 大小,支持 lg 或者是 sm */
	icon?: IconProp;/**添加图标,在右侧悬浮添加一个图标,用于提示 */
	prepand?: string | ReactElement;/**添加前缀 用于配置一些固定组合 */
	append?: string | ReactElement;/**添加后缀 用于配置一些固定组合 */
}

接下来定义组件:

// 1. 引入
import React, {ReactElement, InputHTMLAttributes} from 'react'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
// 2. 定义接口,限制组件的属性
type InputSize = 'lg' | 'sm'
export interface InputProps extends Omit<InputHTMLAttributes<HTMLElement>, 'size' > {
	disabled?: boolean;/**是否禁用 Input */
	size?: InputSize;/**设置 input 大小,支持 lg 或者是 sm */
	icon?: IconProp;/**添加图标,在右侧悬浮添加一个图标,用于提示 */
	prepand?: string | ReactElement;/**添加前缀 用于配置一些固定组合 */
	append?: string | ReactElement;/**添加后缀 用于配置一些固定组合 */
}
//3. 定义组件
export const Input: FC<InputProps> = (props) => {
	// 取出各种属性
	
	// 根据属性计算不同的className
	
	// 返回要显示的内容
	return (
		// 根据属性判断是否要添加特定的节点
	)
}
// (4) 有必要的话,这里可能还需要为组件设置默认属性
//4. 导出组件
export default Input;

接下来在样式文件中添加变量和样式,最后在test文件中添加测试用例

input组件

// 1. 引入
import React, {ReactElement, InputHTMLAttributes} from 'react'
import classNames form 'classnames'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
// 2. 定义接口,限制组件的属性
type InputSize = 'lg' | 'sm'
export interface InputProps extends Omit<InputHTMLAttributes<HTMLElement>, 'size' > {
	disabled?: boolean;/**是否禁用 Input */
	size?: InputSize;/**设置 input 大小,支持 lg 或者是 sm */
	icon?: IconProp;/**添加图标,在右侧悬浮添加一个图标,用于提示 */
	prepand?: string | ReactElement;/**添加前缀 用于配置一些固定组合 */
	append?: string | ReactElement;/**添加后缀 用于配置一些固定组合 */
}
//3. 定义组件
export const Input: FC<InputProps> = (props) => {
	// 取出各种属性
	const { disabled, size, icon, prepand, append, style, ...restProps } = props
	// 根据属性计算不同的className
	const classNames = classNames('viking-input-wrapper',{
		[`input-size-${size}`]: size,
    	'is-disabled': disabled,
    	'input-group': prepend || append,
    	'input-group-append': !!append,
    	'input-group-prepend': !!prepend
	})
	// 返回要显示的内容
	return (
		// 根据属性判断是否要添加特定的节点
		<div className={cnames} style={style}>
      		{prepend && <div className="viking-input-group-prepend">{prepend}</div>}
      		{icon && <div className="icon-wrapper"><Icon icon={icon} title={`title-${icon}`}/></div>}
      		<input 
        		className="viking-input-inner"
        		disabled={disabled}
        		{...restProps}
      		/>
      		{append && <div className="viking-input-group-append">{append}</div>}
    	</div>
	)
}

在react中,组件分为受控组件和非受控组件,受控组件的value由state控制,非受控组件不使用state来管理input的值,使用ref直接和dom打交道,并通过DOM取得input的值,接下来我们考虑input作为受控组件的情况。(??????)

AutoComplete组件实现思路(伪代码)

AutoComplete组件在input组件的基础上,添加一个下拉菜单,根据你的输入进行筛选,下拉菜单中显示筛选结果,点击其中某个结果,该结果会填充到input输入框中。
autoComplete组件肯定有一堆值供大家筛选,那么肯定有一组数据,数据是一个数组,我们先暂定为字符串的数组:const a = ['1','abc','']。接下来定义AutoComplete组件的属性:

interface AutoCompleteProps {
	data: string[],// 供大家筛选的数据
}

接下来思考如何筛选?如何在input中键入新的值?筛选逻辑?筛选逻辑在哪里实现?筛选不能在组件内部做,筛选规则应该交给用户定义,所以在AutoComplete组件中我们应该定义个属性fetchSuggestions:

interface AutoCompleteProps {
	data: string[],// 供大家筛选的数据
	fetchSuggestions: (keyword: string) => string[] | Promise<string[]>// 用户自定义的筛选规则,返回一个字符串类型的数组
}

考虑到选中下拉菜单中的某个值,这个值会填充到input中,所以在AutoComplete组件中我们应该定义个属性onSelect,由用户自定义点击下拉菜单中的某个值后会发生什么操作:

interface AutoCompleteProps {
	data: string[],// 供大家筛选的数据
	fetchSuggestions: (keyword: string) => string[] | Promise<string[]>,// 用户自定义的筛选规则,
	// 这个规则可能直接在数组中查找然后查找结果返回,此时返回值时一个字符串类型的数组;
	// 也可能直接拿着关键字发送网络请求获,让别人去给你查,然后给你返回结果,此时返回值是个promise
	onSelect: (item: string) => string
}

使用组件时:

const handleChange = (keyword: string, data: string[]) => {
	return data.filter(item => item.includes(keyword));// 可能直接在数组中查找然后然会查找结果
	return fetch(`url?keyword=${keyword}`);// 可能直接拿着关键字发送网络请求获,让别人去给你查,然后给你返回结果
	// 注意这里只是伪代码,我只是罗列了查找的两种情况
}
const handleSelect = (item: string) => {
	console.log(item);
}
<AutoCompleteProps fetchSuggestions={handleChange} onSelect={handleSelect} />

在使用组件时,handleChange中不用传入data数据,可以把data放在全局的位置,直接传,那么总的伪代码如下:

const a = ['1','abc','']

interface AutoCompleteProps {
	fetchSuggestions: (keyword: string) => string[] | Promise<string[]>,// 用户自定义的筛选规则,
	// 这个规则可能直接在数组中查找然后查找结果返回,此时返回值时一个字符串类型的数组;
	// 也可能直接拿着关键字发送网络请求获,让别人去给你查,然后给你返回结果,此时返回值是个promise
	onSelect: (item: string) => string
}

// 使用组件:
const handleChange = (keyword: string) => {
	return a.filter(item => item.includes(keyword));// 可能直接在数组中查找然后然会查找结果
	return fetch(`url?keyword=${keyword}`);// 可能直接拿着关键字发送网络请求获,让别人去给你查,然后给你返回结果
}
const handleSelect = (item: string) => {
	console.log(item);
}
<AutoCompleteProps fetchSuggestions={handleChange} onSelect={handleSelect} />

除此之外,我们还能新增一些功能:
(1)下拉菜单可以用户自定义样式
(2)是否支持键盘上下键移动选中下拉菜单中的选项
(3)函数防抖(在异步的情况下,不需要在每次input中值改变的情况下发起一次请求,)
(4)下拉菜单显示时,点击页面空白处,收起下拉菜单

AutoComplete组件基本骨架

本节实现展示下拉菜单,点击下拉菜单中的某一项后触发onSelect

import React, {FC, useState, ChangeEvent} from 'react'
import Input, {InputProps} from '../Input/input'

export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
	fetchSuggestions: (str: string) => string[];
	onSelect?: (item: string) => void
}

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		const value = e.target.value.trim()
		setInputValue(value)
	}
	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
		</div>
	)
}

以上,就实现了输入框的内容被state管理的功能,接下来展示下拉框的值,下拉框中的值是根据用户的回调而发生改变的,所以我们也需要给他添加个state,用以存放展示的所有数据

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//下面这个state用来存放下拉菜单中展示的所有数据
	const [suggestions, setSuggestions] = useState<string[]>([])
	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法查询关键字,将查询结果保存在state中,否则将空数组保存在state中
		if(value){
			const result = fetchSuggestions(value);
			setSuggestions(results)
		}else{
			setSuggestions([])
		}
	}

	const generateDropdown = () => {
		return (
			<ul>
				{suggestions.map((item, index) => {
					return (
						<li key={index}>
							{item}
						</li>
					)
				})
			</ul>
		)
	}

	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)
}

最后我们来实现onSelect,点击下拉列表中的某一项,要触发onSelect,把点击的值填充到input中,隐藏下拉菜单

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//下面这个state用来存放下拉菜单中展示的所有数据
	const [suggestions, setSuggestions] = useState<string[]>([])
	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法查询关键字,将查询结果保存在state中,否则将空数组保存在state中
		if(value){
			const result = fetchSuggestions(value);
			setSuggestions(results)
		}else{
			setSuggestions([])
		}
	}
	const handleSelect = (item:string) => {
		// 把点击的值填充到input中,隐藏下拉菜单
		setInputValue(item);
		setSuggestions([]);
		if(onSelect){// 用户是否自定义onSelect方法,如果定义了,调用一下,并将输入框中的值传入
			onSelect(item)
		}
	}
	const generateDropdown = () => {
		return (
			<ul>
				{suggestions.map((item, index) => {
					return (
						<li key={index} onClick={() => handleSelect(item)}>
							{item}
						</li>
					)
				})
			</ul>
		)
	}

	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)
}

使用时:

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { AutoComplete } from './AutoComplete'

const SimpleComplete = () => {
	const lakers = ['bradley','pope','caruso','cook','cousins','james','AD','green','howard','kuzma','McGee','rando']
	const handleFetch = (query: string) => {
		return lakers.filter(name => name.includes(query))
	}
	return (
		<AutoComplete fetchSuggestions={handleFetch} onSelect={action('selected')}/>
	)
}

会得到以下样式:
在这里插入图片描述

AutoComplete组件支持自定义模板

import React, {FC, useState, ChangeEvent} from 'react'
import Input, {InputProps} from '../Input/input'

export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
	fetchSuggestions: (str: string) => string[];
	onSelect?: (item: string) => void,
	renderOption?: (item: string) => ReactElement;// 用以自定义模板
}

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, renderOption, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//下面这个state用来存放下拉菜单中展示的所有数据
	const [suggestions, setSuggestions] = useState<string[]>([])

	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法查询关键字,将查询结果保存在state中,否则将空数组保存在state中
		if(value){
			const result = fetchSuggestions(value);
			setSuggestions(results)
		}else{
			setSuggestions([])
		}
	}
	const handleSelect = (item:string) => {
		// 把点击的值填充到input中,隐藏下拉菜单
		setInputValue(item);
		setSuggestions([]);
		if(onSelect){// 用户是否自定义onSelect方法,如果定义了,调用一下,并将输入框中的值传入
			onSelect(item)
		}
	}
	//如果用户自定义了renderOption,那么就用用户的来渲染
	const renderTemplate = (item:string) => {
		return renderOption ? renderOption(item) : item;
	}
	const generateDropdown = () => {
		return (
			<ul>
				{suggestions.map((item, index) => {
					return (
						<li key={index} onClick={() => handleSelect(item)}>
							{renderTemplate}
						</li>
					)
				})
			</ul>
		)
	}
	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)
}

使用:

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { AutoComplete } from './AutoComplete'

const SimpleComplete = () => {
	const lakers = ['bradley','pope','caruso','cook','cousins','james','AD','green','howard','kuzma','McGee','rando']
	const handleFetch = (query: string) => {
		return lakers.filter(name => name.includes(query))
	}
	const renderOption = (item: string) => {
		return (
			<h2>Name: {item}</h2>
		)
	}
	return (
		<AutoComplete fetchSuggestions={handleFetch} onSelect={action('selected')} renderOption={renderOption}/>
	)
}

我们前面使用的供筛选的数据是一个字符串类型的数组,这种情况太单一了,如果供筛选的数据是一个对象类型的数组就没法处理,接下来我们改进一下,重新定义个数据类型DataSourceType

import React, {FC, useState, ChangeEvent} from 'react'
import Input, {InputProps} from '../Input/input'

interface DataSourceObject {
	value: string
}
export type DataSourceType<T={}> = T & DataSourceObject

export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
	fetchSuggestions: (str: string) => DataSourceType[];
	onSelect?: (item: DataSourceType) => void,
	renderOption?: (item: DataSourceType) => ReactElement;// 用以自定义模板
}

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, renderOption, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//下面这个state用来存放下拉菜单中展示的所有数据
	const [suggestions, setSuggestions] = useState<DataSourceType[]>([])

	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法查询关键字,将查询结果保存在state中,否则将空数组保存在state中
		if(value){
			const result = fetchSuggestions(value);
			setSuggestions(results)
		}else{
			setSuggestions([])
		}
	}
	const handleSelect = (item:DataSourceType) => {
		// 把点击的值填充到input中,隐藏下拉菜单
		setInputValue(item.value);
		setSuggestions([]);
		if(onSelect){// 用户是否自定义onSelect方法,如果定义了,调用一下,并将输入框中的值传入
			onSelect(item)
		}
	}
	//如果用户自定义了renderOption,那么就用用户的来渲染
	const renderTemplate = (item:DataSourceType) => {
		return renderOption ? renderOption(item) : item.value;
	}
	const generateDropdown = () => {
		return (
			<ul>
				{suggestions.map((item, index) => {
					return (
						<li key={index} onClick={() => handleSelect(item)}>
							{renderTemplate}
						</li>
					)
				})
			</ul>
		)
	}
	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)
}

使用:

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { AutoComplete } from './AutoComplete'
interface LakerPlayerProps {
	value: string;
	number: number;
}
const SimpleComplete = () => {
	const lakersWithNumber = [
		{value:'bradley', number: 11},
		{value:'pope', number: 1},
		{value:'caruso', number: 4},
		{value:'cook', number: 2},
		{value:'cousins', number: 15},
		{value:'james', number: 23},
		{value:'AD', number: 3},
		{value:'green', number: 14},
		{value:'howard', number: 39},
		{value:'kuzma', number: 0}
	]
	const handleFetch = (query: string) => {
		return lakersWithNumber.filter(player => player.value.includes(query)).map(name => ({value: name}))
	}
	const renderOption = (item: DataSourceType<LakerPlayerProps>) => {
		return (
			<>
				<h2>Name: {item.value}</h2>
				<p>Number {item.number}</p>
			</>
		)
	}
	return (
		<AutoComplete fetchSuggestions={handleFetch} onSelect={action('selected')} renderOption={renderOption}/>
	)
}

展示如下:
在这里插入图片描述

AutoComplete组件异步请求

前面我们写的代码fetchSuggestions只支持同步代码,现在我们希望他能支持异步代码,那么fetchSuggestions的返回值还可以是promise,fetchSuggestions: (str: string) => DataSourceType[] | Promise<DataSourceType[]>;,更改handleChange方法,获取到输入框的值然后

	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法发起请求查询关键字,
		//若查询结果是promise,将promise执行成功的结果保存在state中
		if(value){
			const result = fetchSuggestions(value);
			if(results instanceof Promise){
				results.then(data => {setSuggestions(data)})
			}else{setSuggestions(results)}
		}else{
			setSuggestions([])
		}
	}

使用fetch给GitHub官方接口:https://api.github.com/search/users?q=ab(q的值就是你要查询的关键字)发送请求,展示返回结果,即GitHub用户名列表,使用时:

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { AutoComplete } from './AutoComplete'
interface GithubUserProps {
	login: string;
	url: string;
	avatar_url: string
}
const SimpleComplete = () => {
	const handleFetch = (query: string) => {
		return fetch(`https://api.github.com/search/users?q=${query}`)
			.then(res => res.json())
			.then({items}) => {
				const formatItems = items.slice(0, 10).map(item => ({value: item.login, ...item}))
			}
	}
	const renderOption = (item: DataSourceType<GithubUserProps>) => {
     	return (
       		<>
         		<h2>Name: {ite.login}</h2>
         		<p>url: {item.url}</p>
       		</>
     	)
   	}
	return (
		<AutoComplete fetchSuggestions={handleFetch} onSelect={action('selected')} renderOption={renderOption}/>
	)
}

请求过程有点慢,此时页面一片空白,我们希望在请求过程中展示loading,在src/components/AutoComplete/autoComplete.tsx中引入图标import Icon from '../Icon/icon',是否在加载是一个状态,使用state保存该状态:const [ loading, setLoading ] = useState(false)

	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
		const value = e.target.value.trim()
		setInputValue(value)
		//如果有value,则调用用户自定义的fetchSuggestions方法发起请求查询关键字,
		//若查询结果是promise,将promise执行成功的结果保存在state中
		if(value){
			const result = fetchSuggestions(value);
			if(results instanceof Promise){
				setLoading(true)// 发起请求前设置loading
				results.then(data => {
					setLoading(false)//请求成功,置为false
					setSuggestions(data)
				})
			}else{setSuggestions(results)}
		}else{
			setSuggestions([])
		}
	}

展示loading,src/components/AutoComplete/autoComplete.tsx的返回值:

	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			{loading && <ul><Icon icon='spinner' spin /></ul>}
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)

自定义Hook实现函数防抖

像前面发送网络请求,属于副作用effect,我们可以在useEffect中实现,这里useEffect依赖于inputValue

useEffect(() => {
	if(inputValue){
		const result = fetchSuggestions(inputValue);
		if(results instanceof Promise){
			setLoading(true)// 发起请求前设置loading
			results.then(data => {
				setLoading(false)//请求成功,置为false
				setSuggestions(data)
			})
		}else{setSuggestions(results)}
	}else{
		setSuggestions([])
	}
},[inputValue])

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
	//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
	const value = e.target.value.trim()
	setInputValue(value)
}

每次input中的内容一改变就发送请求,异步返回的速度有差异,最终你获得的结果可能不是正确的结果,理想做法是这样的:当用户输入字符后的一段时间内,如果还有字符输入,则不发送请求。我们使用函数防抖来实现这一需求。
防抖:通过闭包保存一个标记,标记中保存着settimeout返回值,每当用户输入时,就把前一个settimeout清除掉,然后创建一个新的settimeout。这样就能保证输入字符后某一段时间间隔内,若还有字符输入的话,就不会执行回调函数了。
接下来我们自定义个Hook,来实现函数防抖,新建src/hooks/useDebounce.tsx文件。代码如下:

import { useState, useEffect } from 'react'
//延迟时间300ms
function useDebounce(value: any, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value)
  useEffect(() => {
    const handler = window.setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
    //useEffect的回调函数返回一个函数时,代表下次更新时清除effect,所以返回函数只用清除定时器,这样debouncedValue就不会被更新
    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])
  return debouncedValue
}
export default useDebounce;

实现逻辑如下图:

  1. 第一行是原来的逻辑:输入框中每输入一个字符就执行effect,执行fetchSuggestions
  2. 第二三行是现在的逻辑:输入框中每输入一个字符,延迟300ms,看还有没有字符输入,如果没有,则保存输入框中的值,如果有,则清除effect和之前的定时器,不保存输入框的值,然后重新定时
    在这里插入图片描述
    在这里插入图片描述
    在src/components/AutoComplete/autoComplete.tsx中使用防抖函数:
import React, {FC, useState, useEffect, ChangeEvent} from 'react'
import Input, {InputProps} from '../Input/input'
import Icon from '../Icon/icon'
import useDebounce from '../../hooks/useDebounce'//引入

interface DataSourceObject {
	value: string
}
export type DataSourceType<T={}> = T & DataSourceObject

export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
	fetchSuggestions: (str: string) => DataSourceType[];
	onSelect?: (item: DataSourceType) => void,
	renderOption?: (item: DataSourceType) => ReactElement;
}

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
	const {fetchSuggestions, onSelect, value, renderOption, ...restProps} = props
	//我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
	const [inputValue, setInputValue] = useState(value)//用state来管理input中的值
	//下面这个state用来存放下拉菜单中展示的所有数据
	const [suggestions, setSuggestions] = useState<DataSourceType[]>([])
	//用来指示是否在加载
  	const [ loading, setLoading ] = useState(false)
  	const debouncedValue = useDebounce(inputValue, 300)// 获取输入框的值
  	
  	useEffect(() => {
		if(debouncedValue){
			const result = fetchSuggestions(debouncedValue);
			if(results instanceof Promise){
				setLoading(true)// 发起请求前设置loading
				results.then(data => {
					setLoading(false)//请求成功,置为false
					setSuggestions(data)
				})
			}else{setSuggestions(results)}
		}else{
			setSuggestions([])
		}
	},[debouncedValue])

	const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
		//当输入框的内容改变时,会调用handleChange,将输入框的值保存在state中
		const value = e.target.value.trim()
		setInputValue(value)
	}
	const handleSelect = (item:DataSourceType) => {
		// 把点击的值填充到input中,隐藏下拉菜单
		setInputValue(item.value);
		setSuggestions([]);
		if(onSelect){// 用户是否自定义onSelect方法,如果定义了,调用一下,并将输入框中的值传入
			onSelect(item)
		}
	}
	//如果用户自定义了renderOption,那么就用用户的来渲染
	const renderTemplate = (item:DataSourceType) => {
		return renderOption ? renderOption(item) : item.value;
	}
	const generateDropdown = () => {
		return (
			<ul>
				{suggestions.map((item, index) => {
					return (
						<li key={index} onClick={() => handleSelect(item)}>
							{renderTemplate}
						</li>
					)
				})
			</ul>
		)
	}
	return (
		<div className='viking-auto-complete'>
			<Input value={inputValue} onChange={handleChange} {...restProps} />
			{loading && <ul><Icon icon='spinner' spin /></ul>}
			//展示下拉列表
			{(suggestions.length > 0) && generateDropdown()}
		</div>
	)
}

AutoComplete支持键盘事件

首先我们创建一个state指示当前高亮的是哪个条目:const [ highlightIndex, setHighlightIndex] = useState(-1),我们可以根据这个变量给条目添加对应的classname,从而实现css样式

  const highlight = (index: number) => {
    if (index < 0) index = 0// 一直点向上,index保持0,不再向上
    if (index >= suggestions.length) {// 一直点向下,index保持最后一项,不再向下
      index = suggestions.length - 1
    }
    setHighlightIndex(index)
  }
  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    switch(e.keyCode) {
      case 13:// 回车键
        if (suggestions[highlightIndex]) {
          handleSelect(suggestions[highlightIndex])
        }
        break
      case 38:// 向上键
        highlight(highlightIndex - 1)
        break
      case 40:// 向下键
        highlight(highlightIndex + 1)
        break
      case 27:// esc键
        setShowDropdown(false)
        break
      default:
        break
    }
  }
const generateDropdown = () => {
	return (
		<ul>
			{suggestions.map((item, index) => {
				const cnames = classNames('suggestion-item', {
					'item-highlighted': index === highlightIndex
				})
				return (
					<li key={index} className={cname} onClick={() => handleSelect(item)}>
						{renderTemplate(item)}
					</li>
				)
			}
		</ul>
	)
}
return (
	<div className='viking-auto-complete'>
		<Input value={inputValue} onChange={handleChange} onKeyDown={handleKeyDown} {...restProps} />
		{loading && <ul><Icon icon='spinner' spin /></ul>}
		//展示下拉列表
		{(suggestions.length > 0) && generateDropdown()}
	</div>
)

在input中输入a后移动下键选中第四个,然后继续在输入框中输入b,就算此时下拉菜单已经更新了,但是第四个依旧是高亮的被选中的状态,所以我们需要在每次输入时,将highlightIndex重置为初始值。在useEffect中新增代码:setHighlightIndex(-1)

bug及解决

bug:当我们选中下拉菜单中某个条目后,点击回车,该条目的值会出现在input中,但是此时会以该条目的值为keyword进行查询,并在下拉菜单中展示查询的结果。
bug产生原因:在useEffect中调用搜索,useEffect依赖debouncedValue,而在handleSelect中更新了input的值,debouncedValue又是依赖于input的值的,所以就出现了这个bug。我们可以定义个变量来解决这个问题,该变量在handleChange中设置为true,在handleSelect中设置为false,在useEffect中使用debouncedValue和该变量一起做判断。由于这个变量不会引起组件的重新渲染,只要保持一个不同的状态即可,那么使用ref是最佳选择:const triggerSearch = useRef(false)

	useEffect(() => {
		if(debouncedValue && triggerSearch.current){
			const result = fetchSuggestions(debouncedValue);
			if(results instanceof Promise){
				setLoading(true)// 发起请求前设置loading
				results.then(data => {
					setLoading(false)//请求成功,置为false
					setSuggestions(data)
				})
			}else{setSuggestions(results)}
		}else{
			setSuggestions([])
		}
	},[debouncedValue])
  
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value.trim()
    setInputValue(value)
    triggerSearch.current = true// ref的值都保存在他的current属性中
  }
  const handleSelect = (item: DataSourceType) => {
    setInputValue(item.value)
    setShowDropdown(false)
    if (onSelect) {
      onSelect(item)
    }
    triggerSearch.current = false
  }

实现点击页面其他位置,隐藏下拉菜单

为了辨认所有的点击事件,那么需要绑定个更大的点击事件,比如给document绑定点击事件,然后查看点击的是什么元素,使用event.target拿到当前到底点击的是什么元素,然后判断在整个document中,是否包含这个节点,如果包含,说明点击的是组件内的元素,不操作,如果不包含,说明点击的是组件外的区域,处理相应的逻辑。src/hooks/useClickOutside.tsx中的代码:

import { RefObject, useEffect } from "react";
//useRef的返回值是RefObject,它的类型的范围应该很大,所以这里使用HTMLElement。handler是点击后执行的回调
function useClickOutside(ref: RefObject<HTMLElement>, handler: Function) {
  useEffect(() => {
    const listener = (event: MouseEvent) => {
      if (!ref.current || ref.current.contains(event.target as HTMLElement)) {
        return
      }
      handler(event)
    }
    document.addEventListener('click', listener)
    return () => {
      document.removeEventListener('click', listener)
    }
  }, [ref, handler])
}

export default useClickOutside

src/components/AutoComplete/autoComplete.tsx中使用useClickOutside:

import useClickOutside from '../../hooks/useClickOutside'
useClickOutside(componentRef, () => { setSugestions([])})

src/components/AutoComplete/autoComplete.tsx中完整代码:

import React, { FC, useState, ChangeEvent, KeyboardEvent, ReactElement, useEffect, useRef } from 'react'
import classNames from 'classnames'
import Input, { InputProps } from '../Input/input'
import Icon from '../Icon/icon'
import Transition from '../Transition/transition'
import useDebounce from '../../hooks/useDebounce'
import useClickOutside from '../../hooks/useClickOutside'
interface DataSourceObject {
  value: string;
}
// T & DataSourceObject:T和DataSourceObject这两种类型
export type DataSourceType<T = {}> = T & DataSourceObject
export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
  fetchSuggestions: (str: string) => DataSourceType[] | Promise<DataSourceType[]>;
  onSelect?: (item: DataSourceType) => void;
  renderOption?: (item: DataSourceType) => ReactElement;// 用以自定义模板
}

export const AutoComplete: FC<AutoCompleteProps> = (props) => {
  const {
    fetchSuggestions,
    onSelect,
    value,
    renderOption,
    ...restProps
  } = props
  //我们需要拿到input中的值给fetchSuggestions使用,所以需要用state来管理,这就是受控组件
  const [ inputValue, setInputValue ] = useState(value as string)//用state来管理input中的值
  //下面这个state用来存放下拉菜单中展示的所有数据
  const [ suggestions, setSugestions ] = useState<DataSourceType[]>([])
  //用来指示是否在加载
  const [ loading, setLoading ] = useState(false)
  const [ showDropdown, setShowDropdown] = useState(false)
  //指示当前高亮的是哪个条目 -1代表没有任何高亮
  const [ highlightIndex, setHighlightIndex] = useState(-1)
  //用来控制是否发起搜索,当我们选中下拉菜单中某个条目后,点击回车,该条目的值会出现在input中,此时不搜索
  const triggerSearch = useRef(false)
  //指向组件的整个dom节点,因为这个componentRef绑定在了div上面,所以他是HTMLDivElement类型
  const componentRef = useRef<HTMLDivElement>(null)
  const debouncedValue = useDebounce(inputValue, 300)
  useClickOutside(componentRef, () => { setSugestions([])})
  useEffect(() => {
    if (debouncedValue && triggerSearch.current) {
      setSugestions([])
      const results = fetchSuggestions(debouncedValue)
      if (results instanceof Promise) {
        setLoading(true)
        results.then(data => {
          setLoading(false)
          setSugestions(data)
          if (data.length > 0) {
            setShowDropdown(true)
          }
        })
      } else {
        setSugestions(results)
        setShowDropdown(true)
        if (results.length > 0) {
          setShowDropdown(true)
        } 
      }
    } else {
      setShowDropdown(false)
    }
    setHighlightIndex(-1)// 每次输入时,将highlightIndex重置为初始值
  }, [debouncedValue, fetchSuggestions])
  const highlight = (index: number) => {
    if (index < 0) index = 0// 一直点向上,index保持0,不再向上
    if (index >= suggestions.length) {// 一直点向下,index保持最后一项,不再向下
      index = suggestions.length - 1
    }
    setHighlightIndex(index)
  }
  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    switch(e.keyCode) {
      case 13:// 回车键
        if (suggestions[highlightIndex]) {
          handleSelect(suggestions[highlightIndex])
        }
        break
      case 38:// 向上键
        highlight(highlightIndex - 1)
        break
      case 40:// 向下键
        highlight(highlightIndex + 1)
        break
      case 27:// esc键
        setShowDropdown(false)
        break
      default:
        break
    }
  }
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value.trim()
    setInputValue(value)
    triggerSearch.current = true// ref的值都保存在他的current属性中
  }
  const handleSelect = (item: DataSourceType) => {
    setInputValue(item.value)
    setShowDropdown(false)
    if (onSelect) {
      onSelect(item)
    }
    triggerSearch.current = false
  }
  //如果用户自定义了renderOption,那么就用用户的来渲染
  const renderTemplate = (item: DataSourceType) => {
    return renderOption ? renderOption(item) : item.value
  }
  // 下拉列表
  const generateDropdown = () => {
    return (
      <Transition
        in={showDropdown || loading}
        animation="zoom-in-top"
        timeout={300}
        onExited={() => {setSugestions([])}}
      >
        <ul className="viking-suggestion-list">
          { loading &&
            <div className="suggstions-loading-icon">
              <Icon icon="spinner" spin/>
            </div>
          }
          {suggestions.map((item, index) => {
            const cnames = classNames('suggestion-item', {
              'is-active': index === highlightIndex
            })
            return (
              <li key={index} className={cnames} onClick={() => handleSelect(item)}>
                {renderTemplate(item)}
              </li>
            )
          })}
        </ul>
      </Transition>
    )
  }
  return (
    <div className="viking-auto-complete" ref={componentRef}>
      <Input
        value={inputValue}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        {...restProps}
      />
      {/*展示下拉列表*/}
      {generateDropdown()}
    </div>
  )
}

export default AutoComplete;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值