# 目标
实现一个搜索框,输入内容后,实时搜索并展示下拉框匹配的结果。
- 输入内容
- 根据内容请求接口,返回字符串数组
- 输入框做防抖
- 以队列形式请求
- 下拉框采用虚拟列表
# 输入内容
通过 input
组件并采用 onChange
事件来获取输入的内容
# 根据内容请求接口
把接口请求操作写到 useEffect
中
获取到结果后 setState
到全局
注意:每次请求完后,需要对整个列表的滚动条进行复原
# 输入框做防抖
防抖单独写一个 hook
: useDebounce<T>(value: T, delay: number): T
- 传入一个值和延时时间
- 在内部通过
setState
维护一个debounceValue
- 每次
value
发生改变时父组件重渲染,整个hook
重新执行 - 卸载时取消原来的延时,挂载时设置延时,延时完后再对
debounceValue
赋值 - 返回
debounceValue
即可
function useDebounce<T>(value: T, delay: number): T { | |
const [debouncedValue, setDebouncedValue] = useState<T>(value); | |
useEffect(() => { | |
const handler = setTimeout(() => { | |
setDebouncedValue(value); | |
}, delay); | |
return () => { | |
clearTimeout(handler); | |
}; | |
}, [value, delay]); | |
return debouncedValue; | |
} |
# 以队列形式请求
有可能一种情况:当前一次的请求比第二次慢,那么接收时就会覆盖最近的一次结果
所以这里就要有个先后顺序
- 以
useRef
来初始化一个发送ID
- 每次发送请求前都记录一下发送的
ID
- 通过闭包 +
useRef
- 当响应到达的时候只需要对比
useRef
和闭包中记录的值是否相等就可以判断是不是最新的
const latestRequestRef = useRef(0); | |
useEffect(() => { | |
if (debouncedInputValue) { | |
setLoading(true); | |
const currentRequest = ++latestRequestRef.current; | |
fetchSearchResults(debouncedInputValue).then((data) => { | |
if (currentRequest === latestRequestRef.current) { | |
setResults(data); | |
setLoading(false); | |
// 当结果更新时,重置滚动位置 | |
if (listRef.current) { | |
listRef.current.scrollTop = 0; | |
setScrollTop(0); | |
} | |
} | |
}); | |
} else { | |
setResults([]); | |
} | |
}, [debouncedInputValue]); |
# 虚拟列表的实现(定高)
目的:只渲染可见部分的 dom
- 通过
scrollTop
获取到滚动条的位置 - 根据公式
startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
获取开始下标 - 根据公式
endIndex = Math.min(results.length - 1, startIndex + VISIBLE_ITEMS_COUNT)
获取结束下标 - 然后根据这两个下标去切分数组,得到的就是我们需要渲染的内容(但是我们得到了要渲染的内容,那上下两边空着的地方怎么办,当然是用
padding
去填充啦) - 那么,
paddingTop = paddingTop = startIndex * ITEM_HEIGHT;
paddingBottom = (results.length - (endIndex + 1)) * ITEM_HEIGHT
这样,整个结构就是,父 div
的样式就是 paddingTop
+ paddingBottom
,子就是需要循环渲染的内容了
滚动时整个链路:滚动事件触发,重渲染,接着从第一步开始执行
# 具体代码
import React, { useState, useEffect, useRef, useCallback } from 'react'; | |
import useDebounce from '../hooks/useDebounce'; | |
// 模拟一个 API 请求 | |
const ITEM_HEIGHT = 36; // 每个列表项的高度,包括 padding 和 border | |
const VISIBLE_ITEMS_COUNT = 8; // 可见的列表项数量 | |
const Search: React.FC = () => { | |
const [inputValue, setInputValue] = useState(''); | |
const [results, setResults] = useState<string[]>([]); | |
const [loading, setLoading] = useState(false); | |
const [isInputFocused, setIsInputFocused] = useState(false); | |
const [scrollTop, setScrollTop] = useState(0); // 滚动位置 | |
const debouncedInputValue = useDebounce(inputValue, 500); | |
const listRef = useRef<HTMLUListElement>(null); // 引用下拉列表 | |
const latestRequestRef = useRef(0); | |
useEffect(() => { | |
if (debouncedInputValue) { | |
setLoading(true); | |
const currentRequest = ++latestRequestRef.current; | |
fetchSearchResults(debouncedInputValue).then((data) => { | |
if (currentRequest === latestRequestRef.current) { | |
setResults(data); | |
setLoading(false); | |
// 当结果更新时,重置滚动位置 | |
if (listRef.current) { | |
listRef.current.scrollTop = 0; | |
setScrollTop(0); | |
} | |
} | |
}); | |
} else { | |
setResults([]); | |
} | |
}, [debouncedInputValue]); | |
const handleResultClick = (result: string) => { | |
setInputValue(result); | |
setIsInputFocused(false); | |
if (listRef.current) { | |
listRef.current.scrollTop = 0; | |
setScrollTop(0); | |
} | |
setResults([]); // 点击后清空结果,隐藏下拉框 | |
}; | |
const handleInputBlur = () => { | |
// 延迟设置 isInputFocused 为 false,以便在点击下拉项时有时间触发 handleResultClick | |
setTimeout(() => { | |
setIsInputFocused(false); | |
if (listRef.current) { | |
listRef.current.scrollTop = 0; | |
setScrollTop(0); | |
} | |
}, 100); | |
}; | |
const handleScroll = useCallback(() => { | |
if (listRef.current) { | |
setScrollTop(listRef.current.scrollTop); | |
} | |
}, []); | |
// 虚拟滚动计算 | |
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT); | |
const endIndex = Math.min(results.length - 1, startIndex + VISIBLE_ITEMS_COUNT); | |
const visibleResults = results.slice(startIndex, endIndex + 1); | |
const paddingTop = startIndex * ITEM_HEIGHT; | |
const paddingBottom = (results.length - (endIndex + 1)) * ITEM_HEIGHT; | |
console.log(startIndex, endIndex, results.slice(startIndex, endIndex), paddingTop, paddingBottom, scrollTop); | |
return ( | |
<div className="relative w-[300px]"> | |
<input | |
type="text" | |
value={inputValue} | |
onChange={(e) => setInputValue(e.target.value)} | |
onFocus={() => setIsInputFocused(true)} | |
onBlur={handleInputBlur} | |
placeholder="Search..." | |
className="w-full p-2 border border-gray-300 rounded-md" | |
/> | |
{loading && <div className="absolute right-2.5 top-2 text-sm text-gray-500">Loading...</div>} | |
{isInputFocused && results.length > 0 && ( | |
<ul | |
ref={listRef} | |
onScroll={handleScroll} | |
className="absolute top-full left-0 right-0 z-10 list-none p-0 m-0 bg-white border border-gray-300 rounded-md shadow-lg overflow-y-auto" | |
style=<!--swig0--> | |
> | |
<div style=<!--swig1-->> | |
{visibleResults.map((result) => ( | |
<li | |
key={result} | |
onClick={() => handleResultClick(result)} | |
className="px-2 flex items-center border-b border-gray-100 cursor-pointer box-border hover:bg-gray-50" | |
style=<!--swig2--> | |
> | |
{result} | |
</li> | |
))} | |
</div> | |
</ul> | |
)} | |
</div> | |
); | |
}; | |
export default Search; |