手把手实现高效的多条件搜索功能优化
项目初期的技术选型
最近做完一个后台管理项目,核心功能是数据列表页加一个多条件搜索。本来以为就是几个输入框加个查询按钮的事儿,结果上线前一周被产品拉着改了三轮交互,差点没把我干趴下。
一开始我图省事,直接用表单收集所有筛选项,每次 change 就更新 state,点击“搜索”才调接口。后来产品说要支持“实时筛选”,也就是选一个条件就自动请求——这下可好,用户点个下拉框都得发请求,后端报警了。
最后我们折中:部分字段(比如状态、类型)支持“即时搜索”,输入框类的还是手动触发。技术上用了 React + Ant Design 的 Form 和 Table 组件,算是老搭档了,熟悉套路也少踩坑。
最大的坑:性能问题
你以为改个触发时机就完事儿了?too young。很快我们就发现,用户在快速连续操作时,比如连点几个筛选项,接口会乱序返回。
举个例子:先点“未处理” → 接口A发出;紧接着点“紧急” → 接口B发出。但因为网络波动,A比B慢,结果页面显示的是“未处理”的数据,尽管用户最后选的是“紧急”。这种体验简直灾难。
折腾了半天,发现解决方案其实也不复杂:加个请求锁 + 取消上一次未完成的请求。Axios 自带 CancelToken(虽然现在推荐用 AbortController),于是上了这个:
let abortController = null;
const fetchData = async (filters) => {
// 取消上一次请求
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
try {
const res = await fetch('https://jztheme.com/api/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters),
signal: abortController.signal,
});
const data = await res.json();
setData(data.list);
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求已取消');
} else {
console.error('请求失败:', err);
}
}
};
这里注意我踩过好几次坑:第一次忘了判断 err.name === 'AbortError',导致每次切换都报错到监控平台,半夜被钉钉叫醒……第二次是没把 abortController 放到组件外层或 useRef 里,导致根本没法取消。
还有个问题是 debounce 没设对。最开始给所有字段都加了300ms防抖,结果输入框输一半就触发了,用户体验很差。后来调整成:文本类输入框用300ms防抖,下拉选择类直接无延迟发送(配合上面的 abort 机制)。
表单状态管理有点乱
随着条件越来越多,form 表单的状态也开始变得难以维护。一开始是每个字段单独 useState,写着写着发现 reset 的时候要一个个清,麻了。
后来改成用 useForm(Ant Design 的 Hook),统一管理:
const [form] = Form.useForm();
// 提交时获取值
const handleSearch = () => {
const values = form.getFieldsValue();
fetchData(values);
};
// 重置并刷新
const handleReset = () => {
form.resetFields();
fetchData({});
};
但这带来新问题:有些字段需要默认值,比如“最近7天”。我在 initialValues 里设了,但 reset 后确实回到了7天,可接口没传这个参数,默认查的是全部时间……这里卡了我一下午才意识到:form.resetFields() 是重置到 initialValues,但我得主动触发一次查询。
所以最后写成了:
const handleReset = () => {
form.resetFields();
const defaultValues = form.getFieldsValue(); // 获取重置后的值
fetchData(defaultValues);
};
看似 trivial,但在多条件组合下很容易遗漏。建议大家只要用了默认值,reset 后一定要重新查一次,别指望页面自己刷新。
高级筛选收起展开的玄学问题
界面空间有限,我们把部分冷门条件做成“高级筛选”,点击展开更多。结构大概是这样:
<Form form={form}>
<Row>
<!-- 常规条件 -->
<Col span={8}><Form.Item name="status">...</Form.Item></Col>
<Col span={8}><Form.Item name="type">...</Form.Item></Col>
<Col span={8}>
<Button onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? '收起' : '展开高级筛选'}
</Button>
</Col>
</Row>
{showAdvanced && (
<Row>
<Col span={8}><Form.Item name="createTime">...</Form.Item></Col>
<Col span={8}><Form.Item name="operator">...</Form.Item></Col>
</Row>
)}
<Row>
<Col span={24} style={{ textAlign: 'right' }}>
<Button type="primary" onClick={handleSearch}>搜索</Button>
<Button onClick={handleReset}>重置</Button>
</Col>
</Row>
</Form>
问题来了:当高级筛选关闭时,那些字段的值还在 form 里存着!也就是说,即使你看不见 createTime,它依然可能带着上次的值参与查询。
这到底是 bug 还是 feature?产品说是 bug。用户点了“收起”,就该当成这些条件不存在。
解决办法有两个方向:
- 隐藏时清空字段值(太暴力,用户体验差)
- 提交时不包含隐藏字段(更合理)
我选了后者。改造了一下 handleSearch:
const handleSearch = () => {
const allValues = form.getFieldsValue();
const activeValues = { ...allValues };
// 如果高级筛选未展开,剔除相关字段
if (!showAdvanced) {
delete activeValues.createTime;
delete activeValues.operator;
}
fetchData(activeValues);
};
简单粗暴有效。虽然代码看起来不优雅,但满足需求又不容易出错。有时候我觉得,比起设计模式,能跑通才是第一位的。
回顾与反思
做完回头看,这个功能总共花了大概6天,其中4天在修各种边界情况和交互细节。核心逻辑其实很简单,真正难的是“用户怎么用才不懵”。
做得好的地方:
- 请求中断机制稳住了性能,没再出现数据错乱
- reset + 默认值的联动处理完整
- 高级筛选的状态隔离让用户感知清晰
还能优化的地方:
- URL 参数同步没做。用户复制链接分享,别人打不开当前筛选结果。本来计划用 query-string 序列化 form 值,但排期紧就没上,靠 sessionStorage 缓存了一波,勉强过关。
- 表单项太多后,加载时 skeleton 显示不自然。应该按条件分组渐进展示,现在是一股脑全出来。
- 移动端适配基本没搞,折叠逻辑在小屏上有点崩。不过目前主要使用场景是 PC 端,暂时不管了。
说实话,到现在还有一个小问题没彻底解决:当用户极速切换多个下拉选项时,偶尔会出现“空白一秒”的现象,是因为 abort 后旧数据要不要保留的问题。我试过保留旧数据加 loading 遮罩,但产品嫌不明显;也试过加骨架屏,又太重。最后妥协方案是:只在首次加载时显示 loading,后续更新不提示。影响不大,但心里总觉得欠了点什么。
以上是我踩坑后的总结,希望对你有帮助
多条件搜索看着简单,真要做顺滑并不容易。尤其是状态管理 + 请求控制 + 用户体验这三者之间,得反复权衡。
以上是我个人对这个功能的完整实践记录,有更优的实现方式欢迎评论区交流。这类“不起眼但巨麻烦”的功能我还会继续分享,毕竟日常搬砖就是这样,一边骂一边修。

暂无评论