# 目标

实现一个搜索框,输入内容后,实时搜索并展示下拉框匹配的结果。

  1. 输入内容
  2. 根据内容请求接口,返回字符串数组
  3. 输入框做防抖
  4. 以队列形式请求
  5. 下拉框采用虚拟列表

# 输入内容

通过 input 组件并采用 onChange 事件来获取输入的内容

# 根据内容请求接口

把接口请求操作写到 useEffect
获取到结果后 setState 到全局
注意:每次请求完后,需要对整个列表的滚动条进行复原

# 输入框做防抖

防抖单独写一个 hookuseDebounce<T>(value: T, delay: number): T

  1. 传入一个值和延时时间
  2. 在内部通过 setState 维护一个 debounceValue
  3. 每次 value 发生改变时父组件重渲染,整个 hook 重新执行
  4. 卸载时取消原来的延时,挂载时设置延时,延时完后再对 debounceValue 赋值
  5. 返回 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;
}

# 以队列形式请求

有可能一种情况:当前一次的请求比第二次慢,那么接收时就会覆盖最近的一次结果
所以这里就要有个先后顺序

  1. useRef 来初始化一个发送 ID
  2. 每次发送请求前都记录一下发送的 ID
  3. 通过闭包 + useRef
  4. 当响应到达的时候只需要对比 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

  1. 通过 scrollTop 获取到滚动条的位置
  2. 根据公式 startIndex = Math.floor(scrollTop / ITEM_HEIGHT) 获取开始下标
  3. 根据公式 endIndex = Math.min(results.length - 1, startIndex + VISIBLE_ITEMS_COUNT) 获取结束下标
  4. 然后根据这两个下标去切分数组,得到的就是我们需要渲染的内容(但是我们得到了要渲染的内容,那上下两边空着的地方怎么办,当然是用 padding 去填充啦)
  5. 那么, paddingTop = paddingTop = startIndex * ITEM_HEIGHT;
  6. 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;