前言
嗨,各位全栈练习生们!👋 欢迎来到我们的 AI 全栈项目第十三天!
如果说首页是应用的“门面”,那么搜索功能就是应用的“传送门”。在这个信息爆炸的时代,让用户能在一个庞大的内容库里,用最快速度找到自己想要的东西,简直是提升用户体验的“必杀技”。🎯
今天,我们就来啃这块硬骨头!我们要实现一个带有搜索建议、历史记录持久化、防抖处理的丝滑搜索功能。别担心,虽然听起来功能点很多,但只要我们要把它拆解开来,你会发现——害,也就那么回事儿!😎
准备好了吗?我们要发车了!🚗💨
🏗️ 总体架构预览
在开始写代码之前,咱们先理理思路。一个成熟的搜索流程大概是这样的:
- 入口:首页得有个显眼的搜索框,点它!
- 交互:用户输入关键词 -> 触发防抖 -> 发送请求。
- 状态:我们需要管理
loading(加载中)、suggestions(搜索建议/结果)、history(历史记录)。 - 后端:接收关键词 -> 过滤数据 -> 返回结果。
为了让大家看得更清楚,我们采用 Mock数据 -> 状态管理(Store) -> 工具函数(Hooks) -> UI组件(Pages) 的顺序来逐个击破!
🛠️ 第一步:后端不够,Mock 来凑
在后端兄弟还没把接口写好(或者我们自己就是后端但想偷懒)的时候,Mock 数据是前端开发的救命稻草。我们需要模拟一个能处理搜索请求的接口。
打开 mock/search.js,让我们看看这个“假后端”是怎么工作的。
1.1 数据源准备
首先,我们准备了一堆文章数据(posts),这里面包含了 title 和 category。
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 进去那么简单,我们需要考虑用户体验:
- 去重:如果搜过了,不要重复存。
- 置顶:如果搜过了,把它移到最前面。
- 截断:不能无限存,保留最近 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>
)}
🎉 总结
到这里,我们的智能搜索功能就大功告成了!🏆
让我们回顾一下今天的硬核知识点:
- URL 解析:用
new URL和searchParams优雅处理后端参数。 - Mock 技巧:用
filter和includes模拟模糊查询。 - Zustand 进阶:Action 互相调用、
persist局部持久化。 - 性能优化:
useDebounce防抖 Hook 的封装与应用。 - 交互细节:历史记录的置顶与去重、Input 的清空与只读处理。
现在的搜索虽然好用,但它是基于关键词匹配的(Exact Match)。用户如果搜 "JS" 可能搜不到 "JavaScript",搜 "好看的衣服" 肯定搜不到 "时尚女装"。
🤔 思考题: 如何让机器理解 "JS" 就是 "JavaScript"?如何实现像 ChatGPT 一样懂你意图的语义化搜索?
👉 下期预告:AI 全栈第十四天 - 赋予搜索灵魂:基于 Vector Embedding 的 AI 语义化搜索实现! 敬请期待!
如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!我们下期见!💖