目录
组件开发思路
组件开发的流程:
- 分析组件的属性
- 一个组件由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;
实现逻辑如下图:
- 第一行是原来的逻辑:输入框中每输入一个字符就执行effect,执行fetchSuggestions
- 第二三行是现在的逻辑:输入框中每输入一个字符,延迟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;