🚀 AI全栈项目第十三天 - 这里的搜索静悄悄:手把手打造丝滑的智能搜索功能

4 阅读8分钟

前言

嗨,各位全栈练习生们!👋 欢迎来到我们的 AI 全栈项目第十三天!

如果说首页是应用的“门面”,那么搜索功能就是应用的“传送门”。在这个信息爆炸的时代,让用户能在一个庞大的内容库里,用最快速度找到自己想要的东西,简直是提升用户体验的“必杀技”。🎯

今天,我们就来啃这块硬骨头!我们要实现一个带有搜索建议、历史记录持久化、防抖处理的丝滑搜索功能。别担心,虽然听起来功能点很多,但只要我们要把它拆解开来,你会发现——害,也就那么回事儿!😎

准备好了吗?我们要发车了!🚗💨


🏗️ 总体架构预览

在开始写代码之前,咱们先理理思路。一个成熟的搜索流程大概是这样的:

  1. 入口:首页得有个显眼的搜索框,点它!
  2. 交互:用户输入关键词 -> 触发防抖 -> 发送请求。
  3. 状态:我们需要管理 loading(加载中)、suggestions(搜索建议/结果)、history(历史记录)。
  4. 后端:接收关键词 -> 过滤数据 -> 返回结果。

为了让大家看得更清楚,我们采用 Mock数据 -> 状态管理(Store) -> 工具函数(Hooks) -> UI组件(Pages) 的顺序来逐个击破!


🛠️ 第一步:后端不够,Mock 来凑

在后端兄弟还没把接口写好(或者我们自己就是后端但想偷懒)的时候,Mock 数据是前端开发的救命稻草。我们需要模拟一个能处理搜索请求的接口。

打开 mock/search.js,让我们看看这个“假后端”是怎么工作的。

1.1 数据源准备

首先,我们准备了一堆文章数据(posts),这里面包含了 titlecategory

const posts = [
    {
      "title": "如何使用 Nuxt.js 进行服务器端渲染",
      "category": "前端开发"
    },
    // ... 假装这里还有几百条数据 ...
    {
      "title": "使用 Tailwind CSS 和 Alpine.js 构建一个简单的交互式表单",
      "category": "前端开发"
    }
];

1.2 接口拦截与参数解析

接下来是重头戏。我们需要拦截 /api/search 的 GET 请求,并从 URL 中提取出用户输入的 keyword

👀 核心代码解析:

export default [
    {
        url: '/api/search',
        method: 'get',
        timeout: 200, // 💡 模拟网络延迟,让 loading 效果能被看见
        response: (req, res) => {
            // 📝 知识点:URL 解析
            // req.url 可能是 '/api/search?keyword=vue'
            // 为了方便解析参数,我们利用浏览器原生的 URL 对象
            // 第二个参数 base 是必须的,但在 mock 环境下其实不重要,只要是合法的 url 结构即可
            const url = new URL(req.url, 'http://localhost:5173');
            
            // 获取查询参数 'keyword',如果没有就默认为空字符串
            const keyword = url.searchParams.get('keyword') || '';

            let filteredPosts = posts;
            
            // 🔍 核心过滤逻辑
            if(keyword){
                // 💡 细节:大小写不敏感搜索
                // 用户输入 "Vue" 应该能搜到 "vue",反之亦然
                const lowerCaseKeyword = keyword.toLowerCase(); 
                
                filteredPosts = posts.filter(post => 
                    // 比对 标题 或 分类 是否包含关键词
                    post.title.toLowerCase().includes(lowerCaseKeyword) ||
                    post.category.toLowerCase().includes(lowerCaseKeyword)
                ).map(post => post.title); // 只返回标题数组,减轻传输量
            }
            
            // 📦 组装统一的响应格式
            return {
                code: 0, // 0 表示成功,这是行业惯例
                msg: 'success',
                data: filteredPosts,
                total: filteredPosts.length
            }
        }
    }
]

👨‍🏫 老师敲黑板: 这里用了 new URL() 构造函数,这是处理 URL 参数的神器,比自己写正则去截取字符串优雅一万倍!不管是中文还是特殊字符,它都能配合后续逻辑处理得很好。


🧠 第二步:Zustand —— 状态管理的“大脑”

前端的搜索非常复杂,涉及到多个状态的流转:

  • 正在搜吗?(loading)
  • 搜到了啥?(suggestions)
  • 以前搜过啥?(history)

我们将使用 Zustand 来管理这些状态。为什么选它?因为它比 Redux 简洁,比 Context API 性能更好,而且支持持久化(Persist)就像呼吸一样自然。

打开 src/store/search.ts

2.1 定义状态接口

TypeScript 大法好,先定义类型,心中不慌。

interface SearchState {
  loading: boolean;          // 加载状态
  suggestions: string[];     // 搜索建议(结果)列表
  history: string[];         // 🔍 重点:搜索历史
  search: (keyword: string) => Promise<void>; // 搜索动作
  addHistory: (keyword: string) => void;      // 添加历史动作
  clearHistory: () => void;                   // 清空历史动作
}

2.2 核心逻辑实现

接下来看看具体的实现。这里有两个非常精彩的逻辑:异步搜索历史记录管理

🔍 Search Action (搜索动作)

search: async (keyword:string) => {
    // 1️⃣ 边界判断:如果关键词全是空格,视为无效,清空建议并返回
    if(!keyword.trim()) { 
        set({suggestions: []});
        return;
    }
    
    // 2️⃣ 开启 loading:让 UI 转圈圈
    set({ loading: true });
    
    try {
        // 3️⃣ 编码与请求
        // encodeURIComponent 是为了处理中文和特殊字符,防止 URL 乱码
        const res = await doSearch(encodeURIComponent(keyword));
        const data: [] = res.data || [];
        
        // 4️⃣ 更新建议列表
        set({
            suggestions: data,
        })
        
        // 5️⃣ 记录历史
        // 注意:这里调用了 get().addHistory,在 Action 内部调用另一个 Action
        get().addHistory(keyword.trim());
    } catch(err) {
        console.error(err);
        set({ suggestions: [] }); // 出错了也要重置建议,防止显示脏数据
    } finally {
        // 6️⃣ 无论成功失败,都要关闭 loading
        set({ loading: false });
    }
},

📜 AddHistory Action (历史记录管理)

历史记录不仅仅是 push 进去那么简单,我们需要考虑用户体验:

  1. 去重:如果搜过了,不要重复存。
  2. 置顶:如果搜过了,把它移到最前面。
  3. 截断:不能无限存,保留最近 10 条即可。
addHistory: (keyword:string) => {
    const trimmed = keyword.trim();
    if(!trimmed) return;
    
    const { history } = get();
    const exists = history.includes(trimmed);
    let newHistory;
    
    if(exists) {
        // 💡 算法:如果存在,先 filter 排除掉旧的,再把新的 spread 到头部
        newHistory = [trimmed, ...history.filter(item => item !== trimmed)];
    } else {
        // 不存在,直接 spread 到头部
        newHistory = [trimmed, ...history];
    }
    
    // ✂️ 截断:只保留前10条
    newHistory = newHistory.slice(0, 10);
    
    set({ history: newHistory });
},

2.3 持久化黑科技

Zustand 的 persist 中间件可以自动把状态同步到 localStorage。但我们不需要持久化所有东西(比如 loading 状态持久化了没意义,用户刷新页面不应该看到还在加载)。

{
    name: 'search-store', // localStorage 中的 key 名称
    // 💡 partialize:白名单机制
    // 只返回需要持久化的字段,这里我们要保存 history
    partialize: (state) => ({
        history: state.history,
    }),
}

🛡️ 第三步:防抖魔法 (Debounce)

在搜索框输入时,如果用户打字很快(比如 "react"),如果不做处理,会触发 "r", "re", "rea", "reac", "react" 5次请求。这对服务器是灾难,对用户也是流量浪费。

我们需要一个 Debounce Hook

import { useState, useEffect } from 'react';

// 泛型 T 保证了我们可以防抖字符串、数字等任意类型
export function useDebounce<T>(value: T, delay: number) : T {
    // 内部维护一个 debouncedValue
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        // ⏰ 开启定时器
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // 🧹 清理函数:这是 Effect 的精髓
        // 如果在 delay 时间内 value 又变了,React 会先调用这个清理函数
        // 清除上一次的定时器,从而取消上一次的更新
        return () => {
            clearTimeout(handler);
        }
    }, [value, delay]); // 依赖项变化时触发

    return debouncedValue;
}

原理解析: 当你快速输入时,useEffect 会不断重新执行。每次执行前,return 的清理函数都会把上一次的定时器 clearTimeout 掉。只有当你停止输入超过 delay 毫秒,定时器里的 setDebouncedValue 才有机会执行。妙啊!🐮


🎨 第四步:UI 构建,颜值即正义

逻辑通了,最后我们来画界面。

4.1 首页入口 (Home.tsx)

首页不需要复杂的逻辑,只需要一个长得像搜索框的按钮。

<div className="fixed top-0 left-0 right-0 px-4 py-2 bg-background"
    // 👉 点击直接跳转到 /search 路由,不在首页做搜索逻辑,减轻首页负担
    onClick={() => navigate("/search")}
>
    <div className="relative max-w-md mx-auto">
        <Search className="absolute top-1/2 left-3 -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
        <Input 
            readOnly // 💡 重点只读防止手机上弹出键盘
            className="pl-9 rounded-full cursor-pointer bg-muted"
            placeholder="搜索你感兴趣的内容"
        />
    </div>
</div>

4.2 搜索页主战场 (Search.tsx)

这是核心战场。我们需要把 Store、Hook 和 UI 结合起来。

引入与初始化

const { 
    loading, 
    search,
    suggestions,
    history,
    clearHistory
} = useSearchStore();

const [keyword, setKeyword] = useState('');
// ✨ 使用防抖 Hook,延迟 500ms
const debouncedKeyword = useDebounce<string>(keyword, 500);

智能触发搜索

我们利用 useEffect 监听防抖后的关键词。

useEffect(() => {
    // 只有当防抖后的关键词有值时,才触发搜索
    // 这样就实现了:用户疯狂输入 -> 没反应 -> 停顿500ms -> 发起一次请求
    if (debouncedKeyword.trim()) {
        search(debouncedKeyword);
    }
}, [debouncedKeyword]); // 依赖 debouncedKeyword

交互细节:点击搜索按钮与清除

// 手动点击搜索按钮时,立即触发,不需要等待防抖
const handleSearch = (keyword: string) => {
    search(keyword);
    setKeyword(keyword); // 💡 记得同步更新 input 的显示
}

// ... JSX 中 ...

// ❌ 清空按钮:只有当有 keyword 时才显示
{keyword && (
    <Button
        // ... 样式略 ...
        onClick={() => setKeyword('')} // 一键清空
    >
        <X className="h-5 w-5" />
    </Button>
)}

历史记录区域

当用户没输入任何内容,且有历史记录时,显示历史标签。

{!keyword && history.length > 0 && (
    <Card className="mb-3">
        <CardContent className="p-3">
            <div className="flex items-center justify-between mb-2">
                <span className="text-sm font-medium">最近搜索</span>
                <Button size="sm" variant="ghost" onClick={() => clearHistory()}>
                    清空
                </Button>
            </div>
            <div className="flex flex-wrap gap-2">
                {history.map((item) => (
                    <Button 
                        key={item} 
                        size="sm" 
                        variant="secondary" 
                        // 👉 点击历史标签直接进行搜索
                        onClick={() => handleSearch(item)}
                    >
                        {item}
                    </Button>
                ))}
            </div>
        </CardContent>
    </Card>
)}

搜索建议/结果区域

当用户有输入时,显示结果。这里用到了 ScrollArea 优化滚动体验。

{keyword && (
    <Card>
        <CardContent className="p-0">
            {/* 📱 移动端友好:固定高度,丝滑滚动 */}
            <ScrollArea className='h-[60vh]'>
                {/* Loading 状态 */}
                {loading && (
                    <div className='p-4 text-center text-sm text-muted-foreground'>
                        加载中...
                    </div>
                )}
                
                {/* 空状态 */}
                {!loading && suggestions.length === 0 && (
                    <div className='p-4 text-center text-sm text-muted-foreground'>
                        暂无搜索结果
                    </div>
                )}
                
                {/* 结果列表 */}
                {suggestions.map((item, index) => (
                    <div key={index} className='px-4 py-3 text-sm border-b active:bg-muted'
                        onClick={() => navigate(`/`)} // 点击跳转详情(这里先跳回首页演示)
                    >
                        {item}
                    </div>
                ))}
            </ScrollArea>
        </CardContent>
    </Card>
)}

🎉 总结

到这里,我们的智能搜索功能就大功告成了!🏆

让我们回顾一下今天的硬核知识点:

  1. URL 解析:用 new URLsearchParams 优雅处理后端参数。
  2. Mock 技巧:用 filterincludes 模拟模糊查询。
  3. Zustand 进阶:Action 互相调用、persist 局部持久化。
  4. 性能优化useDebounce 防抖 Hook 的封装与应用。
  5. 交互细节:历史记录的置顶与去重、Input 的清空与只读处理。

现在的搜索虽然好用,但它是基于关键词匹配的(Exact Match)。用户如果搜 "JS" 可能搜不到 "JavaScript",搜 "好看的衣服" 肯定搜不到 "时尚女装"。

🤔 思考题: 如何让机器理解 "JS" 就是 "JavaScript"?如何实现像 ChatGPT 一样懂你意图的语义化搜索

👉 下期预告:AI 全栈第十四天 - 赋予搜索灵魂:基于 Vector Embedding 的 AI 语义化搜索实现! 敬请期待!


如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!我们下期见!💖